API reference — Core domain
Nuvora.Nexus.Sentinel.Core
Framework-free domain core for Sentinel: permission grammar, authorization evaluator, policy engine, identity model, and ports. No web framework, no storage dependencies.
dotnet add package Nuvora.Nexus.Sentinel.Core
Nuvora.Nexus.Sentinel
SentinelIds
public static class SentinelIds
Id factory for all Sentinel entities: Guid v7 — time-ordered, so B-tree indexes stay append-friendly, and a plain Guid, so Relay’s Guid-keyed AuthContext accepts Sentinel subjects without translation.
Methods
static Guid New()
static Guid New(DateTimeOffset timestamp)
Nuvora.Nexus.Sentinel.Abuse
AbuseCheckOutcome
public enum AbuseCheckOutcome
Provides the base class for enumerations.
Values
AllowedBlocked— A layer’s threshold tripped;AbuseCheckResult.BlockedBynames it.CaptchaRequired— Adaptive CAPTCHA: a counting layer crossed threshold ×SentinelAbuseOptions.CaptchaFactorbut not the hard threshold, and the attempt did not carry a verified captcha. The caller should demand a challenge (HTTP: 429captcha_required+ site key) and retry the attempt withcaptchaPassed: trueonce the token verifies.BlockedByOutage— A fail-closed layer could not reach its counter store — blocked by outage, not by evidence of abuse.
AbuseCheckResult
public sealed record AbuseCheckResult : IEquatable<AbuseCheckResult>
One login-attempt verdict. AbuseCheckResult.BlockedBy is null only when allowed.
Constructors
AbuseCheckResult(AbuseCheckOutcome Outcome, AbuseLayer? BlockedBy = null)
One login-attempt verdict. AbuseCheckResult.BlockedBy is null only when allowed.
Properties
AbuseCheckOutcome Outcome { get; init; }
AbuseLayer? BlockedBy { get; init; }
bool IsAllowed { get; }
Fields
static readonly AbuseCheckResult Allowed
AbuseFailureMode
public enum AbuseFailureMode
What a protection layer does when its counter store is unreachable — configurable PER LAYER, decided by AbuseProtectionService, never by the store.
Values
FailOpen— Treat the layer as passed and alert loudly (theabuse.counter_store_unavailableevent). The default: a counter-store outage must not take logins down with it.FailClosed— Block the attempt (AbuseCheckOutcome.BlockedByOutage). For realms that prefer lockout over exposure.
AbuseLayer
public enum AbuseLayer
The abuse-protection layers, in check order.
Values
PerIpPerIpAccountAccountLockoutCredentialStuffing
AbuseLayerOptions
public record AbuseLayerOptions : IEquatable<AbuseLayerOptions>
Per-layer throttling config. A layer blocks when its windowed count EXCEEDS AbuseLayerOptions.Threshold.
Properties
AbuseFailureMode FailureMode { get; init; }
Counter-store-outage behavior for this layer. Default fail-open.
TimeSpan Window { get; init; }
bool Enabled { get; init; }
int Threshold { get; init; }
Maximum count tolerated inside AbuseLayerOptions.Window; the (Threshold+1)th event blocks.
AbuseProtectionService
public sealed class AbuseProtectionService
Login abuse protection: four layered counters checked in order, per-layer fail-open/fail-closed outage policy. Call flow: AbuseProtectionService.CheckLoginAttemptAsync BEFORE verifying credentials (it counts the attempt), AbuseProtectionService.RecordFailureAsync after a failed verification (lockout counts failures only, so a correct password never locks its own account), AbuseProtectionService.ResetAccountAsync after a successful login.
Constructors
AbuseProtectionService(IRateCounterStore counters, IFirstSeenMarkerStore markers, ISentinelClock clock, ISentinelEventSink events, SentinelAbuseOptions? options = null)
Login abuse protection: four layered counters checked in order, per-layer fail-open/fail-closed outage policy. Call flow: AbuseProtectionService.CheckLoginAttemptAsync BEFORE verifying credentials (it counts the attempt), AbuseProtectionService.RecordFailureAsync after a failed verification (lockout counts failures only, so a correct password never locks its own account), AbuseProtectionService.ResetAccountAsync after a successful login.
Methods
ValueTask RecordFailureAsync(string ip, string normalizedIdentifier, Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Records a FAILED credential verification against the account-lockout layer (successful attempts never count, or an attacker could weaponize a victim’s own logins). When the windowed failure count reaches the threshold, a lockout flag with AccountLockoutOptions.LockoutDuration TTL engages and AbuseProtectionService.AccountLockedEvent fires.
ValueTask ResetAccountAsync(string normalizedIdentifier, Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Clears the account’s failure count and any active lockout after a successful login — a legitimate user who finally remembers the password should not inherit stale strikes.
ValueTask<AbuseCheckResult> CheckLoginAttemptAsync(string ip, string normalizedIdentifier, Guid realmId, bool captchaPassed = false, CancellationToken cancellationToken = default(CancellationToken))
Counts this attempt against every enabled layer, in layer order, and returns the first block. Layers behind a blocking layer are not incremented — a blocked attempt never reaches them, so counting it there would double-punish retries after the block lifts. captchaPassed is the adaptive-captcha handshake: pass true when the caller has ALREADY verified a challenge token for this attempt (via ICaptchaVerifier) — the counting layers then skip their soft AbuseCheckOutcome.CaptchaRequired band and only the hard thresholds apply. A solved captcha never bypasses a hard block or an account lockout.
Fields
const string AccountLockedEvent = "abuse.account_locked"
Event kind emitted when the failure threshold trips and the lockout engages.
const string CounterStoreUnavailableEvent = "abuse.counter_store_unavailable"
Event kind emitted when a counter/marker store call throws — the “loud alerting” half of fail-open.
AccountLockoutOptions
public sealed record AccountLockoutOptions : AbuseLayerOptions, IEquatable<AccountLockoutOptions>
Account-lockout layer config: AbuseLayerOptions.Threshold FAILED attempts inside AbuseLayerOptions.Window locks the account.
Properties
TimeSpan LockoutDuration { get; init; }
How long the account stays locked once the failure threshold trips.
CaptchaProvider
public enum CaptchaProvider
The adaptive-captcha providers. All three verify through the same siteverify-shaped POST API.
Values
Turnstile— Cloudflare Turnstile — the default: free, privacy-forward, no puzzle for most humans.HCaptchaReCaptcha
ICaptchaVerifier
public interface ICaptchaVerifier
Server-side verification of a client-solved CAPTCHA challenge token. The port lives in Core so the abuse/login flow can express “verified” without any HTTP dependency; the provider adapters (Turnstile/hCaptcha/reCAPTCHA, all siteverify-shaped POST APIs) live in the AspNetCore package. Implementations return false — never throw — for invalid, expired, malformed tokens AND for provider outages: an unverifiable token is an unverified token, and the adaptive-captcha layer only ever escalates below the hard block threshold, so false is throttling, not lockout.
Methods
ValueTask<bool> VerifyAsync(string token, string? ip, CancellationToken cancellationToken = default(CancellationToken))
NoopCaptchaVerifier
public sealed class NoopCaptchaVerifier : ICaptchaVerifier
Default verifier: accepts everything. For tests and hosts that want the CaptchaRequired flow exercised without a provider. Deliberately NOT registered by default — a host that enables adaptive captcha (SentinelAbuseOptions.CaptchaEnabled) without registering a real verifier should see challenges fail loudly, not silently pass.
Methods
ValueTask<bool> VerifyAsync(string token, string? ip, CancellationToken cancellationToken = default(CancellationToken))
Fields
static readonly NoopCaptchaVerifier Instance
SentinelAbuseOptions
public sealed class SentinelAbuseOptions
The four abuse-protection layers (Node parity), checked in declaration order. Defaults are deliberately loose enough for shared-NAT offices (per-IP) and password-manager hiccups (lockout) while still stopping scripted attacks; realms tighten them per policy.
Properties
AbuseLayerOptions CredentialStuffing { get; init; }
Layer 4: DISTINCT identifiers attempted per IP — the credential-stuffing signature (many accounts, few attempts each), which the per-account layers are blind to. Threshold is high because legitimate multi-user gateways exist; 200 distinct accounts in 10 minutes is not one.
AbuseLayerOptions PerIp { get; init; }
Layer 1: total login attempts per source IP, account-agnostic.
AbuseLayerOptions PerIpAccount { get; init; }
Layer 2: attempts per (IP, account) pair — targeted guessing of one account from one place.
AccountLockoutOptions AccountLockout { get; init; }
Layer 3: per-account lockout on FAILED attempts, IP-independent (distributed guessing).
bool CaptchaEnabled { get; init; }
Adaptive CAPTCHA: when true, the counting layers (per-IP, per-(IP,account), credential stuffing) demand a captcha once SentinelAbuseOptions.CaptchaFactor × threshold is crossed, softening the ramp toward the hard block. OFF by default — demanding a challenge that no configured provider can render would hard-block legitimate users at half threshold, so hosts flip this together with wiring a verifier (AddSentinelCaptcha). The lockout layer never escalates to captcha: a lock is a binary flag with no gradient to soften.
double CaptchaFactor { get; init; }
Fraction of a layer’s threshold at which captcha kicks in. Default 0.5 — challenged at half the volume that would block.
SentinelCaptchaOptions
public sealed class SentinelCaptchaOptions
CAPTCHA configuration. Settable (not init-only) because it is bound through the options pattern by AddSentinelCaptcha. Enabling the ESCALATION itself is a separate dial (SentinelAbuseOptions.CaptchaEnabled) on the abuse options — this class only says which provider and keys to use.
Properties
CaptchaProvider Provider { get; set; }
string? SecretRef { get; set; }
The provider’s server-side secret, POSTed to siteverify. Named “Ref” because hosts should resolve it from their secret store into this option at startup — Sentinel never persists it, logs it, or sends it anywhere but the provider’s verify endpoint.
string? SiteKey { get; set; }
Public widget key, returned to clients in the 429 captcha_required body so they can render the challenge.
Nuvora.Nexus.Sentinel.Admin
AdminAuditPage
public sealed record AdminAuditPage : IEquatable<AdminAuditPage>
One page of the admin audit ledger plus the chain-verification verdict for the whole realm chain (not just this page — tampering anywhere invalidates trust in every page).
Constructors
AdminAuditPage(IReadOnlyList<AdminAuditEntry> Entries, long? FirstBrokenSequence)
One page of the admin audit ledger plus the chain-verification verdict for the whole realm chain (not just this page — tampering anywhere invalidates trust in every page).
Properties
IReadOnlyList<AdminAuditEntry> Entries { get; init; }
bool ChainIntact { get; }
long? FirstBrokenSequence { get; init; }
AdminDenied
public sealed record AdminDenied : IEquatable<AdminDenied>
The typed denial every admin operation returns when the caller’s grants do not cover the target’s scope. The HTTP layer maps this to a 403 problem+json with the stable code admin_scope; the domain layer never throws for authorization failures — a denial is a normal, expected outcome of delegated administration.
Constructors
AdminDenied(string RequiredPermission, Guid? OrganizationId)
The typed denial every admin operation returns when the caller’s grants do not cover the target’s scope. The HTTP layer maps this to a 403 problem+json with the stable code admin_scope; the domain layer never throws for authorization failures — a denial is a normal, expected outcome of delegated administration.
Properties
Guid? OrganizationId { get; init; }
The target org the check was fenced to; null for realm-level targets.
string Message { get; }
string RequiredPermission { get; init; }
The permission id the caller would have needed (e.g. sentinel:org:manage).
AdminPage<T>
public sealed record AdminPage<T> : IEquatable<AdminPage<T>>
One page of an admin list: offset/limit paging with the total for UIs.
Constructors
AdminPage(IReadOnlyList<T> Items, int Offset, int Limit, int TotalCount)
One page of an admin list: offset/limit paging with the total for UIs.
Properties
IReadOnlyList<T> Items { get; init; }
int Limit { get; init; }
int Offset { get; init; }
int TotalCount { get; init; }
AdminResult<T>
public sealed class AdminResult<T>
Result of one admin operation: exactly one of success, AdminResult.Denied (→ 403), AdminResult.NotFoundKind (→ 404), or AdminResult.ValidationError (→ 400) is set.
Properties
AdminDenied? Denied { get; }
Set when the caller’s grants do not cover the target’s scope.
T Value { get; }
bool Succeeded { get; }
string? NotFoundKind { get; }
Machine kind of the missing target (“organization”, “user”, “role”, …).
string? ValidationError { get; }
Human-readable validation failure (malformed pattern, built-in role delete, …).
Methods
AdminResult<TOther> As<TOther>()
Carries a failure across result types (list op failing → single op result, etc.).
static AdminResult<T> Deny(AdminDenied denied)
static AdminResult<T> Invalid(string message)
static AdminResult<T> NotFound(string targetKind)
static AdminResult<T> Ok(T value)
AuthorizationInspection
public sealed record AuthorizationInspection : IEquatable<AuthorizationInspection>
The full “why” payload: the decision, every grant’s outcome in evaluation order, and the visibility classification per scope.
Constructors
AuthorizationInspection(Guid SubjectId, Guid RealmId, Guid? OrganizationId, string Permission, bool Allowed, string Decision, IReadOnlyList<InspectedGrant> Trace, IReadOnlyList<ScopeVisibility> Visibility)
The full “why” payload: the decision, every grant’s outcome in evaluation order, and the visibility classification per scope.
Properties
Guid RealmId { get; init; }
Guid SubjectId { get; init; }
Guid? OrganizationId { get; init; }
IReadOnlyList<InspectedGrant> Trace { get; init; }
IReadOnlyList<ScopeVisibility> Visibility { get; init; }
bool Allowed { get; init; }
string Decision { get; init; }
string Permission { get; init; }
AuthorizationInspectionRequest
public sealed record AuthorizationInspectionRequest : IEquatable<AuthorizationInspectionRequest>
One authorization question to answer “why was this allowed/denied” for. OrganizationId is the org context the inspected subject’s snapshot is built for (token org); the Resource* fields describe the hypothetical target resource.
Constructors
AuthorizationInspectionRequest(Guid SubjectId, Guid? OrganizationId, string Permission, Guid? ResourceOrganizationId = null, Guid? ResourceOwnerId = null, IReadOnlyList<Guid>? ResourceTeamIds = null, IReadOnlyDictionary<string, object?>? ResourceAttributes = null, IReadOnlyDictionary<string, object?>? ContextAttributes = null)
One authorization question to answer “why was this allowed/denied” for. OrganizationId is the org context the inspected subject’s snapshot is built for (token org); the Resource* fields describe the hypothetical target resource.
Properties
Guid SubjectId { get; init; }
Guid? OrganizationId { get; init; }
Guid? ResourceOrganizationId { get; init; }
Guid? ResourceOwnerId { get; init; }
IReadOnlyDictionary<string, object?>? ContextAttributes { get; init; }
IReadOnlyDictionary<string, object?>? ResourceAttributes { get; init; }
IReadOnlyList<Guid>? ResourceTeamIds { get; init; }
string Permission { get; init; }
AuthorizationInspector
public sealed class AuthorizationInspector
The admin authz debugger: re-runs the ONE evaluation path (AuthorizationEvaluator.Evaluate) with an EvaluationTrace attached, so what admins see is exactly what production decided — same code, plus the opt-in trace. Loads a FRESH snapshot from ISubjectDataSource (not the cache): an admin debugging “why can Alice still do X” must never be answered from a stale cache entry.
This class performs no caller authorization — SentinelAdminService.InspectAsync is the authorized front door; inspection output includes deny grants and provenance, which is exactly what end-user-facing responses redact.
Constructors
AuthorizationInspector(ISubjectDataSource source)
The admin authz debugger: re-runs the ONE evaluation path (AuthorizationEvaluator.Evaluate) with an EvaluationTrace attached, so what admins see is exactly what production decided — same code, plus the opt-in trace. Loads a FRESH snapshot from ISubjectDataSource (not the cache): an admin debugging “why can Alice still do X” must never be answered from a stale cache entry. This class performs no caller authorization — SentinelAdminService.InspectAsync is the authorized front door; inspection output includes deny grants and provenance, which is exactly what end-user-facing responses redact.
Methods
ValueTask<AuthorizationInspection?> InspectAsync(AuthorizationInspectionRequest request, CancellationToken cancellationToken = default(CancellationToken))
Null when the subject does not exist / is not active / is not a member of the requested org — the same three cases the snapshot source folds together. Throws FormatException for a malformed permission id (caller maps to 400).
IAdminStore
public interface IAdminStore
Persistence port for the delegated-administration surface. Deliberately authorization-free: every method trusts its caller, because SentinelAdminService is the ONLY caller and has already run the resolve-target-org → evaluate-caller pass. Kept narrow — these are the queries the admin surface actually makes, not a repository.
Methods
ValueTask AddGrantAsync(GrantRecord grant, CancellationToken cancellationToken = default(CancellationToken))
ValueTask AddOrganizationDomainAsync(OrganizationDomain domain, CancellationToken cancellationToken = default(CancellationToken))
ValueTask AddRoleAssignmentAsync(RoleAssignment assignment, CancellationToken cancellationToken = default(CancellationToken))
ValueTask CreateOrganizationAsync(Organization organization, CancellationToken cancellationToken = default(CancellationToken))
ValueTask CreateRealmAsync(Realm realm, CancellationToken cancellationToken = default(CancellationToken))
Creates a realm row (the deployment-operator / declarative-config surface).
ValueTask CreateRoleAsync(Role role, IReadOnlyList<GrantRecord> grants, CancellationToken cancellationToken = default(CancellationToken))
Creates the role and its grant rows atomically where the store supports it.
ValueTask DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken = default(CancellationToken))
Deletes the role, its grant rows, and every assignment of it.
ValueTask RemoveGrantAsync(Guid grantId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RemoveOrganizationDomainAsync(Guid domainId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RemoveRoleAssignmentAsync(Guid assignmentId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateOrganizationAsync(Organization organization, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (display name / status). The entity was loaded via IAdminStore.GetOrganizationAsync.
ValueTask UpdateOrganizationDomainAsync(OrganizationDomain domain, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (the OrganizationDomain.Verified flag). Loaded via the list/get queries.
ValueTask UpdateRealmAsync(Realm realm, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (display name / default flag). Loaded via IAdminStore.ListRealmsAsync.
ValueTask UpdateRoleAsync(Role role, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (display name). Loaded via IAdminStore.GetRoleAsync.
ValueTask UpdateUserAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminPage<User>> ListUsersInOrganizationAsync(Guid organizationId, int offset, int limit, CancellationToken cancellationToken = default(CancellationToken))
Members of one org, stably ordered (creation time, then id), offset/limit paged.
ValueTask<GrantRecord?> FindGrantByValueAsync(Guid roleId, string pattern, GrantEffect effect, string? conditionJson, CancellationToken cancellationToken = default(CancellationToken))
Value-keyed grant lookup: resolves a role’s grant row by its logical identity (pattern + effect + condition) for remove-by-value callers that never held the row id (declarative tooling, admin UIs working from role definitions).
ValueTask<GrantRecord?> GetGrantAsync(Guid grantId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<GrantRecord>> GetRoleGrantsAsync(Guid roleId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<Organization>> ListOrganizationsAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<OrganizationDomain>> ListOrganizationDomainsAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Every domain row in the realm, stably ordered (org, then value).
ValueTask<IReadOnlyList<Realm>> ListRealmsAsync(CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<Role>> ListRolesAsync(Guid realmId, Guid? organizationId, CancellationToken cancellationToken = default(CancellationToken))
Realm’s roles: organizationId null lists realm-level roles, a value lists that org’s roles.
ValueTask<Organization?> GetOrganizationAsync(Guid organizationId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<OrganizationDomain?> GetOrganizationDomainAsync(Guid domainId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<Role?> GetRoleAsync(Guid roleId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<RoleAssignment?> FindRoleAssignmentAsync(Guid roleId, RolePrincipalKind principalKind, Guid principalId, Guid? organizationId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<User?> GetUserAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<bool> IsOrganizationMemberAsync(Guid userId, Guid organizationId, CancellationToken cancellationToken = default(CancellationToken))
The target-resolution query: is this user a member of this org?
InMemoryAdminStore
public sealed class InMemoryAdminStore : IAdminStore
In-memory IAdminStore for tests and single-process samples. One coarse lock guards everything — admin mutations are rare and correctness beats speed here. The Add* seed helpers exist so tests can arrange tenancy without going through the authorized service surface.
Methods
ValueTask AddGrantAsync(GrantRecord grant, CancellationToken cancellationToken = default(CancellationToken))
ValueTask AddOrganizationDomainAsync(OrganizationDomain domain, CancellationToken cancellationToken = default(CancellationToken))
ValueTask AddRoleAssignmentAsync(RoleAssignment assignment, CancellationToken cancellationToken = default(CancellationToken))
ValueTask CreateOrganizationAsync(Organization organization, CancellationToken cancellationToken = default(CancellationToken))
ValueTask CreateRealmAsync(Realm realm, CancellationToken cancellationToken = default(CancellationToken))
Creates a realm row (the deployment-operator / declarative-config surface).
ValueTask CreateRoleAsync(Role role, IReadOnlyList<GrantRecord> grants, CancellationToken cancellationToken = default(CancellationToken))
Creates the role and its grant rows atomically where the store supports it.
ValueTask DeleteRoleAsync(Guid roleId, CancellationToken cancellationToken = default(CancellationToken))
Deletes the role, its grant rows, and every assignment of it.
ValueTask RemoveGrantAsync(Guid grantId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RemoveOrganizationDomainAsync(Guid domainId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RemoveRoleAssignmentAsync(Guid assignmentId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateOrganizationAsync(Organization organization, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (display name / status). The entity was loaded via IAdminStore.GetOrganizationAsync.
ValueTask UpdateOrganizationDomainAsync(OrganizationDomain domain, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (the OrganizationDomain.Verified flag). Loaded via the list/get queries.
ValueTask UpdateRealmAsync(Realm realm, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (display name / default flag). Loaded via IAdminStore.ListRealmsAsync.
ValueTask UpdateRoleAsync(Role role, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (display name). Loaded via IAdminStore.GetRoleAsync.
ValueTask UpdateUserAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminPage<User>> ListUsersInOrganizationAsync(Guid organizationId, int offset, int limit, CancellationToken cancellationToken = default(CancellationToken))
Members of one org, stably ordered (creation time, then id), offset/limit paged.
ValueTask<GrantRecord?> FindGrantByValueAsync(Guid roleId, string pattern, GrantEffect effect, string? conditionJson, CancellationToken cancellationToken = default(CancellationToken))
Value-keyed grant lookup: resolves a role’s grant row by its logical identity (pattern + effect + condition) for remove-by-value callers that never held the row id (declarative tooling, admin UIs working from role definitions).
ValueTask<GrantRecord?> GetGrantAsync(Guid grantId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<GrantRecord>> GetRoleGrantsAsync(Guid roleId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<Organization>> ListOrganizationsAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<OrganizationDomain>> ListOrganizationDomainsAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Every domain row in the realm, stably ordered (org, then value).
ValueTask<IReadOnlyList<Realm>> ListRealmsAsync(CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<Role>> ListRolesAsync(Guid realmId, Guid? organizationId, CancellationToken cancellationToken = default(CancellationToken))
Realm’s roles: organizationId null lists realm-level roles, a value lists that org’s roles.
ValueTask<Organization?> GetOrganizationAsync(Guid organizationId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<OrganizationDomain?> GetOrganizationDomainAsync(Guid domainId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<Role?> GetRoleAsync(Guid roleId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<RoleAssignment?> FindRoleAssignmentAsync(Guid roleId, RolePrincipalKind principalKind, Guid principalId, Guid? organizationId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<User?> GetUserAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<bool> IsOrganizationMemberAsync(Guid userId, Guid organizationId, CancellationToken cancellationToken = default(CancellationToken))
The target-resolution query: is this user a member of this org?
void AddMembership(Guid userId, Guid organizationId)
void AddOrganization(Organization organization)
void AddRealm(Realm realm)
void AddRole(Role role, params GrantRecord[] grants)
void AddUser(User user, params Guid[] organizationIds)
InspectedGrant
public sealed record InspectedGrant : IEquatable<InspectedGrant>
One grant’s fate during the traced evaluation — the trace row, stringly-typed for the wire.
Constructors
InspectedGrant(string Pattern, string Effect, string Source, string Outcome)
One grant’s fate during the traced evaluation — the trace row, stringly-typed for the wire.
Properties
string Effect { get; init; }
string Outcome { get; init; }
string Pattern { get; init; }
string Source { get; init; }
OidcClientCreated
public sealed record OidcClientCreated : IEquatable<OidcClientCreated>
Secret is the ONE disclosure of the generated plaintext; null for public clients.
Constructors
OidcClientCreated(OidcClient Client, string? Secret)
Secret is the ONE disclosure of the generated plaintext; null for public clients.
Properties
OidcClient Client { get; init; }
string? Secret { get; init; }
OidcClientSpec
public sealed record OidcClientSpec : IEquatable<OidcClientSpec>
Registration metadata for an OIDC client, as submitted by an admin. Secret material is deliberately absent: secrets are GENERATED server-side (create / rotate-secret) and shown once — an admin-supplied secret would put a human-chosen string on a client credential. Null list values mean “leave unchanged” on update (and “empty” on create).
Constructors
OidcClientSpec(string ClientId, OidcClientType Type, IReadOnlyList<string>? RedirectUris = null, IReadOnlyList<string>? PostLogoutRedirectUris = null, IReadOnlyList<string>? AllowedScopes = null, string? Audience = null, bool RequireConsent = false, bool FirstParty = false, TimeSpan? AccessTokenLifetime = null, string? BackChannelLogoutUri = null, OidcClientStatus Status = Active)
Registration metadata for an OIDC client, as submitted by an admin. Secret material is deliberately absent: secrets are GENERATED server-side (create / rotate-secret) and shown once — an admin-supplied secret would put a human-chosen string on a client credential. Null list values mean “leave unchanged” on update (and “empty” on create).
Properties
IReadOnlyList<string>? AllowedScopes { get; init; }
IReadOnlyList<string>? PostLogoutRedirectUris { get; init; }
IReadOnlyList<string>? RedirectUris { get; init; }
OidcClientStatus Status { get; init; }
OidcClientType Type { get; init; }
TimeSpan? AccessTokenLifetime { get; init; }
bool FirstParty { get; init; }
bool RequireConsent { get; init; }
string ClientId { get; init; }
string? Audience { get; init; }
string? BackChannelLogoutUri { get; init; }
RoleDetail
public sealed record RoleDetail : IEquatable<RoleDetail>
A role together with its grant rows — the admin read model for one role.
Constructors
RoleDetail(Role Role, IReadOnlyList<GrantRecord> Grants)
A role together with its grant rows — the admin read model for one role.
Properties
IReadOnlyList<GrantRecord> Grants { get; init; }
Role Role { get; init; }
RoleGrantSpec
public sealed record RoleGrantSpec : IEquatable<RoleGrantSpec>
A grant to attach to a role, as submitted by an admin. The pattern and optional condition are text here; SentinelAdminService validates both with the real parsers before anything is written — a malformed pattern must be a 400 at write time, never a silently-skipped row at snapshot-build time.
Constructors
RoleGrantSpec(string Pattern, GrantEffect Effect, Guid? OrganizationId = null, IReadOnlyList<Guid>? TeamIds = null, string? ConditionJson = null)
A grant to attach to a role, as submitted by an admin. The pattern and optional condition are text here; SentinelAdminService validates both with the real parsers before anything is written — a malformed pattern must be a 400 at write time, never a silently-skipped row at snapshot-build time.
Properties
GrantEffect Effect { get; init; }
Guid? OrganizationId { get; init; }
IReadOnlyList<Guid>? TeamIds { get; init; }
string Pattern { get; init; }
string? ConditionJson { get; init; }
ScopeVisibility
public sealed record ScopeVisibility : IEquatable<ScopeVisibility>
List-visibility classification of the inspected permission’s service:action at one scope.
Constructors
ScopeVisibility(string Scope, string Level)
List-visibility classification of the inspected permission’s service:action at one scope.
Properties
string Level { get; init; }
string Scope { get; init; }
SentinelAdminService
public sealed class SentinelAdminService
Org-scoped delegated administration (the fix for the Node version’s #1 weakness). Every operation follows the same four-step shape:
- Resolve the TARGET’s org — org ops target the org itself; user ops take an explicit acting-org parameter and require the target user to be a member of it; role ops resolve
Role.OrganizationId(null = realm-level). - Evaluate the caller’s snapshot against that target: realm-level targets need
sentinel:global:manage; org-scoped targets needsentinel:org:manageevaluated withAccessCheck.ResourceOrganizationId= the target org. - Perform via
IAdminStore(which is authorization-free by contract). - Audit via
AuditService.RecordAdminActionAsyncwith before/after snapshots on the tamper-evident chain.
── THE STRUCTURAL FENCE ─────────────────────────────────────────────────────────────────── This service NEVER compares organization ids itself to decide authorization. It puts the target’s org into AccessCheck.ResourceOrganizationId and lets AuthorizationEvaluator — the single evaluation path — decide. The evaluator’s cross-org rule (a realm-wide grant is not a license to reach across organizations the token was not minted for, and an org-restricted grant only applies at exactly its org) is therefore the ONE authority on cross-org reach. There is no controller-level org check to forget, and no second code path where cross-org access could be reintroduced — the mistake that was the Node version’s #1 weakness is structurally impossible here. The only org-related lookups this service performs are TARGET RESOLUTION (step 1: “is the target user actually in the acting org?”), which is about the target’s identity, never about the caller’s reach. ───────────────────────────────────────────────────────────────────────────────────────────
All operations are realm-fenced first: a target in another realm is reported as not-found (tenancy isolation — realms must not even leak existence to each other).
Constructors
SentinelAdminService(IAdminStore store, IAuditStore auditStore, AuditService audit, ISentinelClock clock, ISentinelCacheBus cacheBus, AuthorizationInspector inspector, ISessionEndNotifier? sessionEndNotifier = null, IOidcStore? oidcStore = null, PasswordHasher? passwordHasher = null, InvitationService? invitations = null, ISentinelMetrics? metrics = null)
Org-scoped delegated administration (the fix for the Node version’s #1 weakness). Every operation follows the same four-step shape: Resolve the TARGET’s org — org ops target the org itself; user ops take an explicit acting-org parameter and require the target user to be a member of it; role ops resolve Role.OrganizationId (null = realm-level).; Evaluate the caller’s snapshot against that target: realm-level targets need sentinel:global:manage; org-scoped targets need sentinel:org:manage evaluated with AccessCheck.ResourceOrganizationId = the target org.; Perform via IAdminStore (which is authorization-free by contract).; Audit via AuditService.RecordAdminActionAsync with before/after snapshots on the tamper-evident chain. ── THE STRUCTURAL FENCE ─────────────────────────────────────────────────────────────────── This service NEVER compares organization ids itself to decide authorization. It puts the target’s org into AccessCheck.ResourceOrganizationId and lets AuthorizationEvaluator — the single evaluation path — decide. The evaluator’s cross-org rule (a realm-wide grant is not a license to reach across organizations the token was not minted for, and an org-restricted grant only applies at exactly its org) is therefore the ONE authority on cross-org reach. There is no controller-level org check to forget, and no second code path where cross-org access could be reintroduced — the mistake that was the Node version’s #1 weakness is structurally impossible here. The only org-related lookups this service performs are TARGET RESOLUTION (step 1: “is the target user actually in the acting org?”), which is about the target’s identity, never about the caller’s reach. ─────────────────────────────────────────────────────────────────────────────────────────── All operations are realm-fenced first: a target in another realm is reported as not-found (tenancy isolation — realms must not even leak existence to each other).
Methods
ValueTask<AdminResult<AdminAuditPage>> GetAuditEntriesAsync(SubjectSnapshot caller, long fromSequence, int limit, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<AdminPage<User>>> ListUsersAsync(SubjectSnapshot caller, Guid organizationId, int offset, int limit, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<AuthorizationInspection>> InspectAsync(SubjectSnapshot caller, AuthorizationInspectionRequest request, CancellationToken cancellationToken = default(CancellationToken))
Inspection reveals deny grants and provenance (exactly what is redacted from end users), so it is gated like any other admin read on the inspected snapshot’s org context: org context set → sentinel:org:manage at that org; realm-level → global manage.
ValueTask<AdminResult<GrantRecord>> AddRoleGrantAsync(SubjectSnapshot caller, Guid roleId, RoleGrantSpec spec, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<GrantRecord>> RemoveRoleGrantAsync(SubjectSnapshot caller, Guid roleId, Guid grantId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<GrantRecord>> RemoveRoleGrantByValueAsync(SubjectSnapshot caller, Guid roleId, string pattern, GrantEffect effect, string? conditionJson = null, CancellationToken cancellationToken = default(CancellationToken))
Grant removal by VALUE: resolves the grant row by its logical identity (pattern + effect + condition) for callers that never held the row id (declarative tooling, admin UIs working from role definitions), then removes it under the exact same fence and audit as SentinelAdminService.RemoveRoleGrantAsync.
ValueTask<AdminResult<IReadOnlyList<OidcClient>>> ListOidcClientsAsync(SubjectSnapshot caller, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<IReadOnlyList<Organization>>> ListOrganizationsAsync(SubjectSnapshot caller, CancellationToken cancellationToken = default(CancellationToken))
Orgs of the caller’s realm the caller can see. No blanket permission gate: each org is individually evaluated (per-resource authorization), so an org admin gets exactly their orgs and a caller with no admin grants gets an empty list — never a 403 oracle.
ValueTask<AdminResult<IReadOnlyList<OrganizationDomain>>> ListOrganizationDomainsAsync(SubjectSnapshot caller, Guid organizationId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<IReadOnlyList<Realm>>> ListRealmsAsync(SubjectSnapshot caller, CancellationToken cancellationToken = default(CancellationToken))
Realm list (deployment-operator surface). Requires sentinel:global:manage.
ValueTask<AdminResult<IReadOnlyList<Role>>> ListRolesAsync(SubjectSnapshot caller, Guid? organizationId, CancellationToken cancellationToken = default(CancellationToken))
organizationId null lists REALM-level roles (global manage); a value lists that org’s roles.
ValueTask<AdminResult<InvitationIssued>> CreateInvitationAsync(SubjectSnapshot caller, Guid organizationId, Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Mints an invitation for a user in the acting org (user-target pipeline: org manage at that org + target ∈ org). The token is returned to the CALLER for delivery (link/mail) and never persisted.
ValueTask<AdminResult<OidcClient>> UpdateOidcClientAsync(SubjectSnapshot caller, string clientId, OidcClientSpec spec, CancellationToken cancellationToken = default(CancellationToken))
Updates a client’s registration metadata (never its secret — see SentinelAdminService.RotateOidcClientSecretAsync).
ValueTask<AdminResult<OidcClientCreated>> CreateOidcClientAsync(SubjectSnapshot caller, OidcClientSpec spec, CancellationToken cancellationToken = default(CancellationToken))
Registers a client. For confidential clients a 256-bit secret is GENERATED here and returned exactly once in OidcClientCreated.Secret — only its hash is stored. Public clients get no secret (PKCE is their proof).
ValueTask<AdminResult<OidcClientCreated>> RotateOidcClientSecretAsync(SubjectSnapshot caller, string clientId, TimeSpan? overlap = null, CancellationToken cancellationToken = default(CancellationToken))
Rotates a confidential client’s secret with an overlap window (the service-account rotation model): the old secret keeps verifying until overlap (default 24h) passes, then only the new one works. The new secret is returned exactly once.
ValueTask<AdminResult<Organization>> CreateOrganizationAsync(SubjectSnapshot caller, string key, string displayName, CancellationToken cancellationToken = default(CancellationToken))
Creating an org is a REALM-level mutation (the target org does not exist yet) — global manage only.
ValueTask<AdminResult<Organization>> GetOrganizationAsync(SubjectSnapshot caller, Guid organizationId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<Organization>> SuspendOrganizationAsync(SubjectSnapshot caller, Guid organizationId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<Organization>> UpdateOrganizationDisplayNameAsync(SubjectSnapshot caller, Guid organizationId, string displayName, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<OrganizationDomain>> AddOrganizationDomainAsync(SubjectSnapshot caller, Guid organizationId, string value, OrganizationDomainKind kind, bool verified = false, CancellationToken cancellationToken = default(CancellationToken))
Adds a domain row for the org. Org manage suffices for an UNVERIFIED row; the OrganizationDomain.Verified flag — the bit that makes the row route logins — is settable only via sentinel:global:manage (anyone can claim a domain string; DNS-challenge verification is future work, until then verification is an operator attestation).
ValueTask<AdminResult<OrganizationDomain>> RemoveOrganizationDomainAsync(SubjectSnapshot caller, Guid organizationId, Guid domainId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<OrganizationDomain>> SetOrganizationDomainVerifiedAsync(SubjectSnapshot caller, Guid organizationId, Guid domainId, bool verified, CancellationToken cancellationToken = default(CancellationToken))
Flips OrganizationDomain.Verified — global manage only (see SentinelAdminService.AddOrganizationDomainAsync).
ValueTask<AdminResult<Realm>> CreateRealmAsync(SubjectSnapshot caller, string key, string displayName, bool isDefault = false, CancellationToken cancellationToken = default(CancellationToken))
Creates a realm (the deployment-operator surface, same audience as SentinelAdminService.ListRealmsAsync). Requires sentinel:global:manage. Key is unique per deployment; the audit entry lands on the CALLER’s realm chain (the new realm has no history yet).
ValueTask<AdminResult<Realm>> UpdateRealmDisplayNameAsync(SubjectSnapshot caller, Guid realmId, string displayName, CancellationToken cancellationToken = default(CancellationToken))
Realm display-name update (backs declarative-config drift application). Global manage only.
ValueTask<AdminResult<RoleAssignment>> AssignRoleAsync(SubjectSnapshot caller, Guid roleId, Guid userId, Guid? organizationId = null, CancellationToken cancellationToken = default(CancellationToken))
Assigns a role to a user. Target-org resolution: an org-local role’s org wins; a realm-level role may be narrowed to organizationId (org-scoped assignment); a realm-level role with no org is a realm-level assignment (global manage). Org-scoped assignments additionally require the target user to be a member of the target org.
ValueTask<AdminResult<RoleAssignment>> UnassignRoleAsync(SubjectSnapshot caller, Guid roleId, Guid userId, Guid? organizationId = null, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<RoleDetail>> CreateRoleAsync(SubjectSnapshot caller, Guid? organizationId, string key, string displayName, IReadOnlyList<RoleGrantSpec> grants, CancellationToken cancellationToken = default(CancellationToken))
Creates a role with its grants. Org-local roles (organizationId set) require sentinel:org:manage AT THAT ORG — a caller with org:manage elsewhere is denied by the evaluator’s org fence, never by an if-statement here. Realm-level roles (organizationId null) require sentinel:global:manage.
ValueTask<AdminResult<RoleDetail>> DeleteRoleAsync(SubjectSnapshot caller, Guid roleId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<RoleDetail>> GetRoleAsync(SubjectSnapshot caller, Guid roleId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<RoleDetail>> UpdateRoleDisplayNameAsync(SubjectSnapshot caller, Guid roleId, string displayName, CancellationToken cancellationToken = default(CancellationToken))
Role display-name update (backs declarative-config drift application). Fenced like every other role op.
ValueTask<AdminResult<User>> GetUserAsync(SubjectSnapshot caller, Guid organizationId, Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<User>> ReactivateUserAsync(SubjectSnapshot caller, Guid organizationId, Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<User>> SuspendUserAsync(SubjectSnapshot caller, Guid organizationId, Guid userId, CancellationToken cancellationToken = default(CancellationToken))
SentinelSystemDefinitions
public static class SentinelSystemDefinitions
Sentinel’s own permission catalog: the permissions the delegated-admin surface checks are declared here exactly like any other service’s — Sentinel eats its own permissions-as-code dog food. Sync SentinelSystemDefinitions.Definitions through DefinitionSyncService at boot with owner service SentinelSystemDefinitions.OwnerService.
Properties
static SentinelDefinitions Definitions { get; }
The declaration bundle to feed DefinitionSyncService.SyncAsync.
Fields
const string GlobalAuditRead = "sentinel:global:audit_read"
Read-only access to the realm’s audit ledger: browse admin audit entries and verify the hash chain, without any of SentinelSystemDefinitions.GlobalManage’s mutation rights. The compliance-auditor permission — audit reads accept EITHER this or global manage.
const string GlobalImpersonate = "sentinel:global:impersonate"
Realm-level impersonation: impersonate any user of the realm.
const string GlobalManage = "sentinel:global:manage"
Realm-level (“global”) administration: realms, org creation, realm-level roles, the audit ledger. Distinct from org administration by scope segment, so no org-scoped grant pattern can ever match it.
const string OrgImpersonate = "sentinel:org:impersonate"
Org-scoped impersonation: impersonate members of one organization. Fenced exactly like SentinelSystemDefinitions.OrgManage — every check carries the TARGET user’s org as AccessCheck.ResourceOrganizationId, so the evaluator’s cross-org rule decides reach.
const string OrgManage = "sentinel:org:manage"
Org-scoped administration: users, roles, and settings of one organization. Structurally fenced — every check carries the TARGET org as AccessCheck.ResourceOrganizationId, so the evaluator’s cross-org rule decides reach, not any controller.
const string OrgView = "sentinel:org:view"
Read-only org visibility: list/inspect an org’s users and roles without mutation rights.
const string OwnerService = "sentinel"
The owner-service key for definition sync’s ownership-conflict detection.
static readonly PermissionId GlobalAuditReadId
static readonly PermissionId GlobalImpersonateId
static readonly PermissionId GlobalManageId
Pre-parsed ids for the hot check path (parse once, check many).
static readonly PermissionId OrgImpersonateId
static readonly PermissionId OrgManageId
static readonly PermissionId OrgViewId
Nuvora.Nexus.Sentinel.Audit
AdminActorKind
public enum AdminActorKind
What kind of principal performed an admin mutation. Recorded explicitly because AdminAuditEntry.ActorId alone is ambiguous across principal tables.
Values
UserServiceAccount— A machine identity.ApiKeySystem— Sentinel itself (definition sync, retention jobs, migrations).BreakGlass— Break-glass access — always worth spotting in the ledger.
AdminAuditChain
public static class AdminAuditChain
Hashing rules for the tamper-evident admin audit chain. Each entry’s hash covers a canonical pipe-delimited representation of its fields plus the previous entry’s hash, so retroactively modifying any stored entry breaks every hash after it.
The canonical form commits to the before/after payloads by digest (AdminAuditEntry.BeforeDigest/AdminAuditEntry.AfterDigest), not by value: retention redaction can then null the raw BeforeJson/AfterJson columns years later while verification — which only needs the stored digests — still passes. The digests are ordinary persisted columns precisely so they survive that shredding.
Methods
static bool IsEntryIntact(AdminAuditEntry entry, string expectedPreviousHash)
Checks a single link: the entry chains onto expectedPreviousHash, its stored hash matches a recomputation, and — when a payload is still present — the payload matches its committed digest. A redacted entry (null payload, digest kept) passes; a tampered payload with an unchanged digest does not.
static int? Verify(IEnumerable<AdminAuditEntry> orderedEntries)
Walks an ordered chain fragment and returns the 0-based index of the first broken link, or null when intact. A fragment starting at sequence 1 is anchored to AdminAuditChain.GenesisHash; a fragment starting mid-chain anchors trust at its first entry’s stored AdminAuditEntry.PreviousHash (the caller vouches for everything before it — full-chain verification starts at sequence 1, see AuditService.VerifyChainAsync).
static string ComputeEntryHash(AdminAuditEntry entry)
Computes the entry hash over the canonical representation seq|realm|actor|actorKind|action|targetKind|target|org|unixSeconds|beforeDigest|afterDigest|previousHash. Null Guids canonicalize to -; time is unix seconds so the hash is independent of DateTimeOffset formatting and sub-second storage precision. Fills AdminAuditEntry.BeforeDigest/AdminAuditEntry.AfterDigest from the JSON payloads when they are still null (append time); already-set digests are trusted as-is (redacted entries).
static string Sha256Hex(string value)
Lowercase hex sha256 of the UTF-8 bytes — the one digest form used across the audit ledger.
Fields
const string GenesisHash = "0000000000000000000000000000000000000000000000000000000000000000"
Placeholder “previous hash” for the first entry of each realm’s chain: 64 zeros.
AdminAuditEntry
public sealed class AdminAuditEntry
One link of the tamper-evident admin audit chain: a before/after snapshot of a single admin mutation, hash-chained per realm. AdminAuditEntry.SequenceNumber, AdminAuditEntry.PreviousHash and AdminAuditEntry.EntryHash are assigned by the store at append time (see IAuditStore.AppendAdminEntryAsync) — callers fill in only the descriptive fields.
Properties
AdminActorKind ActorKind { get; set; }
DateTimeOffset OccurredAt { get; set; }
Guid ActorId { get; set; }
Guid Id { get; set; }
Guid RealmId { get; set; }
Guid? OrganizationId { get; set; }
Org the mutation happened in, when applicable (delegated admin scoping).
Guid? TargetId { get; set; }
long SequenceNumber { get; set; }
Per-realm monotonic position in the chain, starting at 1. Assigned by the store.
required string Action { get; set; }
Machine id of the mutation (“role.grant_added”, “user.suspended”).
required string TargetKind { get; set; }
Machine id of the target’s entity kind (“user”, “role”, “organization”).
string EntryHash { get; set; }
Lowercase hex sha256 over this entry’s canonical representation. Assigned by the store.
string PreviousHash { get; set; }
AdminAuditEntry.EntryHash of the previous entry in this realm’s chain, or AdminAuditChain.GenesisHash for the first entry. Assigned by the store.
string? AfterDigest { get; set; }
Digest of AdminAuditEntry.AfterJson; same redaction contract as AdminAuditEntry.BeforeDigest.
string? AfterJson { get; set; }
JSON snapshot of the target after the mutation; null for deletions.
string? BeforeDigest { get; set; }
Lowercase hex sha256 of AdminAuditEntry.BeforeJson (of “” when the snapshot is null). The chain commits to this digest, not the raw payload, so retention redaction can null AdminAuditEntry.BeforeJson later without breaking verification — the digest column survives. Filled by AdminAuditChain.ComputeEntryHash when left null.
string? BeforeJson { get; set; }
JSON snapshot of the target before the mutation; null for creations.
AuditService
public sealed class AuditService
Facade over IAuditStore for the dual ledger: stamps time from ISentinelClock, serializes before/after snapshots, and walks the chain for the verification endpoint.
Constructors
AuditService(IAuditStore store, ISentinelClock clock)
Facade over IAuditStore for the dual ledger: stamps time from ISentinelClock, serializes before/after snapshots, and walks the chain for the verification endpoint.
Methods
ValueTask<AdminAuditEntry> RecordAdminActionAsync(Guid realmId, Guid actorId, AdminActorKind actorKind, string action, string targetKind, Guid? targetId = null, Guid? organizationId = null, object? before = null, object? after = null, CancellationToken cancellationToken = default(CancellationToken))
Records an admin mutation on the hash chain. before / after are serialized here so domain call sites pass plain objects; sequencing and hashing are the store’s contract (see IAuditStore.AppendAdminEntryAsync).
ValueTask<SecurityEvent> RecordSecurityEventAsync(Guid realmId, string kind, Guid? userId = null, Guid? organizationId = null, string? ipAddress = null, string? deviceDescription = null, object? data = null, CancellationToken cancellationToken = default(CancellationToken))
Records a user-visible security event; data is serialized to DataJson.
ValueTask<long?> VerifyChainAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Verifies a realm’s whole admin chain page by page, anchored at AdminAuditChain.GenesisHash. Returns the sequence number of the first broken (or missing — a gap is a deletion) entry, or null when the chain is intact. Redacted entries verify via their stored digests.
IAuditStore
public interface IAuditStore
Persistence port for the dual audit ledger. Adapters (Postgres, in-memory) implement it; the domain only ever talks to this interface.
Methods
ValueTask AppendSecurityEventAsync(SecurityEvent securityEvent, CancellationToken cancellationToken = default(CancellationToken))
Appends to the user-visible security ledger. Append-only: no update/delete surface.
ValueTask<AdminAuditEntry> AppendAdminEntryAsync(AdminAuditEntry entry, CancellationToken cancellationToken = default(CancellationToken))
Appends to the admin audit chain. Contract: the store — not the caller — assigns AdminAuditEntry.SequenceNumber (per-realm monotonic, starting at 1) and AdminAuditEntry.PreviousHash (previous entry’s hash, or AdminAuditChain.GenesisHash) atomically per realm, then computes AdminAuditEntry.EntryHash via AdminAuditChain.ComputeEntryHash. Two concurrent appends must never observe the same predecessor, or the chain forks and verification breaks — SQL adapters take a per-realm row lock, the in-memory store a per-realm monitor. Returns the entry with those fields populated.
ValueTask<IReadOnlyList<AdminAuditEntry>> GetAdminEntriesAsync(Guid realmId, long fromSequence, int limit, CancellationToken cancellationToken = default(CancellationToken))
Admin entries for a realm with SequenceNumber >= fromSequence, ascending, at most limit — the paging shape chain verification walks.
ValueTask<IReadOnlyList<SecurityEvent>> GetSecurityEventsForUserAsync(Guid userId, int limit, CancellationToken cancellationToken = default(CancellationToken))
Most recent security events for a user, newest first (the profile activity page query).
InMemoryAuditStore
public sealed class InMemoryAuditStore : IAuditStore, IRetentionStore
In-memory IAuditStore for tests and single-box deployments. Sequence + previous-hash assignment happens under a per-realm monitor so concurrent appends can never fork the chain (the atomicity contract on IAuditStore.AppendAdminEntryAsync). Reads return the live entry instances — convenient for tamper-detection tests, obviously not a durability story. Also implements IRetentionStore: it owns the only in-memory copies of both ledgers, so retention/redaction must act on the same lists.
Methods
ValueTask AppendSecurityEventAsync(SecurityEvent securityEvent, CancellationToken cancellationToken = default(CancellationToken))
Appends to the user-visible security ledger. Append-only: no update/delete surface.
ValueTask<AdminAuditEntry> AppendAdminEntryAsync(AdminAuditEntry entry, CancellationToken cancellationToken = default(CancellationToken))
Appends to the admin audit chain. Contract: the store — not the caller — assigns AdminAuditEntry.SequenceNumber (per-realm monotonic, starting at 1) and AdminAuditEntry.PreviousHash (previous entry’s hash, or AdminAuditChain.GenesisHash) atomically per realm, then computes AdminAuditEntry.EntryHash via AdminAuditChain.ComputeEntryHash. Two concurrent appends must never observe the same predecessor, or the chain forks and verification breaks — SQL adapters take a per-realm row lock, the in-memory store a per-realm monitor. Returns the entry with those fields populated.
ValueTask<IReadOnlyList<AdminAuditEntry>> GetAdminEntriesAsync(Guid realmId, long fromSequence, int limit, CancellationToken cancellationToken = default(CancellationToken))
Admin entries for a realm with SequenceNumber >= fromSequence, ascending, at most limit — the paging shape chain verification walks.
ValueTask<IReadOnlyList<SecurityEvent>> GetSecurityEventsForUserAsync(Guid userId, int limit, CancellationToken cancellationToken = default(CancellationToken))
Most recent security events for a user, newest first (the profile activity page query).
ValueTask<int> DeleteSecurityEventsBeforeAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default(CancellationToken))
Deletes security events older than cutoff (retention). Returns rows affected.
ValueTask<int> RedactAdminAuditPayloadsBeforeAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default(CancellationToken))
Nulls Before/After payloads of admin audit entries older than cutoff, digests untouched — the hash-chain-aware redaction. Returns rows affected.
ValueTask<int> RedactAdminAuditPayloadsForUserAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Nulls Before/After payloads of admin audit entries referencing the user (as target or actor), digests untouched so the chain still verifies. Returns rows affected.
ValueTask<int> RedactSecurityEventsForUserAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Redacts the user’s security events in place (DataJson, IpAddress, DeviceDescription → null; kind and timestamp survive): the erasure companion — the ledger keeps that things happened, not the personal data inside them. Returns rows affected.
SecurityEvent
public sealed class SecurityEvent
One row of the user-visible security ledger. Powers profile “activity” pages (“you signed in from Chrome on macOS”), so it deliberately carries display-oriented context (IP, device description) rather than admin before/after snapshots — those live in AdminAuditEntry, the second half of the dual ledger.
Properties
DateTimeOffset OccurredAt { get; set; }
Guid Id { get; set; }
Guid RealmId { get; set; }
Guid? OrganizationId { get; set; }
Org context when the event happened inside one; realm-level events leave it null.
Guid? UserId { get; set; }
Null for events with no user subject yet (e.g. a failed login against an unknown email).
required string Kind { get; set; }
Machine id of the event kind (“login.success”, “login.failed”, “mfa.enrolled”). A string rather than an enum so embedding apps can append their own kinds without a Core release; display text is an i18n concern, never stored here.
string? DataJson { get; set; }
Small kind-specific JSON payload; subject to retention shredding.
string? DeviceDescription { get; set; }
Human-readable device descriptor (“Chrome on macOS”) — display only, never trusted.
string? IpAddress { get; set; }
Nuvora.Nexus.Sentinel.Authentication
Argon2idPasswordHashAlgorithm
public sealed class Argon2idPasswordHashAlgorithm : IPasswordHashAlgorithm
Argon2id in PHC string format: $argon2id$v=19$m=65536,t=3,p=1$<salt-b64>$<hash-b64>. Defaults follow the OWASP 2024 recommendation (64 MiB, t=3, p=1). Verification reads parameters from the encoded string, so parameter upgrades only affect new hashes — old ones still verify and then rehash on login.
Constructors
Argon2idPasswordHashAlgorithm(int memoryKib = 65536, int iterations = 3, int parallelism = 1)
Properties
string Name { get; }
Stable machine name stored on the credential row (e.g. argon2id).
Methods
bool Verify(string password, string encodedHash)
string Hash(string password)
Fields
const string AlgorithmName = "argon2id"
AspNetIdentityV3PasswordHashAlgorithm
public sealed class AspNetIdentityV3PasswordHashAlgorithm : IPasswordHashAlgorithm
Verify-only algorithms for hashes imported from other identity stacks. They exist so migrated users keep logging in with their old password; the first successful login transparently rehashes to the current default via PasswordHasher.VerifyAndUpgrade. None of them can mint new hashes — Hash throws, by design.
bcrypt lives in Nuvora.Nexus.Sentinel.Importers (as BcryptPasswordHashAlgorithm), not here: a trustworthy bcrypt needs a vetted implementation, and Core takes no dependency beyond Konscious. This file holds only what the BCL can verify on its own.
Properties
string Name { get; }
Stable machine name stored on the credential row (e.g. argon2id).
Methods
bool Verify(string password, string encodedHash)
Verifies the ASP.NET Core Identity V3 binary format (base64-encoded): 0x01 | prf (u32 BE: 0=SHA1, 1=SHA256, 2=SHA512) | iterations (u32 BE) | salt length (u32 BE) | salt | subkey. Parameters come from the header, so hashes from any Identity version/configuration verify without configuration here. The V2 format (leading 0x00, fixed PBKDF2-SHA1) is deliberately not accepted — importers flag it at import time instead of this method guessing.
string Hash(string password)
Import-only: Sentinel never mints hashes in a foreign format.
Fields
const string AlgorithmName = "aspnet-identity-v3"
Base32
public static class Base32
RFC 4648 Base32 (no padding) — the alphabet authenticator apps expect in otpauth URIs.
Methods
static byte[] Decode(string encoded)
static string Encode(ReadOnlySpan<byte> data)
IPasswordHashAlgorithm
public interface IPasswordHashAlgorithm
One password-hash algorithm in the coexistence registry. Credentials are tagged with the algorithm name; verification dispatches on the tag, which is what lets imported bcrypt/PBKDF2/ASP.NET-Identity hashes live alongside argon2id until their owners next log in.
Properties
string Name { get; }
Stable machine name stored on the credential row (e.g. argon2id).
Methods
bool Verify(string password, string encodedHash)
string Hash(string password)
PasswordHasher
public sealed class PasswordHasher
The verifying/rehashing front: verifies against whichever algorithm a credential is tagged with and reports when the hash should be upgraded to the current default. Callers persist the returned rehash inside the same login transaction.
Constructors
PasswordHasher(IPasswordHashAlgorithm current, IEnumerable<IPasswordHashAlgorithm>? legacy = null)
Properties
IPasswordHashAlgorithm Current { get; }
Methods
bool VerifyAndUpgrade(string password, string algorithmName, string encodedHash, out string? rehash)
Verifies and, when the credential uses anything but the current algorithm (or the current algorithm with outdated parameters — the algorithm’s Verify handles parameter drift by reading them from the encoded hash), returns the upgraded hash in rehash. An unknown algorithm tag returns false rather than throwing: treat it like a wrong password, don’t leak which algorithms this deployment knows.
string Hash(string password)
Pbkdf2PasswordHashAlgorithm
public sealed class Pbkdf2PasswordHashAlgorithm : IPasswordHashAlgorithm
PBKDF2-SHA256 — never the default, present for imported credentials and for FIPS-constrained hosts that opt into it explicitly. Format: $pbkdf2-sha256$i=<iterations>$<salt-b64>$<hash-b64>.
Constructors
Pbkdf2PasswordHashAlgorithm(int iterations = 600000)
Properties
string Name { get; }
Stable machine name stored on the credential row (e.g. argon2id).
Methods
bool Verify(string password, string encodedHash)
string Hash(string password)
Fields
const string AlgorithmName = "pbkdf2-sha256"
RecoveryCodes
public static class RecoveryCodes
MFA recovery codes: generated in a human-typable format, stored only as SHA-256 digests, single-use (consumption is the store’s job — a verified code must be deleted in the same transaction), regenerable as a full set (regeneration invalidates all previous codes).
Methods
static IReadOnlyList<string> Generate(int count = 10)
Generates codes like K7MQ-2WPX-9RTZ (3 groups of 4 ≈ 59 bits — ample for a store-side rate-limited factor).
static string HashForStorage(string code)
Digest for storage. Plain SHA-256 (no salt, no work factor) is deliberate: inputs are 59-bit random strings, not human passwords — brute-forcing the digest is harder than guessing the code online, and lookups must be O(1) by digest.
static string Normalize(string code)
Case- and separator-insensitive: users type what they read, we normalize.
Fields
const int DefaultCount = 10
Totp
public static class Totp
RFC 6238 TOTP, implemented in-house like the rest of the token machinery — the algorithm is 40 lines and owning it beats a dependency for something this security-central. SHA-1 is deliberate: it is what RFC 6238 specifies and what every authenticator app implements; the HMAC construction is not affected by SHA-1 collision weaknesses.
Methods
static bool Verify(string code, byte[] secret, DateTimeOffset time, int driftSteps = 1, int digits = 6, int stepSeconds = 30)
Verifies with a ±driftSteps window (default one step each way — the RFC’s recommended tolerance for clock drift; larger windows measurably weaken the factor). Callers must enforce single-use per step on top of this (via challenge tracking): accepting the same code twice within its window is a replay.
static byte[] GenerateSecret()
160-bit secret per RFC 4226 §4 recommendation.
static string BuildProvisioningUri(string issuer, string accountName, byte[] secret, int digits = 6, int stepSeconds = 30)
Builds the otpauth://totp/… provisioning URI encoded into enrollment QR codes.
static string ComputeCode(byte[] secret, DateTimeOffset time, int digits = 6, int stepSeconds = 30)
Fields
const int DefaultDigits = 6
const int DefaultStepSeconds = 30
Nuvora.Nexus.Sentinel.Authorization
AccessCheck
public readonly struct AccessCheck
One authorization question: “may this subject perform AccessCheck.Permission against this resource, in this context?”. Resource fields are optional — what a check omits simply cannot satisfy the corresponding scope (a team-scoped permission checked without resource teams fails closed).
Constructors
AccessCheck(PermissionId permission, Guid? resourceOrganizationId = null, IReadOnlyList<Guid>? resourceTeamIds = null, Guid? resourceOwnerId = null, IReadOnlyDictionary<string, object?>? resourceAttributes = null, IReadOnlyDictionary<string, object?>? contextAttributes = null)
Properties
Guid? ResourceOrganizationId { get; }
Org the target resource belongs to; falls back to the snapshot’s org context when null.
Guid? ResourceOwnerId { get; }
Owning subject of the target resource; required context for self-scoped permissions.
IReadOnlyDictionary<string, object?>? ContextAttributes { get; }
ABAC attributes addressable as context.* (request time, IP country, MFA level…).
IReadOnlyDictionary<string, object?>? ResourceAttributes { get; }
ABAC attributes addressable as resource.*.
IReadOnlyList<Guid>? ResourceTeamIds { get; }
Teams the target resource belongs to; required context for team-scoped permissions.
PermissionId Permission { get; }
AccessDecision
public readonly struct AccessDecision
The result of one evaluation. A struct so the hot path stays allocation-free; diagnostic detail lives in the opt-in EvaluationTrace, never in the decision.
Properties
AccessOutcome Outcome { get; }
bool IsAllowed { get; }
Methods
static AccessDecision Allowed()
static AccessDecision DeniedByDefault()
static AccessDecision DeniedByGrant()
AccessOutcome
public enum AccessOutcome
Provides the base class for enumerations.
Values
DeniedByDefault— No grant matched. The default: absence of permission is denial.DeniedByGrant— A deny grant matched; denies override any number of allows.Allowed
AuthorizationEvaluator
public static class AuthorizationEvaluator
The single authorization evaluation path: point checks (AuthorizationEvaluator.Evaluate) and list visibility (AuthorizationEvaluator.VisibilityFor) share the same grant-matching primitives, so visibility can never be broader than row-by-row evaluation — the Node version’s visibilityFor/evaluate divergence is structurally impossible here. When visibility cannot decide without per-row data (conditions, team restrictions, per-row denies) it says VisibilityLevel.Conditional instead of guessing.
Methods
static AccessDecision Evaluate(SubjectSnapshot subject, in AccessCheck check, EvaluationTrace? trace = null)
Deny-overrides evaluation: any applicable deny wins over any number of allows; no applicable grant at all is a default deny. Without a trace the first applicable deny short-circuits; with a trace the full pass runs so the admin inspector sees every grant’s outcome. Both orders produce the same decision because denies are absolute.
static VisibilityLevel VisibilityFor(SubjectSnapshot subject, string service, string action, PermissionScope scope)
List-visibility classification for service:scope:action at a given scope. VisibilityLevel.Granted: an unconditional allow applies and no matching deny could ever apply — the caller may list without row checks. VisibilityLevel.None: an unconditional, unrestricted deny applies, or nothing allows — the caller can skip the query entirely. VisibilityLevel.Conditional: everything else (ABAC conditions, team restrictions, per-row denies) — the caller must filter by team/owner or row-check with AuthorizationEvaluator.Evaluate. Conditioned denies land here instead of being ignored (a known weakness of the Node version).
EvaluationTrace
public sealed class EvaluationTrace
Opt-in evaluation trace. The evaluator only records entries when a caller passes a trace instance, so production point-checks pay nothing for it — this is the fix for the Node version’s per-grant trace allocation on the hot path. The admin /inspect//trace endpoints always pass one.
Properties
IReadOnlyList<Entry> Entries { get; }
EvaluationTrace.Entry
public readonly record struct EvaluationTrace.Entry : IEquatable<Entry>
Provides the base class for value types.
Constructors
Entry(PermissionPattern Pattern, GrantEffect Effect, string Source, GrantOutcome Outcome)
Properties
GrantEffect Effect { get; init; }
GrantOutcome Outcome { get; init; }
PermissionPattern Pattern { get; init; }
string Source { get; init; }
EvaluationTrace.GrantOutcome
public enum EvaluationTrace.GrantOutcome
Provides the base class for enumerations.
Values
PatternMismatch— Pattern did not match the permission id.ScopeNotApplicable— Pattern matched but the grant’s org/team/self constraints did not apply.ConditionFailed— Pattern and scope matched but the ABAC condition evaluated false (or was unresolvable).Allowed— Grant applied and contributed an allow.Denied— Grant applied and forced a deny (deny-overrides).
Grant
public sealed class Grant
A single grant inside a subject snapshot. Grants are the only unit the evaluator understands — roles, groups, and policies all compile down to grants when the snapshot is built, which is what keeps evaluation a single flat pass.
Constructors
Grant(PermissionPattern pattern, GrantEffect effect, Guid? organizationId = null, IReadOnlyList<Guid>? teamIds = null, ConditionDocument? condition = null, string? source = null)
Properties
ConditionDocument? Condition { get; }
Optional ABAC condition; a failing or unresolvable condition makes the grant inapplicable.
GrantEffect Effect { get; }
Denies override allows across the whole subject, regardless of source.
Guid? OrganizationId { get; }
When set, the grant applies only when the effective organization of the check equals this org. Null means realm-wide (applies in any org context).
IReadOnlyList<Guid>? TeamIds { get; }
When set, restricts team-scoped checks to these teams (instead of the subject’s own memberships). Null means the subject’s team memberships are used.
PermissionPattern Pattern { get; }
Pattern with per-segment wildcards; evaluated at check time.
string Source { get; }
Where the grant came from (e.g. role:org-admin, group:oncall, direct). Attribution only — never consulted by evaluation, but surfaced in traces and the admin inspector.
GrantData
public sealed record GrantData : IEquatable<GrantData>
Raw grant row as stored; the builder parses pattern and condition once here.
Constructors
GrantData(string Pattern, GrantEffect Effect, Guid? OrganizationId, IReadOnlyList<Guid>? TeamIds, string? ConditionJson, string Source)
Raw grant row as stored; the builder parses pattern and condition once here.
Properties
GrantEffect Effect { get; init; }
Guid? OrganizationId { get; init; }
IReadOnlyList<Guid>? TeamIds { get; init; }
string Pattern { get; init; }
string Source { get; init; }
string? ConditionJson { get; init; }
GrantEffect
public enum GrantEffect
Provides the base class for enumerations.
Values
AllowDeny
ISubjectDataSource
public interface ISubjectDataSource
Port the store adapters implement to feed snapshot building.
Methods
ValueTask<SubjectData?> LoadAsync(Guid userId, Guid? organizationId, CancellationToken cancellationToken = default(CancellationToken))
Null when the user does not exist, is not active, or is not a member of the requested org.
SubjectData
public sealed record SubjectData : IEquatable<SubjectData>
Everything the snapshot builder needs about one (user, org) pair, already joined by the store adapter: direct grants plus every grant reachable through role assignments (user-, group-, and team-principal), with role/group/team provenance flattened into GrantData.Source. The store does the joining because that is one query there and N queries here.
Constructors
SubjectData(Guid UserId, Guid RealmId, Guid? OrganizationId, IReadOnlyList<Guid> TeamMemberships, IReadOnlyList<GrantData> Grants, IReadOnlyDictionary<string, object?>? Attributes)
Everything the snapshot builder needs about one (user, org) pair, already joined by the store adapter: direct grants plus every grant reachable through role assignments (user-, group-, and team-principal), with role/group/team provenance flattened into GrantData.Source. The store does the joining because that is one query there and N queries here.
Properties
Guid RealmId { get; init; }
Guid UserId { get; init; }
Guid? OrganizationId { get; init; }
IReadOnlyDictionary<string, object?>? Attributes { get; init; }
IReadOnlyList<GrantData> Grants { get; init; }
IReadOnlyList<Guid> TeamMemberships { get; init; }
SubjectSnapshot
public sealed class SubjectSnapshot
The compiled authorization view of one subject in one organization context — snapshots are per-(subject, org) because org membership is many-to-many.
Everything the evaluator needs is flattened in here: roles, group memberships, and policies have already been compiled to SubjectSnapshot.Grants by the snapshot builder. For API-key principals the grants are already intersected with the key’s scopes and the owner’s current snapshot — the evaluator never knows the difference.
Constructors
SubjectSnapshot(Guid subjectId, Guid realmId, Guid? organizationId, IReadOnlyList<Guid> teamMemberships, IReadOnlyList<Grant> grants, IReadOnlyDictionary<string, object?>? attributes = null)
Properties
Guid RealmId { get; }
Guid SubjectId { get; }
Guid? OrganizationId { get; }
The org context this snapshot was minted for; null for org-less (realm-level) tokens.
IReadOnlyDictionary<string, object?> Attributes { get; }
Subject ABAC attributes, addressable as subject.* in conditions.
IReadOnlyList<Grant> Grants { get; }
IReadOnlyList<Guid> TeamMemberships { get; }
Team memberships within SubjectSnapshot.OrganizationId. Always populated (fixes the Node bug of hardcoded-empty team context).
SubjectSnapshotBuilder
public static class SubjectSnapshotBuilder
Compiles stored grant rows into an evaluable SubjectSnapshot. Parsing happens once per snapshot build, never per check. Rows with an invalid pattern or condition are SKIPPED and reported — a malformed allow silently widening to nothing is safe, and a malformed deny must page an operator rather than silently not-denying, which is why the callback exists instead of a silent drop.
Methods
static SubjectSnapshot Build(SubjectData data, Action<GrantData, Exception>? onInvalidGrant = null)
SubjectSnapshotCache
public sealed class SubjectSnapshotCache : IDisposable
Bounded per-node snapshot cache (the Node version’s unbounded 60s-TTL map is exactly what this replaces). TTL + max-size with LRU-ish eviction (least-recently-written sampling: on overflow the oldest-stamped eighth of entries is evicted; cheap, allocation-light, and good enough because the TTL already bounds staleness). Subscribes to the cache bus so admin mutations invalidate fleet-wide.
Constructors
SubjectSnapshotCache(ISubjectDataSource source, ISentinelClock clock, ISentinelCacheBus bus, TimeSpan? ttl = null, int maxEntries = 10000)
Methods
ValueTask<SubjectSnapshot?> GetAsync(Guid userId, Guid? organizationId, CancellationToken cancellationToken = default(CancellationToken))
void Dispose()
Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
VisibilityLevel
public enum VisibilityLevel
Provides the base class for enumerations.
Values
NoneConditionalGranted
Nuvora.Nexus.Sentinel.Authorization.Conditions
AllOfNode
public sealed class AllOfNode : ConditionNode
A node in a parsed condition tree. Parsing happens once when a grant/policy is loaded; evaluation walks the tree without allocating.
Constructors
AllOfNode(IReadOnlyList<ConditionNode> children)
Properties
IReadOnlyList<ConditionNode> Children { get; }
Methods
override bool Evaluate(IAttributeResolver attributes)
AnyOfNode
public sealed class AnyOfNode : ConditionNode
A node in a parsed condition tree. Parsing happens once when a grant/policy is loaded; evaluation walks the tree without allocating.
Constructors
AnyOfNode(IReadOnlyList<ConditionNode> children)
Properties
IReadOnlyList<ConditionNode> Children { get; }
Methods
override bool Evaluate(IAttributeResolver attributes)
ComparisonNode
public sealed class ComparisonNode : ConditionNode
A comparison leaf: { "op": "...", "attr": "bag.path", "value": <literal or ref> }.
Semantics (pinned by golden vectors — change only with a vector update):
eq/ne— ordinal for strings, numeric for numbers (all numbers are doubles), value for bools; null equals null.in— right side must be a list; true when it contains the attribute value (eq semantics per element).contains— attribute list contains the value, or attribute string contains the value substring (ordinal).gt/gte/lt/lte— numbers only; any non-number operand is false (never throws at evaluation time).exists— the attribute path resolves (value may be null); takes no ‘value’.- A missing attribute makes every comparison false, including
ne— absent is “unknown”, not “different”. Fail-closed.
Properties
ConditionOp Op { get; }
ConditionValue? Value { get; }
Null only for ConditionOp.Exists.
string AttributePath { get; }
Methods
override bool Evaluate(IAttributeResolver attributes)
ConditionDocument
public sealed class ConditionDocument
A versioned ABAC condition document: a boolean tree of allOf/anyOf/not combinators over comparison leaves, evaluated against subject.*, resource.*, and context.* attributes.
Wire format (version 1):
{ "version": 1,
"condition": {
"allOf": [
{ "op": "eq", "attr": "resource.department", "value": { "ref": "subject.department" } },
{ "op": "in", "attr": "subject.clearance", "value": ["secret", "top-secret"] }
] } }
A comparison value is either a JSON literal (scalar or array of scalars) or an attribute reference {"ref": "bag.path"}. Object literals are deliberately not supported as values, which is what makes the ref marker unambiguous. Comparison semantics are pinned by the cross-language golden vectors; the TypeScript client evaluates the same documents and must agree case by case.
Properties
ConditionNode Root { get; }
int Version { get; }
Methods
bool Evaluate(IAttributeResolver attributes)
Evaluates the condition. Missing attributes make any comparison false (including ne — an absent attribute is “unknown”, not “different”); only exists reports on absence directly. This conservative rule means a mistyped attribute path fails closed instead of silently granting.
static ConditionDocument Parse(string json)
Fields
const int CurrentVersion = 1
ConditionFormatException
public sealed class ConditionFormatException : FormatException
The exception that is thrown when the format of an argument is invalid, or when a composite format string is not well formed.
Constructors
ConditionFormatException(string message)
ConditionNode
public abstract class ConditionNode
A node in a parsed condition tree. Parsing happens once when a grant/policy is loaded; evaluation walks the tree without allocating.
Methods
abstract bool Evaluate(IAttributeResolver attributes)
ConditionOp
public enum ConditionOp
Provides the base class for enumerations.
Values
EqNeInContainsGtGteLtLteExists
ConditionValue
public sealed class ConditionValue
The right-hand side of a comparison: a JSON literal (scalar or array of scalars) or an attribute reference {"ref": "bag.path"}. Literals are converted to the condition value model once at parse time; numbers become doubles so both language implementations share comparison semantics.
Properties
bool IsReference { get; }
object? Literal { get; }
string? ReferencePath { get; }
Methods
bool TryResolve(IAttributeResolver attributes, out object? value)
ConditionValues
public static class ConditionValues
Comparison primitives over the condition value model. Kept in one place because every rule here is mirrored in the TypeScript client and pinned by golden vectors.
Methods
static bool AreEqual(object? left, object? right)
static bool CompareNumbers(object? left, object? right, out int comparison)
static bool Contains(object? container, object? value)
static bool ListContains(object? list, object? value)
static bool TryToDouble(object? value, out double result)
Attribute bags may carry any .NET numeric type (EF materialization, JSON parsing, and host code all differ); everything funnels to double before comparison so the semantics match the TypeScript client’s number type.
IAttributeResolver
public interface IAttributeResolver
Resolves dotted attribute paths (subject.department, resource.owner.region, context.ip_country) to values during condition evaluation.
Resolved values use the condition value model: null, Boolean, String, Double (all numbers normalize to double so .NET and the TypeScript client agree on comparison semantics), or IReadOnlyList<object?> of those scalars.
Methods
bool TryResolve(string path, out object? value)
Returns false when the path does not resolve; a resolved-but-null value returns true.
NotNode
public sealed class NotNode : ConditionNode
A node in a parsed condition tree. Parsing happens once when a grant/policy is loaded; evaluation walks the tree without allocating.
Constructors
NotNode(ConditionNode inner)
Properties
ConditionNode Inner { get; }
Methods
override bool Evaluate(IAttributeResolver attributes)
SentinelJsonValues
public static class SentinelJsonValues
Boundary normalization from raw JSON to the condition value model: null / bool / string / double / object?[] / nested Dictionary<string, object?>.
The condition evaluator (ConditionValues) is strictly typed and fails closed — a JsonElement smuggled into an attribute bag compares equal to nothing, so every ABAC condition over it silently denies. Any store or transport that round-trips the attribute bag through JSON must normalize HERE, at the deserialization boundary, before values reach SubjectData.Attributes or a check’s resource/context bags. The evaluation hot path deliberately does NOT re-normalize (no defensive conversion per check); normalization is a boundary contract, not a runtime fallback.
Methods
static Dictionary<string, object?> NormalizeBag(JsonElement bag)
Normalizes a JSON object into a mutable attribute bag (ordinal keys).
static object? Normalize(JsonElement element)
Converts one JSON value exactly like condition literals parse: numbers → double (both language implementations share comparison semantics), arrays → object?[], objects → nested string-keyed dictionaries. Total — never throws on any JsonValueKind; Undefined normalizes to null (fail closed).
Nuvora.Nexus.Sentinel.Definitions
AppCatalogEntry
public sealed class AppCatalogEntry
Catalog projection of an AppDefinition; same lifecycle as PermissionCatalogEntry.
Properties
DateTimeOffset FirstSeenAt { get; set; }
DateTimeOffset LastSyncedAt { get; set; }
IReadOnlyList<string> Modules { get; set; }
bool Retired { get; set; }
required string AppKey { get; set; }
The app key — the natural key of the app catalog.
string DisplayName { get; set; }
string OwnerService { get; set; }
AppDefinition
public sealed record AppDefinition : IEquatable<AppDefinition>
An app (client surface) declared in code with its modules. Same declare-and-sync lifecycle as PermissionDefinition; the key is what OAuth clients and per-app role scoping reference.
Constructors
AppDefinition(string key, string displayName, string ownerService, IEnumerable<string>? modules = null)
Properties
IReadOnlyList<string> Modules { get; }
Module keys within the app, in declaration order.
string DisplayName { get; }
string Key { get; }
string OwnerService { get; }
DefinitionOwnershipException
public sealed class DefinitionOwnershipException : InvalidOperationException
Thrown by DefinitionSyncService.SyncAsync when a declared permission or app id is owned by a different service in the catalog (two-way ownership-conflict detection). This is a boot-time failure by design: silently reassigning ownership would let one deploy hijack another service’s permission semantics.
Constructors
DefinitionOwnershipException(IReadOnlyList<OwnershipConflict> conflicts)
Thrown by DefinitionSyncService.SyncAsync when a declared permission or app id is owned by a different service in the catalog (two-way ownership-conflict detection). This is a boot-time failure by design: silently reassigning ownership would let one deploy hijack another service’s permission semantics.
Properties
IReadOnlyList<OwnershipConflict> Conflicts { get; }
DefinitionSyncResult
public sealed record DefinitionSyncResult : IEquatable<DefinitionSyncResult>
What one DefinitionSyncService.SyncAsync pass changed. All-zero counts (DefinitionSyncResult.IsNoOp) is the expected steady state for every boot after a deploy.
Constructors
DefinitionSyncResult(string CatalogHash, int PermissionsInserted, int PermissionsUpdated, int PermissionsRetired, int AppsInserted, int AppsUpdated, int AppsRetired)
What one DefinitionSyncService.SyncAsync pass changed. All-zero counts (DefinitionSyncResult.IsNoOp) is the expected steady state for every boot after a deploy.
Properties
bool IsNoOp { get; }
int AppsInserted { get; init; }
int AppsRetired { get; init; }
int AppsUpdated { get; init; }
int PermissionsInserted { get; init; }
int PermissionsRetired { get; init; }
int PermissionsUpdated { get; init; }
string CatalogHash { get; init; }
DefinitionSyncService
public sealed class DefinitionSyncService
Reconciles code-declared definitions against the catalog tables at boot: inserts new entries, updates changed metadata, retires what this service stopped declaring (retire, never delete — grants, audit rows, and other services may still reference the id), and refuses to run at all on cross-service ownership conflicts. Re-running against an unchanged declaration is a strict no-op (idempotent boots).
Constructors
DefinitionSyncService(IDefinitionCatalogStore store, ISentinelClock clock)
Reconciles code-declared definitions against the catalog tables at boot: inserts new entries, updates changed metadata, retires what this service stopped declaring (retire, never delete — grants, audit rows, and other services may still reference the id), and refuses to run at all on cross-service ownership conflicts. Re-running against an unchanged declaration is a strict no-op (idempotent boots).
Methods
Task<DefinitionSyncResult> SyncAsync(SentinelDefinitions declared, string thisService, CancellationToken cancellationToken = default(CancellationToken))
Runs one reconciliation pass for thisService’s declarations. Ownership conflicts (either direction: this service claiming another’s id, or re-claiming an id the catalog assigns elsewhere) throw DefinitionOwnershipException before any write happens, so a conflicting deploy leaves the catalog untouched.
Task<IReadOnlyList<string>> ValidateUsageAsync(IEnumerable<string> usedPermissionIds, CancellationToken cancellationToken = default(CancellationToken))
The boot-fail hook: given every permission id a host actually uses (route metadata, [RequirePermission] attributes), returns the ids that are neither declared in the last-synced definitions nor active (non-retired) in the catalog. A non-empty result means the host references unpublished permissions and must refuse to boot (fail-closed, Relay spirit). Call after DefinitionSyncService.SyncAsync so this service’s own fresh declarations count.
IDefinitionCatalogStore
public interface IDefinitionCatalogStore
Persistence port for the permission/app catalog tables. Only DefinitionSyncService writes through it; everything else treats the catalog as read-only reference data.
Methods
ValueTask MarkPermissionsRetiredAsync(IEnumerable<string> permissionIds, DateTimeOffset when, CancellationToken cancellationToken = default(CancellationToken))
Marks the given permission ids retired (tombstoned, never deleted), stamping PermissionCatalogEntry.LastSyncedAt with when. Unknown ids are ignored.
ValueTask UpsertAppsAsync(IEnumerable<AppCatalogEntry> entries, CancellationToken cancellationToken = default(CancellationToken))
Inserts or replaces entries by AppCatalogEntry.AppKey.
ValueTask UpsertPermissionsAsync(IEnumerable<PermissionCatalogEntry> entries, CancellationToken cancellationToken = default(CancellationToken))
Inserts or replaces entries by PermissionCatalogEntry.PermissionId.
ValueTask<IReadOnlyList<AppCatalogEntry>> LoadAppsAsync(CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<PermissionCatalogEntry>> LoadPermissionsAsync(CancellationToken cancellationToken = default(CancellationToken))
InMemoryDefinitionCatalogStore
public sealed class InMemoryDefinitionCatalogStore : IDefinitionCatalogStore
In-memory IDefinitionCatalogStore for tests and single-box deployments. Entries are cloned on write and on read so callers mutating loaded instances can’t accidentally write through to the “database” — the same isolation a SQL adapter gives, which keeps sync idempotency tests honest.
Methods
ValueTask MarkPermissionsRetiredAsync(IEnumerable<string> permissionIds, DateTimeOffset when, CancellationToken cancellationToken = default(CancellationToken))
Marks the given permission ids retired (tombstoned, never deleted), stamping PermissionCatalogEntry.LastSyncedAt with when. Unknown ids are ignored.
ValueTask UpsertAppsAsync(IEnumerable<AppCatalogEntry> entries, CancellationToken cancellationToken = default(CancellationToken))
Inserts or replaces entries by AppCatalogEntry.AppKey.
ValueTask UpsertPermissionsAsync(IEnumerable<PermissionCatalogEntry> entries, CancellationToken cancellationToken = default(CancellationToken))
Inserts or replaces entries by PermissionCatalogEntry.PermissionId.
ValueTask<IReadOnlyList<AppCatalogEntry>> LoadAppsAsync(CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<PermissionCatalogEntry>> LoadPermissionsAsync(CancellationToken cancellationToken = default(CancellationToken))
OwnershipConflict
public readonly record struct OwnershipConflict : IEquatable<OwnershipConflict>
One conflicting id with both claimed owners; OwnershipConflict.Id is a permission id or app key.
Constructors
OwnershipConflict(string Id, string DeclaredOwner, string CatalogOwner)
One conflicting id with both claimed owners; OwnershipConflict.Id is a permission id or app key.
Properties
string CatalogOwner { get; init; }
string DeclaredOwner { get; init; }
string Id { get; init; }
PermissionCatalogEntry
public sealed class PermissionCatalogEntry
Catalog projection of a PermissionDefinition. Rows are only ever written by definition sync; PermissionCatalogEntry.Retired is the tombstone state — retired rather than deleted because grants, audit entries, and other services’ declarations may still reference the id.
Properties
DateTimeOffset FirstSeenAt { get; set; }
DateTimeOffset LastSyncedAt { get; set; }
bool Retired { get; set; }
True once the owner stops declaring the id. Retired ids fail usage validation.
required string PermissionId { get; set; }
The full service:scope:action id — the natural key of the catalog.
string DisplayName { get; set; }
string OwnerService { get; set; }
The declaring service; sync refuses to let any other service redefine the id.
string? Description { get; set; }
PermissionDefinition
public sealed record PermissionDefinition : IEquatable<PermissionDefinition>
A permission declared in code: the source-of-truth descriptor a service ships with its binary. The catalog tables are a projection of these declarations, reconciled at boot by DefinitionSyncService — never edited by hand.
The id is validated eagerly with PermissionId.TryParse: a malformed declared id should fail at construction — i.e. at boot — not when the first check silently never matches.
Constructors
PermissionDefinition(string id, string displayName, string ownerService, string? description = null)
Properties
string DisplayName { get; }
string Id { get; }
The full service:scope:action permission id.
string OwnerService { get; }
The service that declares (and therefore owns) this permission — the ownership-conflict axis.
string? Description { get; }
SentinelDefinitions
public sealed class SentinelDefinitions
The complete set of permissions and apps one service declares. Immutable once built; SentinelDefinitions.CatalogHash is the canonical hash the sync service compares against the catalog to short-circuit no-op boots, and it is deterministic — entries are sorted by id/key before hashing, so declaration order (and re-ordering refactors) never changes it.
Constructors
SentinelDefinitions(IEnumerable<PermissionDefinition> permissions, IEnumerable<AppDefinition> apps)
Properties
IReadOnlyList<AppDefinition> Apps { get; }
Declared apps, sorted by key (ordinal).
IReadOnlyList<PermissionDefinition> Permissions { get; }
Declared permissions, sorted by id (ordinal).
string CatalogHash { get; }
Lowercase hex sha256 over the sorted, serialized entries — the canonical catalog hash.
Nuvora.Nexus.Sentinel.Identity
ApiKey
public sealed class ApiKey
A personal API key / PAT: an snt_-prefixed opaque token owned by a human. Effective permissions are the owner’s current snapshot ∩ the key’s scopes, denies preserved, recomputed at use time — a demoted owner’s keys shrink automatically, and an owner’s deny can never be escaped through a key. The request context separates subjectId (this key’s id, for authorization context) from credentialOwner (ApiKey.OwnerUserId, for attribution).
Properties
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset? ExpiresAt { get; set; }
Null = non-expiring (revocation is the only kill switch then).
DateTimeOffset? LastUsedAt { get; set; }
Best-effort use timestamp for the key-management UI; updated fire-and-forget, never load-bearing.
DateTimeOffset? RevokedAt { get; set; }
Guid Id { get; set; }
Guid OwnerUserId { get; set; }
The human whose current snapshot caps this key — attribution’s credentialOwner.
Guid RealmId { get; set; }
Guid? OrganizationId { get; set; }
Org context the key acts in; null = the owner’s realm-wide (org-less) context.
List<string> Scopes { get; set; }
Permission PATTERNS in the PermissionPattern grammar the key is scoped to; validated at creation, intersected with the owner’s snapshot at use.
required string Prefix { get; set; }
First 12 chars of the token (snt_AbCdEfGh) for display/lookup in key-management UIs; useless for authentication.
required string TokenHash { get; set; }
SHA-256 hex of the full snt_ token — the only lookup key; the token itself is shown once and never stored.
GrantRecord
public sealed class GrantRecord
One grant row inside a role (or attached directly to a principal via GrantRecord.RoleId = null).
Properties
DateTimeOffset CreatedAt { get; set; }
GrantEffect Effect { get; set; }
Guid Id { get; set; }
Guid RealmId { get; set; }
Guid? OrganizationId { get; set; }
Optional org restriction; null = realm-wide.
Guid? PrincipalId { get; set; }
Direct-grant principal (user/service-account id); null when part of a role.
Guid? RoleId { get; set; }
The owning role, or null for a direct grant on a principal.
List<Guid>? TeamIds { get; set; }
Optional team restriction for team-scoped checks.
required string Pattern { get; set; }
Pattern text in the PermissionPattern grammar; validated on write, parsed once on snapshot build.
string? ConditionJson { get; set; }
Serialized condition document; null = unconditional.
Group
public sealed class Group
Groups aggregate role assignments and policies across org boundaries. Unlike teams they are not a resource-scoping axis — team-scoped permissions never consult groups.
Properties
DateTimeOffset CreatedAt { get; set; }
Guid Id { get; set; }
Guid RealmId { get; set; }
Guid? OrganizationId { get; set; }
Null for realm-level groups; set for org-local groups.
required string DisplayName { get; set; }
required string Key { get; set; }
GroupMember
public sealed class GroupMember
Properties
DateTimeOffset AddedAt { get; set; }
Guid GroupId { get; set; }
Guid UserId { get; set; }
IdentityProviderConfig
public sealed class IdentityProviderConfig
An external identity provider Sentinel federates INBOUND logins to: Sentinel is the relying party, the configured issuer (Google, Entra, Okta, any generic OIDC IdP) does the authenticating. Per-realm, keyed by a stable machine IdentityProviderConfig.Key that appears in login URLs (/auth/idp/{key}/start). Successful federated logins attach to users via LinkedIdentity rows; users with no link may be auto-linked by verified email or just-in-time provisioned per IdentityProviderConfig.JitMode.
Properties
DateTimeOffset CreatedAt { get; set; }
Guid Id { get; set; }
Guid RealmId { get; set; }
IdentityProviderKind Kind { get; set; }
IdentityProviderStatus Status { get; set; }
JitProvisioningMode JitMode { get; set; }
What happens when a federated login matches no existing user.
List<JitMappingRule> JitMappingRules { get; set; }
Mapping rules applied when a user IS just-in-time provisioned: each rule whose claim matches contributes an org/team membership, a role assignment, or an attribute. Applied at creation time only — steady-state membership sync is SCIM’s job, not the login path’s.
List<string> Scopes { get; set; }
Scopes requested at the authorize endpoint. The default is what the claim mapping needs and nothing more.
required string ClientId { get; set; }
The client_id Sentinel was registered as at the provider.
required string DisplayName { get; set; }
Human label for login buttons (“Sign in with …”).
required string Issuer { get; set; }
The exact iss the provider mints into id_tokens; also the OIDC discovery base.
required string Key { get; set; }
Stable machine key, unique per realm (e.g. google, acme-okta); appears in login URLs.
string ClientSecret { get; set; }
The client secret for the token-endpoint exchange. Unlike every credential Sentinel VERIFIES (password hashes, API-key digests), this one must be SENT to the provider, so it is stored reversibly — at-rest encryption is the store adapter’s job, the same posture as signing keys and TOTP secrets. Empty for providers registered without a secret (pure-PKCE public-client registrations).
string? AuthorizeEndpoint { get; set; }
Authorization endpoint. Null = resolve from {Issuer}/.well-known/openid-configuration at login time.
string? JwksUri { get; set; }
Where the provider’s signing keys live. Null = resolve from the discovery document (same convention as WorkloadTrustConfig.JwksUri).
string? TokenEndpoint { get; set; }
Token endpoint. Null = resolve from the discovery document.
IdentityProviderKind
public enum IdentityProviderKind
Provides the base class for enumerations.
Values
Oidc— OIDC authorization-code + PKCE against a generic issuer.Saml— Inbound SAML SP. Declared now so configs are forward-compatible; the flow ships withSentinel.Saml— unimplemented in this wave.
IdentityProviderStatus
public enum IdentityProviderStatus
Provides the base class for enumerations.
Values
ActiveSuspended— Administratively disabled; begin/complete both refuse while suspended, re-activation restores them.
JitMappingAction
public enum JitMappingAction
Provides the base class for enumerations.
Values
AddToOrganization— Add anOrganizationMembershiptoJitMappingRule.TargetId.AddToTeam— Add aTeamMemberrow forJitMappingRule.TargetId.AssignRole— Assign the roleJitMappingRule.TargetIdto the new user.SetAttribute— Copy the matched claim value into the user’s attribute bag underJitMappingRule.AttributeKey.
JitMappingRule
public sealed record JitMappingRule : IEquatable<JitMappingRule>
One JIT mapping rule: when the id_token claim ClaimName matches ClaimValuePattern (exact or single-* wildcard — the WorkloadWildcard semantics, shared with workload-trust rules), perform Action. TargetId is the org/team/role id for the membership/assignment actions; AttributeKey names the attribute for JitMappingAction.SetAttribute (null falls back to the claim name). Array-valued claims match when ANY string element does, mirroring workload-trust claim-rule semantics.
Constructors
JitMappingRule(string ClaimName, string ClaimValuePattern, JitMappingAction Action, Guid? TargetId = null, string? AttributeKey = null)
One JIT mapping rule: when the id_token claim ClaimName matches ClaimValuePattern (exact or single-* wildcard — the WorkloadWildcard semantics, shared with workload-trust rules), perform Action. TargetId is the org/team/role id for the membership/assignment actions; AttributeKey names the attribute for JitMappingAction.SetAttribute (null falls back to the claim name). Array-valued claims match when ANY string element does, mirroring workload-trust claim-rule semantics.
Properties
Guid? TargetId { get; init; }
JitMappingAction Action { get; init; }
string ClaimName { get; init; }
string ClaimValuePattern { get; init; }
string? AttributeKey { get; init; }
JitProvisioningMode
public enum JitProvisioningMode
Whether a federated login that matches no existing user may create one.
Values
Disabled— Unknown subjects are denied; only pre-existing (or verified-email-matched) users can log in through this provider.CreateUsers— Unknown subjects get a user created from the id_token claims, withIdentityProviderConfig.JitMappingRulesapplied.
LinkedIdentity
public sealed class LinkedIdentity
A federated identity link: this user is subject at provider.
Properties
DateTimeOffset LinkedAt { get; set; }
Guid Id { get; set; }
Guid IdentityProviderId { get; set; }
The IdP config this link came from.
Guid UserId { get; set; }
required string ProviderSubject { get; set; }
The provider-side subject identifier, verbatim.
OidcClient
public sealed class OidcClient
A registered OAuth2/OIDC relying party: the client registry entry the authorization server validates every authorize/token request against. Confidential clients hold a secret (hashed, rotatable with an overlap window — same model as service accounts); public clients (SPAs, native apps) hold nothing and MUST use PKCE.
Properties
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset? PreviousSecretExpiresAt { get; set; }
End of the rotation overlap window; after this instant only the current secret verifies.
Guid Id { get; set; }
Guid RealmId { get; set; }
List<string> AllowedScopes { get; set; }
The scopes this client may request; an authorize request for anything outside this set fails with invalid_scope.
List<string> PostLogoutRedirectUris { get; set; }
Registered post-logout redirect URIs for RP-initiated logout, exact-match like OidcClient.RedirectUris.
List<string> RedirectUris { get; set; }
Registered redirect URIs. Authorize-time matching is EXACT string equality — no prefix, substring, or wildcard matching, ever.
OidcClientStatus Status { get; set; }
OidcClientType ClientType { get; set; }
TimeSpan? AccessTokenLifetime { get; set; }
Per-client access-token lifetime override; null = the realm default from SentinelTokenOptions.
bool FirstParty { get; set; }
First-party clients skip the consent screen — the realm operator owns both sides.
bool RequireConsent { get; set; }
Per-client consent policy. Combined with OidcClient.FirstParty: consent is asked only when required AND not first-party.
required string ClientId { get; set; }
The OAuth2 client_id: a stable, realm-unique string chosen at registration.
string? Audience { get; set; }
The aud minted into access tokens for this client; null = the OidcClient.ClientId itself (app-scoped audiences).
string? BackChannelLogoutUri { get; set; }
Back-channel logout endpoint; null = this client does not receive logout tokens. Session ends POST a logout_token here (OIDC Back-Channel Logout 1.0).
string? PreviousSecretAlgorithm { get; set; }
Algorithm tag for OidcClient.PreviousSecretHash — separate because a rotation may also upgrade the algorithm.
string? PreviousSecretHash { get; set; }
Rotation-with-overlap: the pre-rotation secret, accepted until OidcClient.PreviousSecretExpiresAt.
string? SecretAlgorithm { get; set; }
Password-hasher algorithm tag (from the hasher registry) for OidcClient.SecretHash.
string? SecretHash { get; set; }
Current secret hash (confidential clients only; null for public clients). Plaintext is shown once at creation/rotation and never stored.
OidcClientStatus
public enum OidcClientStatus
Provides the base class for enumerations.
Values
ActiveSuspended— Administratively disabled: every authorize/token request fails while suspended.
OidcClientType
public enum OidcClientType
Provides the base class for enumerations.
Values
Confidential— Can keep a secret (server-side app). Authenticates at the token endpoint with client_secret_basic or client_secret_post.Public— Cannot keep a secret (SPA, native app). PKCE with S256 is mandatory; no client secret exists.
OidcConsentGrant
public sealed class OidcConsentGrant
A persisted user consent decision: the user granted OidcConsentGrant.ClientId access under OidcConsentGrant.Scopes. Authorize skips the consent screen while an unrevoked grant covers the requested scopes; profile endpoints revoke by stamping OidcConsentGrant.RevokedAt (the row is kept for audit).
Properties
DateTimeOffset GrantedAt { get; set; }
DateTimeOffset? RevokedAt { get; set; }
Set on revocation; a revoked grant never covers anything again — re-approval writes a fresh grant state.
Guid Id { get; set; }
Guid RealmId { get; set; }
Guid UserId { get; set; }
List<string> Scopes { get; set; }
Scopes granted so far; re-approval with new scopes unions into this list.
required string ClientId { get; set; }
The client’s OidcClient.ClientId string (stable across client re-registration, which is what consent should survive).
Organization
public sealed class Organization
Properties
DateTimeOffset CreatedAt { get; set; }
Guid Id { get; set; }
Guid RealmId { get; set; }
Guid? DefaultIdentityProviderId { get; set; }
The IdentityProviderConfig this org’s members should be routed to at login: discovery resolves email domain → org → this provider and suggests it to the login UI. A hint for routing, never an enforcement — null means no preference.
OrganizationStatus Status { get; set; }
required string DisplayName { get; set; }
required string Key { get; set; }
Stable machine key, unique per realm.
OrganizationDomain
public sealed class OrganizationDomain
Email-domain / subdomain login routing: maps user@acme.com or acme.sentinel-host.com to an organization during discovery.
Properties
Guid Id { get; set; }
Guid OrganizationId { get; set; }
Guid RealmId { get; set; }
OrganizationDomainKind Kind { get; set; }
bool Verified { get; set; }
Unverified domains never route logins — anyone can claim a domain string.
required string Value { get; set; }
Lowercase domain (acme.com) or subdomain label (acme), per OrganizationDomain.Kind.
OrganizationDomainKind
public enum OrganizationDomainKind
Provides the base class for enumerations.
Values
EmailDomainSubdomain
OrganizationMembership
public sealed class OrganizationMembership
Membership link user↔org. Snapshots and org-switch enumerate these.
Properties
DateTimeOffset JoinedAt { get; set; }
Guid OrganizationId { get; set; }
Guid UserId { get; set; }
OrganizationStatus
public enum OrganizationStatus
Provides the base class for enumerations.
Values
ActiveSuspendedArchived— Soft-deleted: retained for audit references, invisible everywhere else.
PasskeyCredential
public sealed class PasskeyCredential
A registered passkey / WebAuthn credential (passkeys are both a passwordless first factor and a second factor; positioning is passkeys-first). Core stores only the protocol-agnostic material: the credential id, the COSE public key, and the signature counter. All FIDO2 ceremony mechanics live in the AspNetCore package so Core stays dependency-light.
Properties
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset? LastUsedAt { get; set; }
Guid Id { get; set; }
Guid UserId { get; set; }
Guid? Aaguid { get; set; }
Authenticator model identifier (AAGUID); zero/null for anonymized attestations.
bool UvCapable { get; set; }
Whether this credential performed user verification at registration. Decides first-factor eligibility: only a UV-capable passkey may complete a passwordless login on its own — a possession-only credential is a second factor, never a first.
long SignCount { get; set; }
Last accepted signature counter. Authenticators that implement counters must strictly increase it per assertion; a regression suggests a cloned credential (clone detection). Authenticators that don’t implement counters always report 0.
required byte[] CredentialId { get; set; }
The authenticator-generated credential id, raw bytes. Unique across the store.
required byte[] PublicKey { get; set; }
COSE-encoded public key, verbatim as returned at registration.
string? Label { get; set; }
User-chosen display label (“MacBook Touch ID”) for the credential-management UI.
Realm
public sealed class Realm
The tenancy tree: Realm → Organizations → Teams, with Groups as an orthogonal role/policy-aggregation axis. These are storage-agnostic domain records; the EF Core adapter maps them 1:1. Mutable properties are deliberate — these are persistence-tracked entities, not value objects.
Properties
DateTimeOffset CreatedAt { get; set; }
Guid Id { get; set; }
bool IsDefault { get; set; }
bool IsSystem { get; set; }
System realms host Sentinel’s own operational principals and cannot be deleted.
required string DisplayName { get; set; }
required string Key { get; set; }
Stable machine key, unique per deployment (e.g. default, staging).
Role
public sealed class Role
Roles bundle grants; assignments attach them to users, groups, or teams. At snapshot-build time everything flattens to Grants — the evaluator never sees roles.
Properties
DateTimeOffset CreatedAt { get; set; }
Guid Id { get; set; }
Guid RealmId { get; set; }
Guid? OrganizationId { get; set; }
Null = realm-level role; set = org-local role.
bool IsBuiltIn { get; set; }
Built-in roles ship with Sentinel (e.g. realm-admin) and cannot be deleted.
required string DisplayName { get; set; }
required string Key { get; set; }
RoleAssignment
public sealed class RoleAssignment
Attaches a role to a principal, optionally narrowed to one org.
Properties
DateTimeOffset AssignedAt { get; set; }
Guid Id { get; set; }
Guid PrincipalId { get; set; }
User, group, or team id, per RoleAssignment.PrincipalKind.
Guid RoleId { get; set; }
Guid? OrganizationId { get; set; }
When set, the role’s grants only apply in this org context.
RolePrincipalKind PrincipalKind { get; set; }
RolePrincipalKind
public enum RolePrincipalKind
Provides the base class for enumerations.
Values
UserGroupTeamServiceAccount
SamlAttributeMapping
public sealed record SamlAttributeMapping : IEquatable<SamlAttributeMapping>
One outbound attribute mapping: the Sentinel claim ClaimName (email, name, id, or a user-attribute key) is emitted as the SAML attribute SamlAttributeName in assertions issued to the SP.
Constructors
SamlAttributeMapping(string ClaimName, string SamlAttributeName)
One outbound attribute mapping: the Sentinel claim ClaimName (email, name, id, or a user-attribute key) is emitted as the SAML attribute SamlAttributeName in assertions issued to the SP.
Properties
string ClaimName { get; init; }
string SamlAttributeName { get; init; }
SamlIdpConnection
public sealed class SamlIdpConnection
An external SAML identity provider Sentinel federates INBOUND logins to: Sentinel is the SERVICE PROVIDER, the configured IdP does the authenticating. The SAML sibling of IdentityProviderConfig — a NEW type rather than more nullable fields on the OIDC config, because the two protocols share almost no wire configuration (entity ids and a pinned certificate here; issuer/client/secret/scopes there). Per-realm, keyed by a stable machine SamlIdpConnection.Key that appears in login URLs (/auth/saml/{key}/start). Successful logins attach to users via the same LinkedIdentity rows (the connection’s SamlIdpConnection.Id plays the provider-id role, NameID the subject role), and JIT provisioning reuses JitProvisioningMode/JitMappingRule verbatim — rules match SAML attribute names instead of OIDC claim names.
Properties
DateTimeOffset CreatedAt { get; set; }
Guid Id { get; set; }
Guid RealmId { get; set; }
IdentityProviderStatus Status { get; set; }
JitProvisioningMode JitMode { get; set; }
What happens when a SAML login matches no existing user — same semantics as for OIDC identity providers.
List<JitMappingRule> JitMappingRules { get; set; }
JIT mapping rules; JitMappingRule.ClaimName names a SAML attribute here.
bool AllowIdpInitiated { get; set; }
Whether unsolicited (IdP-initiated) responses are accepted. Default FALSE: without a pending request id there is no InResponseTo binding, so the flow degrades to bearer-assertion-only — an explicit per-connection opt-in, never a default.
required string DisplayName { get; set; }
Human label for login buttons (“Sign in with …”).
required string IdpCertificatePem { get; set; }
The PINNED signature-verification certificate (PEM). Assertion signatures verify against THIS certificate and nothing else — never against certificates embedded in the incoming document, which any attacker controls (the CVE-prone XML-DSig surface: certificate substitution / signature wrapping).
required string IdpEntityId { get; set; }
The exact Issuer the IdP mints into responses/assertions.
required string IdpSsoUrl { get; set; }
The IdP’s SSO endpoint the AuthnRequest is sent to (HTTP-Redirect binding).
required string Key { get; set; }
Stable machine key, unique per realm; appears in login URLs (/auth/saml/{key}/start).
required string SpEntityId { get; set; }
OUR entity id when talking to this IdP: the AuthnRequest Issuer, and the value the assertion’s AudienceRestriction must contain.
SamlSpConnection
public sealed class SamlSpConnection
A relying SERVICE PROVIDER registered against Sentinel-as-IdP: a legacy third-party SP that SSOs against Sentinel. The SP registry entry: where assertions go (SamlSpConnection.AcsUrl), who they are for (SamlSpConnection.SpEntityId/SamlSpConnection.Audience), and which user claims they carry (SamlSpConnection.AttributeMappings).
Properties
DateTimeOffset CreatedAt { get; set; }
Guid Id { get; set; }
Guid RealmId { get; set; }
IdentityProviderStatus Status { get; set; }
List<SamlAttributeMapping> AttributeMappings { get; set; }
Claim → SAML attribute mappings applied when issuing assertions to this SP.
bool RequireSignedRequests { get; set; }
When true, AuthnRequests from this SP must carry a valid signature (verified against SamlSpConnection.SpCertificatePem). Default false: most legacy SPs do not sign.
required string AcsUrl { get; set; }
The SP’s Assertion Consumer Service URL. Responses POST here and ONLY here — a request-supplied ACS must match exactly (assertion-redirect guard).
required string SpEntityId { get; set; }
The SP’s entity id — the AuthnRequest Issuer value that routes to this registration.
string? Audience { get; set; }
AudienceRestriction value minted into assertions; null falls back to SamlSpConnection.SpEntityId.
string? SpCertificatePem { get; set; }
The SP’s request-signing certificate (PEM), for validating signed AuthnRequests. Optional; required when SamlSpConnection.RequireSignedRequests.
ScimToken
public sealed class ScimToken
A SCIM provisioning bearer token. SCIM is per-organization: the token carries its ScimToken.OrganizationId, and every resource the SCIM surface touches is fenced to that org — an upstream directory can only ever provision into the org its token was minted for. Like API keys, the token itself (sct_…) is shown exactly once at mint time and only its SHA-256 lands in ScimToken.TokenHash.
Properties
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset? ExpiresAt { get; set; }
Optional expiry; null means the token lives until revoked.
DateTimeOffset? LastUsedAt { get; set; }
Last successful authentication with this token; provisioning health UIs read it.
DateTimeOffset? RevokedAt { get; set; }
Set on revocation; a revoked token fails authentication permanently.
Guid Id { get; set; }
Guid OrganizationId { get; set; }
The one organization this token may provision into (SCIM is per-org).
Guid RealmId { get; set; }
required string Label { get; set; }
Human label for key-management UIs (e.g. "Okta prod"); never used for authentication.
required string TokenHash { get; set; }
SHA-256 hex of the full sct_ token — the only lookup key; the token itself is never stored.
ServiceAccount
public sealed class ServiceAccount
A client-credentials machine principal: a workload that authenticates with a stable key plus a secret. Secrets rotate with an overlap window — after rotation the previous secret keeps verifying until ServiceAccount.PreviousSecretExpiresAt, so a fleet can roll restarts without a hard cutover. Token minting for service accounts (the OAuth2 client-credentials grant) lands with the OIDC surface in Wave 3; this wave ships the principal, its secret lifecycle, and verification.
Properties
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset? PreviousSecretExpiresAt { get; set; }
End of the rotation overlap window; after this instant only the current secret verifies.
Guid Id { get; set; }
Guid RealmId { get; set; }
Guid? OrganizationId { get; set; }
Null = realm-level service account; set = org-local.
ServiceAccountStatus Status { get; set; }
required string DisplayName { get; set; }
required string Key { get; set; }
Stable machine key (e.g. ci-deployer), unique per realm — the client-id half of the credential pair.
required string SecretAlgorithm { get; set; }
Password-hasher algorithm tag (from the hasher registry) used for ServiceAccount.SecretHash.
required string SecretHash { get; set; }
Current secret, hashed with ServiceAccount.SecretAlgorithm — plaintext is shown once at creation/rotation and never stored.
string? PreviousSecretAlgorithm { get; set; }
Algorithm tag for ServiceAccount.PreviousSecretHash — carried separately because a rotation may also upgrade the hash algorithm.
string? PreviousSecretHash { get; set; }
Rotation-with-overlap: the pre-rotation secret, still accepted until ServiceAccount.PreviousSecretExpiresAt.
ServiceAccountStatus
public enum ServiceAccountStatus
Provides the base class for enumerations.
Values
ActiveSuspended— Administratively disabled; secret verification fails while suspended, re-activation restores it.Revoked— Terminal: retained for audit references, never verifies again.
Session
public sealed class Session
A realm-level session: created at first-factor completion, app-agnostic (access tokens are app-scoped via aud, the session is not). Powers the device list, remote logout, and idle/absolute timeout enforcement.
Properties
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset ExpiresAt { get; set; }
Absolute end-of-life; idle timeout is evaluated against Session.LastSeenAt.
DateTimeOffset LastSeenAt { get; set; }
DateTimeOffset? RevokedAt { get; set; }
Guid Id { get; set; }
Guid RealmId { get; set; }
Guid UserId { get; set; }
Guid? OrganizationId { get; set; }
Org context the session was last minted for; org-switch updates this.
SessionMfaLevel MfaLevel { get; set; }
Highest MFA level satisfied in this session (drives step-up decisions).
string? DeviceDescription { get; set; }
Device descriptor for the sessions page (“Chrome on macOS, Bucharest”) — display only, never trusted.
string? DeviceFingerprint { get; set; }
Stable device fingerprint hash for risk signals; null when unavailable.
string? IpAddress { get; set; }
SessionMfaLevel
public enum SessionMfaLevel
Provides the base class for enumerations.
Values
None— Single factor only.Mfa— A second factor was verified (TOTP, email OTP, recovery code).PhishingResistant— A phishing-resistant factor was used (passkey with user verification).
Team
public sealed class Team
Properties
DateTimeOffset CreatedAt { get; set; }
Guid Id { get; set; }
Guid OrganizationId { get; set; }
Guid RealmId { get; set; }
required string DisplayName { get; set; }
required string Key { get; set; }
TeamMember
public sealed class TeamMember
Properties
DateTimeOffset AddedAt { get; set; }
Guid TeamId { get; set; }
Guid UserId { get; set; }
string? TeamRole { get; set; }
Optional per-member role key within the team (e.g. lead); grants may key off it later.
User
public sealed class User
Users and their credentials. A user belongs to exactly one realm and to any number of organizations through OrganizationMembership (multi-org is the deliberate departure from the Node version’s one-org constraint).
Properties
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset? LastLoginAt { get; set; }
Dictionary<string, object?> Attributes { get; set; }
ABAC attribute bag, addressable as subject.* in conditions.
Guid Id { get; set; }
Guid RealmId { get; set; }
UserStatus Status { get; set; }
bool EmailVerified { get; set; }
required string Email { get; set; }
Lowercase; unique per realm.
string? DisplayName { get; set; }
string? Locale { get; set; }
Preferred locale for user-facing strings, e.g. "de" or "es-MX". Null means “no stated preference” — mail rendering falls back to the host’s SentinelLocalizationOptions.DefaultLocale. Stored verbatim; clamping to the supported set happens at render time, so widening the supported locales later needs no data migration.
string? ScimExternalId { get; set; }
SCIM externalId: the provisioning system’s own identifier for this user, stored verbatim, echoed back on SCIM resources and filterable via externalId eq "…". A single column, not per-org: in practice a user is provisioned by at most one upstream directory. Null for users that were never SCIM-provisioned.
UserCredential
public sealed class UserCredential
A password (or imported foreign-hash) credential. The UserCredential.Algorithm tag is what lets bcrypt/PBKDF2/argon2 imports coexist: verification dispatches on the tag and successful logins transparently rehash to the current default.
Properties
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset? RotatedAt { get; set; }
Guid Id { get; set; }
Guid UserId { get; set; }
required string Algorithm { get; set; }
e.g. argon2id, bcrypt, pbkdf2-sha256, aspnet-identity-v3.
required string Hash { get; set; }
Encoded hash in the algorithm’s native text format (PHC string where applicable).
UserStatus
public enum UserStatus
Provides the base class for enumerations.
Values
ActivePending— Invited but not yet activated; can only complete invitation flows.Suspended— Administratively disabled; all authentication fails, sessions are revoked.Deactivated— Soft-deleted: retained for audit references; PII subject to crypto-shredding.
WorkloadClaimRule
public sealed record WorkloadClaimRule : IEquatable<WorkloadClaimRule>
One claim constraint of a WorkloadTrustConfig: the named claim must match the required value (exact or single-* wildcard).
Constructors
WorkloadClaimRule(string ClaimName, string RequiredValue)
One claim constraint of a WorkloadTrustConfig: the named claim must match the required value (exact or single-* wildcard).
Properties
string ClaimName { get; init; }
string RequiredValue { get; init; }
WorkloadTrustConfig
public sealed class WorkloadTrustConfig
A workload identity federation trust: the declaration that OIDC tokens minted by one external issuer (a Kubernetes cluster, GitHub Actions, a cloud’s managed-identity system), for one expected audience, whose sub and additional claims match this configuration, may be exchanged for Sentinel access tokens acting as WorkloadTrustConfig.ServiceAccountId — secretless CI/CD: the workload proves itself with its platform-issued token, and no Sentinel secret ever lives in the pipeline.
Properties
DateTimeOffset CreatedAt { get; set; }
Guid Id { get; set; }
Guid RealmId { get; set; }
Guid ServiceAccountId { get; set; }
The service account the exchanged tokens act as — the trust maps an external workload onto an existing machine principal.
List<WorkloadClaimRule> ClaimRules { get; set; }
Additional claim constraints, ALL of which must hold (e.g. repository=nuvoralabs/*, ref=refs/heads/master for a GitHub Actions trust pinned to one org’s default branches). An empty list constrains nothing beyond issuer/audience/subject.
WorkloadTrustStatus Status { get; set; }
required string Audience { get; set; }
The aud the external token must carry — the value the workload was told to request its token FOR (never Sentinel’s own API audience).
required string Issuer { get; set; }
The external token’s exact iss (e.g. https://token.actions.githubusercontent.com); trusts are looked up by it.
required string SubjectPattern { get; set; }
Pattern the external token’s sub must match: an exact string or a single-* wildcard (e.g. repo:nuvoralabs/*, system:serviceaccount:ci:*) — see WorkloadWildcard for the exact semantics.
string? JwksUri { get; set; }
Where the issuer’s public keys live. Null = discover it from {Issuer}/.well-known/openid-configuration at exchange time; set it explicitly for issuers without a discovery document (bare JWKS endpoints).
WorkloadTrustStatus
public enum WorkloadTrustStatus
Provides the base class for enumerations.
Values
ActiveSuspended— Administratively disabled; exchanges fail while suspended, re-activation restores them.Revoked— Terminal: retained for audit references, never exchanges again.
WorkloadWildcard
public static class WorkloadWildcard
The wildcard matcher for subject patterns and claim rules — deliberately the same semantics as PermissionPattern.SegmentMatches: a pattern is either an exact string (ordinal comparison) or contains a single * matching any run of characters, where pre*suf must not double-count overlapping characters (a*a does not match a). A pattern with more than one * is invalid and matches NOTHING — conservative by design: a malformed trust row can never widen, only narrow.
Methods
static bool Matches(ReadOnlySpan<char> pattern, ReadOnlySpan<char> candidate)
static bool Matches(string pattern, string candidate)
Nuvora.Nexus.Sentinel.Impersonation
BreakGlassCappingDataSource
public sealed class BreakGlassCappingDataSource : ISubjectDataSource
ISubjectDataSource decorator that caps break-glass subjects’ grants to the policy’s fixed pattern set — registered in DI wrapping the real source, so the cap applies at SNAPSHOT BUILD TIME, before any evaluation. That placement is the point: every consumer of subject data (login-time snapshot stash, the snapshot cache, admin evaluation, API-key owner snapshots) flows through the one data source, so there is no code path where a break-glass account’s stored grants evaluate uncapped — structurally the same fence philosophy as the resolve-then-evaluate authorization pass, applied to break-glass’s “grants capped to a fixed set”.
Semantics mirror the API-key scope intersection exactly (and reuse its MachineAuthService.TryIntersect): every DENY row is preserved verbatim; each ALLOW row survives only as its pattern-intersection with some capped pattern; overlapping-but- incomparable wildcards drop the pair — conservative, the cap may under-allow but never over-allow. Non-break-glass subjects pass through untouched.
Constructors
BreakGlassCappingDataSource(ISubjectDataSource inner, BreakGlassPolicy policy)
ISubjectDataSource decorator that caps break-glass subjects’ grants to the policy’s fixed pattern set — registered in DI wrapping the real source, so the cap applies at SNAPSHOT BUILD TIME, before any evaluation. That placement is the point: every consumer of subject data (login-time snapshot stash, the snapshot cache, admin evaluation, API-key owner snapshots) flows through the one data source, so there is no code path where a break-glass account’s stored grants evaluate uncapped — structurally the same fence philosophy as the resolve-then-evaluate authorization pass, applied to break-glass’s “grants capped to a fixed set”. Semantics mirror the API-key scope intersection exactly (and reuse its MachineAuthService.TryIntersect): every DENY row is preserved verbatim; each ALLOW row survives only as its pattern-intersection with some capped pattern; overlapping-but- incomparable wildcards drop the pair — conservative, the cap may under-allow but never over-allow. Non-break-glass subjects pass through untouched.
Methods
ValueTask<SubjectData?> LoadAsync(Guid userId, Guid? organizationId, CancellationToken cancellationToken = default(CancellationToken))
Null when the user does not exist, is not active, or is not a member of the requested org.
BreakGlassPolicy
public sealed class BreakGlassPolicy
Break-glass policy. Break-glass ACCOUNTS are ordinary users flagged with the BreakGlassPolicy.BreakGlassAttribute attribute — no separate principal table, so every existing flow (login, MFA, sessions, audit) applies to them unchanged; the policy layers caps and alarms on top.
Properties
List<string> CappedPatterns { get; set; }
The fixed cap: a break-glass subject’s effective grants are the intersection of their stored grants with these patterns — however broad the stored grants, nothing outside this set ever evaluates as allowed. Default: Sentinel realm administration only.
List<string> OperatorEmails { get; set; }
Operators alerted (via ISentinelMailer security_alert) on every break-glass login.
int DrillIntervalDays { get; set; }
Drill cadence: the health check degrades when no drill happened within this window.
int MaxSessionMinutes { get; set; }
Declared maximum break-glass session length. v1 records the policy for operators and hosts; session-store enforcement composes in a later wave (LoginService owns session creation and is deliberately not modified here).
Fields
const string BreakGlassAttribute = "sentinel:break_glass"
User attribute (in User.Attributes) that flags a break-glass account: sentinel:break_glass=true.
BreakGlassService
public sealed class BreakGlassService
Break-glass orchestration: alert-on-login, mandatory-rotation signalling, and the drill health surface. Grant capping is NOT here — it happens structurally at snapshot-build time via BreakGlassCappingDataSource, so no login-path hook could ever be skipped to escape the cap.
Constructors
BreakGlassService(IBreakGlassStateStore state, AuditService audit, ISentinelEventSink events, ISentinelMailer mailer, ISentinelClock clock, BreakGlassPolicy policy)
Break-glass orchestration: alert-on-login, mandatory-rotation signalling, and the drill health surface. Grant capping is NOT here — it happens structurally at snapshot-build time via BreakGlassCappingDataSource, so no login-path hook could ever be skipped to escape the cap.
Methods
ValueTask HandleLoginAsync(User user, string? ip = null, bool isDrill = false, CancellationToken cancellationToken = default(CancellationToken))
Handles a successful break-glass login: user-visible breakglass.login event, sink emission (webhook-worthy), a security_alert mail to every configured operator, and the mandatory-rotation signal — a breakglass.rotation_required event pair that a host’s credential-rotation runbook keys off (v1’s honest form of “mandatory rotation after use”: the requirement is recorded loudly and durably; automated rotation composes on top). A drill (isDrill) additionally refreshes the drill clock.
ValueTask<BreakGlassStatus> GetStatusAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
The drill health surface: stale when no drill within BreakGlassPolicy.DrillIntervalDays.
ValueTask<BreakGlassStatus> MarkDrillAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Marks a drill as completed for the realm. Backs the admin drill-marker endpoint: after exercising the break-glass account, an operator (or the break-glass session itself — its capped grants cover sentinel:global:*) stamps the drill clock.
static bool IsBreakGlassUser(IReadOnlyDictionary<string, object?>? attributes)
True when the attribute bag flags a break-glass account. Attributes may arrive as CLR bool/string (in-memory stores) or JsonElement (JSON-column round trips through the EF adapter), so all three encodings of “true” are honored.
BreakGlassState
public sealed class BreakGlassState
Persisted per-realm break-glass state — the two timestamps the drill health check reads.
Properties
DateTimeOffset? LastDrillAt { get; set; }
DateTimeOffset? LastUseAt { get; set; }
Guid RealmId { get; set; }
BreakGlassStatus
public sealed record BreakGlassStatus : IEquatable<BreakGlassStatus>
Per-realm break-glass health surface: drill recency and last real use.
Constructors
BreakGlassStatus(Guid RealmId, DateTimeOffset? LastDrillAt, DateTimeOffset? LastUseAt, bool DrillStale, int DrillIntervalDays)
Per-realm break-glass health surface: drill recency and last real use.
Properties
DateTimeOffset? LastDrillAt { get; init; }
DateTimeOffset? LastUseAt { get; init; }
Guid RealmId { get; init; }
bool DrillStale { get; init; }
int DrillIntervalDays { get; init; }
IBreakGlassStateStore
public interface IBreakGlassStateStore
Store port for BreakGlassState (same posture as the other ports: narrow, authorization-free).
Methods
ValueTask SetLastDrillAsync(Guid realmId, DateTimeOffset at, CancellationToken cancellationToken = default(CancellationToken))
ValueTask SetLastUseAsync(Guid realmId, DateTimeOffset at, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<BreakGlassState?> GetAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
IImpersonationStore
public interface IImpersonationStore
Persistence port for impersonation sessions. Authorization-free by contract — ImpersonationService is the only caller and runs the resolve-target → evaluate-caller pass first.
Methods
ValueTask CreateAsync(ImpersonationRecord record, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateAsync(ImpersonationRecord record, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<ImpersonationRecord?> FindCurrentForActorAsync(Guid actorUserId, DateTimeOffset now, CancellationToken cancellationToken = default(CancellationToken))
The actor’s current (pending-consent or active) impersonation, or null. This is the one-active-per-actor query: records that are Ended or past their ImpersonationRecord.ExpiresAt never count — auto-expiry is a read-side filter, not a background write.
ValueTask<ImpersonationRecord?> GetAsync(Guid id, CancellationToken cancellationToken = default(CancellationToken))
ImpersonationRecord
public sealed class ImpersonationRecord
One impersonation session: actor, target, why, and the time box. Time-boxed by ImpersonationRecord.ExpiresAt — while ImpersonationRecord.Status is ImpersonationStatus.PendingConsent that is the consent window’s end, once ImpersonationStatus.Active it is the impersonation window’s end. A record past its ImpersonationRecord.ExpiresAt is treated as expired everywhere without a write (auto-expiry): tokens minted for it carry an exp capped to it, and IImpersonationStore.FindCurrentForActorAsync filters it out.
Properties
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset ExpiresAt { get; set; }
End of the current window — consent window while pending, impersonation window once active.
DateTimeOffset? EndedAt { get; set; }
Set on explicit end (actor called end, or the flow was superseded); null for auto-expiry.
DateTimeOffset? StartedAt { get; set; }
When the impersonation became active; null while consent is pending.
Guid ActorUserId { get; set; }
The admin doing the impersonating — the act.sub of minted tokens.
Guid Id { get; set; }
Guid RealmId { get; set; }
Guid TargetUserId { get; set; }
The user being impersonated — the sub of minted tokens.
Guid? OrganizationId { get; set; }
Org context the impersonation was authorized in: the org whose sentinel:org:impersonate granted it, or the explicitly requested context. Null for realm-level (global) impersonation. Minted tokens carry it as org.
ImpersonationStatus Status { get; set; }
required string Reason { get; set; }
Free-text justification, mandatory at start — it lands on the admin audit chain.
ImpersonationService
public sealed class ImpersonationService
Impersonation orchestration: time-boxed, one-active-per-actor, structurally fenced, optionally consent-gated, always audited on both ledgers.
── AUTHORIZATION (the structural fence) ─────────────────────────────────────────────────── This service never compares org ids to decide reach. It resolves the TARGET’s org context and asks AuthorizationEvaluator: sentinel:global:impersonate for realm-level impersonation, or sentinel:org:impersonate evaluated with the target org as AccessCheck.ResourceOrganizationId — the evaluator’s cross-org rule is the one authority, exactly like SentinelAdminService.
The minted token authenticates as the TARGET through the ordinary access-token path (typ at+sentinel) but always carries act (RFC 8693-style actor claim: the impersonator’s id) and imp_id (this record), so resource servers can surface the impersonation banner and audit true attribution.
Constructors
ImpersonationService(IImpersonationStore store, IUserStore users, AuditService audit, ISentinelEventSink events, ISentinelMailer mailer, ISentinelClock clock, SentinelTokenOptions tokenOptions, Func<SigningKey> primaryKey, SentinelImpersonationOptions options)
Impersonation orchestration: time-boxed, one-active-per-actor, structurally fenced, optionally consent-gated, always audited on both ledgers. ── AUTHORIZATION (the structural fence) ─────────────────────────────────────────────────── This service never compares org ids to decide reach. It resolves the TARGET’s org context and asks AuthorizationEvaluator: sentinel:global:impersonate for realm-level impersonation, or sentinel:org:impersonate evaluated with the target org as AccessCheck.ResourceOrganizationId — the evaluator’s cross-org rule is the one authority, exactly like SentinelAdminService. The minted token authenticates as the TARGET through the ordinary access-token path (typ at+sentinel) but always carries act (RFC 8693-style actor claim: the impersonator’s id) and imp_id (this record), so resource servers can surface the impersonation banner and audit true attribution.
Methods
ValueTask<AdminResult<ImpersonationRecord>> ApproveConsentAsync(string consentToken, CancellationToken cancellationToken = default(CancellationToken))
Approves a pending impersonation with the mailed consent token. Deliberately unauthenticated beyond the token itself: the token is single-purpose (typ ImpersonationService.ConsentTokenType), bound to one record via imp_id, and short-lived. Activation starts the impersonation time box from now.
ValueTask<AdminResult<ImpersonationRecord>> EndAsync(SubjectSnapshot actor, CancellationToken cancellationToken = default(CancellationToken))
Ends the actor’s current impersonation (pending or active). Outstanding tokens die at their capped exp.
ValueTask<AdminResult<ImpersonationStarted>> GetActiveAsync(SubjectSnapshot actor, string audience = "sentinel", CancellationToken cancellationToken = default(CancellationToken))
The actor’s current impersonation, with a freshly minted access token when it is active. Re-minting here is what bridges consent mode (the approval happens on the target’s side, so the actor polls this to pick up their token) and the lifetime gap (the impersonation window outlives a single short access token; each re-mint stays capped to ImpersonationRecord.ExpiresAt). Only the actor themselves can call this — the record is looked up by the CALLER’s subject id, never by a client-supplied one.
ValueTask<AdminResult<ImpersonationStarted>> StartAsync(SubjectSnapshot actor, Guid targetUserId, string reason, Guid? organizationContext = null, string audience = "sentinel", CancellationToken cancellationToken = default(CancellationToken))
Starts (or, in consent mode, requests) an impersonation of targetUserId. organizationContext optionally pins the org context; the target must be a member of it. Fencing: global impersonate, or org impersonate at the target’s org — see class remarks. One-active-per-actor is enforced here.
Fields
const string ConsentTokenType = "impconsent+sentinel"
Token type of the consent-approval token (single-purpose posture: a consent token can never pass any other token check). Not part of SentinelTokenTypes because that class is login-owned; the value follows the same *+sentinel convention.
ImpersonationStarted
public sealed record ImpersonationStarted : IEquatable<ImpersonationStarted>
Result of a start/active call: the record plus a token when the impersonation is active.
Constructors
ImpersonationStarted(ImpersonationRecord Record, string? AccessToken)
Result of a start/active call: the record plus a token when the impersonation is active.
Properties
ImpersonationRecord Record { get; init; }
The impersonation session.
string? AccessToken { get; init; }
An impersonation access token (target’s identity + act claim), present only while the record is ImpersonationStatus.Active — null while consent is pending.
ImpersonationStatus
public enum ImpersonationStatus
Provides the base class for enumerations.
Values
PendingConsent— Consent mode: waiting for the target to approve via the mailed token link.ActiveEnded— Explicitly ended. Auto-expiry needs no status write — seeImpersonationRecord.ExpiresAt.
InMemoryBreakGlassStateStore
public sealed class InMemoryBreakGlassStateStore : IBreakGlassStateStore
In-memory IBreakGlassStateStore (tests + single-box).
Methods
ValueTask SetLastDrillAsync(Guid realmId, DateTimeOffset at, CancellationToken cancellationToken = default(CancellationToken))
ValueTask SetLastUseAsync(Guid realmId, DateTimeOffset at, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<BreakGlassState?> GetAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
InMemoryImpersonationStore
public sealed class InMemoryImpersonationStore : IImpersonationStore
In-memory IImpersonationStore for tests and single-box deployments.
Methods
ValueTask CreateAsync(ImpersonationRecord record, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateAsync(ImpersonationRecord record, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<ImpersonationRecord?> FindCurrentForActorAsync(Guid actorUserId, DateTimeOffset now, CancellationToken cancellationToken = default(CancellationToken))
The actor’s current (pending-consent or active) impersonation, or null. This is the one-active-per-actor query: records that are Ended or past their ImpersonationRecord.ExpiresAt never count — auto-expiry is a read-side filter, not a background write.
ValueTask<ImpersonationRecord?> GetAsync(Guid id, CancellationToken cancellationToken = default(CancellationToken))
SentinelImpersonationOptions
public sealed class SentinelImpersonationOptions
Options for the impersonation surface.
Properties
Dictionary<Guid, bool> RealmConsentOverrides { get; set; }
Per-realm overrides of SentinelImpersonationOptions.RequireTargetConsent (consent mode is a realm policy).
TimeSpan ConsentWindow { get; set; }
How long a pending consent request (and its mailed token) stays approvable.
TimeSpan MaxDuration { get; set; }
The time box: an impersonation ends at most this long after it becomes active.
bool RequireTargetConsent { get; set; }
Consent mode default: when true, starting an impersonation only requests it — the target gets a security_alert mail with a single-purpose approval token, and the impersonation stays ImpersonationStatus.PendingConsent until approved.
Methods
bool ConsentRequired(Guid realmId)
Nuvora.Nexus.Sentinel.Localization
ISentinelLocalizer
public interface ISentinelLocalizer
The localizer port: every user-facing string Sentinel emits — mail subjects/bodies rendered by the default LocalizedMailerDecorator and the human-readable message on problem responses — resolves through this interface.
Lookup contract: the implementation must apply the fallback chain exact locale → language ("de-AT" → "de") → "en" → the key itself. Returning the key verbatim (never null, never throw) is the terminal fallback — a missing string renders as its stable key, which is ugly but diagnosable, instead of crashing a login flow over a catalog gap. The args values replace {name} placeholders in the resolved string.
Hosts override by registering their own implementation before AddSentinelLocalization() — e.g. one backed by their existing resx pipeline.
Methods
string Get(string locale, string key, IReadOnlyDictionary<string, string>? args = null)
LocalizedMailerDecorator
public sealed class LocalizedMailerDecorator : ISentinelMailer
The DEFAULT mail rendering: sits between Sentinel’s flows and the host’s ISentinelMailer, resolving a localized subject/body for each known SentinelMail.Kind and forwarding a RENDERED mail (SentinelMail.Subject/SentinelMail.Body populated, SentinelMail.Locale pinned to the resolved locale).
The “templates are the host’s job” rule still holds: this decorator only provides the out-of-the-box plain-text rendering. Hosts with their own template pipeline either skip AddSentinelLocalization() entirely (they receive the raw Kind + Data as before) or simply ignore Subject/Body — the structured SentinelMail.Data is always forwarded untouched. To replace the rendering, replace the mailer registration.
Locale resolution: mail.Locale (set by the flows from User.Locale when known), clamped to SentinelLocalizationOptions.SupportedLocales (exact → language), falling back to SentinelLocalizationOptions.DefaultLocale. Catalog keys are mail.{kind}.subject|body, and for security_alert mail.security_alert.{Data["alert"]}.subject|body. A kind (or alert sub-kind) with no catalog entry passes through unrendered — that is a host-custom mail, not ours to render.
Constructors
LocalizedMailerDecorator(ISentinelMailer inner, ISentinelLocalizer localizer, SentinelLocalizationOptions options)
The DEFAULT mail rendering: sits between Sentinel’s flows and the host’s ISentinelMailer, resolving a localized subject/body for each known SentinelMail.Kind and forwarding a RENDERED mail (SentinelMail.Subject/SentinelMail.Body populated, SentinelMail.Locale pinned to the resolved locale). The “templates are the host’s job” rule still holds: this decorator only provides the out-of-the-box plain-text rendering. Hosts with their own template pipeline either skip AddSentinelLocalization() entirely (they receive the raw Kind + Data as before) or simply ignore Subject/Body — the structured SentinelMail.Data is always forwarded untouched. To replace the rendering, replace the mailer registration. Locale resolution: mail.Locale (set by the flows from User.Locale when known), clamped to SentinelLocalizationOptions.SupportedLocales (exact → language), falling back to SentinelLocalizationOptions.DefaultLocale. Catalog keys are mail.{kind}.subject|body, and for security_alert mail.security_alert.{Data["alert"]}.subject|body. A kind (or alert sub-kind) with no catalog entry passes through unrendered — that is a host-custom mail, not ours to render.
Methods
ValueTask SendAsync(SentinelMail mail, CancellationToken cancellationToken = default(CancellationToken))
ResourceSentinelLocalizer
public sealed class ResourceSentinelLocalizer : ISentinelLocalizer
Default ISentinelLocalizer: the v1 string catalog embedded in this assembly as Localization/resources/{locale}.json (flat key → string maps; EN is the authoritative catalog and the completeness tests pin every other locale to its key set).
Fallback chain per the port contract: exact locale → language ("de-AT" → "de") → "en" → the key itself. Placeholder interpolation replaces {name} with args["name"]; unknown placeholders are left verbatim so a template/data drift is visible instead of silently swallowed.
Constructors
ResourceSentinelLocalizer()
Properties
IReadOnlyCollection<string> Locales { get; }
The locales this catalog actually ships — used by the completeness tests.
Methods
IReadOnlyCollection<string> KeysFor(string locale)
All keys of one locale’s catalog; empty for an unknown locale.
string Get(string locale, string key, IReadOnlyDictionary<string, string>? args = null)
SentinelLocales
public static class SentinelLocales
Pure locale matching/negotiation helpers, shared by the mailer decorator and the AspNetCore problem responses. Lives in Core (not the web package) so the negotiation semantics are unit-testable without an HTTP host.
Methods
static string Negotiate(string? acceptLanguageHeader, SentinelLocalizationOptions options)
Accept-Language negotiation: candidates are honored in descending q-value (document order breaks ties), each resolved via SentinelLocales.MatchSupported; when nothing matches — including a null/empty header — the default locale wins. * maps to the default as well.
static string? MatchSupported(string? candidate, IReadOnlyList<string> supported)
Resolves a single candidate locale against the supported set: exact match (case-insensitive) first, then the language prefix ("es-MX" → "es"). Null when nothing matches.
SentinelLocalizationOptions
public sealed class SentinelLocalizationOptions
Locale policy for the host. SentinelLocalizationOptions.DefaultLocale is the realm/host default used when neither the mail nor the user carries a locale; SentinelLocalizationOptions.SupportedLocales is the negotiation set for Accept-Language and for clamping stored user locales. v1 ships catalogs for EN, DE, FR, ES, IT, RO.
Properties
IReadOnlyList<string> SupportedLocales { get; set; }
string DefaultLocale { get; set; }
Fields
static readonly IReadOnlyList<string> V1Locales
The six locales the embedded v1 catalog ships.
Nuvora.Nexus.Sentinel.Login
AccessTokenMinter
public sealed class AccessTokenMinter
Mints Sentinel-profile JWTs. The signing key is a parameter, not a dependency — the key ring resolves the current primary at call time, so rotation needs no coordination with this class.
Constructors
AccessTokenMinter(SentinelTokenOptions options)
Mints Sentinel-profile JWTs. The signing key is a parameter, not a dependency — the key ring resolves the current primary at call time, so rotation needs no coordination with this class.
Methods
string MintAccessToken(SigningKey key, User user, Session session, Guid? organizationId, string audience, DateTimeOffset now)
string MintSinglePurposeToken(SigningKey key, string tokenType, Guid userId, Guid realmId, DateTimeOffset now, TimeSpan lifetime, Action<Utf8JsonWriter>? extraClaims = null)
Single-purpose tokens share the codec but differ in typ, so an mfa_pending token can never pass an access-token check. Audience is the issuer itself: these tokens only ever come back to Sentinel.
AllowAllLoginGate
public sealed class AllowAllLoginGate : ILoginGate
Abuse-protection seam for login. LoginService depends on this narrow gate instead of the full AbuseProtectionService so the two areas compose in DI without a hard type dependency; the default allows everything (single-user dev scenarios).
Methods
ValueTask RecordFailureAsync(string? ip, string identifier, Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RecordSuccessAsync(string identifier, Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Realm-scoped like every counter key: one tenant’s traffic must not touch another’s thresholds.
ValueTask<bool> AllowAttemptAsync(string? ip, string identifier, Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Fields
static readonly AllowAllLoginGate Instance
ApiKeyCreated
public sealed record ApiKeyCreated : IEquatable<ApiKeyCreated>
Creation result: ApiKeyCreated.Token is the full snt_ token, returned ONCE — only its hash is stored.
Constructors
ApiKeyCreated(ApiKey Key, string Token)
Creation result: ApiKeyCreated.Token is the full snt_ token, returned ONCE — only its hash is stored.
Properties
ApiKey Key { get; init; }
string Token { get; init; }
AuthorizationCode
public sealed record AuthorizationCode : IEquatable<AuthorizationCode>
One pending authorization code. The code the client holds is a 256-bit random value; only its SHA-256 (AuthorizationCode.CodeHash) ever reaches a store — a store dump cannot be exchanged for tokens. Everything the token endpoint needs to mint rides in this record, so the code exchange is a single consume: no joins back to the authorize request.
Constructors
AuthorizationCode(string CodeHash, string ClientId, Guid UserId, Guid SessionId, Guid RealmId, Guid? OrgId, IReadOnlyList<string> Scopes, string RedirectUri, string? CodeChallenge, string? CodeChallengeMethod, string? Nonce, DateTimeOffset AuthTime, DateTimeOffset ExpiresAt)
One pending authorization code. The code the client holds is a 256-bit random value; only its SHA-256 (AuthorizationCode.CodeHash) ever reaches a store — a store dump cannot be exchanged for tokens. Everything the token endpoint needs to mint rides in this record, so the code exchange is a single consume: no joins back to the authorize request.
Properties
DateTimeOffset AuthTime { get; init; }
DateTimeOffset ExpiresAt { get; init; }
Guid RealmId { get; init; }
Guid SessionId { get; init; }
Guid UserId { get; init; }
Guid? OrgId { get; init; }
IReadOnlyList<string> Scopes { get; init; }
string ClientId { get; init; }
string CodeHash { get; init; }
string RedirectUri { get; init; }
string? CodeChallenge { get; init; }
string? CodeChallengeMethod { get; init; }
string? Nonce { get; init; }
AuthorizationCodeConsumption
public sealed record AuthorizationCodeConsumption : IEquatable<AuthorizationCodeConsumption>
AuthorizationCode is returned for CodeConsumptionOutcome.Reused too, so the reuse event can carry realm/subject context.
Constructors
AuthorizationCodeConsumption(CodeConsumptionOutcome Outcome, AuthorizationCode? Code)
AuthorizationCode is returned for CodeConsumptionOutcome.Reused too, so the reuse event can carry realm/subject context.
Properties
AuthorizationCode? Code { get; init; }
CodeConsumptionOutcome Outcome { get; init; }
CodeConsumptionOutcome
public enum CodeConsumptionOutcome
Provides the base class for enumerations.
Values
Consumed— First presentation: the code is now consumed and the exchange may proceed.Reused— The code was already consumed — RFC 6749 §4.1.2 code replay. Callers must reject AND emit theoidc.code_reuseevent.NotFound— Unknown code (or one already purged) — indistinguishable from garbage on purpose.
DelegateFederationTokenClient
public sealed class DelegateFederationTokenClient : IFederationTokenClient
Delegate-backed implementation — the injected-fetcher pattern applied to the token endpoint.
Constructors
DelegateFederationTokenClient(Func<string, IReadOnlyDictionary<string, string>, CancellationToken, Task<string>> post)
Delegate-backed implementation — the injected-fetcher pattern applied to the token endpoint.
Methods
ValueTask<string> PostFormAsync(string tokenEndpoint, IReadOnlyDictionary<string, string> form, CancellationToken cancellationToken = default(CancellationToken))
EmailOtpService
public sealed class EmailOtpService
Email OTP as an MFA factor. Unlike the signed single-purpose tokens, a 6-digit code carries only ~20 bits — it is safe only because verification is server-side (see IChallengeStore): the store holds a digest, bounds the ttl, and bounds the guesses; nothing the client holds can verify a code offline. The code is generated from RandomNumberGenerator (never a PRNG) and leaves the process exactly once, inside the mail.
Constructors
EmailOtpService(IChallengeStore challenges, ISentinelMailer mailer, ISentinelClock clock, ISentinelEventSink events)
Email OTP as an MFA factor. Unlike the signed single-purpose tokens, a 6-digit code carries only ~20 bits — it is safe only because verification is server-side (see IChallengeStore): the store holds a digest, bounds the ttl, and bounds the guesses; nothing the client holds can verify a code offline. The code is generated from RandomNumberGenerator (never a PRNG) and leaves the process exactly once, inside the mail.
Methods
ValueTask SendOtpAsync(Guid userId, string email, Guid realmId, string? locale = null, CancellationToken ct = default(CancellationToken))
Issues and mails a fresh code. Re-sending replaces the outstanding challenge (same key), so exactly one code per user is ever live.
ValueTask<bool> VerifyOtpAsync(Guid userId, string code, Guid realmId, CancellationToken ct = default(CancellationToken))
One guess. All failure shapes collapse to false for the caller — wrong, expired, and exhausted must be indistinguishable at the API edge (no-oracle posture); the event stream keeps the distinction for the host’s alerting.
Fields
const int MaxAttempts = 5
Five guesses against 10^6 codes ≈ 0.0005% brute-force odds per challenge.
static readonly TimeSpan CodeLifetime
Ten minutes: a mailbox round-trip, not a standing credential.
ExternalJwtValidator
public static class ExternalJwtValidator
Validation for EXTERNAL IdP tokens in workload federation. JwtCodec deliberately validates only what Sentinel issues (RS256, Sentinel typ values, ring-resolved keys); external issuers need their own path — keys come from the issuer’s remote JWKS document, and this validator keeps JwtCodec’s structural strictness while adapting the policy to foreign tokens:
algallowlist: RS256 and ES256 — the two algorithms workload IdPs actually use (GitHub Actions, Kubernetes, the cloud IMDSes). Anything else, includingnoneand every HMAC alg, is malformed by policy (an HMAC “verification” against a public JWKS value would be attacker-computable).typis IGNORED: external issuers stampJWT,at+jwt, or nothing — there is no cross-issuer convention to enforce. Token-type confusion does not apply here because these tokens are only ever accepted by the exchange endpoint, never by the Sentinel authentication handler.iss,aud,expmandatory;nbfenforced when present — same rules, same skew, same structuralTokenErrorclassification as JwtCodec.kidmandatory and resolved against the JWKS document — no key-guessing across the key set.
Methods
static TokenValidationResult Validate(string token, string jwksJson, ExternalTokenRequirements requirements)
Validates token against the issuer’s JWKS document (the raw JSON of an RFC 7517 {"keys":[…]} set) and requirements. Returns the parsed payload on success; dispose the result when done reading claims.
ExternalTokenRequirements
public readonly record struct ExternalTokenRequirements : IEquatable<ExternalTokenRequirements>
What an external-token validation caller must state up front — none of it optional, mirroring TokenValidationRequirements.
Constructors
ExternalTokenRequirements(string Issuer, string Audience, DateTimeOffset Now)
What an external-token validation caller must state up front — none of it optional, mirroring TokenValidationRequirements.
Properties
DateTimeOffset Now { get; init; }
TimeSpan ClockSkew { get; init; }
string Audience { get; init; }
string Issuer { get; init; }
FederatedLoginBegin
public sealed record FederatedLoginBegin : IEquatable<FederatedLoginBegin>
A started federated login: send the browser to FederatedLoginBegin.AuthorizeUrl; FederatedLoginBegin.State comes back on the callback.
Constructors
FederatedLoginBegin(string AuthorizeUrl, string State)
A started federated login: send the browser to FederatedLoginBegin.AuthorizeUrl; FederatedLoginBegin.State comes back on the callback.
Properties
string AuthorizeUrl { get; init; }
string State { get; init; }
FederatedLoginOptions
public sealed class FederatedLoginOptions
Knobs for inbound federation. One small class, same posture as the other option types.
Properties
TimeSpan JwksCacheTimeToLive { get; set; }
Freshness window for cached provider JWKS/discovery documents (the workload-federation cache, reused).
TimeSpan StateLifetime { get; set; }
How long a begin→callback round-trip may take. A login redirect, not a standing credential.
FederatedLoginOutcome
public enum FederatedLoginOutcome
Provides the base class for enumerations.
Values
Success—FederatedLoginResult.Logincarries the completed session/tokens; redirect toFederatedLoginResult.ReturnUri.Denied— One outcome for every failure mode — a probing caller learns nothing from the shape (no-oracle posture).
FederatedLoginResult
public sealed record FederatedLoginResult : IEquatable<FederatedLoginResult>
Completion result. On denial, FederatedLoginResult.DenialReason is a stable machine identifier for audit/event data — never sent to the caller, who gets one generic failure (no-oracle posture, same as WorkloadExchangeResult.DenialReason).
Constructors
FederatedLoginResult(FederatedLoginOutcome Outcome, LoginResult? Login = null, string? ReturnUri = null, string? DenialReason = null)
Completion result. On denial, FederatedLoginResult.DenialReason is a stable machine identifier for audit/event data — never sent to the caller, who gets one generic failure (no-oracle posture, same as WorkloadExchangeResult.DenialReason).
Properties
FederatedLoginOutcome Outcome { get; init; }
LoginResult? Login { get; init; }
bool IsSuccess { get; }
string? DenialReason { get; init; }
string? ReturnUri { get; init; }
FederatedLoginService
public sealed class FederatedLoginService
Inbound OIDC federation: Sentinel as the RELYING PARTY of external IdPs (Google, Entra, Okta, any generic OIDC issuer) — the outbound mirror of the inbound workload trust, sharing its building blocks (ExternalJwtValidator, the IRemoteJwksCache discovery/JWKS port, WorkloadWildcard rules).
Flow: FederatedLoginService.BeginAsync builds the provider’s authorize URL (code flow + S256 PKCE — public-client-grade hygiene even though Sentinel holds a client secret) and stashes the pending context server-side: the state value is a random 256-bit id, its SHA-256 goes into IChallengeStore (the atomic single-use arbiter) and the context payload (provider, redirect targets, nonce, PKCE verifier) into IFederationStateStore under the same hash. The verifier and nonce deliberately never transit the browser — state in the redirect is just an unguessable handle. FederatedLoginService.CompleteAsync consumes the state (single-use), exchanges the code at the provider’s token endpoint (via the IFederationTokenClient port — Core stays HTTP-free), validates the id_token (issuer, audience = our client_id, signature via JWKS, exp, nonce), then resolves the user: linked identity → login; verified-email match → link + login; otherwise JIT provisioning per the provider’s config, or a typed denial.
Login completion is delegated to PasskeyLoginCompleter — the compose-around completion seam: a federated login turns an externally-verified identity into a session + token pair exactly the way a verified passkey assertion does. SessionMfaLevel.None is minted deliberately: whatever factors the EXTERNAL IdP enforced are not visible to Sentinel in this wave (amr mapping is future work), and claiming an MFA level the session cannot prove would poison risk and permission decisions downstream.
Constructors
FederatedLoginService(IIdentityProviderStore providers, IFederatedIdentityStore federated, IUserStore users, IChallengeStore challenges, IFederationStateStore states, IRemoteJwksCache documents, IFederationTokenClient tokenClient, PasskeyLoginCompleter completer, FederatedLoginOptions options, ISentinelClock clock, ISentinelEventSink events)
Inbound OIDC federation: Sentinel as the RELYING PARTY of external IdPs (Google, Entra, Okta, any generic OIDC issuer) — the outbound mirror of the inbound workload trust, sharing its building blocks (ExternalJwtValidator, the IRemoteJwksCache discovery/JWKS port, WorkloadWildcard rules). Flow: FederatedLoginService.BeginAsync builds the provider’s authorize URL (code flow + S256 PKCE — public-client-grade hygiene even though Sentinel holds a client secret) and stashes the pending context server-side: the state value is a random 256-bit id, its SHA-256 goes into IChallengeStore (the atomic single-use arbiter) and the context payload (provider, redirect targets, nonce, PKCE verifier) into IFederationStateStore under the same hash. The verifier and nonce deliberately never transit the browser — state in the redirect is just an unguessable handle. FederatedLoginService.CompleteAsync consumes the state (single-use), exchanges the code at the provider’s token endpoint (via the IFederationTokenClient port — Core stays HTTP-free), validates the id_token (issuer, audience = our client_id, signature via JWKS, exp, nonce), then resolves the user: linked identity → login; verified-email match → link + login; otherwise JIT provisioning per the provider’s config, or a typed denial. Login completion is delegated to PasskeyLoginCompleter — the compose-around completion seam: a federated login turns an externally-verified identity into a session + token pair exactly the way a verified passkey assertion does. SessionMfaLevel.None is minted deliberately: whatever factors the EXTERNAL IdP enforced are not visible to Sentinel in this wave (amr mapping is future work), and claiming an MFA level the session cannot prove would poison risk and permission decisions downstream.
Methods
ValueTask<FederatedLoginBegin?> BeginAsync(Guid realmId, string idpKey, string callbackUri, string returnUri, Guid? organizationHint = null, CancellationToken ct = default(CancellationToken))
Starts a federated login against the realm’s idpKey provider. callbackUri is Sentinel’s own OAuth redirect_uri (where the provider sends the code); returnUri is where the USER lands after Sentinel completes the login — two different URLs on purpose, and only the former is sent to the provider. Returns null when the provider is unknown, suspended, non-OIDC, or its discovery document cannot be resolved — one shape for all of them, so URL probing cannot map the provider registry.
ValueTask<FederatedLoginResult> CompleteAsync(string state, string code, string actualCallbackUri, string audience = "sentinel", string? ip = null, string? deviceDescription = null, CancellationToken ct = default(CancellationToken))
Completes a federated login from the provider callback. actualCallbackUri is the URI this callback was actually received on — it must equal the one the flow began with (and is re-sent in the token exchange, as RFC 6749 §4.1.3 requires).
ValueTask<FederationDiscoveryResult> DiscoverAsync(Guid realmId, string email, CancellationToken ct = default(CancellationToken))
Org discovery: verified email-domain → organization → suggested provider key (the org’s Organization.DefaultIdentityProviderId). Reads ONLY the org domain registry — never the user table — so the response cannot become a user-existence oracle; an unknown domain and a known-but-unrouted one are the same empty result.
Fields
const string IdentityLinkedEvent = "federation.identity_linked"
Event kind emitted when a verified-email match auto-links an existing user.
const string LoginDeniedEvent = "federation.login_denied"
Event kind emitted when a callback is denied (stable identifier).
const string LoginSucceededEvent = "federation.login_success"
Event kind emitted on a successful federated login (stable identifier), alongside the completer’s login.success.
const string UserProvisionedEvent = "federation.user_provisioned"
Event kind emitted when JIT provisioning creates a user.
FederationDiscoveryResult
public sealed record FederationDiscoveryResult : IEquatable<FederationDiscoveryResult>
Discovery hints. Both null when nothing routes — indistinguishable from a known-but-unconfigured domain (no existence oracle).
Constructors
FederationDiscoveryResult(Guid? OrganizationId = null, string? IdentityProviderKey = null)
Discovery hints. Both null when nothing routes — indistinguishable from a known-but-unconfigured domain (no existence oracle).
Properties
Guid? OrganizationId { get; init; }
string? IdentityProviderKey { get; init; }
IFederatedIdentityStore
public interface IFederatedIdentityStore
Persistence port for federated identity links, JIT provisioning, and org-domain discovery. This is what FederatedLoginService writes when a login links or creates a user — kept separate from IUserStore because that port is read-mostly login machinery and these are the ONLY places the login path ever creates identity rows. Narrow on purpose, like every other store port.
Methods
ValueTask AddLinkAsync(LinkedIdentity link, CancellationToken cancellationToken = default(CancellationToken))
ValueTask AddOrganizationMembershipAsync(Guid userId, Guid organizationId, DateTimeOffset joinedAt, CancellationToken cancellationToken = default(CancellationToken))
Idempotent: adding an existing membership is a no-op, so re-running mapping rules never faults.
ValueTask AddRoleAssignmentAsync(RoleAssignment assignment, CancellationToken cancellationToken = default(CancellationToken))
ValueTask AddTeamMembershipAsync(Guid teamId, Guid userId, DateTimeOffset addedAt, CancellationToken cancellationToken = default(CancellationToken))
Idempotent, same contract as the org membership.
ValueTask CreateUserAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
Creates a JIT-provisioned user. The caller owns uniqueness pre-checks (realm-scoped email).
ValueTask<LinkedIdentity?> FindLinkAsync(Guid identityProviderId, string providerSubject, CancellationToken cancellationToken = default(CancellationToken))
The link lookup: (provider, provider-side subject) → at most one user (unique index).
ValueTask<Organization?> GetOrganizationAsync(Guid organizationId, CancellationToken cancellationToken = default(CancellationToken))
Resolves discovery’s org → Organization.DefaultIdentityProviderId hop.
ValueTask<OrganizationDomain?> FindVerifiedEmailDomainAsync(Guid realmId, string emailDomain, CancellationToken cancellationToken = default(CancellationToken))
The discovery query: verified OrganizationDomainKind.EmailDomain rows only — unverified domains never route logins (anyone can claim a domain string).
IFederationTokenClient
public interface IFederationTokenClient
Port for the OAuth token-endpoint exchange in inbound federation: POSTs a form to the provider’s token endpoint and returns the raw JSON response body. A port so Core stays HTTP-free, the same seam as IRemoteJwksCache’s injected fetcher — the AspNetCore package wires an IHttpClientFactory-backed implementation, tests inject a fake that mints id_tokens locally. Implementations throw on transport or non-2xx failures; the service maps any throw to a denial.
Methods
ValueTask<string> PostFormAsync(string tokenEndpoint, IReadOnlyDictionary<string, string> form, CancellationToken cancellationToken = default(CancellationToken))
IIdentityProviderStore
public interface IIdentityProviderStore
Store port for inbound-federation identity provider configs. Same posture as IWorkloadTrustStore: narrow on purpose — the queries the federated login flow actually makes plus creation, not a generic repository. Returns all statuses; the service filters on IdentityProviderStatus.Active so a suspended provider still shows up in admin listings built on the same queries.
Methods
ValueTask AddAsync(IdentityProviderConfig provider, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateAsync(IdentityProviderConfig provider, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (loaded via the queries above) — declarative-config drift application and admin edits.
ValueTask<IReadOnlyList<IdentityProviderConfig>> ListAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Every provider in the realm (login-button listings, admin UI).
ValueTask<IdentityProviderConfig?> GetAsync(Guid id, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IdentityProviderConfig?> GetByKeyAsync(Guid realmId, string key, CancellationToken cancellationToken = default(CancellationToken))
Lookup by the URL-visible machine key, realm-scoped — the begin flow’s entry query.
ILoginGate
public interface ILoginGate
Abuse-protection seam for login. LoginService depends on this narrow gate instead of the full AbuseProtectionService so the two areas compose in DI without a hard type dependency; the default allows everything (single-user dev scenarios).
Methods
ValueTask RecordFailureAsync(string? ip, string identifier, Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RecordSuccessAsync(string identifier, Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Realm-scoped like every counter key: one tenant’s traffic must not touch another’s thresholds.
ValueTask<LoginGateVerdict> DecideAttemptAsync(string? ip, string identifier, Guid realmId, bool captchaPassed, CancellationToken cancellationToken = default(CancellationToken))
Graded form of ILoginGate.AllowAttemptAsync for the adaptive-captcha flow: captchaPassed is true when the caller already verified a challenge token for this attempt. Default implementation preserves the binary behavior (never demands captcha), so pre-captcha gate implementations keep working unchanged.
ValueTask<bool> AllowAttemptAsync(string? ip, string identifier, Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
IMachineIdentityStore
public interface IMachineIdentityStore
Store port for machine identities. Same posture as the other identity store ports: narrow on purpose — these are the queries the machine-auth flows actually make, not a generic repository. The EF Core adapter implements this; the in-memory implementation below exists for tests and single-process samples.
Methods
ValueTask AddApiKeyAsync(ApiKey key, CancellationToken cancellationToken = default(CancellationToken))
ValueTask AddServiceAccountAsync(ServiceAccount account, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RecordApiKeyUseAsync(Guid id, DateTimeOffset usedAt, CancellationToken cancellationToken = default(CancellationToken))
Best-effort ApiKey.LastUsedAt update; callers fire-and-forget it, so it must never be load-bearing.
ValueTask RevokeApiKeyAsync(Guid id, DateTimeOffset revokedAt, CancellationToken cancellationToken = default(CancellationToken))
Sets ApiKey.RevokedAt; re-revoking keeps the original timestamp, unknown ids are a no-op.
ValueTask UpdateServiceAccountSecretAsync(Guid id, string secretHash, string secretAlgorithm, string? previousSecretHash, string? previousSecretAlgorithm, DateTimeOffset? previousSecretExpiresAt, CancellationToken cancellationToken = default(CancellationToken))
Persists a secret rotation atomically: the new current hash plus the demoted previous hash and its overlap expiry — one write, so a concurrently verifying caller sees either the pre- or the post-rotation pair, never a half-rotated account.
ValueTask<ApiKey?> FindApiKeyByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default(CancellationToken))
Lookup by the SHA-256 hex of the presented token — the store never sees plaintext tokens.
ValueTask<IReadOnlyList<ApiKey>> ListApiKeysForOwnerAsync(Guid ownerUserId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<ServiceAccount?> FindServiceAccountByKeyAsync(Guid realmId, string key, CancellationToken cancellationToken = default(CancellationToken))
Key lookup is realm-scoped: the same machine key may exist in two realms.
ValueTask<ServiceAccount?> GetServiceAccountAsync(Guid id, CancellationToken cancellationToken = default(CancellationToken))
IMfaStore
public interface IMfaStore
TOTP enrollments and recovery codes. Passkeys join in Wave 2.
Methods
ValueTask<IReadOnlyList<TotpEnrollment>> GetTotpEnrollmentsAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<bool> TryConsumeRecoveryCodeAsync(Guid userId, string codeHash, CancellationToken cancellationToken = default(CancellationToken))
Atomic single-use consumption: true removes the code; a second call with the same hash returns false.
IOidcStore
public interface IOidcStore
Store port for the OIDC authorization server: client registry lookups, consent grants, and the authorization-code store. Codes are deliberately NOT an IChallengeStore use: a challenge is a guessable short code with an attempt budget, while an authorization code is unguessable, single-use, and carries a payload — different contract, different port.
Methods
ValueTask CreateClientAsync(OidcClient client, CancellationToken cancellationToken = default(CancellationToken))
Creates a registry entry. Uniqueness of (realm, clientId) is the caller’s pre-check (natural key).
ValueTask RevokeConsentAsync(Guid realmId, Guid userId, string clientId, DateTimeOffset revokedAt, CancellationToken cancellationToken = default(CancellationToken))
Stamps OidcConsentGrant.RevokedAt; a no-op when no grant exists.
ValueTask SaveCodeAsync(AuthorizationCode code, CancellationToken cancellationToken = default(CancellationToken))
ValueTask SaveConsentAsync(OidcConsentGrant grant, CancellationToken cancellationToken = default(CancellationToken))
Upserts by (realm, user, clientId): re-approval replaces the stored scope set and clears any revocation.
ValueTask SaveTokenGrantAsync(OidcTokenGrant grant, CancellationToken cancellationToken = default(CancellationToken))
Records the grant minted by a code exchange. Upserts by OidcTokenGrant.CodeHash (a code is single-use, so in practice this writes once).
ValueTask UpdateClientAsync(OidcClient client, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (loaded via IOidcStore.ListClientsAsync or IOidcStore.FindClientByClientIdAsync).
ValueTask<AuthorizationCodeConsumption> ConsumeCodeAsync(string codeHash, CancellationToken cancellationToken = default(CancellationToken))
Atomic single-use consumption: exactly one caller ever observes CodeConsumptionOutcome.Consumed for a given hash, however racy the callers — same no-TOCTOU contract as IRefreshTokenStore.TryMarkUsedAsync. Consumed codes stay recognizable (as CodeConsumptionOutcome.Reused) until purged so replay detection works; expiry is the caller’s check (the record carries ExpiresAt).
ValueTask<IReadOnlyList<OidcClient>> ListBackChannelLogoutClientsAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Back-channel logout registration lookup: the realm’s active clients with a OidcClient.BackChannelLogoutUri.
ValueTask<IReadOnlyList<OidcClient>> ListClientsAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Every registered client in the realm (admin listings, declarative diffing), all statuses, ordered by OidcClient.ClientId.
ValueTask<IReadOnlyList<OidcTokenGrant>> ListTokenGrantsForSessionAsync(Guid sessionId, CancellationToken cancellationToken = default(CancellationToken))
Every grant minted against a session — back-channel logout derives “which clients saw this session” from the distinct client ids.
ValueTask<IReadOnlyList<OidcTokenGrant>> ListTokenGrantsForUserAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Every grant minted for a user — the subject-wide variant (admin suspend, logout-all).
ValueTask<OidcClient?> FindClientByClientIdAsync(Guid realmId, string clientId, CancellationToken cancellationToken = default(CancellationToken))
Client registry lookup by the wire client_id; realm-scoped like every natural key. Null for unknown ids — status filtering is the caller’s job.
ValueTask<OidcConsentGrant?> FindConsentAsync(Guid realmId, Guid userId, string clientId, CancellationToken cancellationToken = default(CancellationToken))
The consent grant for (user, client), revoked or not; null when none was ever recorded. Callers check OidcConsentGrant.RevokedAt and scope coverage.
ValueTask<OidcTokenGrant?> FindTokenGrantByCodeHashAsync(string codeHash, CancellationToken cancellationToken = default(CancellationToken))
The grant a (possibly replayed) code produced on its first use; null when the code never completed an exchange.
ValueTask<OidcTokenGrant?> FindTokenGrantByFamilyAsync(Guid refreshFamilyId, CancellationToken cancellationToken = default(CancellationToken))
The grant behind a refresh-token family — rotation keeps the family id stable, so this resolves the ORIGINAL granted scopes for any rotation generation.
IPasskeyStore
public interface IPasskeyStore
Store port for passkey credentials. Same posture as the other identity store ports: narrow on purpose — these are the queries the passkey flows actually make, not a generic repository. The EF Core adapter implements this; the in-memory implementation below exists for tests and single-process samples.
Methods
ValueTask AddAsync(PasskeyCredential credential, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RemoveAsync(Guid id, CancellationToken cancellationToken = default(CancellationToken))
Removes by entity id (PasskeyCredential.Id). Removing an unknown id is a no-op.
ValueTask UpdateSignCountAndLastUsedAsync(Guid id, long signCount, DateTimeOffset lastUsedAt, CancellationToken cancellationToken = default(CancellationToken))
Records a successful assertion: the new signature counter and the use timestamp (the counter must only ever move forward; regression detection happens in the verification layer before this is called).
ValueTask<IReadOnlyList<PasskeyCredential>> ListForUserAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<PasskeyCredential?> FindByCredentialIdAsync(byte[] credentialId, CancellationToken cancellationToken = default(CancellationToken))
Lookup by the authenticator-generated credential id (byte content, not reference).
IPasswordResetStore
public interface IPasswordResetStore
Store port for the reset/verify flows. Separate from IUserStore on purpose: login only ever reads credentials, these flows write them, and the two contracts evolve independently. Kept as narrow as the flows require.
Methods
ValueTask MarkEmailVerifiedAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask ReplacePasswordCredentialAsync(Guid userId, string algorithm, string hash, CancellationToken cancellationToken = default(CancellationToken))
Create-or-replace of the user’s password credential: after this call exactly one password — the new one — may work. Implementations remove/overwrite every existing password credential and persist the new hash under the given algorithm tag; for a credential-less user (invitation acceptance) a first credential is CREATED.
ValueTask<bool> TryConsumeJtiAsync(string jti, DateTimeOffset expiresAt, CancellationToken cancellationToken = default(CancellationToken))
Single-use enforcement for reset/verify tokens: deny-list-on-use. First call for a jti returns true and records it until expiresAt (after which the token is dead anyway and the entry can be dropped); any repeat returns false. Must be atomic — two racing resets with one token must not both succeed.
IRemoteJwksCache
public interface IRemoteJwksCache
Cached access to remote issuer documents (JWKS and OIDC discovery) for workload federation. A port so Core stays HTTP-free: the actual fetch is injected — the OidcServer package wires an IHttpClientFactory-backed fetcher, tests inject a dictionary lookup. Returns the raw document body; parsing is the caller’s job.
Methods
ValueTask<string> GetAsync(string uri, CancellationToken cancellationToken = default(CancellationToken))
The document at uri, from cache when fresh. Throws when the document cannot be produced at all.
ISamlStore
public interface ISamlStore
Store port for both SAML surfaces: the SP-side IdP connections, the IdP-side SP registry, and the assertion-id replay cache. Narrow on purpose like every other store port — the queries the flows actually make plus creation. Returns all statuses; the services filter on IdentityProviderStatus.Active so suspended connections still show up in admin listings built on the same queries.
Methods
ValueTask AddIdpConnectionAsync(SamlIdpConnection connection, CancellationToken cancellationToken = default(CancellationToken))
ValueTask AddSpConnectionAsync(SamlSpConnection connection, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateIdpConnectionAsync(SamlIdpConnection connection, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (loaded via the queries above) — declarative-config drift application and admin edits.
ValueTask UpdateSpConnectionAsync(SamlSpConnection connection, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (loaded via the queries above) — declarative-config drift application and admin edits.
ValueTask<IReadOnlyList<SamlIdpConnection>> ListIdpConnectionsAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<SamlSpConnection>> ListSpConnectionsAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<SamlIdpConnection?> FindIdpConnectionByEntityIdAsync(Guid realmId, string idpEntityId, CancellationToken cancellationToken = default(CancellationToken))
Lookup by the IdP’s entity id — routes UNSOLICITED responses, where no pending state names the connection.
ValueTask<SamlIdpConnection?> GetIdpConnectionAsync(Guid id, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<SamlIdpConnection?> GetIdpConnectionByKeyAsync(Guid realmId, string key, CancellationToken cancellationToken = default(CancellationToken))
Lookup by the URL-visible machine key, realm-scoped — the begin flow’s entry query.
ValueTask<SamlSpConnection?> FindSpConnectionByEntityIdAsync(Guid realmId, string spEntityId, CancellationToken cancellationToken = default(CancellationToken))
Lookup by the SP’s entity id — the AuthnRequest Issuer routes here.
ValueTask<bool> TryMarkAssertionConsumedAsync(string assertionId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default(CancellationToken))
Marks an assertion id consumed, atomically: true on FIRST use, false when the id was already marked — the single-use arbiter that makes bearer assertions non-replayable (same posture as IChallengeStore). Entries may be dropped once expiresAt passes: an assertion past its own NotOnOrAfter is rejected by the conditions check before the replay cache is ever consulted.
IScimStore
public interface IScimStore
Store port for the SCIM 2.0 server. Deliberately separate from IUserStore: SCIM needs org-scoped listing/paging, externalId lookup and full entity writes — none of which the narrow login port wants. Every user/group operation here is fenced by organizationId (the SCIM token’s org); implementations MUST NOT return or touch rows outside that org.
Methods
ValueTask AddGroupMembersAsync(Guid groupId, IReadOnlyList<Guid> userIds, DateTimeOffset addedAt, CancellationToken cancellationToken = default(CancellationToken))
Adds the listed users, ignoring ones already present (SCIM PATCH add is idempotent per member).
ValueTask AddTokenAsync(ScimToken token, CancellationToken cancellationToken = default(CancellationToken))
ValueTask CreateGroupAsync(Group group, CancellationToken cancellationToken = default(CancellationToken))
ValueTask CreateOrgUserAsync(User user, Guid organizationId, DateTimeOffset joinedAt, CancellationToken cancellationToken = default(CancellationToken))
Creates the realm user AND its OrganizationMembership in the token’s org, atomically where the store can.
ValueTask DeleteGroupAsync(Guid groupId, CancellationToken cancellationToken = default(CancellationToken))
Hard delete of the group and its membership rows. Groups are aggregation edges, not audit anchors — unlike users, deleting them destroys nothing the audit trail needs.
ValueTask RemoveGroupMembersAsync(Guid groupId, IReadOnlyList<Guid> userIds, CancellationToken cancellationToken = default(CancellationToken))
ValueTask ReplaceGroupMembersAsync(Guid groupId, IReadOnlyList<Guid> userIds, DateTimeOffset addedAt, CancellationToken cancellationToken = default(CancellationToken))
Full replace of the member set (SCIM PATCH replace / PUT members).
ValueTask RevokeTokenAsync(Guid tokenId, DateTimeOffset revokedAt, CancellationToken cancellationToken = default(CancellationToken))
ValueTask TouchTokenAsync(Guid tokenId, DateTimeOffset usedAt, CancellationToken cancellationToken = default(CancellationToken))
Records a successful authentication (LastUsedAt); best-effort, never on the hot path’s critical section.
ValueTask UpdateGroupAsync(Group group, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateUserAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
Persists field updates on an already-fetched org user (PUT/PATCH/deactivate).
ValueTask<Group?> FindOrgGroupByDisplayNameAsync(Guid organizationId, string displayName, CancellationToken cancellationToken = default(CancellationToken))
Exact displayName lookup within the org — the SCIM 409 duplicate-displayName check.
ValueTask<Group?> GetOrgGroupAsync(Guid organizationId, Guid groupId, CancellationToken cancellationToken = default(CancellationToken))
Null unless the group exists AND belongs to organizationId (org-local groups only — realm groups are not SCIM-managed).
ValueTask<IReadOnlyList<ScimGroupMemberInfo>> GetGroupMembersAsync(Guid groupId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<ScimPage<Group>> ListOrgGroupsAsync(Guid organizationId, int offset, int take, string? displayNameEquals = null, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<ScimPage<User>> ListOrgUsersAsync(Guid organizationId, int offset, int take, ScimUserFilter? filter = null, CancellationToken cancellationToken = default(CancellationToken))
Members of the org, stable order (by id — Guid v7 sorts by creation time), offset/take paging.
ValueTask<ScimToken?> FindTokenByHashAsync(string tokenHash, CancellationToken cancellationToken = default(CancellationToken))
Point lookup on the SHA-256 digest of the presented sct_ token. Returns revoked/expired rows as stored — liveness policy is the caller’s.
ValueTask<User?> FindUserByEmailAsync(Guid realmId, string normalizedEmail, CancellationToken cancellationToken = default(CancellationToken))
Realm-wide email lookup (uniqueness is realm-scoped) — the SCIM 409 duplicate-userName check.
ValueTask<User?> GetOrgUserAsync(Guid organizationId, Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Null unless the user exists AND is a member of organizationId — the org fence that makes cross-org ids 404.
ISessionStore
public interface ISessionStore
Methods
ValueTask CreateAsync(Session session, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RevokeAsync(Guid sessionId, DateTimeOffset revokedAt, CancellationToken cancellationToken = default(CancellationToken))
ValueTask TouchAsync(Guid sessionId, DateTimeOffset lastSeenAt, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateOrganizationAsync(Guid sessionId, Guid? organizationId, CancellationToken cancellationToken = default(CancellationToken))
Org-switch: repoints the session’s org context so subsequent refresh rotations keep minting into the newly selected org.
ValueTask<IReadOnlyList<Session>> ListActiveForUserAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<Session?> GetAsync(Guid sessionId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<bool> TryAdvanceTotpStepAsync(Guid enrollmentId, long step, CancellationToken cancellationToken = default(CancellationToken))
Marks a TOTP step consumed; false when that step (or a later one) was already used (anti-replay).
IUserStore
public interface IUserStore
Store ports the login flows depend on. The EF Core adapter implements these; the in-memory implementations below exist for tests and single-process samples. Kept narrow on purpose: these are the queries login actually makes, not a generic repository.
Methods
ValueTask RecordLoginAsync(Guid userId, DateTimeOffset at, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateCredentialAsync(Guid credentialId, string algorithm, string hash, CancellationToken cancellationToken = default(CancellationToken))
Persists a rehash-on-login upgrade — same transaction semantics as the login itself where the store supports it.
ValueTask<IReadOnlyList<Guid>> GetOrganizationIdsAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<UserCredential>> GetCredentialsAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<User?> FindByEmailAsync(Guid realmId, string normalizedEmail, CancellationToken cancellationToken = default(CancellationToken))
Email lookup is realm-scoped and expects a pre-normalized (trimmed, lowercased) value.
ValueTask<User?> GetAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
IWorkloadTrustStore
public interface IWorkloadTrustStore
Store port for workload federation trusts. Same posture as the other identity store ports: narrow on purpose — these are the queries the exchange flow actually makes plus creation, not a generic repository. The EF Core adapter implements this; the in-memory implementation below exists for tests and single-process samples.
Methods
ValueTask AddAsync(WorkloadTrustConfig trust, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateAsync(WorkloadTrustConfig trust, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (loaded via the queries above) — declarative-config drift application and admin edits.
ValueTask<IReadOnlyList<WorkloadTrustConfig>> ListAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Every trust in the realm (admin listings, realm-wide prune reporting).
ValueTask<IReadOnlyList<WorkloadTrustConfig>> ListByIssuerAsync(Guid realmId, string issuer, CancellationToken cancellationToken = default(CancellationToken))
Every trust registered for the external issuer, realm-scoped — the exchange flow’s candidate set. Returns all statuses; the service filters on WorkloadTrustStatus.Active so a suspended trust still shows up in admin listings built on the same query.
ValueTask<WorkloadTrustConfig?> GetAsync(Guid id, CancellationToken cancellationToken = default(CancellationToken))
InMemoryFederatedIdentityStore
public sealed class InMemoryFederatedIdentityStore : IFederatedIdentityStore
Single-node default over an InMemoryIdentityStore: created users and org memberships land in the shared identity store (so the rest of the login stack sees them); links, domains, orgs, teams and role assignments live here with seed/assert helpers for tests.
Constructors
InMemoryFederatedIdentityStore(InMemoryIdentityStore identity)
Single-node default over an InMemoryIdentityStore: created users and org memberships land in the shared identity store (so the rest of the login stack sees them); links, domains, orgs, teams and role assignments live here with seed/assert helpers for tests.
Properties
IReadOnlyCollection<LinkedIdentity> Links { get; }
IReadOnlyCollection<RoleAssignment> RoleAssignments { get; }
IReadOnlyCollection<TeamMember> TeamMembers { get; }
Methods
ValueTask AddLinkAsync(LinkedIdentity link, CancellationToken cancellationToken = default(CancellationToken))
ValueTask AddOrganizationMembershipAsync(Guid userId, Guid organizationId, DateTimeOffset joinedAt, CancellationToken cancellationToken = default(CancellationToken))
Idempotent: adding an existing membership is a no-op, so re-running mapping rules never faults.
ValueTask AddRoleAssignmentAsync(RoleAssignment assignment, CancellationToken cancellationToken = default(CancellationToken))
ValueTask AddTeamMembershipAsync(Guid teamId, Guid userId, DateTimeOffset addedAt, CancellationToken cancellationToken = default(CancellationToken))
Idempotent, same contract as the org membership.
ValueTask CreateUserAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
Creates a JIT-provisioned user. The caller owns uniqueness pre-checks (realm-scoped email).
ValueTask<LinkedIdentity?> FindLinkAsync(Guid identityProviderId, string providerSubject, CancellationToken cancellationToken = default(CancellationToken))
The link lookup: (provider, provider-side subject) → at most one user (unique index).
ValueTask<Organization?> GetOrganizationAsync(Guid organizationId, CancellationToken cancellationToken = default(CancellationToken))
Resolves discovery’s org → Organization.DefaultIdentityProviderId hop.
ValueTask<OrganizationDomain?> FindVerifiedEmailDomainAsync(Guid realmId, string emailDomain, CancellationToken cancellationToken = default(CancellationToken))
The discovery query: verified OrganizationDomainKind.EmailDomain rows only — unverified domains never route logins (anyone can claim a domain string).
void AddDomain(OrganizationDomain domain)
void AddOrganization(Organization organization)
InMemoryIdentityProviderStore
public sealed class InMemoryIdentityProviderStore : IIdentityProviderStore
Single-node default. Acceptable as a DI fallback (unlike the identity stores): an empty provider registry denies every federated login — fail-closed, not silently permissive, the same rationale as InMemoryWorkloadTrustStore.
Methods
ValueTask AddAsync(IdentityProviderConfig provider, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateAsync(IdentityProviderConfig provider, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (loaded via the queries above) — declarative-config drift application and admin edits.
ValueTask<IReadOnlyList<IdentityProviderConfig>> ListAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Every provider in the realm (login-button listings, admin UI).
ValueTask<IdentityProviderConfig?> GetAsync(Guid id, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IdentityProviderConfig?> GetByKeyAsync(Guid realmId, string key, CancellationToken cancellationToken = default(CancellationToken))
Lookup by the URL-visible machine key, realm-scoped — the begin flow’s entry query.
InMemoryIdentityStore
public sealed class InMemoryIdentityStore : IUserStore, IMfaStore, ISessionStore
Store ports the login flows depend on. The EF Core adapter implements these; the in-memory implementations below exist for tests and single-process samples. Kept narrow on purpose: these are the queries login actually makes, not a generic repository.
Methods
ValueTask CreateAsync(Session session, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RecordLoginAsync(Guid userId, DateTimeOffset at, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RevokeAsync(Guid sessionId, DateTimeOffset revokedAt, CancellationToken cancellationToken = default(CancellationToken))
ValueTask TouchAsync(Guid sessionId, DateTimeOffset lastSeenAt, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateCredentialAsync(Guid credentialId, string algorithm, string hash, CancellationToken cancellationToken = default(CancellationToken))
Persists a rehash-on-login upgrade — same transaction semantics as the login itself where the store supports it.
ValueTask UpdateOrganizationAsync(Guid sessionId, Guid? organizationId, CancellationToken cancellationToken = default(CancellationToken))
Org-switch: repoints the session’s org context so subsequent refresh rotations keep minting into the newly selected org.
ValueTask<IReadOnlyList<Guid>> GetOrganizationIdsAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<Session>> ListActiveForUserAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<TotpEnrollment>> GetTotpEnrollmentsAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<UserCredential>> GetCredentialsAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<User?> FindByEmailAsync(Guid realmId, string normalizedEmail, CancellationToken cancellationToken = default(CancellationToken))
Email lookup is realm-scoped and expects a pre-normalized (trimmed, lowercased) value.
ValueTask<User?> GetAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<bool> TryAdvanceTotpStepAsync(Guid enrollmentId, long step, CancellationToken cancellationToken = default(CancellationToken))
Marks a TOTP step consumed; false when that step (or a later one) was already used (anti-replay).
ValueTask<bool> TryConsumeRecoveryCodeAsync(Guid userId, string codeHash, CancellationToken cancellationToken = default(CancellationToken))
Atomic single-use consumption: true removes the code; a second call with the same hash returns false.
void AddOrgMembership(Guid userId, Guid orgId)
void AddRecoveryCodes(Guid userId, IEnumerable<string> hashes)
void AddTotp(TotpEnrollment enrollment)
void AddUser(User user, params UserCredential[] credentials)
InMemoryMachineIdentityStore
public sealed class InMemoryMachineIdentityStore : IMachineIdentityStore
Single-node default. Deliberately simplistic — correctness over speed.
Methods
ValueTask AddApiKeyAsync(ApiKey key, CancellationToken cancellationToken = default(CancellationToken))
ValueTask AddServiceAccountAsync(ServiceAccount account, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RecordApiKeyUseAsync(Guid id, DateTimeOffset usedAt, CancellationToken cancellationToken = default(CancellationToken))
Best-effort ApiKey.LastUsedAt update; callers fire-and-forget it, so it must never be load-bearing.
ValueTask RevokeApiKeyAsync(Guid id, DateTimeOffset revokedAt, CancellationToken cancellationToken = default(CancellationToken))
Sets ApiKey.RevokedAt; re-revoking keeps the original timestamp, unknown ids are a no-op.
ValueTask UpdateServiceAccountSecretAsync(Guid id, string secretHash, string secretAlgorithm, string? previousSecretHash, string? previousSecretAlgorithm, DateTimeOffset? previousSecretExpiresAt, CancellationToken cancellationToken = default(CancellationToken))
Persists a secret rotation atomically: the new current hash plus the demoted previous hash and its overlap expiry — one write, so a concurrently verifying caller sees either the pre- or the post-rotation pair, never a half-rotated account.
ValueTask<ApiKey?> FindApiKeyByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default(CancellationToken))
Lookup by the SHA-256 hex of the presented token — the store never sees plaintext tokens.
ValueTask<IReadOnlyList<ApiKey>> ListApiKeysForOwnerAsync(Guid ownerUserId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<ServiceAccount?> FindServiceAccountByKeyAsync(Guid realmId, string key, CancellationToken cancellationToken = default(CancellationToken))
Key lookup is realm-scoped: the same machine key may exist in two realms.
ValueTask<ServiceAccount?> GetServiceAccountAsync(Guid id, CancellationToken cancellationToken = default(CancellationToken))
InMemoryOidcStore
public sealed class InMemoryOidcStore : IOidcStore
Single-node default for tests and samples; the EF adapter is the persistent implementation.
Methods
ValueTask CreateClientAsync(OidcClient client, CancellationToken cancellationToken = default(CancellationToken))
Creates a registry entry. Uniqueness of (realm, clientId) is the caller’s pre-check (natural key).
ValueTask RevokeConsentAsync(Guid realmId, Guid userId, string clientId, DateTimeOffset revokedAt, CancellationToken cancellationToken = default(CancellationToken))
Stamps OidcConsentGrant.RevokedAt; a no-op when no grant exists.
ValueTask SaveCodeAsync(AuthorizationCode code, CancellationToken cancellationToken = default(CancellationToken))
ValueTask SaveConsentAsync(OidcConsentGrant grant, CancellationToken cancellationToken = default(CancellationToken))
Upserts by (realm, user, clientId): re-approval replaces the stored scope set and clears any revocation.
ValueTask SaveTokenGrantAsync(OidcTokenGrant grant, CancellationToken cancellationToken = default(CancellationToken))
Records the grant minted by a code exchange. Upserts by OidcTokenGrant.CodeHash (a code is single-use, so in practice this writes once).
ValueTask UpdateClientAsync(OidcClient client, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (loaded via IOidcStore.ListClientsAsync or IOidcStore.FindClientByClientIdAsync).
ValueTask<AuthorizationCodeConsumption> ConsumeCodeAsync(string codeHash, CancellationToken cancellationToken = default(CancellationToken))
Atomic single-use consumption: exactly one caller ever observes CodeConsumptionOutcome.Consumed for a given hash, however racy the callers — same no-TOCTOU contract as IRefreshTokenStore.TryMarkUsedAsync. Consumed codes stay recognizable (as CodeConsumptionOutcome.Reused) until purged so replay detection works; expiry is the caller’s check (the record carries ExpiresAt).
ValueTask<IReadOnlyList<OidcClient>> ListBackChannelLogoutClientsAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Back-channel logout registration lookup: the realm’s active clients with a OidcClient.BackChannelLogoutUri.
ValueTask<IReadOnlyList<OidcClient>> ListClientsAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Every registered client in the realm (admin listings, declarative diffing), all statuses, ordered by OidcClient.ClientId.
ValueTask<IReadOnlyList<OidcTokenGrant>> ListTokenGrantsForSessionAsync(Guid sessionId, CancellationToken cancellationToken = default(CancellationToken))
Every grant minted against a session — back-channel logout derives “which clients saw this session” from the distinct client ids.
ValueTask<IReadOnlyList<OidcTokenGrant>> ListTokenGrantsForUserAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Every grant minted for a user — the subject-wide variant (admin suspend, logout-all).
ValueTask<OidcClient?> FindClientByClientIdAsync(Guid realmId, string clientId, CancellationToken cancellationToken = default(CancellationToken))
Client registry lookup by the wire client_id; realm-scoped like every natural key. Null for unknown ids — status filtering is the caller’s job.
ValueTask<OidcConsentGrant?> FindConsentAsync(Guid realmId, Guid userId, string clientId, CancellationToken cancellationToken = default(CancellationToken))
The consent grant for (user, client), revoked or not; null when none was ever recorded. Callers check OidcConsentGrant.RevokedAt and scope coverage.
ValueTask<OidcTokenGrant?> FindTokenGrantByCodeHashAsync(string codeHash, CancellationToken cancellationToken = default(CancellationToken))
The grant a (possibly replayed) code produced on its first use; null when the code never completed an exchange.
ValueTask<OidcTokenGrant?> FindTokenGrantByFamilyAsync(Guid refreshFamilyId, CancellationToken cancellationToken = default(CancellationToken))
The grant behind a refresh-token family — rotation keeps the family id stable, so this resolves the ORIGINAL granted scopes for any rotation generation.
void AddClient(OidcClient client)
InMemoryPasskeyStore
public sealed class InMemoryPasskeyStore : IPasskeyStore
Single-node default. Deliberately simplistic — correctness over speed.
Methods
ValueTask AddAsync(PasskeyCredential credential, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RemoveAsync(Guid id, CancellationToken cancellationToken = default(CancellationToken))
Removes by entity id (PasskeyCredential.Id). Removing an unknown id is a no-op.
ValueTask UpdateSignCountAndLastUsedAsync(Guid id, long signCount, DateTimeOffset lastUsedAt, CancellationToken cancellationToken = default(CancellationToken))
Records a successful assertion: the new signature counter and the use timestamp (the counter must only ever move forward; regression detection happens in the verification layer before this is called).
ValueTask<IReadOnlyList<PasskeyCredential>> ListForUserAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<PasskeyCredential?> FindByCredentialIdAsync(byte[] credentialId, CancellationToken cancellationToken = default(CancellationToken))
Lookup by the authenticator-generated credential id (byte content, not reference).
InMemoryPasswordResetStore
public sealed class InMemoryPasswordResetStore : IPasswordResetStore
Single-node default. Credential writes delegate to IUserStore’s rehash hook — for the in-memory store “replace” and “overwrite every existing password credential” coincide; the EF adapter does a true delete-and-insert.
Constructors
InMemoryPasswordResetStore(IUserStore users, ISentinelClock clock)
Single-node default. Credential writes delegate to IUserStore’s rehash hook — for the in-memory store “replace” and “overwrite every existing password credential” coincide; the EF adapter does a true delete-and-insert.
Methods
ValueTask MarkEmailVerifiedAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask ReplacePasswordCredentialAsync(Guid userId, string algorithm, string hash, CancellationToken cancellationToken = default(CancellationToken))
Create-or-replace of the user’s password credential: after this call exactly one password — the new one — may work. Implementations remove/overwrite every existing password credential and persist the new hash under the given algorithm tag; for a credential-less user (invitation acceptance) a first credential is CREATED.
ValueTask<bool> TryConsumeJtiAsync(string jti, DateTimeOffset expiresAt, CancellationToken cancellationToken = default(CancellationToken))
Single-use enforcement for reset/verify tokens: deny-list-on-use. First call for a jti returns true and records it until expiresAt (after which the token is dead anyway and the entry can be dropped); any repeat returns false. Must be atomic — two racing resets with one token must not both succeed.
InMemoryRemoteJwksCache
public sealed class InMemoryRemoteJwksCache : IRemoteJwksCache
In-memory TTL cache over an injected fetcher, suited to single-node deployments. External-issuer keys rotate rarely and JWKS endpoints are built to be polled, so a short TTL keeps exchanges off the network without meaningfully delaying key rotation pickup. When a refetch after expiry fails, the stale document is served instead — a flaky IdP endpoint should degrade exchange freshness, not availability (a token that only verifies against a rotated-out key fails validation anyway).
Constructors
InMemoryRemoteJwksCache(Func<string, CancellationToken, Task<string>> fetch, ISentinelClock clock, TimeSpan? timeToLive = null)
In-memory TTL cache over an injected fetcher, suited to single-node deployments. External-issuer keys rotate rarely and JWKS endpoints are built to be polled, so a short TTL keeps exchanges off the network without meaningfully delaying key rotation pickup. When a refetch after expiry fails, the stale document is served instead — a flaky IdP endpoint should degrade exchange freshness, not availability (a token that only verifies against a rotated-out key fails validation anyway).
Methods
ValueTask<string> GetAsync(string uri, CancellationToken cancellationToken = default(CancellationToken))
The document at uri, from cache when fresh. Throws when the document cannot be produced at all.
Fields
static readonly TimeSpan DefaultTimeToLive
Default freshness window; conservative against the ~daily-at-most rotation cadence of workload IdPs.
InMemorySamlStore
public sealed class InMemorySamlStore : ISamlStore
Single-node default, clock-driven like InMemoryChallengeStore. Acceptable as a DI fallback: an empty connection registry denies every SAML flow — fail-closed, the same rationale as InMemoryIdentityProviderStore.
Constructors
InMemorySamlStore(ISentinelClock clock)
Single-node default, clock-driven like InMemoryChallengeStore. Acceptable as a DI fallback: an empty connection registry denies every SAML flow — fail-closed, the same rationale as InMemoryIdentityProviderStore.
Methods
ValueTask AddIdpConnectionAsync(SamlIdpConnection connection, CancellationToken cancellationToken = default(CancellationToken))
ValueTask AddSpConnectionAsync(SamlSpConnection connection, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateIdpConnectionAsync(SamlIdpConnection connection, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (loaded via the queries above) — declarative-config drift application and admin edits.
ValueTask UpdateSpConnectionAsync(SamlSpConnection connection, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (loaded via the queries above) — declarative-config drift application and admin edits.
ValueTask<IReadOnlyList<SamlIdpConnection>> ListIdpConnectionsAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<IReadOnlyList<SamlSpConnection>> ListSpConnectionsAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<SamlIdpConnection?> FindIdpConnectionByEntityIdAsync(Guid realmId, string idpEntityId, CancellationToken cancellationToken = default(CancellationToken))
Lookup by the IdP’s entity id — routes UNSOLICITED responses, where no pending state names the connection.
ValueTask<SamlIdpConnection?> GetIdpConnectionAsync(Guid id, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<SamlIdpConnection?> GetIdpConnectionByKeyAsync(Guid realmId, string key, CancellationToken cancellationToken = default(CancellationToken))
Lookup by the URL-visible machine key, realm-scoped — the begin flow’s entry query.
ValueTask<SamlSpConnection?> FindSpConnectionByEntityIdAsync(Guid realmId, string spEntityId, CancellationToken cancellationToken = default(CancellationToken))
Lookup by the SP’s entity id — the AuthnRequest Issuer routes here.
ValueTask<bool> TryMarkAssertionConsumedAsync(string assertionId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default(CancellationToken))
Marks an assertion id consumed, atomically: true on FIRST use, false when the id was already marked — the single-use arbiter that makes bearer assertions non-replayable (same posture as IChallengeStore). Entries may be dropped once expiresAt passes: an assertion past its own NotOnOrAfter is rejected by the conditions check before the replay cache is ever consulted.
InMemoryScimStore
public sealed class InMemoryScimStore : IScimStore
In-memory IScimStore (tests + samples). Same posture as InMemoryIdentityStore: correctness over speed, one coarse lock.
Methods
ValueTask AddGroupMembersAsync(Guid groupId, IReadOnlyList<Guid> userIds, DateTimeOffset addedAt, CancellationToken cancellationToken = default(CancellationToken))
Adds the listed users, ignoring ones already present (SCIM PATCH add is idempotent per member).
ValueTask AddTokenAsync(ScimToken token, CancellationToken cancellationToken = default(CancellationToken))
ValueTask CreateGroupAsync(Group group, CancellationToken cancellationToken = default(CancellationToken))
ValueTask CreateOrgUserAsync(User user, Guid organizationId, DateTimeOffset joinedAt, CancellationToken cancellationToken = default(CancellationToken))
Creates the realm user AND its OrganizationMembership in the token’s org, atomically where the store can.
ValueTask DeleteGroupAsync(Guid groupId, CancellationToken cancellationToken = default(CancellationToken))
Hard delete of the group and its membership rows. Groups are aggregation edges, not audit anchors — unlike users, deleting them destroys nothing the audit trail needs.
ValueTask RemoveGroupMembersAsync(Guid groupId, IReadOnlyList<Guid> userIds, CancellationToken cancellationToken = default(CancellationToken))
ValueTask ReplaceGroupMembersAsync(Guid groupId, IReadOnlyList<Guid> userIds, DateTimeOffset addedAt, CancellationToken cancellationToken = default(CancellationToken))
Full replace of the member set (SCIM PATCH replace / PUT members).
ValueTask RevokeTokenAsync(Guid tokenId, DateTimeOffset revokedAt, CancellationToken cancellationToken = default(CancellationToken))
ValueTask TouchTokenAsync(Guid tokenId, DateTimeOffset usedAt, CancellationToken cancellationToken = default(CancellationToken))
Records a successful authentication (LastUsedAt); best-effort, never on the hot path’s critical section.
ValueTask UpdateGroupAsync(Group group, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateUserAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
Persists field updates on an already-fetched org user (PUT/PATCH/deactivate).
ValueTask<Group?> FindOrgGroupByDisplayNameAsync(Guid organizationId, string displayName, CancellationToken cancellationToken = default(CancellationToken))
Exact displayName lookup within the org — the SCIM 409 duplicate-displayName check.
ValueTask<Group?> GetOrgGroupAsync(Guid organizationId, Guid groupId, CancellationToken cancellationToken = default(CancellationToken))
Null unless the group exists AND belongs to organizationId (org-local groups only — realm groups are not SCIM-managed).
ValueTask<IReadOnlyList<ScimGroupMemberInfo>> GetGroupMembersAsync(Guid groupId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<ScimPage<Group>> ListOrgGroupsAsync(Guid organizationId, int offset, int take, string? displayNameEquals = null, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<ScimPage<User>> ListOrgUsersAsync(Guid organizationId, int offset, int take, ScimUserFilter? filter = null, CancellationToken cancellationToken = default(CancellationToken))
Members of the org, stable order (by id — Guid v7 sorts by creation time), offset/take paging.
ValueTask<ScimToken?> FindTokenByHashAsync(string tokenHash, CancellationToken cancellationToken = default(CancellationToken))
Point lookup on the SHA-256 digest of the presented sct_ token. Returns revoked/expired rows as stored — liveness policy is the caller’s.
ValueTask<User?> FindUserByEmailAsync(Guid realmId, string normalizedEmail, CancellationToken cancellationToken = default(CancellationToken))
Realm-wide email lookup (uniqueness is realm-scoped) — the SCIM 409 duplicate-userName check.
ValueTask<User?> GetOrgUserAsync(Guid organizationId, Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Null unless the user exists AND is a member of organizationId — the org fence that makes cross-org ids 404.
void AddUser(User user, params Guid[] organizationIds)
Test seeding: a user that exists in the realm without going through SCIM create.
InMemoryWorkloadTrustStore
public sealed class InMemoryWorkloadTrustStore : IWorkloadTrustStore
Single-node default. Deliberately simplistic — correctness over speed.
Methods
ValueTask AddAsync(WorkloadTrustConfig trust, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateAsync(WorkloadTrustConfig trust, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (loaded via the queries above) — declarative-config drift application and admin edits.
ValueTask<IReadOnlyList<WorkloadTrustConfig>> ListAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
Every trust in the realm (admin listings, realm-wide prune reporting).
ValueTask<IReadOnlyList<WorkloadTrustConfig>> ListByIssuerAsync(Guid realmId, string issuer, CancellationToken cancellationToken = default(CancellationToken))
Every trust registered for the external issuer, realm-scoped — the exchange flow’s candidate set. Returns all statuses; the service filters on WorkloadTrustStatus.Active so a suspended trust still shows up in admin listings built on the same query.
ValueTask<WorkloadTrustConfig?> GetAsync(Guid id, CancellationToken cancellationToken = default(CancellationToken))
InvitationIssued
public sealed record InvitationIssued : IEquatable<InvitationIssued>
The minted invitation: hand the token to the invitee (link or mail); it is never persisted in plaintext.
Constructors
InvitationIssued(Guid UserId, string Token, DateTimeOffset ExpiresAt)
The minted invitation: hand the token to the invitee (link or mail); it is never persisted in plaintext.
Properties
DateTimeOffset ExpiresAt { get; init; }
Guid UserId { get; init; }
string Token { get; init; }
InvitationOptions
public sealed class InvitationOptions
Lifetime of the mailed/printed invitation token.
Properties
TimeSpan TokenLifetime { get; set; }
Long enough to cross a weekend inbox, short enough not to be a standing credential.
int MinimumPasswordLength { get; set; }
Minimum accepted password length at acceptance time (NIST-style length-only check).
InvitationOutcome
public enum InvitationOutcome
Provides the base class for enumerations.
Values
SuccessInvalidToken— Bad signature, wrong typ, expired, already used, or no such user — one bucket, no oracle.PasswordTooWeak— The chosen password fails the length policy — the ONE failure that names itself (the token was fine; the user must retry with a better password).
InvitationService
public sealed class InvitationService
Invitation acceptance flow: an invite+sentinel single-purpose token goes out (admin creates it; the reference host prints a link on first run), comes back exactly once with the invitee’s chosen password (deny-listed on use via IPasswordResetStore.TryConsumeJtiAsync — same mechanics as password reset), the credential is created and the email is marked verified (clicking the invitation link IS proof of mailbox control). Replaces the printed-one-time-password bootstrap.
Constructors
InvitationService(IUserStore users, IPasswordResetStore resetStore, PasswordHasher hasher, AccessTokenMinter minter, Func<SigningKey> primaryKey, SentinelTokenOptions tokenOptions, InvitationOptions options, ISentinelClock clock, ISentinelEventSink events)
Invitation acceptance flow: an invite+sentinel single-purpose token goes out (admin creates it; the reference host prints a link on first run), comes back exactly once with the invitee’s chosen password (deny-listed on use via IPasswordResetStore.TryConsumeJtiAsync — same mechanics as password reset), the credential is created and the email is marked verified (clicking the invitation link IS proof of mailbox control). Replaces the printed-one-time-password bootstrap.
Methods
ValueTask<InvitationIssued?> CreateInvitationAsync(Guid userId, CancellationToken ct = default(CancellationToken))
Mints an invitation token for an existing (usually credential-less) user. Authorization is the CALLER’s job — SentinelAdminService.CreateInvitationAsync is the fenced front door; this method is also the bootstrap path where no caller exists yet.
ValueTask<InvitationOutcome> AcceptAsync(string invitationToken, string password, CancellationToken ct = default(CancellationToken))
Accepts an invitation: consumes the token (single-use), creates the password credential, and marks the email verified. All token-shaped failures collapse to InvitationOutcome.InvalidToken (no oracle).
LoginGateVerdict
public enum LoginGateVerdict
The graded attempt verdict: captcha sits between allowed and blocked on the escalation ramp.
Values
AllowedBlockedCaptchaRequired— Adaptive captcha demanded: the attempt may be retried carrying a verified challenge token.
LoginResult
public sealed record LoginResult : IEquatable<LoginResult>
Defines a generalized method that a value type or class implements to create a type-specific method for determining equality of instances.
Constructors
LoginResult(LoginStatus Status, string? AccessToken = null, string? RefreshToken = null, string? MfaPendingToken = null, Session? Session = null, IReadOnlyList<Guid>? OrganizationIds = null, string? MfaFactor = null)
Properties
IReadOnlyList<Guid>? OrganizationIds { get; init; }
LoginStatus Status { get; init; }
Session? Session { get; init; }
string? AccessToken { get; init; }
string? MfaFactor { get; init; }
string? MfaPendingToken { get; init; }
string? RefreshToken { get; init; }
Fields
const string FactorEmailOtp = "email_otp"
LoginResult.MfaFactor value: a code was mailed; verify with email OTP (risk step-up fallback).
const string FactorTotp = "totp"
LoginResult.MfaFactor value: verify with TOTP (or a recovery code).
LoginService
public sealed class LoginService
Password login + second-factor step-up orchestration, with the risk gate consulted after first-factor success. The optional collaborators (risk gate, email OTP, device history, mailer) default to inert so the service still composes minimally — the meta package wires the full set. The completion path (LoginService.CompleteAsync) is factor-agnostic on purpose.
Constructors
LoginService(IUserStore users, IMfaStore mfa, ISessionStore sessions, PasswordHasher passwordHasher, RefreshTokenService refreshTokens, AccessTokenMinter minter, Func<SigningKey> primaryKey, SentinelTokenOptions tokenOptions, ISentinelClock clock, ISentinelEventSink events, ILoginGate gate, IRiskGate? riskGate = null, EmailOtpService? emailOtp = null, IDeviceHistoryStore? deviceHistory = null, ISentinelMailer? mailer = null, ISentinelMetrics? metrics = null)
Password login + second-factor step-up orchestration, with the risk gate consulted after first-factor success. The optional collaborators (risk gate, email OTP, device history, mailer) default to inert so the service still composes minimally — the meta package wires the full set. The completion path (LoginService.CompleteAsync) is factor-agnostic on purpose.
Methods
ValueTask<LoginResult> LoginWithPasswordAsync(Guid realmId, string email, string password, string? ip = null, string? deviceDescription = null, string audience = "sentinel", Guid? organizationId = null, string? deviceFingerprint = null, bool captchaPassed = false, CancellationToken ct = default(CancellationToken))
ValueTask<LoginResult> VerifyEmailOtpAsync(string mfaPendingToken, string code, string? ip = null, string? deviceDescription = null, string? deviceFingerprint = null, CancellationToken ct = default(CancellationToken))
Completes a risk-stepped login whose second factor is the mailed OTP. Safe against factor mix-ups without extra token claims: the challenge store only holds a code if THIS service mailed one for this user, so a TOTP user’s pending token presented here fails closed (no live challenge → indistinguishable failure).
ValueTask<LoginResult> VerifyRecoveryCodeAsync(string mfaPendingToken, string recoveryCode, string? ip = null, string? deviceDescription = null, string? deviceFingerprint = null, CancellationToken ct = default(CancellationToken))
ValueTask<LoginResult> VerifyTotpAsync(string mfaPendingToken, string code, string? ip = null, string? deviceDescription = null, string? deviceFingerprint = null, CancellationToken ct = default(CancellationToken))
Fields
const string NewDeviceEvent = "login.new_device"
Sink-event kind: a successful login completed from a device fingerprint never seen for this user.
const string StepUpUnavailableEvent = "risk.stepup_unavailable"
Sink-event kind: risk demanded step-up but the user has no usable second factor and no email-OTP fallback is wired — the honest gap, allowed WITH an alert rather than silently.
LoginStatus
public enum LoginStatus
Provides the base class for enumerations.
Values
SuccessInvalidCredentials— One status for wrong-email and wrong-password alike: no user enumeration. Risk blocks hide here too — opacity by design.MfaRequired— First factor passed; present the mfa_pending token plus a second factor.LoginResult.MfaFactorsays which.Blocked— Abuse protection refused the attempt.CaptchaRequired— Adaptive captcha demanded: retry the same credentials with a solved challenge token.
MachineAuthService
public sealed class MachineAuthService
Machine identity flows: API-key lifecycle + authentication + the capped snapshot, and service-account secret lifecycle + verification with rotation overlap.
The API-key capping semantics, exactly: an API key’s effective permissions are the owner’s current snapshot ∩ the key’s scope patterns, denies preserved, recomputed at use time. The intersection (MachineAuthService.BuildCappedSnapshot) copies every owner DENY unconditionally and keeps each owner ALLOW only as its pattern-intersection with some key scope (MachineAuthService.TryIntersect). Where two single-star wildcard patterns overlap without one containing the other, the pair is dropped — conservative by design: the capped snapshot may under-allow, never over-allow. The invariant that matters (pinned by property tests): capped allows ⇒ owner allows AND some scope matches the permission.
Service accounts here cover creation, rotation, and secret verification only; minting access tokens for them is the OAuth2 client-credentials grant and lands with the OIDC surface in Wave 3.
Constructors
MachineAuthService(IMachineIdentityStore store, ISubjectDataSource subjects, PasswordHasher passwordHasher, ISentinelClock clock, ISentinelEventSink events)
Machine identity flows: API-key lifecycle + authentication + the capped snapshot, and service-account secret lifecycle + verification with rotation overlap. The API-key capping semantics, exactly: an API key’s effective permissions are the owner’s current snapshot ∩ the key’s scope patterns, denies preserved, recomputed at use time. The intersection (MachineAuthService.BuildCappedSnapshot) copies every owner DENY unconditionally and keeps each owner ALLOW only as its pattern-intersection with some key scope (MachineAuthService.TryIntersect). Where two single-star wildcard patterns overlap without one containing the other, the pair is dropped — conservative by design: the capped snapshot may under-allow, never over-allow. The invariant that matters (pinned by property tests): capped allows ⇒ owner allows AND some scope matches the permission. Service accounts here cover creation, rotation, and secret verification only; minting access tokens for them is the OAuth2 client-credentials grant and lands with the OIDC surface in Wave 3.
Methods
ValueTask RecordApiKeyUseAsync(Guid keyId, CancellationToken cancellationToken = default(CancellationToken))
Best-effort ApiKey.LastUsedAt touch; callers fire-and-forget it.
ValueTask RevokeApiKeyAsync(ApiKey key, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<ApiKey?> AuthenticateApiKeyAsync(string token, CancellationToken cancellationToken = default(CancellationToken))
Null for unknown, revoked, or expired tokens alike — a probing caller learns nothing.
ValueTask<ApiKeyCreated> CreateApiKeyAsync(Guid realmId, Guid ownerUserId, IReadOnlyList<string> scopes, Guid? organizationId = null, DateTimeOffset? expiresAt = null, CancellationToken cancellationToken = default(CancellationToken))
Mints a new API key for ownerUserId. Every scope must parse as a PermissionPattern — a bad scope fails loudly here, not silently at check time. The returned token is shown exactly once; only its SHA-256 lands in the store.
ValueTask<ServiceAccount?> VerifyServiceAccountSecretAsync(Guid realmId, string key, string secret, CancellationToken cancellationToken = default(CancellationToken))
Client-credential verification: current secret first, then — inside the overlap window only — the previous one. Null for unknown key, non-active status, and wrong secret alike. No rehash-on-verify: secrets are always minted here with the current algorithm, so the import-coexistence machinery has nothing to upgrade.
ValueTask<ServiceAccountCreated> CreateServiceAccountAsync(Guid realmId, string key, string displayName, Guid? organizationId = null, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<SubjectSnapshot?> BuildApiKeySnapshotAsync(ApiKey key, CancellationToken cancellationToken = default(CancellationToken))
The recompute-at-use half of the capped-snapshot contract: loads the OWNER’s current subject data, builds the owner snapshot, and caps it with the key’s scopes via MachineAuthService.BuildCappedSnapshot. Null when the owner no longer resolves (deactivated, suspended, or out of the key’s org) — the key dies with its owner.
ValueTask<string> RotateServiceAccountSecretAsync(Guid accountId, TimeSpan overlapWindow, CancellationToken cancellationToken = default(CancellationToken))
Rotation with overlap: the current secret becomes the previous one and keeps verifying for overlapWindow, so every replica of the workload can pick up the new secret without a hard cutover. Returns the new plaintext secret ONCE.
static SubjectSnapshot BuildCappedSnapshot(SubjectSnapshot owner, ApiKey key)
The capping intersection, owner snapshot ∩ key scopes, denies preserved: Every owner DENY grant is copied verbatim — a key can never escape its owner’s denies.; Each owner ALLOW grant is kept once per key scope it intersects with, its pattern replaced by the intersection (MachineAuthService.TryIntersect); org/team/condition restrictions and provenance carry over unchanged, so the grant applies in exactly the situations it would for the owner.; Scopes never ADD authority: a scope with no intersecting owner allow contributes nothing. The snapshot keeps the owner’s subject id, teams, and attributes — self-scoped checks and ABAC conditions behave exactly as they would for the owner, which is what makes the capped snapshot a true subset of the owner’s (the credential id lives on the principal, not here — the subjectId/credentialOwner split).
static bool TryIntersect(PermissionPattern a, PermissionPattern b, out PermissionPattern intersection)
Segment-wise intersection of two permission patterns (at most one * per segment). Per segment: two equal concrete segments → that segment; concrete + wildcard that matches it → the concrete one; two wildcards where one’s language contains the other’s (single-star prefix*suffix containment: prefix starts-with prefix AND suffix ends-with suffix) → the more specific one. Anything else — including overlapping wildcards where neither contains the other — fails, and the caller drops the pair. Conservative on purpose: the result’s matches are always a subset of BOTH inputs’ matches; uncertainty costs an allow, never grants one.
static string HashToken(string token)
SHA-256 hex — same digest the refresh-token store uses; only ever this, never plaintext, hits a store.
Fields
const int DisplayPrefixLength = 12
Display prefix length: snt_ + 8 token chars — enough to recognize, useless to authenticate.
const string ApiKeyTokenPrefix = "snt_"
API-key token prefix: greppable in leaked logs, fingerprintable by secret scanners — same rationale as srt_ refresh tokens.
const string ServiceAccountSecretPrefix = "sns_"
Service-account secret prefix, same fingerprinting rationale as MachineAuthService.ApiKeyTokenPrefix.
OidcTokenGrant
public sealed record OidcTokenGrant : IEquatable<OidcTokenGrant>
The durable record of one successful authorization-code exchange: which client, user and session the tokens were minted for, the EXACT scope set granted, and — when offline_access bought a refresh token — the refresh-token family that exchange started. This is what fixes three RFC deviations at once:
- Refresh re-mints read
OidcTokenGrant.Scopesinstead of re-deriving a registration/consent superset — the token endpoint never silently widens a grant. - A replayed code (
CodeHashlookup) revokesOidcTokenGrant.RefreshFamilyId— the RFC 6749 §4.1.2 SHOULD of revoking tokens minted from the code’s first use. - Back-channel logout knows which clients saw a session (
OidcTokenGrant.SessionId→ distinctOidcTokenGrant.ClientIds).
Keyed by OidcTokenGrant.CodeHash — one grant per code first-use; refresh rotation keeps OidcTokenGrant.RefreshFamilyId stable, so the family lookup survives any number of rotations.
Constructors
OidcTokenGrant(string CodeHash, string ClientId, Guid RealmId, Guid UserId, Guid SessionId, Guid? RefreshFamilyId, IReadOnlyList<string> Scopes, DateTimeOffset IssuedAt)
The durable record of one successful authorization-code exchange: which client, user and session the tokens were minted for, the EXACT scope set granted, and — when offline_access bought a refresh token — the refresh-token family that exchange started. This is what fixes three RFC deviations at once: Refresh re-mints read OidcTokenGrant.Scopes instead of re-deriving a registration/consent superset — the token endpoint never silently widens a grant.; A replayed code (CodeHash lookup) revokes OidcTokenGrant.RefreshFamilyId — the RFC 6749 §4.1.2 SHOULD of revoking tokens minted from the code’s first use.; Back-channel logout knows which clients saw a session (OidcTokenGrant.SessionId → distinct OidcTokenGrant.ClientIds). Keyed by OidcTokenGrant.CodeHash — one grant per code first-use; refresh rotation keeps OidcTokenGrant.RefreshFamilyId stable, so the family lookup survives any number of rotations.
Properties
DateTimeOffset IssuedAt { get; init; }
Guid RealmId { get; init; }
Guid SessionId { get; init; }
Guid UserId { get; init; }
Guid? RefreshFamilyId { get; init; }
IReadOnlyList<string> Scopes { get; init; }
string ClientId { get; init; }
string CodeHash { get; init; }
OrgSwitchResult
public sealed record OrgSwitchResult : IEquatable<OrgSwitchResult>
Defines a generalized method that a value type or class implements to create a type-specific method for determining equality of instances.
Constructors
OrgSwitchResult(OrgSwitchStatus Status, string? AccessToken = null, string? RefreshToken = null, Session? Session = null)
Properties
OrgSwitchStatus Status { get; init; }
Session? Session { get; init; }
string? AccessToken { get; init; }
string? RefreshToken { get; init; }
OrgSwitchService
public sealed class OrgSwitchService
Org-context switching WITHOUT re-authentication: a user holding a valid session picks another organization they belong to and gets a fresh access token (new org claim → new per-(user, org) snapshot) plus a fresh refresh-token family minted into the new context; the session record is repointed so later refresh rotations stay in the chosen org. Membership is validated against the live store, not the presented token — a user removed from an org since login cannot switch into it. Core, not HTTP: non-HTTP hosts drive the same flow directly.
Constructors
OrgSwitchService(IUserStore users, ISessionStore sessions, RefreshTokenService refreshTokens, AccessTokenMinter minter, Func<SigningKey> primaryKey, SentinelTokenOptions tokenOptions, ISentinelClock clock, ISentinelEventSink events, ISentinelCacheBus cacheBus)
Org-context switching WITHOUT re-authentication: a user holding a valid session picks another organization they belong to and gets a fresh access token (new org claim → new per-(user, org) snapshot) plus a fresh refresh-token family minted into the new context; the session record is repointed so later refresh rotations stay in the chosen org. Membership is validated against the live store, not the presented token — a user removed from an org since login cannot switch into it. Core, not HTTP: non-HTTP hosts drive the same flow directly.
Methods
ValueTask<OrgSwitchResult> SwitchAsync(Guid userId, Guid? sessionId, Guid organizationId, string audience = "sentinel", CancellationToken ct = default(CancellationToken))
OrgSwitchStatus
public enum OrgSwitchStatus
Provides the base class for enumerations.
Values
SuccessNotMember— The caller is not a member of the requested organization (checked against the live store).InvalidSession— No live session to switch: missing, revoked, expired, wrong user, or the user is not active.
PasskeyLoginCompleter
public sealed class PasskeyLoginCompleter
Framework-free completion seam for passkey login. Passkey ceremonies are verified in the AspNetCore package (Fido2NetLib lives there); once an assertion has been cryptographically verified and mapped to a user, this class turns that verdict into a session + token pair the same way a password login would.
DELIBERATE DUPLICATION: PasskeyLoginCompleter.CompleteAsync replicates the private LoginService.CompleteAsync semantics line for line (org selection, session creation, refresh + access minting, login.success event). LoginService is factor-complete for password/TOTP/recovery and is not modified for passkeys; the two completion paths MUST STAY IN SYNC — any change to LoginService.CompleteAsync must be mirrored here, and vice versa.
Constructors
PasskeyLoginCompleter(IUserStore users, ISessionStore sessions, RefreshTokenService refreshTokens, AccessTokenMinter minter, Func<SigningKey> primaryKey, SentinelTokenOptions tokenOptions, ISentinelClock clock, ISentinelEventSink events)
Framework-free completion seam for passkey login. Passkey ceremonies are verified in the AspNetCore package (Fido2NetLib lives there); once an assertion has been cryptographically verified and mapped to a user, this class turns that verdict into a session + token pair the same way a password login would. DELIBERATE DUPLICATION: PasskeyLoginCompleter.CompleteAsync replicates the private LoginService.CompleteAsync semantics line for line (org selection, session creation, refresh + access minting, login.success event). LoginService is factor-complete for password/TOTP/recovery and is not modified for passkeys; the two completion paths MUST STAY IN SYNC — any change to LoginService.CompleteAsync must be mirrored here, and vice versa.
Methods
ValueTask<LoginResult> CompleteAsync(User user, Guid? requestedOrganizationId, string audience, SessionMfaLevel mfaLevel, string? ip = null, string? deviceDescription = null, CancellationToken ct = default(CancellationToken))
Completes an already-verified passkey authentication. mfaLevel is decided by the caller: SessionMfaLevel.PhishingResistant for a user-verified passkey (first factor or step-up); callers must never pass a level the ceremony did not actually earn.
PasswordResetOptions
public sealed class PasswordResetOptions
Lifetimes for the mail-delivered single-purpose tokens this service mints.
Properties
TimeSpan EmailVerifyTokenLifetime { get; set; }
Verification links are lower-stakes (they can only confirm an address), so a day is fine.
TimeSpan TokenLifetime { get; set; }
Reset links are short-lived: long enough to check a mailbox, no longer.
PasswordResetOutcome
public enum PasswordResetOutcome
Provides the base class for enumerations.
Values
SuccessInvalidToken— Bad signature, wrong typ, expired, already used, or no such user — one bucket, no oracle.
PasswordResetService
public sealed class PasswordResetService
Forgot/reset password and email verification. Both flows ride the same mechanics: a signed single-purpose token goes out by mail, comes back exactly once (deny-listed on use via IPasswordResetStore.TryConsumeJtiAsync), and the typ header keeps the two token kinds — and access tokens — mutually unusable.
Constructors
PasswordResetService(IUserStore users, IPasswordResetStore resetStore, PasswordHasher hasher, AccessTokenMinter minter, Func<SigningKey> primaryKey, SentinelTokenOptions tokenOptions, PasswordResetOptions options, ISentinelClock clock, ISentinelMailer mailer, ISentinelEventSink events, RefreshTokenService refreshTokens)
Forgot/reset password and email verification. Both flows ride the same mechanics: a signed single-purpose token goes out by mail, comes back exactly once (deny-listed on use via IPasswordResetStore.TryConsumeJtiAsync), and the typ header keeps the two token kinds — and access tokens — mutually unusable.
Methods
ValueTask RequestAsync(Guid realmId, string email, CancellationToken ct = default(CancellationToken))
Requests a reset mail. ALWAYS returns as plain success — the response must not reveal whether the address has an account (no user enumeration); only an existing active user actually gets mail.
ValueTask SendVerificationEmailAsync(Guid userId, CancellationToken ct = default(CancellationToken))
Sends (or re-sends) the verification mail for the user’s current address.
ValueTask<PasswordResetOutcome> ResetAsync(string resetToken, string newPassword, CancellationToken ct = default(CancellationToken))
ValueTask<PasswordResetOutcome> VerifyEmailAsync(string verifyToken, CancellationToken ct = default(CancellationToken))
ScimGroupMemberInfo
public sealed record ScimGroupMemberInfo : IEquatable<ScimGroupMemberInfo>
A group member row with its display value (the member user’s email) pre-joined.
Constructors
ScimGroupMemberInfo(Guid UserId, string? Display)
A group member row with its display value (the member user’s email) pre-joined.
Properties
Guid UserId { get; init; }
string? Display { get; init; }
ScimPage<T>
public sealed record ScimPage<T> : IEquatable<ScimPage<T>>
One page of a SCIM listing; ScimPage.TotalCount is the unpaged match count (RFC 7644 totalResults).
Constructors
ScimPage(IReadOnlyList<T> Items, int TotalCount)
One page of a SCIM listing; ScimPage.TotalCount is the unpaged match count (RFC 7644 totalResults).
Properties
IReadOnlyList<T> Items { get; init; }
int TotalCount { get; init; }
ScimUserFilter
public sealed record ScimUserFilter : IEquatable<ScimUserFilter>
Exact-match constraints for the SCIM user filter subset: userName eq "…" maps to ScimUserFilter.UserName (pre-normalized: trimmed, lowercased — userName is the email), externalId eq "…" to ScimUserFilter.ExternalId (verbatim). Null means unconstrained.
Constructors
ScimUserFilter(string? UserName = null, string? ExternalId = null)
Exact-match constraints for the SCIM user filter subset: userName eq "…" maps to ScimUserFilter.UserName (pre-normalized: trimmed, lowercased — userName is the email), externalId eq "…" to ScimUserFilter.ExternalId (verbatim). Null means unconstrained.
Properties
string? ExternalId { get; init; }
string? UserName { get; init; }
SentinelTokenOptions
public sealed class SentinelTokenOptions
Token lifetimes and issuer identity.
Properties
TimeSpan AccessTokenLifetime { get; set; }
Short by design: access tokens are stateless, so lifetime bounds revocation lag.
TimeSpan MfaPendingLifetime { get; set; }
The mfa_pending single-purpose token: just long enough to type a code.
TimeSpan RefreshTokenLifetime { get; set; }
string Issuer { get; set; }
The iss claim and the value verifiers must expect. Usually the public base URL.
SentinelTokenTypes
public static class SentinelTokenTypes
Sentinel token type identifiers, carried in the protected header’s typ.
Fields
const string Access = "at+sentinel"
const string EmailVerify = "everify+sentinel"
const string Invitation = "invite+sentinel"
const string MfaPending = "mfa+sentinel"
const string PasswordReset = "pwreset+sentinel"
ServiceAccountCreated
public sealed record ServiceAccountCreated : IEquatable<ServiceAccountCreated>
Creation result: ServiceAccountCreated.Secret is the plaintext secret, returned ONCE — only its hash is stored.
Constructors
ServiceAccountCreated(ServiceAccount Account, string Secret)
Creation result: ServiceAccountCreated.Secret is the plaintext secret, returned ONCE — only its hash is stored.
Properties
ServiceAccount Account { get; init; }
string Secret { get; init; }
TotpEnrollment
public sealed class TotpEnrollment
Properties
DateTimeOffset EnrolledAt { get; set; }
Guid Id { get; set; }
Guid UserId { get; set; }
long LastAcceptedStep { get; set; }
Last accepted TOTP step; replaying the same or an older step is rejected (anti-replay).
required byte[] Secret { get; set; }
Encrypted at rest by the store adapter (the encryption-at-rest posture applies to MFA secrets too).
string? Label { get; set; }
WorkloadExchangeOutcome
public enum WorkloadExchangeOutcome
Provides the base class for enumerations.
Values
Exchanged— The external token matched a trust;WorkloadExchangeResult.AccessTokencarries the Sentinel token.Denied— No active trust accepted the token. One outcome for every failure mode — a probing caller learns nothing from the shape.
WorkloadExchangeResult
public sealed record WorkloadExchangeResult : IEquatable<WorkloadExchangeResult>
Exchange result. On denial, WorkloadExchangeResult.DenialReason is a stable machine identifier for the FIRST failure that ruled out the last candidate trust (audit/event data — never sent to the caller, who gets an opaque invalid_grant).
Constructors
WorkloadExchangeResult(WorkloadExchangeOutcome Outcome, string? AccessToken = null, TimeSpan? ExpiresIn = null, WorkloadTrustConfig? Trust = null, string? DenialReason = null)
Exchange result. On denial, WorkloadExchangeResult.DenialReason is a stable machine identifier for the FIRST failure that ruled out the last candidate trust (audit/event data — never sent to the caller, who gets an opaque invalid_grant).
Properties
TimeSpan? ExpiresIn { get; init; }
WorkloadExchangeOutcome Outcome { get; init; }
WorkloadTrustConfig? Trust { get; init; }
bool IsExchanged { get; }
string? AccessToken { get; init; }
string? DenialReason { get; init; }
WorkloadFederationOptions
public sealed class WorkloadFederationOptions
Knobs for workload federation exchanges. One small class, same posture as the other option types.
Properties
TimeSpan JwksCacheTimeToLive { get; set; }
Freshness window for cached remote JWKS/discovery documents.
string DefaultAudience { get; set; }
The aud minted into exchanged Sentinel tokens when the caller does not request one — normally the host’s API audience (the OidcServer wiring defaults it from SentinelAspNetOptions.Audience). Distinct from WorkloadTrustConfig.Audience, which is what the EXTERNAL token must carry.
WorkloadFederationService
public sealed class WorkloadFederationService
Workload identity federation: exchanges an external workload’s OIDC token (Kubernetes service account, GitHub Actions, cloud managed identity) for a Sentinel access token acting as the matching trust’s service account — secretless CI/CD via trust configurations instead of distributed secrets.
Flow: read the UNVERIFIED iss (only ever used to select candidate trusts — trust comes from the signature check that follows, exactly the pattern of the userinfo endpoint’s unverified-aud read), load the realm’s active trusts for that issuer, then for each candidate validate the token against the issuer’s JWKS (ExternalJwtValidator, keys via the IRemoteJwksCache port) and check the trust’s subject pattern and claim rules. The first fully-matching trust wins and a short-lived Sentinel access token is minted for its service account, with a wtrust claim carrying the trust id for audit.
NO refresh tokens, deliberately: the workload’s platform re-issues external tokens on demand (that renewable credential is the whole point of federation), so a workload that needs a fresh Sentinel token simply re-exchanges. A refresh token would reintroduce exactly the long-lived bearer secret in CI that workload federation exists to remove.
Constructors
WorkloadFederationService(IWorkloadTrustStore trusts, IMachineIdentityStore machines, IRemoteJwksCache jwksCache, Func<SigningKey> primaryKey, SentinelTokenOptions tokenOptions, WorkloadFederationOptions options, ISentinelClock clock, ISentinelEventSink events)
Workload identity federation: exchanges an external workload’s OIDC token (Kubernetes service account, GitHub Actions, cloud managed identity) for a Sentinel access token acting as the matching trust’s service account — secretless CI/CD via trust configurations instead of distributed secrets. Flow: read the UNVERIFIED iss (only ever used to select candidate trusts — trust comes from the signature check that follows, exactly the pattern of the userinfo endpoint’s unverified-aud read), load the realm’s active trusts for that issuer, then for each candidate validate the token against the issuer’s JWKS (ExternalJwtValidator, keys via the IRemoteJwksCache port) and check the trust’s subject pattern and claim rules. The first fully-matching trust wins and a short-lived Sentinel access token is minted for its service account, with a wtrust claim carrying the trust id for audit. NO refresh tokens, deliberately: the workload’s platform re-issues external tokens on demand (that renewable credential is the whole point of federation), so a workload that needs a fresh Sentinel token simply re-exchanges. A refresh token would reintroduce exactly the long-lived bearer secret in CI that workload federation exists to remove.
Methods
ValueTask<WorkloadExchangeResult> ExchangeAsync(Guid realmId, string externalToken, string? requestedAudience = null, CancellationToken cancellationToken = default(CancellationToken))
Exchanges externalToken for a Sentinel access token in realmId. requestedAudience overrides the minted aud (the same per-request audience override the client_credentials grant offers); null/empty uses WorkloadFederationOptions.DefaultAudience.
Fields
const string ExchangeDeniedEvent = "workload.exchange_denied"
Event kind emitted when no trust accepts the token (stable identifier).
const string ExchangeSucceededEvent = "workload.exchange_success"
Event kind emitted on a successful exchange (stable identifier).
Nuvora.Nexus.Sentinel.Permissions
PermissionId
public readonly struct PermissionId : IEquatable<PermissionId>
A fully-qualified permission id in the service:scope:action grammar.
Segments are lowercase [a-z0-9_-]+; the scope segment must be one of global|org|team|self. A PermissionId is always concrete — wildcards are only legal in PermissionPattern. The struct stores the original string plus pre-computed segment boundaries so the matcher can compare segments without allocating.
Properties
PermissionScope Scope { get; }
ReadOnlySpan<char> Action { get; }
ReadOnlySpan<char> ScopeSegment { get; }
ReadOnlySpan<char> Service { get; }
string Value { get; }
The full service:scope:action string.
Methods
bool Equals(PermissionId other)
Indicates whether the current object is equal to another object of the same type.
override bool Equals(object? obj)
Indicates whether this instance and a specified object are equal.
override int GetHashCode()
Returns the hash code for this instance.
override string ToString()
Returns the fully qualified type name of this instance.
static PermissionId Parse(string value)
static bool TryParse(string? value, out PermissionId id)
Operators
static bool operator !=(PermissionId left, PermissionId right)
static bool operator ==(PermissionId left, PermissionId right)
PermissionPattern
public readonly struct PermissionPattern : IEquatable<PermissionPattern>
A grant pattern over permission ids: service:scope:action where each segment is either concrete or contains a single * wildcard (*, can_view_*, *_reports, can_*_own).
Patterns are evaluated at check time, so a grant like *:org:* automatically covers permissions published after the grant was written — no data migration when a service adds a permission. This matcher is the semantic twin of the TypeScript client’s matcher; both are pinned by the cross-language golden vectors. Do not change matching behavior without updating the vector suite in the same commit.
Properties
ReadOnlySpan<char> ActionSegment { get; }
ReadOnlySpan<char> ScopeSegment { get; }
ReadOnlySpan<char> ServiceSegment { get; }
string Value { get; }
Methods
bool Equals(PermissionPattern other)
Indicates whether the current object is equal to another object of the same type.
bool Matches(in PermissionId id)
Allocation-free match of a concrete permission id against this pattern.
bool TryGetConcreteScope(out PermissionScope scope)
True when the scope segment is concrete (not *).
override bool Equals(object? obj)
Indicates whether this instance and a specified object are equal.
override int GetHashCode()
Returns the hash code for this instance.
override string ToString()
Returns the fully qualified type name of this instance.
static PermissionPattern Parse(string value)
static bool TryParse(string? value, out PermissionPattern pattern)
Operators
static bool operator !=(PermissionPattern left, PermissionPattern right)
static bool operator ==(PermissionPattern left, PermissionPattern right)
PermissionScope
public enum PermissionScope
The scope segment of a permission id. Scope is part of the permission’s identity, not a property of the grant: docs:org:read and docs:self:read are two different permissions. This keeps checks explicit at call sites and lets patterns wildcard the scope segment independently.
Values
Global— Applies realm-wide, independent of any organization.Org— Applies within an organization; evaluation requires an org context.Team— Applies within a team; evaluation requires resource/subject team context.Self— Applies only to resources owned by the acting subject.
PermissionScopes
public static class PermissionScopes
Methods
static bool TryParse(ReadOnlySpan<char> text, out PermissionScope scope)
static string Name(this PermissionScope scope)
Fields
static readonly string[] Names
Canonical lowercase names, index-aligned with PermissionScope.
Nuvora.Nexus.Sentinel.Policies
PolicyAttributeDefinition
public sealed class PolicyAttributeDefinition
A declared policy attribute. The registry of these is extensible by hosts; Sentinel’s built-ins (session lifetime, MFA requirement, allowed factors, …) use the same declaration path as host-defined attributes.
Constructors
PolicyAttributeDefinition(string key, PolicyMonotonicity monotonicity, object? defaultValue)
Properties
PolicyMonotonicity Monotonicity { get; }
object? DefaultValue { get; }
The effective value when no level sets the attribute.
string Key { get; }
PolicyMerge
public static class PolicyMerge
The tighten-only merge engine. Levels are ordered outermost-first (global → realm → org → group); each level may set a value or leave the attribute unset. Because every monotonicity’s combine operation is commutative, associative, and idempotent, the effective value cannot be loosened by any level — a child can only tighten. Write validation (PolicyMerge.ValidateWrite) is derived from the same combine: a proposed value is legal iff combining it with the parent’s effective value changes nothing about the parent’s contribution (i.e. the proposal is at-least-as-tight).
Methods
static bool ValidateWrite(PolicyAttributeDefinition definition, object? parentEffective, object? proposed)
True when proposed is at-least-as-tight as parentEffective — i.e. writing it at a child level cannot loosen the inherited policy. A null proposal (unsetting the level) is always legal: the parent value simply flows through.
static object? Effective(PolicyAttributeDefinition definition, IEnumerable<object?> levelValues)
Computes the effective value across levels (outermost first, nulls = unset). Falls back to the definition’s default when no level sets the attribute.
PolicyMonotonicity
public enum PolicyMonotonicity
How values of a policy attribute combine across levels, and equivalently which direction is “tighter”. Declaring monotonicity is the entire integration cost of a new policy attribute: both effective-value merging and cannot-loosen write validation are derived from it — there is no per-attribute engine code.
Values
Max— Numeric; higher is tighter (e.g. minimum password length). Effective = max of levels.Min— Numeric; lower is tighter (e.g. session lifetime minutes). Effective = min of levels.And— Boolean; false is tighter (e.g. “password login enabled”). Effective = AND.Or— Boolean; true is tighter (e.g. “MFA required”). Effective = OR.Intersection— Set of strings; smaller is tighter (e.g. allowed auth methods). Effective = intersection.
PolicyValues
public static class PolicyValues
Value-model helpers for policy attributes: numbers are doubles, booleans are bools, sets are string lists (order-insensitive, duplicates ignored). Mirrors the condition value model so host code learns one set of rules.
Methods
static bool SetEquals(IReadOnlyCollection<string> left, IReadOnlyCollection<string> right)
static double AsNumber(object? value)
static void EnsureValidForMonotonicity(PolicyMonotonicity monotonicity, object? value, string key)
Nuvora.Nexus.Sentinel.Ports
CacheInvalidation
public sealed record CacheInvalidation : IEquatable<CacheInvalidation>
What to invalidate. CacheInvalidation.Topic is a closed set; CacheInvalidation.EntityId narrows to one entity when set, otherwise the whole topic flushes.
Constructors
CacheInvalidation(CacheTopic Topic, Guid? EntityId = null)
What to invalidate. CacheInvalidation.Topic is a closed set; CacheInvalidation.EntityId narrows to one entity when set, otherwise the whole topic flushes.
Properties
CacheTopic Topic { get; init; }
Guid? EntityId { get; init; }
CacheTopic
public enum CacheTopic
Provides the base class for enumerations.
Values
SubjectPolicyPermissionsAppsJwksIdentityProvidersIpRules
ChallengeVerifyResult
public enum ChallengeVerifyResult
Provides the base class for enumerations.
Values
Success— Correct code; the challenge is consumed (single-use).WrongCode— Wrong code; one attempt burned, the challenge survives if any remain.ExpiredOrMissing— Unknown id or past its ttl — deliberately one bucket, so callers can’t probe for live challenge ids.TooManyAttempts— Attempt budget exhausted; the challenge is consumed — a fresh code must be requested.
IChallengeStore
public interface IChallengeStore
Short-lived server-side challenge codes (email OTP). This state MUST live on the server: a 6-digit code has ~20 bits of entropy, so it must never be offline-verifiable from anything the client holds — no signed token, no client-side hash. The server keeps the digest, bounds the lifetime, and bounds the guesses; the client only ever gets yes/no answers at online rates. Stores hold the hash of the code, never the code itself, so a store dump is not a bypass.
Methods
ValueTask StoreAsync(string challengeId, string codeHash, TimeSpan ttl, int maxAttempts, CancellationToken cancellationToken = default(CancellationToken))
Creates (or replaces) the challenge. Re-storing the same id resets ttl and attempts — “resend code” semantics.
ValueTask<ChallengeVerifyResult> VerifyAsync(string challengeId, string codeHash, CancellationToken cancellationToken = default(CancellationToken))
Verifies one guess. ChallengeVerifyResult.Success and the terminal failures (ChallengeVerifyResult.ExpiredOrMissing, ChallengeVerifyResult.TooManyAttempts) consume the challenge; ChallengeVerifyResult.WrongCode decrements the remaining attempts atomically — concurrent wrong guesses must not exceed the budget.
IFederationStateStore
public interface IFederationStateStore
Server-side stash for pending federated-login context: the nonce, PKCE verifier, and redirect targets between begin and the provider’s callback. Deliberately a dumb ttl’d payload shelf, NOT a single-use arbiter — atomic single-use of the state value is IChallengeStore’s contract and the federated flow uses BOTH: the challenge store consumes the state exactly once, then this store surrenders the payload. Keys are the SHA-256 of the state value, so a store dump alone cannot forge callbacks (the same hash-at-rest posture as the challenge store). In-memory default below; fleet deployments provide a shared adapter, same as the other hot-state ports.
Methods
ValueTask PutAsync(string key, string payload, TimeSpan ttl, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default(CancellationToken))
The payload, or null when unknown/expired — deliberately one bucket, mirroring the challenge store.
IFirstSeenMarkerStore
public interface IFirstSeenMarkerStore
Set-if-absent-with-TTL markers backing the credential-stuffing distinct-count approximation. Kept separate from IRateCounterStore on purpose: counters are increment/read, this is a one-shot existence check (ValKey adapter: SET NX EX) — a different primitive with a different contract, and widening the counter port would break every existing adapter for one caller’s need.
Availability contract, same as the counter store: implementations throw on backend failure; the caller (AbuseProtectionService) owns the fail-open/fail-closed policy — the store never makes that call.
Methods
ValueTask<bool> TryMarkAsync(string key, TimeSpan ttl, CancellationToken cancellationToken = default(CancellationToken))
Atomically creates the marker with the given TTL. True when the marker was newly set; false when it already existed (and had not yet expired).
IRateCounterStore
public interface IRateCounterStore
Sliding-window counters backing abuse protection. Keys are opaque composites built by the abuse layers (per-IP, per-(IP,account), per-account…). Implementations must be safe under concurrency; the ValKey adapter is the fleet-wide implementation, this port’s in-memory default is per-node (adequate for single-box deployments).
Availability contract: implementations throw on backend failure; the caller (AbuseProtection) decides fail-open vs fail-closed per layer — the store never makes that policy call.
Methods
ValueTask ResetAsync(string key, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<long> GetAsync(string key, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<long> IncrementAsync(string key, TimeSpan window, CancellationToken cancellationToken = default(CancellationToken))
Increments the counter and returns the new count within the window.
ISentinelCacheBus
public interface ISentinelCacheBus
Typed invalidation pub/sub across nodes. When an admin mutates a subject, policy, or key material, every node’s in-memory caches must drop the affected entries; this bus carries those notifications. The default is an in-process loopback — correct on one node, and the ValKey adapter extends the same messages across a fleet.
Methods
IDisposable Subscribe(Action<CacheInvalidation> handler)
Subscriptions receive local publications too (loopback) — the publishing node’s own caches also need to drop entries.
ValueTask PublishAsync(CacheInvalidation invalidation, CancellationToken cancellationToken = default(CancellationToken))
ISentinelClock
public interface ISentinelClock
Time source for everything in Sentinel. Token lifetimes, rotation overlap windows, and rate-counter buckets all read this — tests and importers substitute it; nothing in the domain calls DateTimeOffset.UtcNow directly.
Properties
DateTimeOffset UtcNow { get; }
ISentinelEventSink
public interface ISentinelEventSink
Fire-and-forget side-channel for security-relevant happenings: login failures, lockouts, break-glass use, webhook-worthy admin changes. This is in addition to durable persistence in the audit ledgers — the sink is for the host’s alerting, never the system of record, so implementations must not throw and must not block the caller.
Methods
ValueTask EmitAsync(SentinelEvent evt, CancellationToken cancellationToken = default(CancellationToken))
Must never throw; failures are the sink’s own problem (log-and-drop is acceptable).
ISentinelMailer
public interface ISentinelMailer
Outbound-mail port. Sentinel never renders markup: templates are the host’s job — per-locale overridable through the localizer port — and Sentinel supplies only the structured SentinelMail.Data the template needs (token, code, expiry minutes…). This keeps Core free of any templating or SMTP dependency and lets hosts route through whatever delivery pipeline they already run.
Methods
ValueTask SendAsync(SentinelMail mail, CancellationToken cancellationToken = default(CancellationToken))
ISentinelMetrics
public interface ISentinelMetrics
Metrics port: Core services report the hot-path counters/durations through this seam so Sentinel.Core stays free of any OpenTelemetry dependency. The Nuvora.Nexus.Sentinel.Diagnostics package implements it over a System.Diagnostics.Metrics.Meter; the default is a no-op. Implementations MUST be cheap and non-throwing — these calls sit on login and authorization hot paths.
Methods
void RecordAuthzCheck(bool allowed, double durationSeconds)
One point authorization check (single evaluation path); outcome allowed/denied.
void RecordLogin(string outcome, double durationSeconds)
One password-login attempt: outcome is the snake_case LoginStatus (success, invalid_credentials, mfa_required, blocked, captcha_required).
void RecordTokenMint(string kind)
One token minted; kind ∈ access | refresh | single_purpose.
void RecordWebhookDelivery(string outcome)
One webhook delivery attempt; outcome ∈ delivered | retried | abandoned.
ISessionEndNotifier
public interface ISessionEndNotifier
Port through which session-ending flows announce “this session (or subject) is done” (back-channel logout is the flagship consumer). Core and the AspNetCore auth endpoints only ever talk to this port; the OIDC server package wires its back-channel-logout delivery service into it, and hosts without the OIDC package keep the no-op default. Same posture as ISentinelEventSink: implementations must not throw and must not meaningfully block the caller — logout must never fail because a relying party’s logout endpoint is down.
Methods
ValueTask NotifySessionEndAsync(Guid realmId, Guid userId, Guid sessionId, CancellationToken cancellationToken = default(CancellationToken))
One session ended (user logout, RP-initiated logout, remote device logout).
ValueTask NotifySubjectSessionsEndAsync(Guid realmId, Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Every session of the subject is (being) ended — admin suspend, logout-all.
InMemoryChallengeStore
public sealed class InMemoryChallengeStore : IChallengeStore
Single-node default, clock-driven like InMemoryRateCounterStore so tests can advance time. The ValKey adapter is the fleet-wide implementation.
Constructors
InMemoryChallengeStore(ISentinelClock clock)
Single-node default, clock-driven like InMemoryRateCounterStore so tests can advance time. The ValKey adapter is the fleet-wide implementation.
Methods
ValueTask StoreAsync(string challengeId, string codeHash, TimeSpan ttl, int maxAttempts, CancellationToken cancellationToken = default(CancellationToken))
Creates (or replaces) the challenge. Re-storing the same id resets ttl and attempts — “resend code” semantics.
ValueTask<ChallengeVerifyResult> VerifyAsync(string challengeId, string codeHash, CancellationToken cancellationToken = default(CancellationToken))
Verifies one guess. ChallengeVerifyResult.Success and the terminal failures (ChallengeVerifyResult.ExpiredOrMissing, ChallengeVerifyResult.TooManyAttempts) consume the challenge; ChallengeVerifyResult.WrongCode decrements the remaining attempts atomically — concurrent wrong guesses must not exceed the budget.
InMemoryFederationStateStore
public sealed class InMemoryFederationStateStore : IFederationStateStore
Single-node default, clock-driven like InMemoryChallengeStore so tests can advance time.
Constructors
InMemoryFederationStateStore(ISentinelClock clock)
Single-node default, clock-driven like InMemoryChallengeStore so tests can advance time.
Methods
ValueTask PutAsync(string key, string payload, TimeSpan ttl, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<string?> GetAsync(string key, CancellationToken cancellationToken = default(CancellationToken))
The payload, or null when unknown/expired — deliberately one bucket, mirroring the challenge store.
InMemoryFirstSeenMarkerStore
public sealed class InMemoryFirstSeenMarkerStore : IFirstSeenMarkerStore
Single-node default, mirroring InMemoryRateCounterStore: one entry per key holding its expiry; expired entries are reclaimed lazily on the next TryMark for the same key, so memory is bounded by the number of distinct keys per TTL window.
Constructors
InMemoryFirstSeenMarkerStore(ISentinelClock clock)
Single-node default, mirroring InMemoryRateCounterStore: one entry per key holding its expiry; expired entries are reclaimed lazily on the next TryMark for the same key, so memory is bounded by the number of distinct keys per TTL window.
Methods
ValueTask<bool> TryMarkAsync(string key, TimeSpan ttl, CancellationToken cancellationToken = default(CancellationToken))
Atomically creates the marker with the given TTL. True when the marker was newly set; false when it already existed (and had not yet expired).
InMemoryRateCounterStore
public sealed class InMemoryRateCounterStore : IRateCounterStore
Fixed-window in-memory counter. Chosen over a true sliding window for predictable memory (one entry per key); the window boundary imprecision is acceptable for abuse thresholds and identical to the ValKey adapter’s INCR+EXPIRE behavior, so switching stores doesn’t change enforcement character.
Constructors
InMemoryRateCounterStore(ISentinelClock clock)
Fixed-window in-memory counter. Chosen over a true sliding window for predictable memory (one entry per key); the window boundary imprecision is acceptable for abuse thresholds and identical to the ValKey adapter’s INCR+EXPIRE behavior, so switching stores doesn’t change enforcement character.
Methods
ValueTask ResetAsync(string key, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<long> GetAsync(string key, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<long> IncrementAsync(string key, TimeSpan window, CancellationToken cancellationToken = default(CancellationToken))
Increments the counter and returns the new count within the window.
InProcessCacheBus
public sealed class InProcessCacheBus : ISentinelCacheBus
Typed invalidation pub/sub across nodes. When an admin mutates a subject, policy, or key material, every node’s in-memory caches must drop the affected entries; this bus carries those notifications. The default is an in-process loopback — correct on one node, and the ValKey adapter extends the same messages across a fleet.
Methods
IDisposable Subscribe(Action<CacheInvalidation> handler)
Subscriptions receive local publications too (loopback) — the publishing node’s own caches also need to drop entries.
ValueTask PublishAsync(CacheInvalidation invalidation, CancellationToken cancellationToken = default(CancellationToken))
NoopEventSink
public sealed class NoopEventSink : ISentinelEventSink
Default sink: drops everything. Hosts opt into forwarding.
Methods
ValueTask EmitAsync(SentinelEvent evt, CancellationToken cancellationToken = default(CancellationToken))
Must never throw; failures are the sink’s own problem (log-and-drop is acceptable).
Fields
static readonly NoopEventSink Instance
NoopMailer
public sealed class NoopMailer : ISentinelMailer
Default mailer: drops everything (every port has a working default). Fine for tests and single-user dev; production hosts must register a real implementation or the mail-driven flows (reset, verification, email OTP) silently go nowhere.
Methods
ValueTask SendAsync(SentinelMail mail, CancellationToken cancellationToken = default(CancellationToken))
Fields
static readonly NoopMailer Instance
NoopSentinelMetrics
public sealed class NoopSentinelMetrics : ISentinelMetrics
The default (every port has a working default): counts nothing, costs nothing.
Methods
void RecordAuthzCheck(bool allowed, double durationSeconds)
One point authorization check (single evaluation path); outcome allowed/denied.
void RecordLogin(string outcome, double durationSeconds)
One password-login attempt: outcome is the snake_case LoginStatus (success, invalid_credentials, mfa_required, blocked, captcha_required).
void RecordTokenMint(string kind)
One token minted; kind ∈ access | refresh | single_purpose.
void RecordWebhookDelivery(string outcome)
One webhook delivery attempt; outcome ∈ delivered | retried | abandoned.
Fields
static readonly NoopSentinelMetrics Instance
NoopSessionEndNotifier
public sealed class NoopSessionEndNotifier : ISessionEndNotifier
Default: nobody to notify. Registered/used wherever no OIDC server is mounted.
Methods
ValueTask NotifySessionEndAsync(Guid realmId, Guid userId, Guid sessionId, CancellationToken cancellationToken = default(CancellationToken))
One session ended (user logout, RP-initiated logout, remote device logout).
ValueTask NotifySubjectSessionsEndAsync(Guid realmId, Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Every session of the subject is (being) ended — admin suspend, logout-all.
Fields
static readonly NoopSessionEndNotifier Instance
SentinelEvent
public sealed record SentinelEvent : IEquatable<SentinelEvent>
One emitted event. SentinelEvent.Kind values are stable machine identifiers (documented in the webhook event catalog) — hosts switch on them, so renaming one is a breaking change.
Constructors
SentinelEvent(string Kind, Guid RealmId, DateTimeOffset OccurredAt, Guid? SubjectId = null, Guid? OrganizationId = null, IReadOnlyDictionary<string, object?>? Data = null)
One emitted event. SentinelEvent.Kind values are stable machine identifiers (documented in the webhook event catalog) — hosts switch on them, so renaming one is a breaking change.
Properties
DateTimeOffset OccurredAt { get; init; }
Guid RealmId { get; init; }
Guid? OrganizationId { get; init; }
Guid? SubjectId { get; init; }
IReadOnlyDictionary<string, object?>? Data { get; init; }
string Kind { get; init; }
SentinelMail
public sealed record SentinelMail : IEquatable<SentinelMail>
One message to deliver. SentinelMail.Kind selects the host’s template and is a stable machine identifier (same contract as SentinelEvent.Kind) — see SentinelMailKinds for the values Sentinel emits.
The last three properties are additive: SentinelMail.Locale is the recipient’s preferred locale when the sending flow knows it (User.Locale), null otherwise; SentinelMail.Subject/SentinelMail.Body are populated only when the default localization decorator (Localization.LocalizedMailerDecorator, wired by AddSentinelLocalization()) has rendered the mail. Hosts with their own template pipeline keep keying off SentinelMail.Kind + SentinelMail.Data and ignore the rendered fields — they are a convenience, not a contract change.
Constructors
SentinelMail(string Kind, string To, Guid RealmId, IReadOnlyDictionary<string, string> Data, string? Locale = null, string? Subject = null, string? Body = null)
One message to deliver. SentinelMail.Kind selects the host’s template and is a stable machine identifier (same contract as SentinelEvent.Kind) — see SentinelMailKinds for the values Sentinel emits. The last three properties are additive: SentinelMail.Locale is the recipient’s preferred locale when the sending flow knows it (User.Locale), null otherwise; SentinelMail.Subject/SentinelMail.Body are populated only when the default localization decorator (Localization.LocalizedMailerDecorator, wired by AddSentinelLocalization()) has rendered the mail. Hosts with their own template pipeline keep keying off SentinelMail.Kind + SentinelMail.Data and ignore the rendered fields — they are a convenience, not a contract change.
Properties
Guid RealmId { get; init; }
IReadOnlyDictionary<string, string> Data { get; init; }
string Kind { get; init; }
string To { get; init; }
string? Body { get; init; }
string? Locale { get; init; }
string? Subject { get; init; }
SentinelMailKinds
public static class SentinelMailKinds
Mail kinds Sentinel sends in Wave 1. Hosts key templates off these.
Fields
const string EmailOtp = "email_otp"
const string EmailVerify = "email_verify"
const string PasswordReset = "password_reset"
const string SecurityAlert = "security_alert"
SystemClock
public sealed class SystemClock : ISentinelClock
Time source for everything in Sentinel. Token lifetimes, rotation overlap windows, and rate-counter buckets all read this — tests and importers substitute it; nothing in the domain calls DateTimeOffset.UtcNow directly.
Properties
DateTimeOffset UtcNow { get; }
Fields
static readonly SystemClock Instance
Nuvora.Nexus.Sentinel.Privacy
ErasureResult
public sealed record ErasureResult : IEquatable<ErasureResult>
What an erasure did — returned to the admin, and the only shape that ever leaves the operation.
Constructors
ErasureResult(Guid UserId, int SessionsRevoked, int SecurityEventsRedacted, int AuditEntriesRedacted)
What an erasure did — returned to the admin, and the only shape that ever leaves the operation.
Properties
Guid UserId { get; init; }
int AuditEntriesRedacted { get; init; }
int SecurityEventsRedacted { get; init; }
int SessionsRevoked { get; init; }
IPersonalDataSource
public interface IPersonalDataSource
Narrow store port for the identity-side pieces of export and erasure that no existing login/admin port covers: linked-identity reads, authenticator deletion, and the anonymizing user write. Deliberately not a repository — exactly the queries PersonalDataService makes. The event-ledger side (retention and redaction) lives on IRetentionStore.
Methods
ValueTask DeleteAuthenticatorsAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Deletes every authenticator of the user: password credentials, passkeys, TOTP enrollments and recovery codes (erasure — the account must be unusable, not just unnamed).
ValueTask DeleteLinkedIdentitiesAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask UpdateUserAsync(User user, CancellationToken cancellationToken = default(CancellationToken))
Persists the anonymized user record (the erasure write).
ValueTask<IReadOnlyList<LinkedIdentity>> GetLinkedIdentitiesAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Federated identity links for the export bundle — and the rows erasure deletes.
IRetentionStore
public interface IRetentionStore
Ledger-side retention and redaction port. Two families of operation, one invariant: ADMIN-AUDIT REDACTION ONLY EVER NULLS THE RAW BeforeJson/AfterJson PAYLOADS — never the digest columns, the sequence, or the hashes. The chain commits to payloads by digest (see AdminAuditChain), so a redacted entry still verifies; deleting or re-hashing a row would (correctly) read as tampering. Security events have no chain, so aged-out rows are deleted outright while user-targeted redaction nulls the PII-bearing columns (payload, IP, device) and keeps the fact.
Methods
ValueTask<int> DeleteSecurityEventsBeforeAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default(CancellationToken))
Deletes security events older than cutoff (retention). Returns rows affected.
ValueTask<int> RedactAdminAuditPayloadsBeforeAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default(CancellationToken))
Nulls Before/After payloads of admin audit entries older than cutoff, digests untouched — the hash-chain-aware redaction. Returns rows affected.
ValueTask<int> RedactAdminAuditPayloadsForUserAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Nulls Before/After payloads of admin audit entries referencing the user (as target or actor), digests untouched so the chain still verifies. Returns rows affected.
ValueTask<int> RedactSecurityEventsForUserAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
Redacts the user’s security events in place (DataJson, IpAddress, DeviceDescription → null; kind and timestamp survive): the erasure companion — the ledger keeps that things happened, not the personal data inside them. Returns rows affected.
ISentinelCryptoKeyStore
public interface ISentinelCryptoKeyStore
Per-subject data-encryption keys for crypto-shredding (pattern per Relay’s ICryptoKeyStore, reimplemented here so Core stays dependency-free). PII is encrypted under a key bound to the data subject; destroying that key (ISentinelCryptoKeyStore.DestroyAsync) renders everything encrypted under it permanently unreadable — which is how erasure works over data that cannot (or must not) be rewritten row by row. Implementations are thread-safe.
Methods
ValueTask DestroyAsync(Guid subjectId, CancellationToken cancellationToken = default(CancellationToken))
Destroys the subject’s key (erasure). Idempotent; data under it becomes undecryptable.
ValueTask<byte[]> GetOrCreateKeyAsync(Guid subjectId, CancellationToken cancellationToken = default(CancellationToken))
The subject’s key, creating a fresh random AES-256 key on first use.
ValueTask<byte[]?> FindKeyAsync(Guid subjectId, CancellationToken cancellationToken = default(CancellationToken))
The subject’s key, or null when it was never created or has been destroyed (erased).
ISentinelCryptoShredder
public interface ISentinelCryptoShredder
Encrypts/decrypts values under a data subject’s key for crypto-shredding. Once the subject’s key is destroyed (ISentinelCryptoKeyStore.DestroyAsync), ISentinelCryptoShredder.TryDecryptAsync returns null — the data is irrecoverable, which is how erasure is achieved over stores that keep their rows.
Methods
ValueTask<string> EncryptAsync(Guid subjectId, string plaintext, CancellationToken cancellationToken = default(CancellationToken))
Encrypts under subjectId’s key (created on first use).
ValueTask<string?> TryDecryptAsync(Guid subjectId, string ciphertext, CancellationToken cancellationToken = default(CancellationToken))
Decrypts a value from ISentinelCryptoShredder.EncryptAsync, or null when the subject’s key was destroyed.
InMemorySentinelCryptoKeyStore
public sealed class InMemorySentinelCryptoKeyStore : ISentinelCryptoKeyStore
In-process ISentinelCryptoKeyStore for tests and single-box use. A durable, shared store (the EF adapter’s sentinel_subject_keys) is needed for multi-node erasure to actually erase.
Methods
ValueTask DestroyAsync(Guid subjectId, CancellationToken cancellationToken = default(CancellationToken))
Destroys the subject’s key (erasure). Idempotent; data under it becomes undecryptable.
ValueTask<byte[]> GetOrCreateKeyAsync(Guid subjectId, CancellationToken cancellationToken = default(CancellationToken))
The subject’s key, creating a fresh random AES-256 key on first use.
ValueTask<byte[]?> FindKeyAsync(Guid subjectId, CancellationToken cancellationToken = default(CancellationToken))
The subject’s key, or null when it was never created or has been destroyed (erased).
PersonalDataExport
public sealed record PersonalDataExport : IEquatable<PersonalDataExport>
The Art. 20 data-portability bundle: everything Sentinel holds about one person.
Constructors
PersonalDataExport(PersonalDataUser User, IReadOnlyList<Guid> OrganizationIds, IReadOnlyList<PersonalDataSession> Sessions, IReadOnlyList<PersonalDataSecurityEvent> SecurityEvents, IReadOnlyList<PersonalDataLinkedIdentity> LinkedIdentities, DateTimeOffset ExportedAt)
The Art. 20 data-portability bundle: everything Sentinel holds about one person.
Properties
DateTimeOffset ExportedAt { get; init; }
IReadOnlyList<Guid> OrganizationIds { get; init; }
IReadOnlyList<PersonalDataLinkedIdentity> LinkedIdentities { get; init; }
IReadOnlyList<PersonalDataSecurityEvent> SecurityEvents { get; init; }
IReadOnlyList<PersonalDataSession> Sessions { get; init; }
PersonalDataUser User { get; init; }
PersonalDataLinkedIdentity
public sealed record PersonalDataLinkedIdentity : IEquatable<PersonalDataLinkedIdentity>
Defines a generalized method that a value type or class implements to create a type-specific method for determining equality of instances.
Constructors
PersonalDataLinkedIdentity(Guid IdentityProviderId, string ProviderSubject, DateTimeOffset LinkedAt)
Properties
DateTimeOffset LinkedAt { get; init; }
Guid IdentityProviderId { get; init; }
string ProviderSubject { get; init; }
PersonalDataSecurityEvent
public sealed record PersonalDataSecurityEvent : IEquatable<PersonalDataSecurityEvent>
Defines a generalized method that a value type or class implements to create a type-specific method for determining equality of instances.
Constructors
PersonalDataSecurityEvent(string Kind, DateTimeOffset OccurredAt, Guid? OrganizationId, string? IpAddress, string? DeviceDescription, string? DataJson)
Properties
DateTimeOffset OccurredAt { get; init; }
Guid? OrganizationId { get; init; }
string Kind { get; init; }
string? DataJson { get; init; }
string? DeviceDescription { get; init; }
string? IpAddress { get; init; }
PersonalDataService
public sealed class PersonalDataService
GDPR data-subject operations: export (Art. 20) and erasure (Art. 17).
Both are fenced to sentinel:global:manage — erasure is a realm-level, irreversible operation, and the export bundle contains everything an account-takeover would want, so neither is delegated to org admins. The fence is the evaluator check, no org compare.
Erasure composes four mechanisms: crypto-shred (destroy the subject’s data key), anonymize (the user row keeps its id for audit references but loses every identifying field), disable (sessions revoked + notified, authenticators and federated links deleted), and chain-aware ledger redaction (payloads nulled, digests kept, chain still verifies). The ledgers themselves are KEPT: that an admin acted, and when, is not the subject’s personal data — the payloads were.
Constructors
PersonalDataService(IUserStore users, ISessionStore sessions, IAuditStore auditStore, AuditService audit, IPersonalDataSource personalData, IRetentionStore retention, ISentinelCryptoKeyStore cryptoKeys, ISentinelClock clock, ISentinelEventSink events, RefreshTokenService? refreshTokens = null, ISessionEndNotifier? sessionEndNotifier = null)
GDPR data-subject operations: export (Art. 20) and erasure (Art. 17). Both are fenced to sentinel:global:manage — erasure is a realm-level, irreversible operation, and the export bundle contains everything an account-takeover would want, so neither is delegated to org admins. The fence is the evaluator check, no org compare. Erasure composes four mechanisms: crypto-shred (destroy the subject’s data key), anonymize (the user row keeps its id for audit references but loses every identifying field), disable (sessions revoked + notified, authenticators and federated links deleted), and chain-aware ledger redaction (payloads nulled, digests kept, chain still verifies). The ledgers themselves are KEPT: that an admin acted, and when, is not the subject’s personal data — the payloads were.
Methods
ValueTask<AdminResult<ErasureResult>> EraseAsync(SubjectSnapshot caller, Guid userId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<PersonalDataExport>> ExportAsync(SubjectSnapshot caller, Guid userId, CancellationToken cancellationToken = default(CancellationToken))
PersonalDataSession
public sealed record PersonalDataSession : IEquatable<PersonalDataSession>
Defines a generalized method that a value type or class implements to create a type-specific method for determining equality of instances.
Constructors
PersonalDataSession(Guid Id, Guid? OrganizationId, DateTimeOffset CreatedAt, DateTimeOffset LastSeenAt, DateTimeOffset ExpiresAt, string? DeviceDescription, string? IpAddress)
Properties
DateTimeOffset CreatedAt { get; init; }
DateTimeOffset ExpiresAt { get; init; }
DateTimeOffset LastSeenAt { get; init; }
Guid Id { get; init; }
Guid? OrganizationId { get; init; }
string? DeviceDescription { get; init; }
string? IpAddress { get; init; }
PersonalDataUser
public sealed record PersonalDataUser : IEquatable<PersonalDataUser>
User record view for export — identity data only, never credential hashes or secrets.
Constructors
PersonalDataUser(Guid Id, string Email, string? DisplayName, bool EmailVerified, string Status, IReadOnlyDictionary<string, object?> Attributes, string? ScimExternalId, DateTimeOffset CreatedAt, DateTimeOffset? LastLoginAt)
User record view for export — identity data only, never credential hashes or secrets.
Properties
DateTimeOffset CreatedAt { get; init; }
DateTimeOffset? LastLoginAt { get; init; }
Guid Id { get; init; }
IReadOnlyDictionary<string, object?> Attributes { get; init; }
bool EmailVerified { get; init; }
string Email { get; init; }
string Status { get; init; }
string? DisplayName { get; init; }
string? ScimExternalId { get; init; }
RetentionService
public sealed class RetentionService
The retention sweep: deletes aged-out security events and payload-redacts aged-out admin audit entries, chain-awareness delegated to IRetentionStore (payloads nulled, digests kept — the chain still verifies). Host scheduling is the AspNetCore package’s SentinelRetentionService hosted service; this class is one idempotent pass.
Constructors
RetentionService(IRetentionStore store, ISentinelClock clock, ISentinelEventSink events, SentinelRetentionOptions options)
The retention sweep: deletes aged-out security events and payload-redacts aged-out admin audit entries, chain-awareness delegated to IRetentionStore (payloads nulled, digests kept — the chain still verifies). Host scheduling is the AspNetCore package’s SentinelRetentionService hosted service; this class is one idempotent pass.
Methods
ValueTask<RetentionSweepResult> RunOnceAsync(CancellationToken cancellationToken = default(CancellationToken))
RetentionSweepResult
public sealed record RetentionSweepResult : IEquatable<RetentionSweepResult>
One sweep’s outcome.
Constructors
RetentionSweepResult(int SecurityEventsDeleted, int AuditPayloadsRedacted)
One sweep’s outcome.
Properties
int AuditPayloadsRedacted { get; init; }
int SecurityEventsDeleted { get; init; }
SentinelCryptoShredder
public sealed class SentinelCryptoShredder : ISentinelCryptoShredder
AES-GCM ISentinelCryptoShredder over an ISentinelCryptoKeyStore. Same wire shape as Relay’s CryptoShredder (the pattern reference, reimplemented in Core — no Relay dependency): each value packs as nonce | tag | ciphertext, Base64-encoded; decryption is authenticated, so tampering throws rather than yielding garbage.
Constructors
SentinelCryptoShredder(ISentinelCryptoKeyStore keyStore)
AES-GCM ISentinelCryptoShredder over an ISentinelCryptoKeyStore. Same wire shape as Relay’s CryptoShredder (the pattern reference, reimplemented in Core — no Relay dependency): each value packs as nonce | tag | ciphertext, Base64-encoded; decryption is authenticated, so tampering throws rather than yielding garbage.
Methods
ValueTask<string> EncryptAsync(Guid subjectId, string plaintext, CancellationToken cancellationToken = default(CancellationToken))
Encrypts under subjectId’s key (created on first use).
ValueTask<string?> TryDecryptAsync(Guid subjectId, string ciphertext, CancellationToken cancellationToken = default(CancellationToken))
Decrypts a value from ISentinelCryptoShredder.EncryptAsync, or null when the subject’s key was destroyed.
SentinelRetentionOptions
public sealed class SentinelRetentionOptions
Retention policy. Null means “keep forever” for either ledger.
Properties
TimeSpan SweepInterval { get; set; }
How often the hosted retention sweep runs. Daily by default.
TimeSpan? AdminAuditRetention { get; set; }
Admin audit entries older than this get their payloads redacted — never deleted, the chain is forever; only the Before/After payloads age out. Default: never.
TimeSpan? SecurityEventRetention { get; set; }
Security events older than this are deleted. Default one year.
Nuvora.Nexus.Sentinel.Risk
AllowAllRiskGate
public sealed class AllowAllRiskGate : IRiskGate
Default gate: every login is fine (the working default) — the adaptive engine is opt-in wiring.
Methods
ValueTask<RiskDecision> AssessLoginAsync(User user, string? ip, string? deviceFingerprint, CancellationToken cancellationToken = default(CancellationToken))
Fields
static readonly AllowAllRiskGate Instance
DeviceFingerprints
public static class DeviceFingerprints
Shared digest so every IDeviceHistoryStore implementation agrees on what “the same device” means.
Methods
static string Hash(string fingerprint)
GeoLocation
public sealed record GeoLocation : IEquatable<GeoLocation>
A resolved IP location. Coordinates are whatever precision the host’s database offers — city centroid is typical.
Constructors
GeoLocation(double Latitude, double Longitude, string? Country = null)
A resolved IP location. Coordinates are whatever precision the host’s database offers — city centroid is typical.
Properties
double Latitude { get; init; }
double Longitude { get; init; }
string? Country { get; init; }
IDeviceHistoryStore
public interface IDeviceHistoryStore
Device-familiarity history backing NewDeviceSignal and the new-device security-alert mail. Callers pass the RAW fingerprint; implementations hash it via DeviceFingerprints.Hash before storage or lookup.
Methods
ValueTask<IReadOnlyList<KnownDevice>> ListAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
The user’s known devices, for device pages and diagnostics. Fingerprints come back hashed.
ValueTask<bool> IsKnownAsync(Guid userId, string fingerprint, CancellationToken cancellationToken = default(CancellationToken))
True when this user has logged in from this fingerprint before.
ValueTask<bool> RecordAsync(Guid userId, string fingerprint, DateTimeOffset seenAt, CancellationToken cancellationToken = default(CancellationToken))
Records a successful login from the device. Returns true when the device was NEW for the user (first-seen recorded now) — the trigger for the new-device event and alert mail; false when it was already known (last-seen refreshed).
IGeoResolver
public interface IGeoResolver
IP-to-location port for the impossible-travel signal. Sentinel deliberately ships NO geo database — licensing, size and freshness make that the host’s choice (MaxMind, IP2Location, a CDN header…). The default NoopGeoResolver resolves nothing, and the signal honestly contributes zero when either endpoint of the trip cannot be resolved — no resolver, no travel verdict.
Methods
ValueTask<GeoLocation?> ResolveAsync(string ip, CancellationToken cancellationToken = default(CancellationToken))
Null when the IP cannot be located (private range, unknown, or no database configured).
IIpReputationProvider
public interface IIpReputationProvider
IP-reputation port for the reputation signal: is this address on a list the host cares about (botnet, TOR exit, commercial denylist…)? Sentinel ships no list — same posture as IGeoResolver. Implementations may call out; the engine’s per-signal isolation means a slow or failing provider degrades to a zero contribution, never a blocked login.
Methods
ValueTask<bool> IsListedAsync(string ip, CancellationToken cancellationToken = default(CancellationToken))
IRiskGate
public interface IRiskGate
Risk seam for login, shaped like ILoginGate: LoginService depends on this narrow gate instead of RiskEngine so the two areas compose in DI without a hard type dependency — the meta package binds the real adapter, tests and single-user hosts keep AllowAllRiskGate. Consulted AFTER first-factor success only: risk decides what a verified login still owes (nothing / a second factor / a refusal), it never substitutes for credential verification.
Methods
ValueTask<RiskDecision> AssessLoginAsync(User user, string? ip, string? deviceFingerprint, CancellationToken cancellationToken = default(CancellationToken))
IRiskScoreProvider
public interface IRiskScoreProvider
External risk-score port: hosts plug commercial risk APIs here. The returned score composes ADDITIVELY with the built-in signals — it appears as one more contribution (signal name external) in the explainability record, subject to the same 0–100 clamp and the same failure isolation (a throwing provider contributes zero plus a risk.signal_error event).
Methods
ValueTask<int?> GetScoreAsync(RiskContext context, CancellationToken cancellationToken = default(CancellationToken))
Additive score in 0–100, or null when the provider has no opinion for this login.
IRiskSignal
public interface IRiskSignal
One deterministic risk signal. Signals are additive and explainable: each returns a bounded score plus the human-readable reason that will be recorded verbatim on the risk.evaluated security event. No ML, no black boxes — the same context always produces the same contribution. A signal that cannot decide (missing fact, no resolver) contributes zero rather than guessing; a signal that THROWS is treated as zero by the engine and surfaces as a risk.signal_error event, so one broken provider never blocks logins.
Properties
string Name { get; }
Stable machine name of the signal, used in contributions and error events.
Methods
ValueTask<RiskContribution> AssessAsync(RiskContext context, CancellationToken cancellationToken = default(CancellationToken))
InMemoryDeviceHistoryStore
public sealed class InMemoryDeviceHistoryStore : IDeviceHistoryStore
Single-node default, same posture as the other in-memory ports: adequate for tests, samples and one-box hosts.
Methods
ValueTask<IReadOnlyList<KnownDevice>> ListAsync(Guid userId, CancellationToken cancellationToken = default(CancellationToken))
The user’s known devices, for device pages and diagnostics. Fingerprints come back hashed.
ValueTask<bool> IsKnownAsync(Guid userId, string fingerprint, CancellationToken cancellationToken = default(CancellationToken))
True when this user has logged in from this fingerprint before.
ValueTask<bool> RecordAsync(Guid userId, string fingerprint, DateTimeOffset seenAt, CancellationToken cancellationToken = default(CancellationToken))
Records a successful login from the device. Returns true when the device was NEW for the user (first-seen recorded now) — the trigger for the new-device event and alert mail; false when it was already known (last-seen refreshed).
KnownDevice
public sealed class KnownDevice
A device a user has successfully logged in from. Fingerprints are stored only as SHA-256 digests (DeviceFingerprints.Hash) — the raw client-supplied fingerprint never reaches persistence, matching the abuse counters’ data-minimization posture: device history is operational exhaust, not the system of record.
Properties
DateTimeOffset FirstSeenAt { get; set; }
DateTimeOffset LastSeenAt { get; set; }
Guid Id { get; set; }
Guid UserId { get; set; }
required string FingerprintHash { get; set; }
SHA-256 hex digest of the presented fingerprint; unique per (user, digest).
NoopGeoResolver
public sealed class NoopGeoResolver : IGeoResolver
Default resolver: locates nothing, so the impossible-travel signal is inert until the host plugs a real one.
Methods
ValueTask<GeoLocation?> ResolveAsync(string ip, CancellationToken cancellationToken = default(CancellationToken))
Null when the IP cannot be located (private range, unknown, or no database configured).
Fields
static readonly NoopGeoResolver Instance
NoopIpReputationProvider
public sealed class NoopIpReputationProvider : IIpReputationProvider
Default provider: nothing is listed, so the reputation signal is inert until the host plugs a real source.
Methods
ValueTask<bool> IsListedAsync(string ip, CancellationToken cancellationToken = default(CancellationToken))
Fields
static readonly NoopIpReputationProvider Instance
NoopRiskScoreProvider
public sealed class NoopRiskScoreProvider : IRiskScoreProvider
Default provider: no opinion, ever (the port exists, the integration is the host’s).
Methods
ValueTask<int?> GetScoreAsync(RiskContext context, CancellationToken cancellationToken = default(CancellationToken))
Additive score in 0–100, or null when the provider has no opinion for this login.
Fields
static readonly NoopRiskScoreProvider Instance
RiskAssessment
public sealed record RiskAssessment : IEquatable<RiskAssessment>
One evaluated login: the decision, the clamped total, and every signal’s contribution — including the zero ones, because “we checked and found nothing” is part of the explanation.
Constructors
RiskAssessment(RiskDecision Decision, int Score, IReadOnlyList<RiskContribution> Contributions)
One evaluated login: the decision, the clamped total, and every signal’s contribution — including the zero ones, because “we checked and found nothing” is part of the explanation.
Properties
IReadOnlyList<RiskContribution> Contributions { get; init; }
RiskDecision Decision { get; init; }
int Score { get; init; }
RiskContext
public sealed record RiskContext : IEquatable<RiskContext>
Everything a risk signal may consult about the login being assessed. Assembled by the login integration AFTER first-factor success — risk never sees failed attempts (abuse protection owns those) and never substitutes for a credential check. All members are optional except identity: signals must degrade to a zero contribution when the fact they need is absent, never guess.
Constructors
RiskContext(Guid UserId, Guid RealmId, string? Ip = null, string? DeviceFingerprint = null, string? CountryHint = null, DateTimeOffset? LastLoginAt = null, bool? KnownDevice = null, IReadOnlyDictionary<string, object?>? Extra = null)
Everything a risk signal may consult about the login being assessed. Assembled by the login integration AFTER first-factor success — risk never sees failed attempts (abuse protection owns those) and never substitutes for a credential check. All members are optional except identity: signals must degrade to a zero contribution when the fact they need is absent, never guess.
Properties
DateTimeOffset? LastLoginAt { get; init; }
The user’s previous successful login instant, for travel-time math.
Guid RealmId { get; init; }
Tenant scope — every counter/marker key a signal mints must include it.
Guid UserId { get; init; }
Subject being assessed.
IReadOnlyDictionary<string, object?>? Extra { get; init; }
Open bag for host- and adapter-supplied facts; well-known keys live in RiskContextKeys.
bool? KnownDevice { get; init; }
Precomputed device-familiarity verdict; when null, NewDeviceSignal asks IDeviceHistoryStore itself.
string? CountryHint { get; init; }
Host-supplied country (e.g. from a CDN header) for signals that want geography without a resolver.
string? DeviceFingerprint { get; init; }
Opaque client-supplied device fingerprint; same value that lands on Session.DeviceFingerprint.
string? Ip { get; init; }
Presenting IP, when the transport knows it.
RiskContextKeys
public static class RiskContextKeys
Well-known RiskContext.Extra keys. Stable machine identifiers, same contract as event kinds.
Fields
const string LastIp = "last_ip"
The IP of the user’s previous login (string) — the “from” end of the impossible-travel computation.
RiskContribution
public sealed record RiskContribution : IEquatable<RiskContribution>
One signal’s verdict. RiskContribution.Score is additive, expected in 0–100 (the engine clamps defensively); RiskContribution.Reason is the explainability payload — write it for the analyst reading the security event, not for code.
Constructors
RiskContribution(int Score, string Reason, string Signal)
One signal’s verdict. RiskContribution.Score is additive, expected in 0–100 (the engine clamps defensively); RiskContribution.Reason is the explainability payload — write it for the analyst reading the security event, not for code.
Properties
int Score { get; init; }
string Reason { get; init; }
string Signal { get; init; }
Methods
static RiskContribution None(string signal, string reason)
Convenience for the “nothing to report” case — signals should still say WHY.
RiskDecision
public enum RiskDecision
The three actions a risk score can map to: allow / step-up MFA / block + notify.
Values
AllowStepUpMfa— Force a second factor even where enrollment alone wouldn’t.Block— Refuse the login. The caller must keep the refusal OPAQUE on the wire (indistinguishable from bad credentials); the notify half is therisk.blockedevent.
RiskEngine
public sealed class RiskEngine
The adaptive-authentication engine: runs every registered IRiskSignal in parallel, adds the external provider’s score, clamps the sum to 0–100, and maps it to a RiskDecision through the SentinelRiskOptions thresholds. Failure posture mirrors the abuse-protection fail-open: a throwing signal (or external provider) contributes ZERO and emits risk.signal_error — risk scoring is defense in depth on top of a verified first factor, so a broken provider must degrade coverage, never availability. Every evaluation is recorded as a risk.evaluated security event carrying all contributions and both thresholds; block and step-up outcomes additionally emit risk.blocked / risk.stepup sink events for the host’s alerting (“block + notify”).
Constructors
RiskEngine(IEnumerable<IRiskSignal> signals, IRiskScoreProvider externalScore, AuditService audit, ISentinelClock clock, ISentinelEventSink events, SentinelRiskOptions? options = null)
The adaptive-authentication engine: runs every registered IRiskSignal in parallel, adds the external provider’s score, clamps the sum to 0–100, and maps it to a RiskDecision through the SentinelRiskOptions thresholds. Failure posture mirrors the abuse-protection fail-open: a throwing signal (or external provider) contributes ZERO and emits risk.signal_error — risk scoring is defense in depth on top of a verified first factor, so a broken provider must degrade coverage, never availability. Every evaluation is recorded as a risk.evaluated security event carrying all contributions and both thresholds; block and step-up outcomes additionally emit risk.blocked / risk.stepup sink events for the host’s alerting (“block + notify”).
Methods
RiskDecision Decide(int score)
Threshold mapping: block wins over step-up, and both bounds are inclusive — a score AT the threshold acts.
ValueTask<RiskAssessment> EvaluateAsync(RiskContext context, CancellationToken cancellationToken = default(CancellationToken))
Fields
const string BlockedEvent = "risk.blocked"
Sink-event kind for the notify half of block + notify.
const string EvaluatedEvent = "risk.evaluated"
Security-event kind recorded for EVERY evaluation, whatever the decision.
const string ExternalSignalName = "external"
Contribution name for the external provider’s score.
const string SignalErrorEvent = "risk.signal_error"
Sink-event kind for a signal (or the external provider) that threw; the login proceeded with that signal at zero.
const string StepUpEvent = "risk.stepup"
Sink-event kind emitted when a login is stepped up by risk rather than by enrollment.
SentinelRiskOptions
public sealed class SentinelRiskOptions
Adaptive-authentication configuration: the score-to-action thresholds and the dials of the built-in signals. Signal WEIGHTS are deliberately fixed constants on the signal classes (deterministic and documented — no ML claims); what a realm tunes is where the summed score starts to hurt.
Properties
TimeSpan VelocityWindow { get; init; }
Window for the velocity signal’s distinct-IP count. Default one hour.
double MaxPlausibleSpeedKmh { get; init; }
Fastest plausible travel; apparent speed above this is “impossible travel”. Default 900 km/h — airliner cruise.
double TravelMinDistanceKm { get; init; }
Trips shorter than this never count as travel, whatever the apparent speed — city-scale resolver jitter (two IPs of one metro pool resolving to different centroids) would otherwise read as teleportation. Default 100 km.
int BlockThreshold { get; init; }
Summed score at or above this blocks the login outright, opaquely. Default 80 — takes at least two strong signals.
int StepUpThreshold { get; init; }
Summed score at or above this forces MFA step-up. Default 40 — one strong signal alone (new device +30) stays under it.
int VelocityDistinctIpThreshold { get; init; }
Distinct IPs per user tolerated inside SentinelRiskOptions.VelocityWindow; MORE than this contributes the velocity weight.
Nuvora.Nexus.Sentinel.Risk.Signals
ImpossibleTravelSignal
public sealed class ImpossibleTravelSignal : IRiskSignal
Impossible-travel signal: the apparent trip from the previous login’s IP (RiskContextKeys.LastIp + RiskContext.LastLoginAt) to the current one, at faster than SentinelRiskOptions.MaxPlausibleSpeedKmh, contributes ImpossibleTravelSignal.Weight. Geography comes from the host’s IGeoResolver — Sentinel ships no geo database (the host’s licensing/freshness choice, see the resolver port), so with the default noop resolver this signal honestly contributes zero: no location, no verdict. Trips under SentinelRiskOptions.TravelMinDistanceKm are ignored as resolver jitter.
Constructors
ImpossibleTravelSignal(IGeoResolver geo, ISentinelClock clock, SentinelRiskOptions? options = null)
Impossible-travel signal: the apparent trip from the previous login’s IP (RiskContextKeys.LastIp + RiskContext.LastLoginAt) to the current one, at faster than SentinelRiskOptions.MaxPlausibleSpeedKmh, contributes ImpossibleTravelSignal.Weight. Geography comes from the host’s IGeoResolver — Sentinel ships no geo database (the host’s licensing/freshness choice, see the resolver port), so with the default noop resolver this signal honestly contributes zero: no location, no verdict. Trips under SentinelRiskOptions.TravelMinDistanceKm are ignored as resolver jitter.
Properties
string Name { get; }
Stable machine name of the signal, used in contributions and error events.
Methods
ValueTask<RiskContribution> AssessAsync(RiskContext context, CancellationToken cancellationToken = default(CancellationToken))
Fields
const int Weight = 40
+40: hits the default step-up threshold on its own — a physically impossible trip always deserves a second factor.
const string SignalName = "impossible_travel"
IpReputationSignal
public sealed class IpReputationSignal : IRiskSignal
IP-reputation signal: a presenting IP on the host’s denylist source contributes IpReputationSignal.Weight. Inert with the default noop provider — Sentinel ships no reputation data; the port (IIpReputationProvider) is the integration point.
Constructors
IpReputationSignal(IIpReputationProvider reputation)
IP-reputation signal: a presenting IP on the host’s denylist source contributes IpReputationSignal.Weight. Inert with the default noop provider — Sentinel ships no reputation data; the port (IIpReputationProvider) is the integration point.
Properties
string Name { get; }
Stable machine name of the signal, used in contributions and error events.
Methods
ValueTask<RiskContribution> AssessAsync(RiskContext context, CancellationToken cancellationToken = default(CancellationToken))
Fields
const int Weight = 50
+50: the strongest single signal — a listed IP plus ANY other signal crosses the default 80 block threshold, alone it forces step-up.
const string SignalName = "ip_reputation"
NewDeviceSignal
public sealed class NewDeviceSignal : IRiskSignal
New-device signal: a device fingerprint this user has never completed a login from contributes NewDeviceSignal.Weight. Uses the caller’s precomputed RiskContext.KnownDevice verdict when present, otherwise asks IDeviceHistoryStore directly. No fingerprint presented means no verdict — zero, honestly labeled — because punishing clients that don’t fingerprint would turn a visibility gap into a risk score.
Constructors
NewDeviceSignal(IDeviceHistoryStore devices)
New-device signal: a device fingerprint this user has never completed a login from contributes NewDeviceSignal.Weight. Uses the caller’s precomputed RiskContext.KnownDevice verdict when present, otherwise asks IDeviceHistoryStore directly. No fingerprint presented means no verdict — zero, honestly labeled — because punishing clients that don’t fingerprint would turn a visibility gap into a risk score.
Properties
string Name { get; }
Stable machine name of the signal, used in contributions and error events.
Methods
ValueTask<RiskContribution> AssessAsync(RiskContext context, CancellationToken cancellationToken = default(CancellationToken))
Fields
const int Weight = 30
+30: notable alone, but under the default 40 step-up threshold — a lone new device (every first login, every new laptop) shouldn’t step up by itself.
const string SignalName = "new_device"
VelocitySignal
public sealed class VelocitySignal : IRiskSignal
Velocity signal: successful first-factor logins for ONE user arriving from many DISTINCT IPs inside SentinelRiskOptions.VelocityWindow contribute VelocitySignal.Weight — the account-takeover-in-progress shape that per-IP abuse counters are blind to. Distinct counting uses the same first-seen-marker technique as the credential-stuffing layer: the per-user counter increments only when the (user, ip) marker is newly created this window, so retries from one IP count once. Store keys are realm-scoped and IPs appear only as truncated SHA-256 digests (data minimization).
Constructors
VelocitySignal(IRateCounterStore counters, IFirstSeenMarkerStore markers, SentinelRiskOptions? options = null)
Velocity signal: successful first-factor logins for ONE user arriving from many DISTINCT IPs inside SentinelRiskOptions.VelocityWindow contribute VelocitySignal.Weight — the account-takeover-in-progress shape that per-IP abuse counters are blind to. Distinct counting uses the same first-seen-marker technique as the credential-stuffing layer: the per-user counter increments only when the (user, ip) marker is newly created this window, so retries from one IP count once. Store keys are realm-scoped and IPs appear only as truncated SHA-256 digests (data minimization).
Properties
string Name { get; }
Stable machine name of the signal, used in contributions and error events.
Methods
ValueTask<RiskContribution> AssessAsync(RiskContext context, CancellationToken cancellationToken = default(CancellationToken))
Fields
const int Weight = 25
+25: suspicious but weak alone — VPN switchers and mobile carriers legitimately hop IPs; velocity is meant to compound with other signals.
const string SignalName = "velocity"
Nuvora.Nexus.Sentinel.Tokens
Base64Url
public static class Base64Url
RFC 7515 base64url (no padding) over the BCL’s vectorized implementation, plus the non-throwing decode every token parser needs. (Named the same as the BCL type on purpose — within Sentinel code this is the one to use, hence the global:: qualification inside.)
Methods
static bool TryDecode(ReadOnlySpan<char> text, out byte[] data)
static byte[] Decode(ReadOnlySpan<char> text)
static string Encode(ReadOnlySpan<byte> data)
IRefreshTokenStore
public interface IRefreshTokenStore
Hot-state store for refresh tokens. Contract notes: IRefreshTokenStore.TryMarkUsedAsync must be atomic check-and-set (returns false when the token was already used) — reuse detection is a race with the attacker and must not TOCTOU.
Methods
ValueTask RevokeAllForSessionAsync(Guid sessionId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RevokeAllForSubjectAsync(Guid subjectId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RevokeFamilyAsync(Guid familyId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask StoreAsync(RefreshTokenRecord record, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<RefreshTokenRecord?> GetAsync(string tokenHash, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<bool> TryMarkUsedAsync(string tokenHash, CancellationToken cancellationToken = default(CancellationToken))
Atomically transitions Used false→true; false when already used or missing.
ISigningKeyStore
public interface ISigningKeyStore
Persistence port for signing-key material. The ring (SigningKeyRing) owns rotation policy; the store only durably holds what it is given. Encryption at rest is the store adapter’s job — the port trades raw PKCS#8 bytes so the domain stays free of KMS/crypto-provider dependencies, and the EF Core / Key Vault / AWS KMS adapters each apply their own envelope encryption before touching disk.
Methods
ValueTask MarkRetiredAsync(Guid realmId, string keyId, DateTimeOffset retiredAt, CancellationToken cancellationToken = default(CancellationToken))
Stamps PersistedSigningKey.RetiredAt and clears PersistedSigningKey.IsPrimary. Retiring an unknown key id is a no-op — rotation of an ephemeral (never-persisted) primary must not fault the store.
ValueTask SaveAsync(Guid realmId, PersistedSigningKey key, CancellationToken cancellationToken = default(CancellationToken))
Upserts by PersistedSigningKey.KeyId.
ValueTask<IReadOnlyList<PersistedSigningKey>> LoadAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
All keys for the realm, including retired ones still inside their overlap window.
InMemoryRefreshTokenStore
public sealed class InMemoryRefreshTokenStore : IRefreshTokenStore
Single-node default. The ValKey adapter is the fleet implementation.
Methods
ValueTask RevokeAllForSessionAsync(Guid sessionId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RevokeAllForSubjectAsync(Guid subjectId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RevokeFamilyAsync(Guid familyId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask StoreAsync(RefreshTokenRecord record, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<RefreshTokenRecord?> GetAsync(string tokenHash, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<bool> TryMarkUsedAsync(string tokenHash, CancellationToken cancellationToken = default(CancellationToken))
Atomically transitions Used false→true; false when already used or missing.
InMemorySigningKeyStore
public sealed class InMemorySigningKeyStore : ISigningKeyStore
Single-node default. Holds material in process memory only — suitable for tests and single-box development; production hosts use the EF Core or KMS-backed adapters.
Methods
ValueTask MarkRetiredAsync(Guid realmId, string keyId, DateTimeOffset retiredAt, CancellationToken cancellationToken = default(CancellationToken))
Stamps PersistedSigningKey.RetiredAt and clears PersistedSigningKey.IsPrimary. Retiring an unknown key id is a no-op — rotation of an ephemeral (never-persisted) primary must not fault the store.
ValueTask SaveAsync(Guid realmId, PersistedSigningKey key, CancellationToken cancellationToken = default(CancellationToken))
Upserts by PersistedSigningKey.KeyId.
ValueTask<IReadOnlyList<PersistedSigningKey>> LoadAsync(Guid realmId, CancellationToken cancellationToken = default(CancellationToken))
All keys for the realm, including retired ones still inside their overlap window.
JwtCodec
public static class JwtCodec
In-house RS256 JWT encode/validate (Sentinel owns its protocol stack).
Validation is deliberately strict and non-optional (fixing the Node weaknesses): typ is ALWAYS enforced (token-type confusion is a vulnerability class, not an option), aud is ALWAYS enforced, and error classification is structural (TokenError) — never message-string sniffing.
Methods
static TokenValidationResult Validate(string token, Func<string, SigningKey?> resolveKey, TokenValidationRequirements requirements)
Validates signature + standard claims and returns the parsed payload. All requirements are mandatory by construction — there is no overload that skips typ or aud.
static string Encode(SigningKey key, string tokenType, Action<Utf8JsonWriter> writePayload)
Encodes and signs. writePayload writes the claim set body; the standard claims (iss/aud/sub/typ/iat/exp/jti…) are the caller’s responsibility via the writer too — the codec adds nothing silently, so what you write is exactly what is signed.
Fields
static readonly TimeSpan DefaultClockSkew
Small positive skew tolerance for distributed clocks; kept conservative on purpose.
PersistedSigningKey
public sealed record PersistedSigningKey : IEquatable<PersistedSigningKey>
Storage shape of one signing key. PersistedSigningKey.Pkcs8PrivateKey is the full private key in PKCS#8 DER form (RSA.ExportPkcs8PrivateKey()) — plaintext at this boundary by design: encryption at rest is the store adapter’s responsibility, so different hosts can pick KMS envelope encryption vs DPAPI vs filesystem permissions without the domain caring.
Constructors
PersistedSigningKey(string KeyId, byte[] Pkcs8PrivateKey, DateTimeOffset CreatedAt, DateTimeOffset? RetiredAt, bool IsPrimary)
Storage shape of one signing key. PersistedSigningKey.Pkcs8PrivateKey is the full private key in PKCS#8 DER form (RSA.ExportPkcs8PrivateKey()) — plaintext at this boundary by design: encryption at rest is the store adapter’s responsibility, so different hosts can pick KMS envelope encryption vs DPAPI vs filesystem permissions without the domain caring.
Properties
DateTimeOffset CreatedAt { get; init; }
DateTimeOffset? RetiredAt { get; init; }
bool IsPrimary { get; init; }
byte[] Pkcs8PrivateKey { get; init; }
string KeyId { get; init; }
Methods
static PersistedSigningKey FromKey(SigningKey key)
Snapshot of a live SigningKey for persistence.
RefreshOutcome
public enum RefreshOutcome
Provides the base class for enumerations.
Values
RotatedInvalid— Unknown, expired, or family-revoked token.ReuseDetected— An already-used token was presented: family revoked, event emitted.
RefreshResult
public sealed record RefreshResult : IEquatable<RefreshResult>
Defines a generalized method that a value type or class implements to create a type-specific method for determining equality of instances.
Constructors
RefreshResult(RefreshOutcome Outcome, string? NewToken, RefreshTokenRecord? Record)
Properties
RefreshOutcome Outcome { get; init; }
RefreshTokenRecord? Record { get; init; }
string? NewToken { get; init; }
RefreshTokenRecord
public sealed record RefreshTokenRecord : IEquatable<RefreshTokenRecord>
Opaque rotating refresh tokens with family-based reuse detection.
Model: every login creates a token family; each refresh rotates to a new token in the same family and marks the old one used. Presenting a used token means the token leaked (client replay or theft) — the whole family is revoked and a security event fires. Tokens are opaque 256-bit random values; the store only ever sees their SHA-256 digest, so a leaked store dump cannot be replayed.
Constructors
RefreshTokenRecord(string TokenHash, Guid FamilyId, Guid SessionId, Guid SubjectId, Guid RealmId, Guid? OrganizationId, DateTimeOffset ExpiresAt)
Opaque rotating refresh tokens with family-based reuse detection. Model: every login creates a token family; each refresh rotates to a new token in the same family and marks the old one used. Presenting a used token means the token leaked (client replay or theft) — the whole family is revoked and a security event fires. Tokens are opaque 256-bit random values; the store only ever sees their SHA-256 digest, so a leaked store dump cannot be replayed.
Properties
DateTimeOffset ExpiresAt { get; init; }
Guid FamilyId { get; init; }
Guid RealmId { get; init; }
Guid SessionId { get; init; }
Guid SubjectId { get; init; }
Guid? OrganizationId { get; init; }
bool Used { get; init; }
Set once the token has been rotated away; a used token presented again = reuse.
string TokenHash { get; init; }
string? ClientId { get; init; }
OAuth2 client binding (RFC 6749 §6): set (by the OIDC token endpoint) when the token was issued to a registered client — a refresh_token grant presenting it under any OTHER client_id is invalid_grant. Null for the session/cookie flow’s client-less tokens (/auth/login → /auth/refresh), which have no OAuth2 client at all. Rotation preserves it: RefreshTokenService.RefreshAsync copies the record forward with with, so the binding rides the whole family.
RefreshTokenService
public sealed class RefreshTokenService
Constructors
RefreshTokenService(IRefreshTokenStore store, ISentinelClock clock, ISentinelEventSink events, ISentinelMetrics? metrics = null)
Methods
ValueTask RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask RevokeSubjectAsync(Guid subjectId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<RefreshResult> RefreshAsync(string presentedToken, TimeSpan lifetime, CancellationToken cancellationToken = default(CancellationToken))
Rotates: old token is consumed, a new one in the same family is returned.
ValueTask<string> IssueAsync(Guid subjectId, Guid realmId, Guid sessionId, Guid? organizationId, TimeSpan lifetime, CancellationToken cancellationToken = default(CancellationToken))
Issues the first token of a new family (login) or a fresh family after org-switch.
static string HashToken(string token)
SentinelKeyOptions
public sealed class SentinelKeyOptions
Key-management knobs for the signing key ring.
Properties
TimeSpan OverlapWindow { get; init; }
How long a retired key keeps verifying (and stays in the JWKS) after rotation. Must exceed the longest access-token lifetime plus verifier JWKS cache TTLs, otherwise tokens signed just before rotation die early. 7 days is generous for both.
bool AllowEphemeralDevelopmentKeys { get; init; }
When true and no key is persisted, the ring generates an in-memory key instead of failing fast. Default false ON PURPOSE: ephemeral-by-default was the Node stack’s worst footgun (every restart silently invalidated all outstanding tokens). Development hosts opt in explicitly; production hosts must persist a key.
int KeySizeBits { get; init; }
RSA modulus size for newly generated keys. 2048 is the RS256 floor; 3072/4096 allowed.
SigningKey
public sealed class SigningKey : IDisposable
One RSA signing key in the rotation set. Rotation model: exactly one key signs at a time (SigningKey.IsPrimary); retired keys stay in the JWKS until every token they signed has expired (the overlap window), then drop out. Private material never leaves this object — persistence stores it encrypted and the JWKS export is public-only.
Constructors
SigningKey(string keyId, RSA rsa, DateTimeOffset createdAt, bool isPrimary)
Properties
DateTimeOffset CreatedAt { get; }
RSA Rsa { get; }
bool IsPrimary { get; internal set; }
The signing key. Non-primary keys only verify (rotation rollover).
string KeyId { get; }
JWK kid; stable for the key’s lifetime, unique per realm.
Methods
Dictionary<string, string> ToPublicJwk()
RFC 7517 public JWK for the JWKS endpoint. Public parameters only.
static SigningKey CreateNew(DateTimeOffset now, bool isPrimary = true, int keySizeBits = 2048)
void Dispose()
Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
SigningKeyRing
public sealed class SigningKeyRing : IDisposable
Per-realm signing key ring: loads persisted keys once and caches them (no per-request key import), exposes the current SigningKeyRing.Primary signer, resolves verification keys by kid for JwtCodec.Validate, serves the JWKS document, and rotates with an overlap window so in-flight tokens survive rotation.
Lifecycle: construct one ring per realm, call SigningKeyRing.InitializeAsync before first use. Reads after initialization are lock-free against an immutable snapshot; rotation swaps the snapshot atomically.
Constructors
SigningKeyRing(Guid realmId, ISigningKeyStore store, ISentinelClock clock, SentinelKeyOptions? options = null, Action<string>? warn = null)
Properties
SigningKey Primary { get; }
The key that signs new tokens. Exactly one at any time.
Methods
SigningKey? Resolve(string kid)
Verification-key resolver for JwtCodec.Validate — pass as ring.Resolve. Includes retired keys still inside the overlap window; returns null once RetiredAt + OverlapWindow has passed (RetiredAt is stamped at rotation time, i.e. the successor’s CreatedAt, so this is exactly the “successor CreatedAt + overlap” cutoff).
ValueTask InitializeAsync(CancellationToken cancellationToken = default(CancellationToken))
Loads and caches the realm’s keys. Fail-fast contract: with an empty store this THROWS unless SentinelKeyOptions.AllowEphemeralDevelopmentKeys is set — silently generating ephemeral keys (the Node default) means every restart invalidates all outstanding tokens, which must be an explicit development-only choice, never an accident.
ValueTask<SigningKey> RotateAsync(CancellationToken cancellationToken = default(CancellationToken))
Rotation: generates and persists a new primary, demotes the old primary to retired-as-of-now. The old key keeps verifying (and stays in the JWKS) for SentinelKeyOptions.OverlapWindow so tokens signed just before rotation remain valid; then it drops out of SigningKeyRing.Resolve and the JWKS.
string ToJwksDocument()
RFC 7517 JWKS document ({"keys":[…]}) for the JWKS endpoint: the primary plus retired-but-overlapping keys, public parameters only. Expired keys drop out here at the same instant they drop out of SigningKeyRing.Resolve.
void Dispose()
Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
TokenError
public enum TokenError
Provides the base class for enumerations.
Values
NoneMalformedUnknownKeySignatureInvalidWrongTypeWrongIssuerWrongAudienceExpiredNotYetValid
TokenValidationRequirements
public readonly record struct TokenValidationRequirements : IEquatable<TokenValidationRequirements>
What a validation caller must state up front — none of it optional.
Constructors
TokenValidationRequirements(string Issuer, string Audience, string TokenType, DateTimeOffset Now)
What a validation caller must state up front — none of it optional.
Properties
DateTimeOffset Now { get; init; }
TimeSpan ClockSkew { get; init; }
string Audience { get; init; }
string Issuer { get; init; }
string TokenType { get; init; }
TokenValidationResult
public readonly struct TokenValidationResult : IDisposable
Owns the payload document on success; dispose when done reading claims.
Properties
JsonDocument? Payload { get; }
TokenError Error { get; }
bool IsValid { get; }
Methods
static TokenValidationResult Failed(TokenError error)
static TokenValidationResult Success(JsonDocument payload)
void Dispose()
Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
Nuvora.Nexus.Sentinel.Webhooks
CompositeEventSink
public sealed class CompositeEventSink : ISentinelEventSink
Fans one SentinelEvent out to several sinks — the “both channels” shape: the host’s own alerting/broker bridge keeps receiving everything it did before, AND the webhook outbox (WebhookEnqueueSink) gets its copy. Members are isolated from each other: a throwing member is swallowed (the sink contract already demands members never throw — this is belt-and-braces) so one broken bridge cannot starve the others.
Constructors
CompositeEventSink(IReadOnlyList<ISentinelEventSink> sinks)
Fans one SentinelEvent out to several sinks — the “both channels” shape: the host’s own alerting/broker bridge keeps receiving everything it did before, AND the webhook outbox (WebhookEnqueueSink) gets its copy. Members are isolated from each other: a throwing member is swallowed (the sink contract already demands members never throw — this is belt-and-braces) so one broken bridge cannot starve the others.
Properties
IReadOnlyList<ISentinelEventSink> Sinks { get; }
Exposed for wiring introspection/tests; emission order follows list order.
Methods
ValueTask EmitAsync(SentinelEvent evt, CancellationToken cancellationToken = default(CancellationToken))
Must never throw; failures are the sink’s own problem (log-and-drop is acceptable).
IWebhookStore
public interface IWebhookStore
Persistence port for webhook endpoints and the delivery outbox. Authorization-free by contract — WebhookAdminService runs the delegated-admin fencing before any endpoint mutation, and the enqueue/dispatch paths are internal machinery. Kept narrow: exactly the queries the sink, dispatcher, and admin surface make.
Methods
ValueTask CreateEndpointAsync(WebhookEndpoint endpoint, CancellationToken cancellationToken = default(CancellationToken))
ValueTask DeleteEndpointAsync(Guid endpointId, CancellationToken cancellationToken = default(CancellationToken))
Deletes the endpoint and every delivery row owed to it.
ValueTask EnqueueAsync(IReadOnlyList<WebhookDelivery> deliveries, CancellationToken cancellationToken = default(CancellationToken))
ValueTask MarkAbandonedAsync(Guid deliveryId, int attemptCount, string error, CancellationToken cancellationToken = default(CancellationToken))
Terminal failure: sets Abandoned, keeping the row as the dead-letter record.
ValueTask MarkDeliveredAsync(Guid deliveryId, int attemptCount, DateTimeOffset deliveredAt, CancellationToken cancellationToken = default(CancellationToken))
Terminal success: stamps DeliveredAt, records the final attempt count, releases the claim.
ValueTask MarkFailedAsync(Guid deliveryId, int attemptCount, DateTimeOffset nextAttemptAt, string error, CancellationToken cancellationToken = default(CancellationToken))
Failed attempt with retries left: bumps the attempt count, schedules the next attempt, releases the claim.
ValueTask UpdateEndpointAsync(WebhookEndpoint endpoint, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (url/kinds/status/secret/failure bookkeeping). Loaded via IWebhookStore.GetEndpointAsync.
ValueTask<AdminPage<WebhookDelivery>> ListDeliveriesAsync(Guid endpointId, int offset, int limit, CancellationToken cancellationToken = default(CancellationToken))
Delivery log for one endpoint (dead-letter visibility), newest first, offset/limit paged.
ValueTask<IReadOnlyList<WebhookDelivery>> ClaimDueAsync(DateTimeOffset now, TimeSpan lease, int maxCount, CancellationToken cancellationToken = default(CancellationToken))
Atomically claims up to maxCount due outbox rows. A row is due when it is non-terminal (DeliveredAt == null && !Abandoned), NextAttemptAt <= now, and unclaimed (ClaimedUntil null or elapsed). Claiming sets ClaimedUntil = now + lease row by row with compare-and-swap semantics: when two dispatcher nodes race, each row is won by exactly one of them — no double-send within a lease window. A node that dies mid-delivery leaks nothing; its lease expires and the row becomes due again (at-least-once, see WebhookDelivery.ClaimedUntil).
ValueTask<IReadOnlyList<WebhookEndpoint>> ListEndpointsAsync(Guid realmId, Guid? organizationId = null, CancellationToken cancellationToken = default(CancellationToken))
Realm’s endpoints, stably ordered. organizationId null lists ALL (realm-level and org-scoped); a value lists that org’s only.
ValueTask<IReadOnlyList<WebhookEndpoint>> ListMatchCandidatesAsync(Guid realmId, Guid? organizationId, CancellationToken cancellationToken = default(CancellationToken))
The matching-endpoints-for-event query: ACTIVE endpoints of the realm visible to an event in the given org context — realm-level (null-org) endpoints always, org-scoped endpoints only when organizationId equals their org. Kind-pattern matching happens in the caller (WebhookEndpoint.SubscribesTo) so wildcard semantics live in exactly one place instead of being re-encoded per provider.
ValueTask<WebhookEndpoint?> GetEndpointAsync(Guid endpointId, CancellationToken cancellationToken = default(CancellationToken))
InMemoryWebhookStore
public sealed class InMemoryWebhookStore : IWebhookStore
In-memory IWebhookStore for tests and single-process samples. One coarse lock guards everything — webhook traffic is low-rate and correctness beats speed here; in particular the claim-due scan and lease stamp happen under the same lock, giving the same no-double-send guarantee the EF adapter gets from conditional updates.
Methods
ValueTask CreateEndpointAsync(WebhookEndpoint endpoint, CancellationToken cancellationToken = default(CancellationToken))
ValueTask DeleteEndpointAsync(Guid endpointId, CancellationToken cancellationToken = default(CancellationToken))
Deletes the endpoint and every delivery row owed to it.
ValueTask EnqueueAsync(IReadOnlyList<WebhookDelivery> deliveries, CancellationToken cancellationToken = default(CancellationToken))
ValueTask MarkAbandonedAsync(Guid deliveryId, int attemptCount, string error, CancellationToken cancellationToken = default(CancellationToken))
Terminal failure: sets Abandoned, keeping the row as the dead-letter record.
ValueTask MarkDeliveredAsync(Guid deliveryId, int attemptCount, DateTimeOffset deliveredAt, CancellationToken cancellationToken = default(CancellationToken))
Terminal success: stamps DeliveredAt, records the final attempt count, releases the claim.
ValueTask MarkFailedAsync(Guid deliveryId, int attemptCount, DateTimeOffset nextAttemptAt, string error, CancellationToken cancellationToken = default(CancellationToken))
Failed attempt with retries left: bumps the attempt count, schedules the next attempt, releases the claim.
ValueTask UpdateEndpointAsync(WebhookEndpoint endpoint, CancellationToken cancellationToken = default(CancellationToken))
Persists the mutated entity (url/kinds/status/secret/failure bookkeeping). Loaded via IWebhookStore.GetEndpointAsync.
ValueTask<AdminPage<WebhookDelivery>> ListDeliveriesAsync(Guid endpointId, int offset, int limit, CancellationToken cancellationToken = default(CancellationToken))
Delivery log for one endpoint (dead-letter visibility), newest first, offset/limit paged.
ValueTask<IReadOnlyList<WebhookDelivery>> ClaimDueAsync(DateTimeOffset now, TimeSpan lease, int maxCount, CancellationToken cancellationToken = default(CancellationToken))
Atomically claims up to maxCount due outbox rows. A row is due when it is non-terminal (DeliveredAt == null && !Abandoned), NextAttemptAt <= now, and unclaimed (ClaimedUntil null or elapsed). Claiming sets ClaimedUntil = now + lease row by row with compare-and-swap semantics: when two dispatcher nodes race, each row is won by exactly one of them — no double-send within a lease window. A node that dies mid-delivery leaks nothing; its lease expires and the row becomes due again (at-least-once, see WebhookDelivery.ClaimedUntil).
ValueTask<IReadOnlyList<WebhookEndpoint>> ListEndpointsAsync(Guid realmId, Guid? organizationId = null, CancellationToken cancellationToken = default(CancellationToken))
Realm’s endpoints, stably ordered. organizationId null lists ALL (realm-level and org-scoped); a value lists that org’s only.
ValueTask<IReadOnlyList<WebhookEndpoint>> ListMatchCandidatesAsync(Guid realmId, Guid? organizationId, CancellationToken cancellationToken = default(CancellationToken))
The matching-endpoints-for-event query: ACTIVE endpoints of the realm visible to an event in the given org context — realm-level (null-org) endpoints always, org-scoped endpoints only when organizationId equals their org. Kind-pattern matching happens in the caller (WebhookEndpoint.SubscribesTo) so wildcard semantics live in exactly one place instead of being re-encoded per provider.
ValueTask<WebhookEndpoint?> GetEndpointAsync(Guid endpointId, CancellationToken cancellationToken = default(CancellationToken))
WebhookAdminService
public sealed class WebhookAdminService
The admin surface for outbound webhooks, following the SentinelAdminService shape exactly: resolve the TARGET’s org (the endpoint’s own WebhookEndpoint.OrganizationId, null = realm-level), evaluate the caller against it — realm-level endpoints need sentinel:global:manage, org-scoped ones sentinel:org:manage AT that org — perform via IWebhookStore, and audit every mutation on the tamper-evident chain.
The structural fence applies unchanged: this service never compares org ids to decide authorization — it hands the endpoint’s org to AuthorizationEvaluator as AccessCheck.ResourceOrganizationId and returns the verdict. Realm fencing first: an endpoint in another realm is reported not-found.
Secrets: WebhookEndpoint.Secret is returned by exactly two operations — create and rotate (the “shown once” surface) — and NEVER serialized into audit snapshots. Reads through this service return the entity as stored; the HTTP layer’s views omit the secret.
Constructors
WebhookAdminService(IWebhookStore store, AuditService audit, ISentinelClock clock)
The admin surface for outbound webhooks, following the SentinelAdminService shape exactly: resolve the TARGET’s org (the endpoint’s own WebhookEndpoint.OrganizationId, null = realm-level), evaluate the caller against it — realm-level endpoints need sentinel:global:manage, org-scoped ones sentinel:org:manage AT that org — perform via IWebhookStore, and audit every mutation on the tamper-evident chain. The structural fence applies unchanged: this service never compares org ids to decide authorization — it hands the endpoint’s org to AuthorizationEvaluator as AccessCheck.ResourceOrganizationId and returns the verdict. Realm fencing first: an endpoint in another realm is reported not-found. Secrets: WebhookEndpoint.Secret is returned by exactly two operations — create and rotate (the “shown once” surface) — and NEVER serialized into audit snapshots. Reads through this service return the entity as stored; the HTTP layer’s views omit the secret.
Methods
ValueTask<AdminResult<AdminPage<WebhookDelivery>>> ListDeliveriesAsync(SubjectSnapshot caller, Guid endpointId, int offset, int limit, CancellationToken cancellationToken = default(CancellationToken))
The dead-letter/delivery log for one endpoint, newest first.
ValueTask<AdminResult<IReadOnlyList<WebhookEndpoint>>> ListEndpointsAsync(SubjectSnapshot caller, Guid? organizationId = null, CancellationToken cancellationToken = default(CancellationToken))
organizationId null → every endpoint in the caller’s realm (global manage); a value → that org’s endpoints only (org:manage at that org).
ValueTask<AdminResult<WebhookDelivery>> SendTestAsync(SubjectSnapshot caller, Guid endpointId, CancellationToken cancellationToken = default(CancellationToken))
Enqueues a synthetic webhook.test delivery (due immediately, normal retry policy), bypassing subscription matching — its whole point is verifying the endpoint’s URL/secret wiring regardless of what it subscribes to.
ValueTask<AdminResult<WebhookEndpoint>> CreateEndpointAsync(SubjectSnapshot caller, Guid? organizationId, string url, IReadOnlyList<string> eventKinds, CancellationToken cancellationToken = default(CancellationToken))
Creates an endpoint with a freshly minted secret. The returned entity carries the raw secret — the ONE moment (besides rotation) a caller sees it.
ValueTask<AdminResult<WebhookEndpoint>> DeleteEndpointAsync(SubjectSnapshot caller, Guid endpointId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<WebhookEndpoint>> GetEndpointAsync(SubjectSnapshot caller, Guid endpointId, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<WebhookEndpoint>> UpdateEndpointAsync(SubjectSnapshot caller, Guid endpointId, WebhookEndpointUpdate update, CancellationToken cancellationToken = default(CancellationToken))
ValueTask<AdminResult<string>> RotateSecretAsync(SubjectSnapshot caller, Guid endpointId, CancellationToken cancellationToken = default(CancellationToken))
Mints and stores a new secret, returning it ONCE. In-flight deliveries signed with the old secret may still arrive; consumers should hot-swap and briefly accept both. The audit entry records THAT rotation happened, never either secret value.
WebhookDelivery
public sealed class WebhookDelivery
One outbox row: a serialized event payload owed to one endpoint. Enqueued transactionally close to the event, delivered asynchronously by WebhookDispatcher with exponential backoff, and left in place after the terminal state (delivered or abandoned) as the dead-letter/delivery log the admin surface pages through.
Properties
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset NextAttemptAt { get; set; }
Due time for the next attempt; the first attempt is due immediately (= WebhookDelivery.CreatedAt).
DateTimeOffset? ClaimedUntil { get; set; }
The outbox dispatch lease: claiming sets this to now + lease, and claim-due queries skip rows whose lease is still in the future — so two dispatcher nodes never deliver the same row concurrently. A crashed node’s claim simply expires, making the row due again; deliveries are therefore at-least-once and consumers must treat WebhookDelivery.Id as their idempotency key.
DateTimeOffset? DeliveredAt { get; set; }
Terminal success marker; null while pending or abandoned.
Guid EndpointId { get; set; }
Guid Id { get; set; }
Also the wire delivery id (X-Sentinel-Delivery) consumers deduplicate on.
bool Abandoned { get; set; }
Terminal failure marker: the retry schedule was exhausted (or the endpoint disappeared/was disabled).
int AttemptCount { get; set; }
Completed delivery attempts so far (successful or not).
required string EventKind { get; set; }
Denormalized event kind, so the delivery log filters without parsing payloads.
required string PayloadJson { get; set; }
The exact JSON body POSTed (and signed) on every attempt — attempts never re-serialize.
string? LastError { get; set; }
Last failure: HTTP {status} or the exception message.
WebhookDispatcher
public sealed class WebhookDispatcher
The outbox dispatcher: claims due deliveries under a lease (IWebhookStore.ClaimDueAsync — no double-send across nodes), POSTs each with the timestamp-bound HMAC signature headers (WebhookSignature), and applies the retry/abandon/auto-disable policy from WebhookDispatcherOptions.
The HTTP transport is the injected httpPost delegate returning the response status code — Core stays HTTP-client-free; the AspNetCore package injects an IHttpClientFactory-backed implementation and hosts the poll loop (WebhookDispatcherService). A thrown transport exception counts as a failed attempt.
Operational events: exhausting the retry schedule emits webhook.abandoned; crossing the consecutive-failure threshold disables the endpoint and emits webhook.endpoint_disabled. Both carry endpointId in their data, which WebhookEnqueueSink uses to keep them from being webhooked back to the very endpoint that is failing.
Constructors
WebhookDispatcher(IWebhookStore store, ISentinelClock clock, Func<WebhookRequest, CancellationToken, Task<int>> httpPost, WebhookDispatcherOptions options, ISentinelEventSink events, ISentinelMetrics? metrics = null)
The outbox dispatcher: claims due deliveries under a lease (IWebhookStore.ClaimDueAsync — no double-send across nodes), POSTs each with the timestamp-bound HMAC signature headers (WebhookSignature), and applies the retry/abandon/auto-disable policy from WebhookDispatcherOptions. The HTTP transport is the injected httpPost delegate returning the response status code — Core stays HTTP-client-free; the AspNetCore package injects an IHttpClientFactory-backed implementation and hosts the poll loop (WebhookDispatcherService). A thrown transport exception counts as a failed attempt. Operational events: exhausting the retry schedule emits webhook.abandoned; crossing the consecutive-failure threshold disables the endpoint and emits webhook.endpoint_disabled. Both carry endpointId in their data, which WebhookEnqueueSink uses to keep them from being webhooked back to the very endpoint that is failing.
Methods
ValueTask<int> RunOnceAsync(CancellationToken cancellationToken = default(CancellationToken))
One poll: claim due deliveries and attempt each. Returns how many were delivered (2xx).
WebhookDispatcherOptions
public sealed class WebhookDispatcherOptions
Tuning for the outbox dispatcher; hosts configure via AddSentinelWebhooks(o => …).
Properties
IReadOnlyList<TimeSpan> RetryBackoff { get; set; }
Exponential backoff schedule: wait RetryBackoff[n-1] after failed attempt n; a failure with no schedule slot left abandons the delivery. Default 1m, 5m, 30m, 2h, 12h — six attempts total, then dead-letter.
TimeSpan ClaimLease { get; set; }
The claim lease (see IWebhookStore.ClaimDueAsync). Must comfortably exceed WebhookDispatcherOptions.RequestTimeout × WebhookDispatcherOptions.BatchSize in the worst case; a lease that expires mid-flight re-opens the row to other nodes (at-least-once, never lost).
TimeSpan PollInterval { get; set; }
How often the hosted service polls for due deliveries.
TimeSpan RequestTimeout { get; set; }
Per-request HTTP timeout applied by the transport (the AspNetCore sender).
int BatchSize { get; set; }
Maximum deliveries claimed (and attempted) per poll.
int DisableAfterConsecutiveFailures { get; set; }
Endpoint-level circuit breaker: this many consecutive failed attempts (across deliveries) auto-disables the endpoint.
WebhookEndpoint
public sealed class WebhookEndpoint
An admin-managed outbound webhook destination: a URL that receives HMAC-signed JSON deliveries for the SentinelEvent kinds it subscribes to. Endpoints are realm resources; an endpoint with WebhookEndpoint.OrganizationId set is org-scoped and receives ONLY events carrying exactly that organization id — an org admin’s webhook can never observe another org’s (or realm-wide) activity.
Properties
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset? DisabledAt { get; set; }
DateTimeOffset? LastFailureAt { get; set; }
DateTimeOffset? LastSuccessAt { get; set; }
Guid Id { get; set; }
Guid RealmId { get; set; }
Guid? OrganizationId { get; set; }
Null = realm-level endpoint (receives every matching event in the realm, managed under sentinel:global:manage). Set = org-scoped endpoint (receives only that org’s events, managed under sentinel:org:manage at that org — delegated-admin fencing).
List<string> EventKinds { get; set; }
Subscription patterns over SentinelEvent.Kind: exact kinds ("login.failed") or single-* wildcards ("login.*"), with WorkloadWildcard semantics — a malformed pattern (two stars) matches nothing, and is rejected at write time by WebhookAdminService.
WebhookEndpointStatus Status { get; set; }
int ConsecutiveFailures { get; set; }
Failed delivery attempts since the last success, across all deliveries. Reset to zero by any 2xx; at WebhookDispatcherOptions.DisableAfterConsecutiveFailures the dispatcher auto-disables the endpoint and emits webhook.endpoint_disabled.
required string Secret { get; set; }
The shared HMAC signing secret. Stored retrievable — not hashed — because HMAC signing needs the raw value on every delivery; at-rest protection follows the same posture (column/disk encryption now, KMS envelope-encryption adapters in Wave 5) as TotpEnrollment.Secret and SigningKeyRecord. Never written to the audit ledger; the admin surface returns it exactly once (on create and on rotate).
required string Url { get; set; }
Absolute http(s) URL deliveries are POSTed to.
Methods
bool Matches(SentinelEvent evt)
Full match check for one event: active, same realm, org fence (an org-scoped endpoint requires the event to carry exactly its org; a realm-level endpoint takes everything in the realm — including org events), and a subscription covering the kind.
bool SubscribesTo(string kind)
Does any subscription pattern cover this event kind? (Exact or single-* wildcard.)
WebhookEndpointStatus
public enum WebhookEndpointStatus
Provides the base class for enumerations.
Values
ActiveDisabled— Disabled by an admin or auto-disabled by the dispatcher; no new deliveries are enqueued and pending ones are abandoned.
WebhookEndpointUpdate
public sealed record WebhookEndpointUpdate : IEquatable<WebhookEndpointUpdate>
Patch document for one endpoint: only non-null members change (null = keep current value).
Constructors
WebhookEndpointUpdate(string? Url = null, IReadOnlyList<string>? EventKinds = null, WebhookEndpointStatus? Status = null)
Patch document for one endpoint: only non-null members change (null = keep current value).
Properties
IReadOnlyList<string>? EventKinds { get; init; }
Replacement subscription list (validated like create).
WebhookEndpointStatus? Status { get; init; }
Enable/disable; enabling resets the failure bookkeeping so a repaired consumer starts clean.
string? Url { get; init; }
New delivery URL (validated: absolute http/https).
WebhookEnqueueSink
public sealed class WebhookEnqueueSink : ISentinelEventSink
The bridge from the in-process event stream to the webhook outbox: for every SentinelEvent, finds the endpoints whose realm/org/kind subscription matches (WebhookEndpoint.Matches) and enqueues one WebhookDelivery per endpoint — delivery itself is WebhookDispatcher’s asynchronous job, so emitting never blocks a login on a slow webhook consumer.
Wired as one member of a composite alongside whatever sink the host registered (the in-process port remains for hosts bridging to their own alerting; see AddSentinelWebhooks()). Honors the sink contract: never throws — a broken store loses webhook fan-out for that event, never the operation that emitted it.
Recursion guard: the dispatcher’s own operational events (webhook.abandoned, webhook.endpoint_disabled) flow through this sink too, but are never enqueued to the endpoint they are ABOUT (matched via endpointId in the event data) — a failing endpoint must not receive an ever-growing stream of its own failure reports.
Constructors
WebhookEnqueueSink(IWebhookStore store, ISentinelClock clock)
The bridge from the in-process event stream to the webhook outbox: for every SentinelEvent, finds the endpoints whose realm/org/kind subscription matches (WebhookEndpoint.Matches) and enqueues one WebhookDelivery per endpoint — delivery itself is WebhookDispatcher’s asynchronous job, so emitting never blocks a login on a slow webhook consumer. Wired as one member of a composite alongside whatever sink the host registered (the in-process port remains for hosts bridging to their own alerting; see AddSentinelWebhooks()). Honors the sink contract: never throws — a broken store loses webhook fan-out for that event, never the operation that emitted it. Recursion guard: the dispatcher’s own operational events (webhook.abandoned, webhook.endpoint_disabled) flow through this sink too, but are never enqueued to the endpoint they are ABOUT (matched via endpointId in the event data) — a failing endpoint must not receive an ever-growing stream of its own failure reports.
Methods
ValueTask EmitAsync(SentinelEvent evt, CancellationToken cancellationToken = default(CancellationToken))
Must never throw; failures are the sink’s own problem (log-and-drop is acceptable).
WebhookPayload
public static class WebhookPayload
The wire payload: {id, kind, occurredAt, realmId, organizationId?, subjectId?, data?}. Serialized ONCE at enqueue time and stored verbatim in the outbox — every retry re-sends (and re-signs) byte-identical JSON, so consumer-side signature verification never fights re-serialization drift.
Methods
static string Build(Guid eventId, SentinelEvent evt)
WebhookRequest
public sealed record WebhookRequest : IEquatable<WebhookRequest>
One HTTP delivery attempt as the dispatcher hands it to the transport: POST WebhookRequest.Body to WebhookRequest.Url with WebhookRequest.Headers.
Constructors
WebhookRequest(string Url, string Body, IReadOnlyDictionary<string, string> Headers)
One HTTP delivery attempt as the dispatcher hands it to the transport: POST WebhookRequest.Body to WebhookRequest.Url with WebhookRequest.Headers.
Properties
IReadOnlyDictionary<string, string> Headers { get; init; }
X-Sentinel-Signature, X-Sentinel-Event, X-Sentinel-Delivery (see WebhookSignature).
string Body { get; init; }
The stored payload JSON, byte-identical across retries.
string Url { get; init; }
The endpoint’s configured absolute URL.
WebhookSecrets
public static class WebhookSecrets
Signing-secret minting for WebhookEndpoint.Secret.
Methods
static string NewSecret()
256-bit random secret, hex-encoded: whsec_<64 hex chars>.
Fields
const string Prefix = "whsec_"
Recognizable prefix so leaked secrets are greppable/attributable, like API-key prefixes.
WebhookSignature
public static class WebhookSignature
The delivery signature: HMAC with a timestamp bound INTO the signed string, so a captured request cannot be replayed later — moving the timestamp breaks the MAC, and keeping it fails the consumer’s freshness window.
Wire format (header X-Sentinel-Signature): t=<unix-seconds>,v1=<lowercase-hex HMACSHA256(secret, "<t>.<body>")>.
── CONSUMER VERIFICATION RECIPE ───────────────────────────────────────────────────────────
- Read the raw request body as UTF-8 text — verify the EXACT bytes received, before any JSON parsing or re-serialization.
- Parse the header into
tandv1(comma-separatedkey=valuepairs). - Reject if
|now − t|exceeds your tolerance (5 minutes is a sane default). - Compute
HMACSHA256(secret, "<t>.<body>")with the endpoint’s shared secret (UTF-8 bytes of the secret string), hex-encode lowercase. - Compare against
v1in constant time. Deduplicate on theX-Sentinel-Deliveryheader — retries re-send the same delivery id.
Or call WebhookSignature.Verify, which is exactly that recipe (and what the tests hold the dispatcher to). ───────────────────────────────────────────────────────────────────────────────────────────
Methods
static bool Verify(string secret, string signatureHeader, string body, DateTimeOffset now, TimeSpan? tolerance = null)
Reference consumer-side verification (see the class remarks for the recipe this encodes). Constant-time comparison; false on malformed headers, stale timestamps, or MAC mismatch.
static string Compute(string secret, long unixTimeSeconds, string body)
Produces the X-Sentinel-Signature header value for one attempt.
static string ComputeV1(string secret, long unixTimeSeconds, string body)
The v1 component: lowercase hex of HMACSHA256(secret, "<t>.<body>").
Fields
const string DeliveryHeader = "X-Sentinel-Delivery"
The delivery id — the consumer’s idempotency key across retries.
const string EventHeader = "X-Sentinel-Event"
The event kind (e.g. login.failed) — route without parsing the body.
const string SignatureHeader = "X-Sentinel-Signature"
Signature header: t=<unix>,v1=<hex>.
static readonly TimeSpan DefaultTolerance
Default freshness window for WebhookSignature.Verify.