Skip to content

Release v2.0.0

Choose a tag to compare

@hyspdrt hyspdrt released this 26 Apr 06:08

Highlights

IProtectedRepository<T> is now a closed ACL-aware contract. The interface no longer extends IRepository<T>, so handlers that inject the protected repository can no longer accidentally call an unprotected method and bypass object-level access control. The unprotected surface is reachable only through a new explicit, audited escape hatch: UseInnerRepositoryAsync, which logs every entry with caller member, file, and line at Information level.

This is the only breaking change in the release. Everything else is additive or internal.

Breaking changes

IProtectedRepository<T> no longer extends IRepository<T>

Before After
IProtectedRepository<T> : IRepository<T> IProtectedRepository<T> (standalone)

Why: an inherited unprotected surface meant any handler holding IProtectedRepository<T> could type repo.DeleteAsync(value, ct) and bypass the ACL check with no compiler warning. The type system now enforces an explicit, audited verb for every non-ACL operation.

Migration paths:

  1. If your handler only needs ACL-aware operations — no change required. The permission-aware methods (GetAsync(id, permission, ct), CreateAsync(value, parentResourceId, permission, ct), etc.) work exactly as before.

  2. If your handler needs an unprotected operation (system maintenance, bulk projections, cross-cutting reads, queries that can't be expressed via ACL) — wrap the call in UseInnerRepositoryAsync:

    // Before (relied on inherited unprotected method):
    await protectedRepo.UpdatePartialAsync(id, ops => ops.Set(d => d.Indexed, true), ct: ct);
    
    // After (explicit, audited escape hatch):
    await protectedRepo.UseInnerRepositoryAsync(async (inner, token) => {
        await inner.UpdatePartialAsync(id, ops => ops.Set(d => d.Indexed, true), cancellationToken: token);
    }, ct);
  3. If your handler genuinely needs the unprotected repository as its primary surface — inject IRepository<T> directly instead of IProtectedRepository<T>. Both registrations remain available via DI.

Every UseInnerRepositoryAsync call emits a structured log event (EventIds.CosmosInnerRepositoryScopeOpenedId = 20_101, Information level) with the entity type and caller location — making ACL-bypass entry points trivially auditable.

Notable additions

UseInnerRepositoryAsync escape hatch

Two overloads on IProtectedRepository<T>:

ValueTask UseInnerRepositoryAsync(
    Func<IRepository<TEntity>, CancellationToken, ValueTask> action,
    CancellationToken cancellationToken = default,
    [CallerMemberName] string callerMember = "",
    [CallerFilePath] string callerFile = "",
    [CallerLineNumber] int callerLine = 0);

ValueTask<TResult> UseInnerRepositoryAsync<TResult>(
    Func<IRepository<TEntity>, CancellationToken, ValueTask<TResult>> action,
    CancellationToken cancellationToken = default,
    [CallerMemberName] string callerMember = "",
    [CallerFilePath] string callerFile = "",
    [CallerLineNumber] int callerLine = 0);

The caller-info attributes are declared on the interface (not just the impl) so the compiler binds values at the actual call site through interface dispatch.

Soft-delete on the protected repository

The four-overload DeleteAsync pattern from IRepository<T> is now mirrored on IProtectedRepository<T>:

ValueTask DeleteAsync(TEntity value, Permission permission, CancellationToken ct = default);
ValueTask DeleteAsync(string id, Permission permission, CancellationToken ct = default);
ValueTask<TEntity> DeleteAsync(TEntity value, Permission permission, CancellationToken ct, bool softDelete = true);
ValueTask<TEntity> DeleteAsync(string id, Permission permission, CancellationToken ct, bool softDelete = true);

softDelete = true is the default destructive path — hard-delete requires explicit opt-in.

Dependency updates

Package From To
Cirreum.Core 3.0.4 4.0.0
Microsoft.Azure.Cosmos 3.58.0 3.59.0
Cirreum.ServiceProvider 1.0.14 1.0.15

Internal cleanup

  • Removed DefaultProtectedRepository.delegated.cs (197 lines) — every member existed solely to satisfy the dropped inheritance.
  • Removed dead IsEmulatorConnectionString helper and EmulatorAccountKey constant from CosmosRegistrationExtensions.
  • Refactored DefaultRepository<T>.DeleteAsync(value, ct, softDelete) — collapsed an unreachable defensive else branch and reordered the hard-delete branch before soft-delete for readability.

Documentation

The README now covers the protected-resources surface, soft-delete semantics, and the UseInnerRepositoryAsync escape hatch with copy-pasteable handler examples. The Quick Start example now uses primary-constructor style for consistency with later examples.

Versioning note

.gitattributes now enforces CRLF line endings via * text=auto eol=crlf, with *.sh text eol=lf as a defensive override for shell scripts. This eliminates the "LF will be replaced by CRLF" warnings from contributors using different core.autocrlf settings.