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.GetUserReadableFieldsAsync suggests 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 Update is 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. RefusePermissionFieldWrites covers 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.

Version Downloads Last updated
10.0.0.1 4 10/22/2026