up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
This commit is contained in:
@@ -4,14 +4,16 @@ using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
using StellaOps.Notify.Storage.Mongo.Tenancy;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyChannelRepository : INotifyChannelRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public NotifyChannelRepository(NotifyMongoContext context)
|
||||
public NotifyChannelRepository(NotifyMongoContext context, ITenantContext? tenantContext = null)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
@@ -19,23 +21,34 @@ internal sealed class NotifyChannelRepository : INotifyChannelRepository
|
||||
}
|
||||
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.ChannelsCollection);
|
||||
_tenantContext = tenantContext ?? NullTenantContext.Instance;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
_tenantContext.ValidateTenant(channel.TenantId);
|
||||
|
||||
var document = NotifyChannelDocumentMapper.ToBsonDocument(channel);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(channel.TenantId, channel.ChannelId));
|
||||
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(channel.TenantId, channel.ChannelId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", channel.TenantId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, channelId))
|
||||
& Builders<BsonDocument>.Filter.Or(
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
// RLS: Dual-filter with both ID and explicit tenantId check
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, channelId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : NotifyChannelDocumentMapper.FromBsonDocument(document);
|
||||
@@ -43,28 +56,30 @@ internal sealed class NotifyChannelRepository : INotifyChannelRepository
|
||||
|
||||
public async Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
& Builders<BsonDocument>.Filter.Or(
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
|
||||
|
||||
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return cursor.Select(NotifyChannelDocumentMapper.FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, channelId));
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, channelId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId));
|
||||
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow).Set("enabled", false),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,14 +5,16 @@ using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
using StellaOps.Notify.Storage.Mongo.Tenancy;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyRuleRepository : INotifyRuleRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public NotifyRuleRepository(NotifyMongoContext context)
|
||||
public NotifyRuleRepository(NotifyMongoContext context, ITenantContext? tenantContext = null)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
@@ -20,23 +22,34 @@ internal sealed class NotifyRuleRepository : INotifyRuleRepository
|
||||
}
|
||||
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.RulesCollection);
|
||||
_tenantContext = tenantContext ?? NullTenantContext.Instance;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rule);
|
||||
_tenantContext.ValidateTenant(rule.TenantId);
|
||||
|
||||
var document = NotifyRuleDocumentMapper.ToBsonDocument(rule);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(rule.TenantId, rule.RuleId));
|
||||
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(rule.TenantId, rule.RuleId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", rule.TenantId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, ruleId))
|
||||
& Builders<BsonDocument>.Filter.Or(
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
// RLS: Dual-filter with both ID and explicit tenantId check
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, ruleId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : NotifyRuleDocumentMapper.FromBsonDocument(document);
|
||||
@@ -44,17 +57,27 @@ internal sealed class NotifyRuleRepository : INotifyRuleRepository
|
||||
|
||||
public async Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
& Builders<BsonDocument>.Filter.Or(
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
|
||||
|
||||
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return cursor.Select(NotifyRuleDocumentMapper.FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, ruleId));
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, ruleId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId));
|
||||
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update
|
||||
.Set("deletedAt", DateTime.UtcNow)
|
||||
@@ -62,12 +85,4 @@ internal sealed class NotifyRuleRepository : INotifyRuleRepository
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ using MongoDB.Driver;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Internal;
|
||||
using StellaOps.Notify.Storage.Mongo.Serialization;
|
||||
using StellaOps.Notify.Storage.Mongo.Tenancy;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
internal sealed class NotifyTemplateRepository : INotifyTemplateRepository
|
||||
{
|
||||
private readonly IMongoCollection<BsonDocument> _collection;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public NotifyTemplateRepository(NotifyMongoContext context)
|
||||
public NotifyTemplateRepository(NotifyMongoContext context, ITenantContext? tenantContext = null)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
@@ -19,23 +21,34 @@ internal sealed class NotifyTemplateRepository : INotifyTemplateRepository
|
||||
}
|
||||
|
||||
_collection = context.Database.GetCollection<BsonDocument>(context.Options.TemplatesCollection);
|
||||
_tenantContext = tenantContext ?? NullTenantContext.Instance;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
_tenantContext.ValidateTenant(template.TenantId);
|
||||
|
||||
var document = NotifyTemplateDocumentMapper.ToBsonDocument(template);
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(template.TenantId, template.TemplateId));
|
||||
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(template.TenantId, template.TemplateId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", template.TenantId));
|
||||
|
||||
await _collection.ReplaceOneAsync(filter, document, new ReplaceOptions { IsUpsert = true }, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, templateId))
|
||||
& Builders<BsonDocument>.Filter.Or(
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
// RLS: Dual-filter with both ID and explicit tenantId check
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, templateId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
|
||||
|
||||
var document = await _collection.Find(filter).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false);
|
||||
return document is null ? null : NotifyTemplateDocumentMapper.FromBsonDocument(document);
|
||||
@@ -43,28 +56,30 @@ internal sealed class NotifyTemplateRepository : INotifyTemplateRepository
|
||||
|
||||
public async Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
& Builders<BsonDocument>.Filter.Or(
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value));
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)));
|
||||
|
||||
var cursor = await _collection.Find(filter).ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||
return cursor.Select(NotifyTemplateDocumentMapper.FromBsonDocument).ToArray();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("_id", CreateDocumentId(tenantId, templateId));
|
||||
_tenantContext.ValidateTenant(tenantId);
|
||||
|
||||
// RLS: Dual-filter with both ID and tenantId for defense-in-depth
|
||||
var filter = Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", TenantScopedId.Create(tenantId, templateId)),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId));
|
||||
|
||||
await _collection.UpdateOneAsync(filter,
|
||||
Builders<BsonDocument>.Update.Set("deletedAt", DateTime.UtcNow),
|
||||
new UpdateOptions { IsUpsert = false },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = ':';
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Provides tenant context for RLS-like tenant isolation in storage operations.
|
||||
/// </summary>
|
||||
public interface ITenantContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current authenticated tenant ID, or null if not authenticated.
|
||||
/// </summary>
|
||||
string? CurrentTenantId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the current context has a valid tenant.
|
||||
/// </summary>
|
||||
bool HasTenant { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the requested tenant matches the current context.
|
||||
/// Throws <see cref="TenantMismatchException"/> if validation fails.
|
||||
/// </summary>
|
||||
/// <param name="requestedTenantId">The tenant ID being requested.</param>
|
||||
/// <exception cref="TenantMismatchException">Thrown when tenants don't match.</exception>
|
||||
void ValidateTenant(string requestedTenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the current context allows access to the specified tenant.
|
||||
/// Admin tenants may access other tenants.
|
||||
/// </summary>
|
||||
bool CanAccessTenant(string targetTenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a tenant isolation violation is detected.
|
||||
/// </summary>
|
||||
public sealed class TenantMismatchException : InvalidOperationException
|
||||
{
|
||||
public string RequestedTenantId { get; }
|
||||
public string? CurrentTenantId { get; }
|
||||
|
||||
public TenantMismatchException(string requestedTenantId, string? currentTenantId)
|
||||
: base($"Tenant isolation violation: requested tenant '{requestedTenantId}' does not match current tenant '{currentTenantId ?? "(none)"}'")
|
||||
{
|
||||
RequestedTenantId = requestedTenantId;
|
||||
CurrentTenantId = currentTenantId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation that uses AsyncLocal to track tenant context.
|
||||
/// </summary>
|
||||
public sealed class DefaultTenantContext : ITenantContext
|
||||
{
|
||||
private static readonly AsyncLocal<string?> _currentTenant = new();
|
||||
private readonly HashSet<string> _adminTenants;
|
||||
|
||||
public DefaultTenantContext(IEnumerable<string>? adminTenants = null)
|
||||
{
|
||||
_adminTenants = adminTenants?.ToHashSet(StringComparer.OrdinalIgnoreCase)
|
||||
?? new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "admin", "system" };
|
||||
}
|
||||
|
||||
public string? CurrentTenantId
|
||||
{
|
||||
get => _currentTenant.Value;
|
||||
set => _currentTenant.Value = value;
|
||||
}
|
||||
|
||||
public bool HasTenant => !string.IsNullOrWhiteSpace(_currentTenant.Value);
|
||||
|
||||
public void ValidateTenant(string requestedTenantId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(requestedTenantId);
|
||||
|
||||
if (!CanAccessTenant(requestedTenantId))
|
||||
{
|
||||
throw new TenantMismatchException(requestedTenantId, CurrentTenantId);
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanAccessTenant(string targetTenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetTenantId))
|
||||
return false;
|
||||
|
||||
// No current tenant means no access
|
||||
if (!HasTenant)
|
||||
return false;
|
||||
|
||||
// Same tenant always allowed
|
||||
if (string.Equals(CurrentTenantId, targetTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
// Admin tenants can access other tenants
|
||||
if (_adminTenants.Contains(CurrentTenantId!))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current tenant context. Returns a disposable to restore previous value.
|
||||
/// </summary>
|
||||
public IDisposable SetTenant(string tenantId)
|
||||
{
|
||||
var previous = _currentTenant.Value;
|
||||
_currentTenant.Value = tenantId;
|
||||
return new TenantScope(previous);
|
||||
}
|
||||
|
||||
private sealed class TenantScope : IDisposable
|
||||
{
|
||||
private readonly string? _previousTenant;
|
||||
private bool _disposed;
|
||||
|
||||
public TenantScope(string? previousTenant) => _previousTenant = previousTenant;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_currentTenant.Value = _previousTenant;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation for testing or contexts without tenant isolation.
|
||||
/// </summary>
|
||||
public sealed class NullTenantContext : ITenantContext
|
||||
{
|
||||
public static readonly NullTenantContext Instance = new();
|
||||
|
||||
public string? CurrentTenantId => null;
|
||||
public bool HasTenant => false;
|
||||
|
||||
public void ValidateTenant(string requestedTenantId)
|
||||
{
|
||||
// No-op - allows all access
|
||||
}
|
||||
|
||||
public bool CanAccessTenant(string targetTenantId) => true;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for tenant-aware MongoDB repositories with RLS-like filtering.
|
||||
/// </summary>
|
||||
public abstract class TenantAwareRepository
|
||||
{
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
protected TenantAwareRepository(ITenantContext? tenantContext = null)
|
||||
{
|
||||
_tenantContext = tenantContext ?? NullTenantContext.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant context for validation.
|
||||
/// </summary>
|
||||
protected ITenantContext TenantContext => _tenantContext;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the requested tenant is accessible from the current context.
|
||||
/// </summary>
|
||||
/// <param name="requestedTenantId">The tenant ID being requested.</param>
|
||||
protected void ValidateTenantAccess(string requestedTenantId)
|
||||
{
|
||||
_tenantContext.ValidateTenant(requestedTenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a filter that includes both ID and explicit tenantId check (dual-filter pattern).
|
||||
/// This provides RLS-like defense-in-depth.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="documentId">The full document ID (typically tenant-scoped).</param>
|
||||
/// <returns>A filter requiring both ID match and tenantId match.</returns>
|
||||
protected static FilterDefinition<BsonDocument> CreateTenantSafeIdFilter(
|
||||
string tenantId,
|
||||
string documentId)
|
||||
{
|
||||
return Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", documentId),
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a filter with an explicit tenantId check.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID to scope the query to.</param>
|
||||
/// <param name="baseFilter">The base filter to wrap.</param>
|
||||
/// <returns>A filter that includes the tenantId check.</returns>
|
||||
protected static FilterDefinition<BsonDocument> WithTenantScope(
|
||||
string tenantId,
|
||||
FilterDefinition<BsonDocument> baseFilter)
|
||||
{
|
||||
return Builders<BsonDocument>.Filter.And(
|
||||
Builders<BsonDocument>.Filter.Eq("tenantId", tenantId),
|
||||
baseFilter
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a filter for listing documents within a tenant.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="includeDeleted">Whether to include soft-deleted documents.</param>
|
||||
/// <returns>A filter for the tenant's documents.</returns>
|
||||
protected static FilterDefinition<BsonDocument> CreateTenantListFilter(
|
||||
string tenantId,
|
||||
bool includeDeleted = false)
|
||||
{
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenantId", tenantId);
|
||||
|
||||
if (!includeDeleted)
|
||||
{
|
||||
filter = Builders<BsonDocument>.Filter.And(
|
||||
filter,
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("deletedAt", false),
|
||||
Builders<BsonDocument>.Filter.Eq("deletedAt", BsonNull.Value)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a sort definition for common ordering patterns.
|
||||
/// </summary>
|
||||
/// <param name="sortBy">The field to sort by.</param>
|
||||
/// <param name="ascending">True for ascending, false for descending.</param>
|
||||
/// <returns>A sort definition.</returns>
|
||||
protected static SortDefinition<BsonDocument> CreateSort(string sortBy, bool ascending = true)
|
||||
{
|
||||
return ascending
|
||||
? Builders<BsonDocument>.Sort.Ascending(sortBy)
|
||||
: Builders<BsonDocument>.Sort.Descending(sortBy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a document ID using the tenant-scoped format.
|
||||
/// </summary>
|
||||
protected static string CreateDocumentId(string tenantId, string resourceId)
|
||||
=> TenantScopedId.Create(tenantId, resourceId);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
namespace StellaOps.Notify.Storage.Mongo.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for constructing tenant-scoped document IDs with consistent format.
|
||||
/// </summary>
|
||||
public static class TenantScopedId
|
||||
{
|
||||
private const char Separator = ':';
|
||||
|
||||
/// <summary>
|
||||
/// Creates a tenant-scoped ID in the format "{tenantId}:{resourceId}".
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID (required).</param>
|
||||
/// <param name="resourceId">The resource ID (required).</param>
|
||||
/// <returns>A composite ID string.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown if either parameter is null or whitespace.</exception>
|
||||
public static string Create(string tenantId, string resourceId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(resourceId);
|
||||
|
||||
// Validate no separator in tenant or resource IDs to prevent injection
|
||||
if (tenantId.Contains(Separator))
|
||||
throw new ArgumentException($"Tenant ID cannot contain '{Separator}'", nameof(tenantId));
|
||||
|
||||
if (resourceId.Contains(Separator))
|
||||
throw new ArgumentException($"Resource ID cannot contain '{Separator}'", nameof(resourceId));
|
||||
|
||||
return string.Create(tenantId.Length + resourceId.Length + 1, (tenantId, resourceId), static (span, value) =>
|
||||
{
|
||||
value.tenantId.AsSpan().CopyTo(span);
|
||||
span[value.tenantId.Length] = Separator;
|
||||
value.resourceId.AsSpan().CopyTo(span[(value.tenantId.Length + 1)..]);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a tenant-scoped ID into its components.
|
||||
/// </summary>
|
||||
/// <param name="scopedId">The composite ID to parse.</param>
|
||||
/// <param name="tenantId">Output: the extracted tenant ID.</param>
|
||||
/// <param name="resourceId">Output: the extracted resource ID.</param>
|
||||
/// <returns>True if parsing succeeded, false otherwise.</returns>
|
||||
public static bool TryParse(string scopedId, out string tenantId, out string resourceId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
resourceId = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(scopedId))
|
||||
return false;
|
||||
|
||||
var separatorIndex = scopedId.IndexOf(Separator);
|
||||
if (separatorIndex <= 0 || separatorIndex >= scopedId.Length - 1)
|
||||
return false;
|
||||
|
||||
tenantId = scopedId[..separatorIndex];
|
||||
resourceId = scopedId[(separatorIndex + 1)..];
|
||||
|
||||
return !string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(resourceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the tenant ID from a tenant-scoped ID.
|
||||
/// </summary>
|
||||
/// <param name="scopedId">The composite ID.</param>
|
||||
/// <returns>The tenant ID, or null if parsing failed.</returns>
|
||||
public static string? ExtractTenantId(string scopedId)
|
||||
{
|
||||
return TryParse(scopedId, out var tenantId, out _) ? tenantId : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a scoped ID belongs to the expected tenant.
|
||||
/// </summary>
|
||||
/// <param name="scopedId">The composite ID to validate.</param>
|
||||
/// <param name="expectedTenantId">The expected tenant ID.</param>
|
||||
/// <returns>True if the ID belongs to the expected tenant.</returns>
|
||||
public static bool BelongsToTenant(string scopedId, string expectedTenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scopedId) || string.IsNullOrWhiteSpace(expectedTenantId))
|
||||
return false;
|
||||
|
||||
var extractedTenant = ExtractTenantId(scopedId);
|
||||
return string.Equals(extractedTenant, expectedTenantId, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user