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

This commit is contained in:
StellaOps Bot
2025-11-27 08:51:10 +02:00
parent ea970ead2a
commit c34fb7256d
126 changed files with 18553 additions and 693 deletions

View File

@@ -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)..]);
});
}

View File

@@ -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)..]);
});
}

View File

@@ -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)..]);
});
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}
}