API reference — Storage
Nuvora.Nexus.Sentinel.Stores.EfCore
EF Core persistence adapter for Sentinel: provider-neutral identity/authorization model with ModelBuilder extensions per Relay's .EfCore conventions. Works on PostgreSQL, SQL Server and SQLite.
dotnet add package Nuvora.Nexus.Sentinel.Stores.EfCore
Nuvora.Nexus.Sentinel.Stores.EfCore
SentinelDbContext
public sealed class SentinelDbContext : DbContext
Standalone DbContext over the Sentinel identity/authorization tables. Hosts that want the tables inside their own context (single database, single transaction) call SentinelModelBuilderExtensions.ApplySentinelModel from their own OnModelCreating instead — both paths produce the identical model.
No EF migrations ship yet: the schema is still moving in Wave 1. Until it settles, create the schema via Database.EnsureCreated() (tests/dev) or host-managed tooling.
Constructors
SentinelDbContext(DbContextOptions<SentinelDbContext> options)
Standalone DbContext over the Sentinel identity/authorization tables. Hosts that want the tables inside their own context (single database, single transaction) call SentinelModelBuilderExtensions.ApplySentinelModel from their own OnModelCreating instead — both paths produce the identical model.
Properties
DbSet<AdminAuditEntry> AdminAuditEntries { get; }
DbSet<ApiKey> ApiKeys { get; }
DbSet<AppCatalogEntry> AppCatalog { get; }
DbSet<BreakGlassStateRecord> BreakGlassStates { get; }
DbSet<ChallengeRecord> Challenges { get; }
DbSet<ConsumedTokenRecord> ConsumedTokens { get; }
DbSet<GrantRecord> Grants { get; }
DbSet<Group> Groups { get; }
DbSet<GroupMember> GroupMembers { get; }
DbSet<IdentityProviderConfig> IdentityProviders { get; }
DbSet<ImpersonationRecord> Impersonations { get; }
DbSet<KnownDevice> KnownDevices { get; }
DbSet<LinkedIdentity> LinkedIdentities { get; }
DbSet<OidcAuthorizationCodeRecord> OidcAuthorizationCodes { get; }
DbSet<OidcClient> OidcClients { get; }
DbSet<OidcConsentGrant> OidcConsentGrants { get; }
DbSet<OidcTokenGrantRecord> OidcTokenGrants { get; }
DbSet<Organization> Organizations { get; }
DbSet<OrganizationDomain> OrganizationDomains { get; }
DbSet<OrganizationMembership> OrganizationMemberships { get; }
DbSet<PasskeyCredential> Passkeys { get; }
DbSet<PermissionCatalogEntry> PermissionCatalog { get; }
DbSet<Realm> Realms { get; }
DbSet<RecoveryCodeRecord> RecoveryCodes { get; }
DbSet<Role> Roles { get; }
DbSet<RoleAssignment> RoleAssignments { get; }
DbSet<SamlConsumedAssertionRecord> SamlConsumedAssertions { get; }
DbSet<SamlIdpConnection> SamlIdpConnections { get; }
DbSet<SamlSpConnection> SamlSpConnections { get; }
DbSet<ScimToken> ScimTokens { get; }
DbSet<SecurityEvent> SecurityEvents { get; }
DbSet<ServiceAccount> ServiceAccounts { get; }
DbSet<Session> Sessions { get; }
DbSet<SigningKeyRecord> SigningKeys { get; }
DbSet<SubjectKeyRecord> SubjectKeys { get; }
DbSet<Team> Teams { get; }
DbSet<TeamMember> TeamMembers { get; }
DbSet<TotpEnrollment> TotpEnrollments { get; }
DbSet<User> Users { get; }
DbSet<UserCredential> UserCredentials { get; }
DbSet<WebhookDelivery> WebhookDeliveries { get; }
DbSet<WebhookEndpoint> WebhookEndpoints { get; }
DbSet<WorkloadTrustConfig> WorkloadTrusts { get; }
Methods
override void OnModelCreating(ModelBuilder modelBuilder)
Override this method to further configure the model that was discovered by convention from the entity types exposed in DbSet properties on your derived context. The resulting model may be cached and re-used for subsequent instances of your derived context.
Nuvora.Nexus.Sentinel.Stores.EfCore.DependencyInjection
EfAdminStoreServiceCollectionExtensions
public static class EfAdminStoreServiceCollectionExtensions
Registration for the EF Core delegated-administration store, split from EfCoreStoresServiceCollectionExtensions.AddSentinelEfCoreStores because the admin surface is an opt-in mount — resource servers that only authenticate never need it.
Methods
static IServiceCollection AddSentinelEfCoreAdminStore(this IServiceCollection services)
Registers EfAdminStore as the IAdminStore port. Call together with AddSentinelEfCoreStores(...) (which registers SentinelDbContext) and BEFORE AddSentinelAdmin(), whose in-memory default is TryAdd too — first registration wins.
EfCoreStoresServiceCollectionExtensions
public static class EfCoreStoresServiceCollectionExtensions
Registration helpers for the Sentinel EF Core persistence adapter, following the Relay DI style: AddSentinel* extensions with idempotent registrations.
Methods
static IServiceCollection AddSentinelEfCoreStores(this IServiceCollection services, Action<DbContextOptionsBuilder> configure)
Registers SentinelDbContext with the given provider configuration, e.g. services.AddSentinelEfCoreStores(o => o.UseNpgsql(connectionString)), plus the EF-backed implementation of every Core store port. Any of the relational providers works — the model is provider-neutral. Every registration uses TryAdd, so a host’s own earlier registration wins. Call this BEFORE AddSentinel(): Core registers in-memory fallbacks for ISigningKeyStore, IAuditStore and IDefinitionCatalogStore via TryAdd too, and whichever registration lands first is the one that sticks.
EfFederationServiceCollectionExtensions
public static class EfFederationServiceCollectionExtensions
Registration for the EF-backed federation stores. A separate extension (not folded into AddSentinelEfCoreStores) because inbound federation is an opt-in surface — same rationale as the workload trust store.
Methods
static IServiceCollection AddSentinelEfFederationStores(this IServiceCollection services)
Registers EfIdentityProviderStore as BOTH federation ports (IIdentityProviderStore and IFederatedIdentityStore). Call alongside AddSentinelEfCoreStores (which owns the SentinelDbContext registration) and BEFORE AddSentinelFederation, whose in-memory TryAdd defaults must not land first.
EfImportTargetServiceCollectionExtensions
public static class EfImportTargetServiceCollectionExtensions
Registration for the EF Core import target, same idempotent TryAdd style as EfCoreStoresServiceCollectionExtensions.
Methods
static IServiceCollection AddSentinelEfImportTarget(this IServiceCollection services)
Registers IImportTarget backed by SentinelDbContext. Call alongside AddSentinelEfCoreStores (which registers the context); the importers themselves (AspNetIdentityImporter, KeycloakRealmImporter, …) are plain classes constructed over this target by the migration CLI or host code. Remember to register the foreign hash algorithms (ImporterHashAlgorithms.All()) into PasswordHasher so imported credentials verify at login.
EfSamlServiceCollectionExtensions
public static class EfSamlServiceCollectionExtensions
Registration for the EF-backed SAML store. A separate extension because SAML is an opt-in surface — same rationale as EfFederationServiceCollectionExtensions.
Methods
static IServiceCollection AddSentinelEfSamlStore(this IServiceCollection services)
Registers EfSamlStore as ISamlStore. Call alongside AddSentinelEfCoreStores (which owns the SentinelDbContext registration) and BEFORE AddSentinelSaml, whose in-memory TryAdd default must not land first. SAML logins also need the federation stores (AddSentinelEfFederationStores) for links and JIT writes.
EfWorkloadFederationServiceCollectionExtensions
public static class EfWorkloadFederationServiceCollectionExtensions
Registration for the EF-backed workload trust store. A separate extension (not folded into AddSentinelEfCoreStores) because workload federation is an opt-in surface: hosts that never exchange workload tokens should not carry the registration.
Methods
static IServiceCollection AddSentinelEfWorkloadTrustStore(this IServiceCollection services)
Registers EfWorkloadTrustStore as the IWorkloadTrustStore. Call alongside AddSentinelEfCoreStores (which owns the SentinelDbContext registration) and BEFORE AddSentinelWorkloadFederation, whose in-memory TryAdd default must not land first.
Nuvora.Nexus.Sentinel.Stores.EfCore.Persistence
BreakGlassStateRecord
public sealed class BreakGlassStateRecord
Per-realm break-glass drill/use timestamps — the durable form of the health-check state.
Properties
DateTimeOffset? LastDrillAt { get; set; }
DateTimeOffset? LastUseAt { get; set; }
Guid RealmId { get; set; }
One row per realm — the PK.
ChallengeRecord
public sealed class ChallengeRecord
One live challenge (email OTP): the code’s hash, its expiry, and the remaining guess budget. Success and terminal failures DELETE the row; wrong guesses decrement ChallengeRecord.RemainingAttempts via a conditional UPDATE whose rowcount keeps concurrent guesses inside the budget (same no-TOCTOU shape as the other single-use stores).
Properties
DateTimeOffset ExpiresAt { get; set; }
int RemainingAttempts { get; set; }
required string ChallengeId { get; set; }
The caller-chosen challenge id (e.g. emailotp:{userId}) — the PK; re-store replaces it.
required string CodeHash { get; set; }
ConsumedTokenRecord
public sealed class ConsumedTokenRecord
One consumed single-purpose-token jti (deny-list-on-use): password reset, email verification, and invitation tokens land here on first use; a second presentation finds the row and loses. Insertion is the atomic arbiter (PK violation = already consumed). Rows are prunable once ConsumedTokenRecord.ExpiresAt passes — the token is dead on its own by then.
Properties
DateTimeOffset ExpiresAt { get; set; }
required string Jti { get; set; }
The token’s jti claim — the PK.
IdentityProviderConfigConfiguration
public sealed class IdentityProviderConfigConfiguration : IEntityTypeConfiguration<IdentityProviderConfig>
Mapping for the inbound-federation identity providers (sentinel_identity_providers). Lives in its own IEntityTypeConfiguration file (applied by SentinelModelBuilderExtensions.ApplySentinelModel) and follows the same conventions as the rest of the model: sentinel_-prefixed snake_case table, domain-minted ids, string enums, portable-JSON list columns. The client_secret column stores the RP secret reversibly (it must be SENT to the provider, not verified) — at-rest encryption follows the at-rest protection posture, same as signing keys.
See Modeling entity types and relationships in EF Core for more information and examples.
Methods
void Configure(EntityTypeBuilder<IdentityProviderConfig> entity)
Configures the entity of type TEntity.
ImpersonationRecordConfiguration
public sealed class ImpersonationRecordConfiguration : IEntityTypeConfiguration<ImpersonationRecord>
Maps ImpersonationRecord to sentinel_impersonations.
See Modeling entity types and relationships in EF Core for more information and examples.
Methods
void Configure(EntityTypeBuilder<ImpersonationRecord> entity)
Configures the entity of type TEntity.
KnownDeviceConfiguration
public sealed class KnownDeviceConfiguration : IEntityTypeConfiguration<KnownDevice>
Mapping for the device-history table (sentinel_known_devices), backing EfDeviceHistoryStore. Follows the model conventions: sentinel_-prefixed snake_case table, domain-minted ids, UTC-ticks timestamps (the ledger convention — orderable on SQLite). The (user, fingerprint hash) pair is unique: one row per device per user, refreshed in place. Only the SHA-256 digest of the fingerprint is ever stored (see IDeviceHistoryStore — data minimization).
See Modeling entity types and relationships in EF Core for more information and examples.
Methods
void Configure(EntityTypeBuilder<KnownDevice> entity)
Configures the entity of type TEntity.
OidcAuthorizationCodeRecord
public sealed class OidcAuthorizationCodeRecord
Adapter-side row shape for one authorization code. AuthorizationCode is a Core positional record, so the EF adapter mirrors it in this keyed entity plus the OidcAuthorizationCodeRecord.Consumed flag the atomic single-use UPDATE flips. Rows are kept after consumption (until purged) so a replayed code is recognized as REUSE — the RFC-mandated distinction — rather than “unknown”.
Properties
DateTimeOffset AuthTime { get; set; }
DateTimeOffset ExpiresAt { get; set; }
Guid RealmId { get; set; }
Guid SessionId { get; set; }
Guid UserId { get; set; }
Guid? OrgId { get; set; }
List<string> Scopes { get; set; }
bool Consumed { get; set; }
Flipped exactly once by the store’s atomic consume (single-use).
required string ClientId { get; set; }
required string CodeHash { get; set; }
SHA-256 hex of the code the client holds — the PK and only lookup key; plaintext never lands here.
required string RedirectUri { get; set; }
string? CodeChallenge { get; set; }
string? CodeChallengeMethod { get; set; }
string? Nonce { get; set; }
OidcTokenGrantRecord
public sealed class OidcTokenGrantRecord
Adapter-side row shape for one code→tokens grant linkage. OidcTokenGrant is a Core positional record, so the EF adapter mirrors it here, keyed by OidcTokenGrantRecord.CodeHash (one grant per code first-use). Rows power refresh re-mints (exact granted scopes), code-replay revocation (family lookup by code hash) and back-channel logout (session/user → clients).
Properties
DateTimeOffset IssuedAt { get; set; }
Guid RealmId { get; set; }
Guid SessionId { get; set; }
Guid UserId { get; set; }
Guid? RefreshFamilyId { get; set; }
The rotating refresh-token family the exchange started; null when no offline_access was granted.
List<string> Scopes { get; set; }
required string ClientId { get; set; }
required string CodeHash { get; set; }
SHA-256 hex of the authorization code the grant was minted from — the PK.
RecoveryCodeRecord
public sealed class RecoveryCodeRecord
One unused recovery code, stored as a hash — never plaintext. This entity is adapter-side (not Core): the domain only ever talks in code hashes through IMfaStore.TryConsumeRecoveryCodeAsync, so the row shape is purely a persistence detail. Consumption is a DELETE, which is what makes single-use atomic — a consumed code has no row to race over.
Properties
DateTimeOffset CreatedAt { get; set; }
Guid Id { get; set; }
Guid UserId { get; set; }
required string CodeHash { get; set; }
Hash of the recovery code; the lookup key for consumption.
SamlConsumedAssertionConfiguration
public sealed class SamlConsumedAssertionConfiguration : IEntityTypeConfiguration<SamlConsumedAssertionRecord>
Mapping for the replay markers (sentinel_saml_consumed_assertions): the id IS the primary key — see SamlConsumedAssertionRecord.
See Modeling entity types and relationships in EF Core for more information and examples.
Methods
void Configure(EntityTypeBuilder<SamlConsumedAssertionRecord> entity)
Configures the entity of type TEntity.
SamlConsumedAssertionRecord
public sealed class SamlConsumedAssertionRecord
Consumed-assertion replay marker: one row per accepted assertion id, unique PK = the atomic single-use arbiter (the insert either lands or violates the key). Rows past SamlConsumedAssertionRecord.ExpiresAt are dead weight only — an assertion past its own NotOnOrAfter is rejected before the replay cache is consulted, so pruning is a hygiene job, not a security one.
Properties
DateTimeOffset ExpiresAt { get; set; }
required string AssertionId { get; set; }
Connection-scoped assertion id ({connectionId}:{assertionId}), as handed to the store port.
SamlIdpConnectionConfiguration
public sealed class SamlIdpConnectionConfiguration : IEntityTypeConfiguration<SamlIdpConnection>
Mapping for the SP-side SAML IdP connections (sentinel_saml_idp_connections). Same conventions as IdentityProviderConfigConfiguration: sentinel_-prefixed snake_case table, domain-minted ids, string enums, portable-JSON list columns. The pinned certificate is PUBLIC material — stored plainly, unlike the OIDC client secret.
See Modeling entity types and relationships in EF Core for more information and examples.
Methods
void Configure(EntityTypeBuilder<SamlIdpConnection> entity)
Configures the entity of type TEntity.
SamlSpConnectionConfiguration
public sealed class SamlSpConnectionConfiguration : IEntityTypeConfiguration<SamlSpConnection>
Mapping for the IdP-side SP registry (sentinel_saml_sp_connections), same conventions as above.
See Modeling entity types and relationships in EF Core for more information and examples.
Methods
void Configure(EntityTypeBuilder<SamlSpConnection> entity)
Configures the entity of type TEntity.
ScimTokenConfiguration
public sealed class ScimTokenConfiguration : IEntityTypeConfiguration<ScimToken>
Mapping for the SCIM provisioning tokens (sentinel_scim_tokens). Lives in its own IEntityTypeConfiguration file (applied by SentinelModelBuilderExtensions.ApplySentinelModel) so the shared model file only grows by one line; conventions match the rest of the model. The scim_external_id column on sentinel_users needs no mapping here — EF discovers the additive User.ScimExternalId property by convention and the shared snake_case pass names it.
See Modeling entity types and relationships in EF Core for more information and examples.
Methods
void Configure(EntityTypeBuilder<ScimToken> entity)
Configures the entity of type TEntity.
SentinelModelBuilderExtensions
public static class SentinelModelBuilderExtensions
Maps the Sentinel identity/authorization model onto a ModelBuilder. Call from a host’s OnModelCreating to embed the Sentinel tables in the host’s own DbContext (the Relay .EfCore convention), or use SentinelDbContext which does the same.
Methods
static ModelBuilder ApplySentinelModel(this ModelBuilder modelBuilder)
Configures every Sentinel entity: sentinel_-prefixed snake_case tables, composite keys, uniqueness constraints, and JSON value conversions. Provider-neutral on purpose — no jsonb or other provider-specific column types, so the same model works on PostgreSQL, SQL Server and SQLite.
SigningKeyRecord
public sealed class SigningKeyRecord
Adapter-side row shape for one persisted signing key. PersistedSigningKey is a Core record (no parameterless constructor, no realm id), so the EF adapter wraps it in this keyed entity — (SigningKeyRecord.RealmId, SigningKeyRecord.KeyId) — and maps back and forth at the store boundary.
At-rest protection of SigningKeyRecord.Pkcs8PrivateKey in this wave is the deployment’s column/tablespace/disk encryption; KMS envelope-encryption adapters (Azure Key Vault, AWS KMS) land in Wave 5 and will supersede raw-column storage for hosts that opt into them.
Properties
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset? RetiredAt { get; set; }
Set when the key leaves the ring; retired keys stay readable through their overlap window.
Guid RealmId { get; set; }
bool IsPrimary { get; set; }
required byte[] Pkcs8PrivateKey { get; set; }
Full private key in PKCS#8 DER form — see the class remarks on at-rest encryption.
required string KeyId { get; set; }
The JWKS kid; unique per realm (composite PK with SigningKeyRecord.RealmId).
SubjectKeyRecord
public sealed class SubjectKeyRecord
One per-subject crypto-shredding key, table sentinel_subject_keys. Adapter-side entity: the domain only ever talks in raw key bytes through ISentinelCryptoKeyStore. Destruction is a row DELETE — no tombstone, nothing to recover, which is the whole point: destroying the key renders the PII unrecoverable. At-rest protection of SubjectKeyRecord.Key follows the standard posture (column/disk encryption now, KMS envelope encryption in Wave 5 — same as signing keys).
Properties
DateTimeOffset CreatedAt { get; set; }
Guid SubjectId { get; set; }
The data subject (user id) — natural PK, one key per subject.
required byte[] Key { get; set; }
Raw AES-256 key bytes.
SubjectKeyRecordConfiguration
public sealed class SubjectKeyRecordConfiguration : IEntityTypeConfiguration<SubjectKeyRecord>
Maps SubjectKeyRecord to sentinel_subject_keys.
See Modeling entity types and relationships in EF Core for more information and examples.
Methods
void Configure(EntityTypeBuilder<SubjectKeyRecord> entity)
Configures the entity of type TEntity.
WorkloadTrustConfigConfiguration
public sealed class WorkloadTrustConfigConfiguration : IEntityTypeConfiguration<WorkloadTrustConfig>
Mapping for the workload federation trusts (sentinel_workload_trusts). Lives in its own IEntityTypeConfiguration file (applied by SentinelModelBuilderExtensions.ApplySentinelModel) and follows the same conventions as the rest of the model: sentinel_-prefixed snake_case table, domain-minted ids, string enums, portable-JSON list columns.
See Modeling entity types and relationships in EF Core for more information and examples.
Methods
void Configure(EntityTypeBuilder<WorkloadTrustConfig> entity)
Configures the entity of type TEntity.
Nuvora.Nexus.Sentinel.Stores.EfCore.Stores
EfAdminStore
public sealed class EfAdminStore : IAdminStore
EF Core IAdminStore. Authorization-free by port contract — SentinelAdminService has already run the resolve-then-evaluate pass before any method here executes. Reads are no-tracking; mutations attach and save explicitly.
Constructors
EfAdminStore(SentinelDbContext context)
EF Core IAdminStore. Authorization-free by port contract — SentinelAdminService has already run the resolve-then-evaluate pass before any method here executes. Reads are no-tracking; mutations attach and save explicitly.
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?
EfAuditStore
public sealed class EfAuditStore : IAuditStore
EF Core IAuditStore. Security events are plain appends; admin entries carry the hash chain, whose sequence/previous-hash assignment must be atomic per realm — see EfAuditStore.AppendAdminEntryAsync for the chosen strategy.
Constructors
EfAuditStore(SentinelDbContext context)
EF Core IAuditStore. Security events are plain appends; admin entries carry the hash chain, whose sequence/previous-hash assignment must be atomic per realm — see EfAuditStore.AppendAdminEntryAsync for the chosen strategy.
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))
Chain-append strategy (the IAuditStore contract): optimistic read-then-insert, arbitrated by the uq_sentinel_admin_audit_realm_sequence unique index rather than by a lock. Each attempt reads the realm’s tail (max sequence + its hash), links the new entry onto it, and inserts. Two concurrent appenders that observed the same tail race to insert the same sequence number; the database serializes them, the loser’s insert violates the unique index, and the loser re-reads the new tail and relinks — so no two entries can ever claim the same predecessor and the chain cannot fork. Chosen over a serializable transaction / row lock because it behaves identically on PostgreSQL, SQL Server and SQLite with no provider-specific locking hints, and admin mutations are low-frequency so retries are rare. A genuine storage fault (not a sequence collision) keeps failing and surfaces after EfAuditStore.MaxAppendAttempts.
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).
EfBreakGlassStateStore
public sealed class EfBreakGlassStateStore : IBreakGlassStateStore
EF Core IBreakGlassStateStore: the drill/use timestamps must survive restarts — a rebooted host that forgot its last drill would flap the break-glass health check and erase the accountability trail.
Constructors
EfBreakGlassStateStore(SentinelDbContext context)
EF Core IBreakGlassStateStore: the drill/use timestamps must survive restarts — a rebooted host that forgot its last drill would flap the break-glass health check and erase the accountability trail.
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))
EfChallengeStore
public sealed class EfChallengeStore : IChallengeStore
EF Core IChallengeStore (the EF fallback): durable challenge codes for single-database deployments that run without a ValKey tier. The port’s atomicity contract is honored with conditional UPDATE/DELETE rowcounts (the same no-TOCTOU shape as refresh rotation): a success claim is one DELETE that only one caller can win, and the attempt budget is decremented server-side so concurrent wrong guesses cannot exceed it.
Constructors
EfChallengeStore(SentinelDbContext context, ISentinelClock clock)
EF Core IChallengeStore (the EF fallback): durable challenge codes for single-database deployments that run without a ValKey tier. The port’s atomicity contract is honored with conditional UPDATE/DELETE rowcounts (the same no-TOCTOU shape as refresh rotation): a success claim is one DELETE that only one caller can win, and the attempt budget is decremented server-side so concurrent wrong guesses cannot exceed it.
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.
EfDefinitionCatalogStore
public sealed class EfDefinitionCatalogStore : IDefinitionCatalogStore
EF Core IDefinitionCatalogStore. Only definition sync writes through this store, at boot, single-writer per deployment — so upserts are plain read-then-write with no concurrency ceremony.
Constructors
EfDefinitionCatalogStore(SentinelDbContext context)
EF Core IDefinitionCatalogStore. Only definition sync writes through this store, at boot, single-writer per deployment — so upserts are plain read-then-write with no concurrency ceremony.
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))
EfDeviceHistoryStore
public sealed class EfDeviceHistoryStore : IDeviceHistoryStore
EF-backed IDeviceHistoryStore over sentinel_known_devices. Fingerprints are digested via DeviceFingerprints.Hash before touching the database — the port’s contract.
Constructors
EfDeviceHistoryStore(SentinelDbContext db)
EF-backed IDeviceHistoryStore over sentinel_known_devices. Fingerprints are digested via DeviceFingerprints.Hash before touching the database — the port’s contract.
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).
EfIdentityProviderStore
public sealed class EfIdentityProviderStore : IIdentityProviderStore, IFederatedIdentityStore
EF Core adapter for the two federation ports: IIdentityProviderStore (provider configs) and IFederatedIdentityStore (links, JIT writes, org-domain discovery). One class because the flows always use them together and both are thin queries over the same context. Reads are no-tracking, same posture as the other store adapters.
Constructors
EfIdentityProviderStore(SentinelDbContext context)
EF Core adapter for the two federation ports: IIdentityProviderStore (provider configs) and IFederatedIdentityStore (links, JIT writes, org-domain discovery). One class because the flows always use them together and both are thin queries over the same context. Reads are no-tracking, same posture as the other store adapters.
Methods
ValueTask AddAsync(IdentityProviderConfig provider, CancellationToken cancellationToken = default(CancellationToken))
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 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.
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).
EfImpersonationStore
public sealed class EfImpersonationStore : IImpersonationStore
EF Core IImpersonationStore.
Constructors
EfImpersonationStore(SentinelDbContext context)
EF Core IImpersonationStore.
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))
EfImportTarget
public sealed class EfImportTarget : IImportTarget
EF Core IImportTarget: bulk upserts an importer batch through SentinelDbContext, matching on natural keys — users by (realm, email), roles and groups by (realm, key) at realm level, clients by (realm, client_id), credentials by (user, algorithm tag) — so re-running an import updates rather than duplicates. Existing rows are loaded per-kind in one Contains query, not per item. Under ImportOptions.DryRun the full matching pass runs (identical counts) and SaveChanges is simply never called.
Constructors
EfImportTarget(SentinelDbContext context, ISentinelClock clock)
EF Core IImportTarget: bulk upserts an importer batch through SentinelDbContext, matching on natural keys — users by (realm, email), roles and groups by (realm, key) at realm level, clients by (realm, client_id), credentials by (user, algorithm tag) — so re-running an import updates rather than duplicates. Existing rows are loaded per-kind in one Contains query, not per item. Under ImportOptions.DryRun the full matching pass runs (identical counts) and SaveChanges is simply never called.
Methods
ValueTask ApplyAsync(ImportBatch batch, ImportOptions options, ImportReport report, CancellationToken cancellationToken = default(CancellationToken))
Upserts the batch into ImportOptions.TargetRealmId, adding per-entity created/updated/skipped counts to report. Under ImportOptions.DryRun the same matching and counting runs but nothing is persisted.
EfMachineIdentityStore
public sealed class EfMachineIdentityStore : IMachineIdentityStore
EF Core IMachineIdentityStore. Reads are no-tracking; mutations after insert go through ExecuteUpdate — one statement, no entity load, which also makes the secret-rotation write a single atomic UPDATE.
Constructors
EfMachineIdentityStore(SentinelDbContext context)
EF Core IMachineIdentityStore. Reads are no-tracking; mutations after insert go through ExecuteUpdate — one statement, no entity load, which also makes the secret-rotation write a single atomic UPDATE.
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))
EfMfaStore
public sealed class EfMfaStore : IMfaStore
EF Core IMfaStore: TOTP enrollments and recovery codes.
Constructors
EfMfaStore(SentinelDbContext context)
EF Core IMfaStore: TOTP enrollments and recovery codes.
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.
EfOidcStore
public sealed class EfOidcStore : IOidcStore
EF Core IOidcStore. The load-bearing detail is EfOidcStore.ConsumeCodeAsync: single-use consumption is one conditional UPDATE (WHERE code_hash = @h AND NOT consumed) whose rowcount IS the consumed/not-consumed boolean — the same no-TOCTOU shape as refresh-token rotation. Two racing exchanges of the same code can never both win, whatever the isolation level.
Constructors
EfOidcStore(SentinelDbContext context)
EF Core IOidcStore. The load-bearing detail is EfOidcStore.ConsumeCodeAsync: single-use consumption is one conditional UPDATE (WHERE code_hash = @h AND NOT consumed) whose rowcount IS the consumed/not-consumed boolean — the same no-TOCTOU shape as refresh-token rotation. Two racing exchanges of the same code can never both win, whatever the isolation level.
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.
EfPasskeyStore
public sealed class EfPasskeyStore : IPasskeyStore
EF Core IPasskeyStore. Reads are no-tracking; the two mutations (EfPasskeyStore.RemoveAsync, EfPasskeyStore.UpdateSignCountAndLastUsedAsync) go through ExecuteDelete/ExecuteUpdate — one statement, no entity load.
Constructors
EfPasskeyStore(SentinelDbContext context)
EF Core IPasskeyStore. Reads are no-tracking; the two mutations (EfPasskeyStore.RemoveAsync, EfPasskeyStore.UpdateSignCountAndLastUsedAsync) go through ExecuteDelete/ExecuteUpdate — one statement, no entity load.
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).
EfPasswordResetStore
public sealed class EfPasswordResetStore : IPasswordResetStore
EF Core IPasswordResetStore: the credential create-or-replace behind password reset AND invitation acceptance, the jti deny-list, and the email-verified stamp. Jti consumption leans on the primary key for atomicity — the losing concurrent insert faults and reports “already used”, the same shape as the SAML replay cache.
Constructors
EfPasswordResetStore(SentinelDbContext context, ISentinelClock clock)
EF Core IPasswordResetStore: the credential create-or-replace behind password reset AND invitation acceptance, the jti deny-list, and the email-verified stamp. Jti consumption leans on the primary key for atomicity — the losing concurrent insert faults and reports “already used”, the same shape as the SAML replay cache.
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.
EfPersonalDataSource
public sealed class EfPersonalDataSource : IPersonalDataSource
EF Core IPersonalDataSource: the identity-side reads and writes of export/erasure. Deletions are single-statement ExecuteDeletes — erasure must not depend on loading what it destroys.
Constructors
EfPersonalDataSource(SentinelDbContext context)
EF Core IPersonalDataSource: the identity-side reads and writes of export/erasure. Deletions are single-statement ExecuteDeletes — erasure must not depend on loading what it destroys.
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.
EfRetentionStore
public sealed class EfRetentionStore : IRetentionStore
EF Core IRetentionStore. The port’s invariant, honored by construction: admin-audit redaction is an UPDATE that touches only before_json / after_json — sequence, digests and hashes are never in a setter, so the chain still verifies afterwards (the digest columns are what AdminAuditChain commits to). Everything is a single set-based statement: retention runs over years of rows.
Constructors
EfRetentionStore(SentinelDbContext context)
EF Core IRetentionStore. The port’s invariant, honored by construction: admin-audit redaction is an UPDATE that touches only before_json / after_json — sequence, digests and hashes are never in a setter, so the chain still verifies afterwards (the digest columns are what AdminAuditChain commits to). Everything is a single set-based statement: retention runs over years of rows.
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.
EfSamlStore
public sealed class EfSamlStore : ISamlStore
EF Core adapter for ISamlStore: both connection registries plus the assertion replay markers. Reads are no-tracking, same posture as the other store adapters; replay marking leans on the primary key for atomicity — the losing concurrent insert faults and reports “already consumed”.
Constructors
EfSamlStore(SentinelDbContext context)
EF Core adapter for ISamlStore: both connection registries plus the assertion replay markers. Reads are no-tracking, same posture as the other store adapters; replay marking leans on the primary key for atomicity — the losing concurrent insert faults and reports “already consumed”.
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.
EfScimStore
public sealed class EfScimStore : IScimStore
EF Core IScimStore. Every user query is fenced through sentinel_organization_memberships and every group query through Group.OrganizationId — the org scope comes from the SCIM token and no query here can reach outside it. Reads are no-tracking; point writes go through ExecuteUpdate/ExecuteDelete; entity updates attach-and-save.
Constructors
EfScimStore(SentinelDbContext context)
EF Core IScimStore. Every user query is fenced through sentinel_organization_memberships and every group query through Group.OrganizationId — the org scope comes from the SCIM token and no query here can reach outside it. Reads are no-tracking; point writes go through ExecuteUpdate/ExecuteDelete; entity updates attach-and-save.
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.
EfSessionStore
public sealed class EfSessionStore : ISessionStore
EF Core ISessionStore. Touch/revoke are single-statement ExecuteUpdates — these run on every authenticated request and must not load entities.
Constructors
EfSessionStore(SentinelDbContext context)
EF Core ISessionStore. Touch/revoke are single-statement ExecuteUpdates — these run on every authenticated request and must not load entities.
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).
EfSigningKeyStore
public sealed class EfSigningKeyStore : ISigningKeyStore
EF Core ISigningKeyStore, mapping the Core PersistedSigningKey record onto SigningKeyRecord rows keyed (realm, kid). See SigningKeyRecord for the at-rest encryption posture in this wave (deployment column/disk encryption; KMS adapters in Wave 5).
Constructors
EfSigningKeyStore(SentinelDbContext context)
EF Core ISigningKeyStore, mapping the Core PersistedSigningKey record onto SigningKeyRecord rows keyed (realm, kid). See SigningKeyRecord for the at-rest encryption posture in this wave (deployment column/disk encryption; KMS adapters in Wave 5).
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.
EfSubjectDataSource
public sealed class EfSubjectDataSource : ISubjectDataSource
EF Core ISubjectDataSource — THE snapshot-build join. The store owns the flattening from roles/groups/teams down to raw grant rows because it can do the whole reachability walk in one UNION ALL round trip; done in Core against the narrow ports it would be one query per role assignment source (user, each group, each team). The snapshot builder then parses patterns/conditions exactly once per snapshot.
Constructors
EfSubjectDataSource(SentinelDbContext context)
EF Core ISubjectDataSource — THE snapshot-build join. The store owns the flattening from roles/groups/teams down to raw grant rows because it can do the whole reachability walk in one UNION ALL round trip; done in Core against the narrow ports it would be one query per role assignment source (user, each group, each team). The snapshot builder then parses patterns/conditions exactly once per snapshot.
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.
EfSubjectKeyStore
public sealed class EfSubjectKeyStore : ISentinelCryptoKeyStore
EF Core ISentinelCryptoKeyStore over sentinel_subject_keys. Destroy is a hard DELETE — the row’s absence IS the erasure. Creation handles the first-use race by yielding to the concurrent winner (both callers must see the SAME key, or one side’s ciphertexts would be born unreadable).
Constructors
EfSubjectKeyStore(SentinelDbContext context)
EF Core ISentinelCryptoKeyStore over sentinel_subject_keys. Destroy is a hard DELETE — the row’s absence IS the erasure. Creation handles the first-use race by yielding to the concurrent winner (both callers must see the SAME key, or one side’s ciphertexts would be born unreadable).
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).
EfUserStore
public sealed class EfUserStore : IUserStore
EF Core IUserStore. Reads are no-tracking — login never mutates the user through the change tracker; the two writes it does make (EfUserStore.UpdateCredentialAsync, EfUserStore.RecordLoginAsync) go through ExecuteUpdate so they cost one statement and no entity load.
Constructors
EfUserStore(SentinelDbContext context)
EF Core IUserStore. Reads are no-tracking — login never mutates the user through the change tracker; the two writes it does make (EfUserStore.UpdateCredentialAsync, EfUserStore.RecordLoginAsync) go through ExecuteUpdate so they cost one statement and no entity load.
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))
EfWebhookStore
public sealed class EfWebhookStore : IWebhookStore
EF Core IWebhookStore. Authorization-free by port contract — WebhookAdminService fences before calling. Reads are no-tracking; outbox state transitions are single-statement ExecuteUpdates so they need no tracked entity and release the claim atomically with the state change.
Claim-due leasing is a per-row conditional-update compare-and-swap: candidates are read without a lock, then each is claimed with UPDATE … SET claimed_until = @until WHERE id = @id AND (claimed_until IS NULL OR claimed_until <= @now) AND delivered_at IS NULL AND NOT abandoned — a rowcount of 1 means this node won the row; 0 means another node did. Portable to PostgreSQL, SQL Server and SQLite (no FOR UPDATE SKIP LOCKED required), and per-statement atomicity is all it relies on.
Constructors
EfWebhookStore(SentinelDbContext context)
EF Core IWebhookStore. Authorization-free by port contract — WebhookAdminService fences before calling. Reads are no-tracking; outbox state transitions are single-statement ExecuteUpdates so they need no tracked entity and release the claim atomically with the state change. Claim-due leasing is a per-row conditional-update compare-and-swap: candidates are read without a lock, then each is claimed with UPDATE … SET claimed_until = @until WHERE id = @id AND (claimed_until IS NULL OR claimed_until <= @now) AND delivered_at IS NULL AND NOT abandoned — a rowcount of 1 means this node won the row; 0 means another node did. Portable to PostgreSQL, SQL Server and SQLite (no FOR UPDATE SKIP LOCKED required), and per-statement atomicity is all it relies on.
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))
EfWorkloadTrustStore
public sealed class EfWorkloadTrustStore : IWorkloadTrustStore
EF Core IWorkloadTrustStore. Reads are no-tracking, same posture as the other store adapters — the exchange flow only ever reads trust rows.
Constructors
EfWorkloadTrustStore(SentinelDbContext context)
EF Core IWorkloadTrustStore. Reads are no-tracking, same posture as the other store adapters — the exchange flow only ever reads trust rows.
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))