MongoDbRepositories 11.0.0

MongoDbRepositories

Generic, tenant-scoped MongoDB repository for entities deriving from EntityBase (SeliseBlocks.Genesis.Framework.PDS.Entity). Intended to be shared across projects that need plain EntityBase persistence without any domain-specific entity handling.

Multi-targets net8.0 and net10.0, so services that have not yet migrated to .NET 10 can consume it.

Integrating this package into an application? Start with INTEGRATION.md — the full reference: every option, every collection schema with example documents, a worked end-to-end example and a troubleshooting table. This README explains why each rule is the way it is; the guide explains how to wire it up.

The two targets differ only in dependency pins, not behaviour. The SeliseBlocks framework packages publish a net10.0-only 10.x line, so each target framework pins its own:

Dependency net8.0 net10.0
SeliseBlocks.Genesis.Framework 8.0.1.1 10.0.0.2
SeliseBlocks.Genesis.Framework.PDS.Entity 8.0.1.9 10.0.0.6
Microsoft.Extensions.* 8.0.x 10.0.11
FinMongoDbDataContext 10.1.1.1 10.1.1.1
MongoDB.Driver / MongoDB.Bson 3.11.0 3.11.0

EntityBase carries the same 17 properties in both the 8.x and 10.x PDS.Entity lines, and SecurityContext exposes the same members, so no source is conditionally compiled and there are no #if directives.

One pin is not obvious: on net8.0, Microsoft.Extensions.Logging.Abstractions is 8.0.3, not 8.0.2. SeliseBlocks.Genesis.Framework 8.0.1.1 pulls Azure.Identity -> Azure.Core -> System.ClientModel, which requires >= 8.0.3; pinning 8.0.2 fails restore with NU1605.

Relationship to FinMongoDbRepositories

This package is the hierarchy-free counterpart of FinMongoDbRepositories. It keeps the same method surface and the same tenant-per-database access through FinMongoDbDataContext, and drops everything that is specific to the BURO domain:

FinMongoDbRepositories MongoDbRepositories
applyHierarchy on every read, filtering Hid / Path / Depth via IHierarchy applyRowLevelSecurity, filtering RolesAllowedTo* / IdsAllowedTo* on EntityBase
IFiscalYearInfo / BuroFiscalYear resolution, Redis-cached removed
BuroEntityBase.BusinessDate resolution with fallbacks removed
IEmployeeActionContext and delegation-source lookup removed
ChangePropagator notifications on update removed
RowLevelPermission, SecurityClaims, SeliseBlocks.Entities dependencies removed; the permission filter and entity stamping are implemented here
applyHierarchy branches on typeof(IHierarchy).IsAssignableFrom(typeof(T)) T stays unconstrained; row-level permission applies when T derives from EntityBase, and plain documents pass through untouched

Registration

AddMongoDbRepositoryServices registers the repository, the permission filter, the entity stamper and the collection-name resolver, and registers FinMongoDbDataContext if the host has not already done so. The host is responsible for the Genesis security context (ISecurityContextProvider) and tenant registration (ITenants).

services.AddMongoDbRepositoryServices();

Every collaborator is registered with TryAdd, so a host can replace any of them by registering its own implementation first.

Options

services.AddMongoDbRepositoryServices(options =>
{
    options.CollectionNameResolver = type => $"{type.Name}s";
    options.ApplyRowLevelSecurityByDefault = true;
    options.StampAuditFieldsOnUpdate = true;
    options.SeedCreatorPermissionsOnInsert = true;
    options.FailOpenOnSecurityContextFailure = false;
    options.CreatePermission = CreatePermissionMode.Off;   // Off | Shadow | Enforce
    options.RefusePermissionFieldWrites = false;
    options.SettingsCacheTtl = TimeSpan.FromSeconds(60);
    options.SystemRoles = new HashSet<string>(
        ["admin", "tenantadmin", "tenantmanager"],
        StringComparer.OrdinalIgnoreCase);
});

Entity types versus plain documents

T is not constrained to EntityBase. The repository stores plain documents — reference data, lookup tables, value documents — alongside entities, and those have no permission arrays.

The rule is runtime, not compile time:

  • T derives from EntityBase -> row-level permission is applied, and inserts are stamped and seeded.
  • T does not -> filters pass through unchanged, nothing is stamped, and no audit fields are appended.

EntityPermissionFilter.IsPermissionedEntity(Type) is the single predicate behind that decision. Because T is unconstrained, the permission filter and the audit stamper build their definitions from field names rather than typed lambdas; the driver resolves those to element names when the filter or update is rendered.

Why there is no row-level Insert permission

EntityPermission covers Read, Write, Update and Delete, but not Insert, and that is deliberate.

A row-level filter restricts a query against the permission arrays of documents that already exist. An insert has no existing document. The only arrays available are the ones the caller just populated on the object being inserted, so filtering on them would be self-authorizing: the caller sets the very value that would grant the permission.

Insert is therefore gated in two other places:

  • Field level — FieldOperation.Insert controls which fields a caller may populate.
  • Type level — whether a caller may create documents of a type at all is a policy question, not a property of a document that does not yet exist.

Write maps to RolesAllowedToWrite / IdsAllowedToWrite. Those arrays exist on EntityBase but were never consulted by FinMongoDbRepositories; they are now available as a general write check.

Row-level security

Where a method takes applyRowLevelSecurity, the supplied filter is ANDed with a permission filter built from the current security context:

  • reads, counts, projections and aggregations match RolesAllowedToRead / IdsAllowedToRead
  • updates match RolesAllowedToUpdate / IdsAllowedToUpdate
  • deletes match RolesAllowedToDelete / IdsAllowedToDelete

Callers holding one of the configured SystemRoles are not filtered. Passing null (the default) uses ApplyRowLevelSecurityByDefault; pass false explicitly for calls that are legitimately unscoped, such as a background job reading across users.

Two behaviours differ from the older repositories on purpose:

  • Writes are filtered. FinMongoDbRepositories applies its filter to reads only, so an update or delete reaches any document the caller's own filter matches. Here the update and delete permission sets are enforced as well.
  • The filter fails closed. FinMongoDbRepositories wraps filter construction in catch (Exception) { } and returns the unfiltered query, so an unreadable security context silently widens the query to the whole collection. Here that throws InvalidOperationException. Set FailOpenOnSecurityContextFailure to restore the old behaviour.

Insert stamping

InsertOne/InsertMany and their async forms stamp each entity before writing:

  • ItemId when empty, TenantId, CreatedBy, LastUpdatedBy
  • CreateDate and LastUpdateDate when still DateTime.MinValue
  • Language when empty, from the security context
  • the six RolesAllowedTo* / IdsAllowedTo* arrays for Read, Update and Delete, resolved in this order:
    1. Caller-supplied arrays win. Whatever the caller populated is kept as is.

    2. Settings defaults fill the gaps. For each array the caller left empty, the tenant's EntityDefaultPermissionSettingss row for that entity fills it, with OWNER / OWNERROLE resolved by the framework handler.

    3. Creator seeding is the last resort, and all-or-nothing. The creating user id is placed in IdsAllowedToRead / IdsAllowedToUpdate / IdsAllowedToDelete only when all six role/id arrays are still empty after step 2, and SeedCreatorPermissionsOnInsert is on. If step 1 or step 2 populated any one of the six, the creator is not seeded: a populated array means access has been taken ownership of, and an empty sibling is a deliberate "admin only".

      The question is "does anything grant access", not "was there a settings row". A settings row that carries only RolesAllowedToWrite / IdsAllowedToWrite - which is exactly how type-level create permission is granted, below - configures no read, update or delete access at all, so it does not suppress seeding. Keying off the row's mere existence wrote documents with six empty arrays that nobody, including the creator, could reach.

Correction. Earlier versions of this README said the creator id was added "when the caller left them empty". That was never true. The old code appended the creator id unconditionally, to arrays the caller had already populated, which widened access in ways the caller never asked for: it decided who could read, update and delete the document. Step 3 above is the actual rule, and it is narrower on purpose.

The seeding matters: with row-level read filtering on and no permissions set, a document would otherwise be invisible to the user who just wrote it. Hosts that resolve default permissions from their own configuration should turn SeedCreatorPermissionsOnInsert off and set the arrays themselves.

Updates append LastUpdateDate and LastUpdatedBy to the caller's update definition unless StampAuditFieldsOnUpdate is false.

UpdateOneAsync(expression, entity) writes only the properties the caller actually populated:

  • a property still at its declared default is skipped — null for a reference type, and 0, false or DateTime.MinValue for a value type — so a partially populated entity does not blank or zero the fields it left unset. A nullable value type is exempt, because an explicit 0 on a decimal? is distinguishable from an unset one. Use the UpdateDefinition<T> or dictionary overload to write a default deliberately.
  • ItemId, TenantId, CreateDate, CreatedBy, LastUpdateDate, LastUpdatedBy and all eight RolesAllowedTo* / IdsAllowedTo* arrays are never written from an entity. Identity and authorship are fixed at insert, the audit fields are stamped separately, and permissions should change deliberately rather than as a side effect of saving an entity.

That matters more than it looks: without it, saving an entity that was built in memory rather than loaded from Mongo sets every permission array to null, and with read filtering on the document becomes invisible to everyone, including an admin-less service account.

Type-level create permission

Whether a caller may create documents of an entity type is a type-level question, answered from the tenant's EntityDefaultPermissionSettingss document for that entity.

Which fields carry it. That document inherits EntityBase, so it has RolesAllowedToWrite and IdsAllowedToWrite. For this purpose those two fields are repurposed to mean who may create documents of this entity: a caller is allowed if they hold any listed role (compared case-insensitively) or their user id is listed (compared exactly). Null and empty arrays both grant nobody. Callers holding a SystemRoles role bypass the check before the settings row is read.

OWNER and OWNERROLE are a configuration error here, not an allow. In a row-level array those tokens mean "the document's creator". In a create check there is no document yet, so they would resolve to "is the caller the caller", which is always true, silently granting everyone. They are therefore reported as a configuration error and never grant.

Literal user ids are matched. This differs from the framework's GetInterprettedIds, which keeps only OWNER; a literal id listed in IdsAllowedToWrite allows that user to create.

Rollout. CreatePermission has three modes, default Off:

Mode Behaviour
Off Nothing is checked and no settings are read.
Shadow Evaluates and logs what would be refused, but allows the insert.
Enforce Refuses the insert.

Run Shadow first, read the log for refusals you did not intend, correct the settings, then move to Enforce. The check runs on all four insert entry points of this interface - InsertOne, InsertOneAsync, InsertMany, InsertManyAsync - keyed on the runtime type, once per distinct type for InsertMany, after the empty-sequence return and before any write. Settings are cached per tenant for SettingsCacheTtl (default 60 seconds), including the absence of a row.

Two things it does not cover, by construction:

  • GetCollection<T>() and GetUnrestrictedCollection<T>(reason) bypass it, along with row-level and field-level security. Anything written through the raw driver collection they hand back is unchecked and unstamped. GetCollection<T>() is refused outright while field security is enforcing; GetUnrestrictedCollection is the deliberate, logged escape hatch.
  • Upserts cannot create. UpdateOneAsync with new UpdateOptions { IsUpsert = true } can insert a document, and that insert runs neither this check nor insert stamping - the driver builds the new document from the update alone, so it would carry no TenantId, no ItemId, no CreatedBy and null permission arrays. While CreatePermission is Enforce such a call is refused with InvalidOperationException; while it is Shadow it is logged and allowed; while it is Off nothing changes. Unsecured types are exempt, so the settings row itself stays upsertable. Insert explicitly when you mean to create.

The guard must be present. Constructing MongoDbRepository with no ICreatePermissionGuard while CreatePermission is not Off throws at construction rather than leaving the check silently inert. Use AddMongoDbRepositoryServices, or pass a guard to the constructor yourself.

FailOpenOnSecurityContextFailure does not apply to this check. Enforce always fails closed: it is explicit opt-in, so there is no legacy behaviour to restore. A missing settings row, a configuration error and an unreadable security context all refuse. Check that every entity type you insert has a settings row before leaving Shadow.

The per-document pair is reserved and inert. EntityBase.RolesAllowedToWrite / IdsAllowedToWrite on an ordinary document are still not consulted by anything. They are not connected to the identically-named fields on the settings document, even though the names match. At insert they are never templated from settings; on a secured type they are only normalised to empty arrays so the stored document is uniform. On an unsecured type nothing is normalised: that path returns before permission resolution, which is the point of the exemption, so an unsecured row persists with whatever the caller left on it, null included.

Unsecured types

UnsecuredTypes (a set of Type) and UnsecuredTypeNames (a set of simple type names) list entity types whose own rows are not access-controlled: no row filter on reads, updates or deletes, no create-permission check and no permission-array resolution at insert. Audit stamping still applies. This is needed because the settings row that defines an entity's permissions is itself an EntityBase; row-filtering it would need the permissions it defines.

These are the only options on this branch whose defaults ship enabled: the five field-policy types of this package in UnsecuredTypes, and EntityDefaultPermissionSettings in UnsecuredTypeNames (that type lives in a net6.0-only package this one cannot reference, hence a second set).

  • Name matching is by simple name. An unrelated host type with the same simple name is also exempted. That is a deliberate trade-off for not referencing the type; pick names no host type uses.
  • Exact type only. A subclass of an unsecured type is not exempt.
  • Add to the sets, do not replace them. Both properties are settable. Assigning a new set drops the defaults, including the settings exclusion, and the settings row becomes row-filtered by its own arrays. Use options.UnsecuredTypes.Add(...).
  • RefusePermissionFieldWrites (below) does not apply to unsecured types. On such a type the permission arrays are configuration, not that row's own security: create permission is granted by writing RolesAllowedToWrite / IdsAllowedToWrite on the settings row, and refusing those writes would leave an operator able to create a settings row but never able to change who may create afterwards.
  • Insert stamping skips array normalisation, so an unsecured row persists with whatever the caller left on the eight arrays, null included.
  • The exemption is keyed on the type the call site knows. Row filtering, the permission-write refusal and the upsert refusal are generic in T and test the declared type; insert stamping and the create check test the entity's runtime type. Calling UpdateOne<EntityBase> against a collection of an unsecured type therefore does not pick the exemption up. Call these with the concrete type.

Refusing permission-field writes

With RefusePermissionFieldWrites = true, the dictionary and UpdateDefinition update overloads throw InvalidOperationException naming the field if they write any of the eight RolesAllowedTo* / IdsAllowedTo* arrays of a permissioned entity. Unsecured types are exempt, so create permission stays administrable once this is on. Pipeline updates cannot be inspected and are refused outright. Field names are resolved through the driver's class map, so a host naming convention (for example camelCase) cannot make the check fail open. Default false, so hosts that grant permissions through the repository keep working; turn it on once nothing legitimate does.

Designing permission arrays

Roles are for classes of principals; ids are for individuals. Owned data carries RolesAllowedTo* = [] and grants by id. The row filter is an $or across the role and id arrays, so a broad role such as appuser in RolesAllowedToRead grants every authenticated user and makes the id leg decorative.

Stripping appuser / anonymous from the filter is deliberately not done. An entity meant to be readable by all authenticated users carries that role on purpose, and stripping it would make such an entity readable by nobody. The rule is applied when permissions are written, not by second-guessing them when they are read.

Other differences worth knowing

  • Mutating methods return the affected document count instead of void/Task.
  • DeleteMany<T> runs synchronously. The FinMongoDbRepositories version calls DeleteManyAsync without awaiting it, so failures are swallowed and the caller cannot tell whether the delete happened.
  • UpdateOneAsync/UpdateManyAsync with an IDictionary build a single $set document. The FinMongoDbRepositories version adds a duplicate $set key per field into a BsonDocument(allowDuplicateNames: true), which Mongo rejects once more than one field is passed.
  • Counts return long rather than being cast down to int.
  • GetDistinctValueByFieldAsync is generic in the field type instead of being fixed to BsonDocument.
  • Async methods accept a CancellationToken.
  • FindPagedAsync returns a page and its total count from one call.
  • GetCollection<T>() exposes the tenant collection for operations this interface does not wrap. Nothing issued through it is filtered, so it is refused while field security is enforcing; see below.

Field-level security

Off by default. EnableFieldLevelSecurity = false means no policy is read, no projection is built and behaviour is byte-for-byte what it was before.

Field policy lives in the tenant database, not in appsettings, so access can be granted and revoked without a redeploy. Three collections carry it:

Collection Holds
FieldSecurityScopes Whether fields in a scope require configuration, plus defaults
FieldClassificationMaps field -> classification
FieldClassificationFeatures classification -> feature + operation

The indirection is the point. Policy grants a classification to a feature, never a field to a role, so its size is independent of how many roles and users exist:

200 fields  -> ~10 classifications = 200 rows
500 roles   -> features, already granted and revoked by the host

Features are read from the cache the host already populates, so a role gaining or losing a feature takes effect through machinery that already exists rather than a second system to keep in sync. Point FeatureSource at it:

options.FeatureSource.KeyPattern = "{sessionId}-{userId}-FeaturePermissions";
options.FeatureSource.FeatureIdProperty = "FeatureId";

Enablement is separate from the master switch

EnableFieldLevelSecurity decides whether the subsystem runs at all. Whether a given field requires configuration is a per-scope setting in the database, resolved field -> entity -> global:

Effective Enabled Behaviour
true Readable only if classified and that classification is granted to a feature the caller holds. Unclassified means denied
false Always accessible

A null inherits from the enclosing scope. That is what allows a graduated rollout: switch the subsystem on globally while it is inert, then lock down one entity, or one field, at a time.

Rolling it out

  1. Turn the subsystem on in Shadow mode and leave it. It computes what would be denied and logs it under [field-security:shadow], throttled, changing no query and no result.
  2. Classify fields and grant classifications to features until the shadow log is quiet.
  3. Switch to Enforce.

Shadow and enforce resolve identical masks, so step 1 genuinely predicts step 3.

What enforcement does

  • Reads apply a Mongo projection, so denied fields never leave the server. A projection the caller supplies is merged with that one rather than replacing it: the driver's Project replaces whatever projection is already on a query, so the two have to be combined before the query is sent. Including a parent of a denied leaf drops the parent, since a subdocument carries its leaves out with it.
  • A computed projection — Project(e => new { ... }) and anything else whose keys name its output rather than the document's — has nothing to merge into, so what it reads is verified instead and it is refused when that includes a denied field. A projection reaching the whole document through $$ROOT is refused outright.
  • Updates and inserts are checked against the write mask, and refused or stripped per WriteViolation. An insert is always refused rather than stripped: stripping would persist a document the caller never asked for. The insert check walks nested paths, because the schema indexes leaves — HomeAddress.Street is a field and HomeAddress is not — and a value inside a collection counts as populated.
  • A write also requires read access to the same field. A field redacted on the way out would otherwise be written back over the stored value as a type default.
  • Distinct and aggregation over a denied field are refused, since a projection cannot express a restriction on a field that is the subject of the query.

The escape hatch

GetCollection<T>() returns a collection that bypasses both row-level and field-level security. While enforcing, it throws. While shadowing, it works and logs a warning naming the call site, so the code that enforcement will break can be found first.

Two sanctioned alternatives:

  • Aggregate<T>() returns a pipeline that already has the row filter and the field projection as its first stages. Later stages cannot reach documents or fields the caller is not entitled to.
  • GetUnrestrictedCollection<T>(reason) always works and logs the reason at warning level, for migrations and bulk maintenance that genuinely need raw access.

UnrestrictedCollectionAccess = Allow restores the old behaviour for hosts that have audited their own call sites.

Known limits

  • $lookup does not carry the joined collection's policy.
  • Synchronous reads resolve the mask without awaiting. The warm path completes synchronously; only the first call per caller per feature-cache lifetime blocks. Blocking is chosen over skipping because skipping would return unfiltered data.
  • Policy changes propagate to other instances within PolicyVersionPollInterval. Feature revocation propagates at whatever rate the host invalidates its own feature cache, which is the same window the host's endpoint authorization already has.

Release notes

11.0.0.0

A major bump. Several of these changes are breaking, and none is offered behind a compatibility switch: another option to carry forever costs more than it saves, and a major version is honest about the change where 10.2.0.0 would have read as compatible.

  • Breaking: IEntityStamper gained a member. StampForInsertAsync is new, so any external implementer of that interface no longer compiles and must add it. Source- and binary-breaking. Hosts that only use the registered default are unaffected.
  • Breaking: insert stamping behaviour changes on upgrade, with no switch to restore the old behaviour. The permission arrays are now resolved in a defined order: caller-supplied values win, the tenant's EntityDefaultPermissionSettingss row fills the arrays left empty, and the creator is seeded only when nothing grants access to anyone after that. Previously the creator id was appended unconditionally, including to arrays the caller had already populated. Review any entity whose permissions you set at the call site, and any tenant that already has settings rows, before upgrading.
  • Breaking when opted into: an upsert is refused while CreatePermission is Enforce. UpdateOneAsync with IsUpsert = true could insert a document without the create check or insert stamping. Off hosts are unaffected; Shadow logs instead of refusing.
  • Breaking when opted into: constructing MongoDbRepository without an ICreatePermissionGuard while CreatePermission is not Off now throws, instead of leaving the check silently inert.
  • Added type-level create permission (CreatePermission, default Off) and SettingsCacheTtl.
  • Added RefusePermissionFieldWrites (default false). Unsecured types are exempt from it, so create permission stays administrable.
  • Added UnsecuredTypes / UnsecuredTypeNames, enabled by default, excluding the field-policy types and the entity settings row from row-level filtering.

Packing

PacknPush.bat / PacknPush.sh read the feed key from the NUGET_API_KEY environment variable rather than carrying it in the file. Publishing is the package owner's call; this repository's agent instructions forbid pushing on the user's behalf.

No packages depend on MongoDbRepositories.

Version Downloads Last updated
12.2.0 2 11/02/2026
12.1.0 3 11/02/2026
12.0.0 2 11/02/2026
11.0.0 0 11/01/2026
10.2.0.1 0 11/02/2026