SecuredRepositories 10.0.0.1
SecuredRepositories
Row-level security for SeliseBlocks.Genesis.Framework.Infrastructure.IRepository.
It decorates the IRepository a host already registered and restricts every read, update and
delete to the documents whose RolesAllowedTo* / IdsAllowedTo* arrays admit the caller. Call
sites keep injecting IRepository and do not change.
Multi-targets net8.0 and net10.0, pinned the same way as the other packages in this
repository. It takes no MongoDB dependency: it decorates an interface, so it works over
whatever implementation the host registered.
Why a decorator
The persistence semantics — what a save does to an existing document, how collections are named, how a tenant is resolved — stay where they already are, in the host's own repository. This package only narrows which documents those semantics are allowed to reach. Nothing about how data is written changes, which is what makes it safe to drop in front of an existing implementation.
Registration
Register it after the repository it decorates. There is nothing to wrap otherwise, and silently registering an unsecured repository would be worse than failing, so that throws.
services.AddScoped<IRepository, MongoDbRepository>(); // the host's own
services.AddSecuredRepositoryServices(); // must come after
The existing registration keeps its lifetime: a scoped repository stays scoped, a singleton stays a singleton. An instance registration is wrapped rather than reconstructed. Hosts that build the inner repository themselves can pass it directly:
services.AddSecuredRepositoryServices(provider => new MongoDbRepository(...));
Calling twice is a no-op. Registering a different repository afterwards and calling again decorates that one.
What is enforced
| Method | Filtered against |
|---|---|
GetItem, GetItemAsync, GetItems, ExistsAsync |
RolesAllowedToRead / IdsAllowedToRead |
Update, UpdateAsync, UpdateMany, UpdateManyAsync |
RolesAllowedToUpdate / IdsAllowedToUpdate |
Delete, DeleteAsync |
RolesAllowedToDelete / IdsAllowedToDelete |
Save, SaveAsync, SaveExpectingFailuresAsync |
not filtered — see below |
The filter is server-side. The permission predicate is ANDed into the expression the inner repository translates into a query, so excluded documents are never fetched:
repository.GetItems<Invoice>(invoice => invoice.Status == "Open");
{ "$and": [
{ "Status": "Open" },
{ "$or": [ { "RolesAllowedToRead": { "$ne": null, "$in": ["manager"] } },
{ "IdsAllowedToRead": { "$ne": null, "$in": ["user-1"] } } ] } ] }
That is the same MQL MongoDbRepositories produces from a driver filter, so the two packages
agree on what a permission means.
The $ne: null is not redundant. Mongo simply does not match a missing array, but
Enumerable.Any throws on a null source, so without it a document with no permission arrays would
be denied by the server and an exception to anything evaluating the predicate in memory.
A caller with no roles and no user id gets { "$in": [] }, which matches nothing. The dangerous
failure here is a filter that silently widens to the whole collection, so the empty case is left
to render rather than special-cased away.
Callers holding one of the configured SystemRoles are not filtered at all.
The tenant overloads
GetItemAsync(tenantId, …), SaveAsync(tenantId, …) and UpdateAsync(…, tenantId, …) are still
filtered. Addressing another tenant does not confer authority over its rows, so the caller's own
roles and id still decide which of them are visible.
Why saves are not filtered
A row-level filter restricts a query against documents that already exist. A save has none: the only arrays available are the ones the caller just populated on the object being saved, so filtering on them would be self-authorizing — the caller sets the very value that would grant the permission. Whether a caller may create a document of a type at all is a type-level question, not a property of a document that does not yet exist.
Saves are stamped and seeded
Every save stamps ItemId, TenantId, CreatedBy, LastUpdatedBy, the timestamps and
Language, and adds the creating user's id to IdsAllowedToRead, IdsAllowedToUpdate and
IdsAllowedToDelete when the caller left them empty.
That seeding is what keeps row-level reads usable. Without it, a document saved with empty
permission arrays is invisible to everyone, including the user who just created it. Hosts whose
repository already populates the arrays — through the framework's IRowLevelSecurityHandler, for
instance — should set SeedCreatorPermissionsOnSave = false and let that decide.
A permission the caller set explicitly is kept; seeding adds to it rather than replacing it.
Unscoped work
IRepository has no parameter for "do not filter this call", so the escape hatch is an ambient
scope — the shape the framework already uses for a related job in ImpersonatedContext:
using (UnrestrictedScope.Suspend("nightly reconciliation across all owners"))
{
var everything = repository.GetItems<Invoice>().ToList();
}
The reason is required and is exposed on UnrestrictedScope.Reason, so an unfiltered query is
attributable rather than anonymous. The flow is an AsyncLocal, so it follows awaits into the
work it wraps and no further: a request on another thread is unaffected, and the suspension ends
with the using block even if the body throws. UnrestrictedScope.Resume() reinstates filtering
inside an enclosing suspension, so a broad bypass does not silently cover a nested call that
should still be scoped.
Options
services.AddSecuredRepositoryServices(options =>
{
options.ApplyRowLevelSecurity = true;
options.SystemRoles = new HashSet<string>(["admin", "tenantadmin"], StringComparer.OrdinalIgnoreCase);
options.UnsecuredTypes.Add(typeof(FeatureSetting));
options.FailOpenOnSecurityContextFailure = false;
options.StampOnSave = true;
options.SeedCreatorPermissionsOnSave = true;
options.StampAuditFieldsOnUpdate = true;
options.RefusePermissionFieldWrites = false;
});
UnsecuredTypes matters more than it looks: a configuration or permission store whose own rows
are filtered by the permissions it holds cannot be read to resolve those permissions. Put such
types here.
ApplyRowLevelSecurity = false makes the decorator a pass-through, which is the quickest way to
confirm that a problem is or is not caused by filtering.
FailOpenOnSecurityContextFailure is false by default: an unreadable security context throws
rather than quietly producing an unfiltered query.
RefusePermissionFieldWrites refuses a dictionary update that writes a RolesAllowedTo* or
IdsAllowedTo* field. It is off by default, because a host that grants and revokes
permissions through this repository would break the moment it was switched on. Until it is on, a
caller who can update a document can widen its own access to it. Audit those call sites, route
them through a dedicated path, then turn it on.
What this does not do
- No field-level security. Every field of a document the caller may read comes back. The
framework's
IRowLevelSecurityHandler.GetUserReadableFieldsAsyncsuggests the platform has a notion of readable fields already; wiring it in would mean projections, which a decorator cannot apply without owning the query. - An entity-shaped
Updateis not inspected.Update(filters, entity)hands the entity to the inner repository, which decides which of its fields to write. This package cannot stop that entity from carrying permission arrays or authorship, because it does not do the writing.RefusePermissionFieldWritescovers the dictionary-shaped overloads only. UpdateMany(filters, object data, …)payloads are not inspected for the same reason.- Nothing is filtered after the fact. That is a feature, not a gap: redacting values that have already been fetched is not security, since they have been in process memory, logs and traces by then.
Relationship to the other packages
MongoDbRepositories is a Mongo-specific repository with its own interface, row-level filtering
and an optional field-level layer. This package is for hosts that already use the framework's
IRepository everywhere and want the row-level half without changing call sites. They apply the
same permission semantics and render the same MQL; they are not meant to be stacked on one
another.
Packing
PacknPush.bat reads the feed key from the NUGET_API_KEY environment variable rather than
carrying it in the file. Publishing is the package owner's call.
No packages depend on SecuredRepositories.
.NET 10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.11)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.11)
- Microsoft.Extensions.Options (>= 10.0.11)
- SeliseBlocks.Genesis.Framework (>= 10.0.0.2)
- SeliseBlocks.Genesis.Framework.PDS.Entity (>= 10.0.0.6)
.NET 8.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.3)
- Microsoft.Extensions.Options (>= 8.0.2)
- SeliseBlocks.Genesis.Framework (>= 8.0.1.1)
- SeliseBlocks.Genesis.Framework.PDS.Entity (>= 8.0.1.9)
| Version | Downloads | Last updated |
|---|---|---|
| 10.0.0.1 | 4 | 10/22/2026 |