I'm weighing up having separate DBs (one per company) vs one multi-tenanted DB (with all companies). Criteria:
Question #1. Are there any "good practices" for designing a multi-tenanted database in RavenDB?
There is a similar post for MongoDB. Would it be the same for RavenDB? More records will affect indexes, but would it potentially make some tenants suffer from active usage of an index by other tenants?
If I were to design a multi-tenanted DB for RavenDB, then I see the implementation as
Question #2.1. Is tagging the best way to utilise the Authorization Bundle for resolving users' permissions and prevent accessing documents of other tenants?
Question #2.2. How important is to have the Tenant ID in the ID prefix of top-level documents? I guess, the main consideration here is performance once permissions gets resolved via tags or I'm missing something?
UPDATE (Sep-2021): after 4 years I produced:
Original answer:
My attempt to engage @AyendeRahien in a discussion of the technical implementation by editing his post was unsuccessful :), so below I'll address my concerns from the above:
1. Multi-tenanted DB vs multiple DBs
Here are some Ayende's thoughts on multi-tenancy in general.
In my view the question boils down to
Simply, in a case of a couple of tenants with a huge number of records, adding the tenant information into the indexes will unnecessary increase the index size and handling the tenant ID will bring some overhead you'd rather avoid, so go for two DBs then.
2. Design of multi-tenanted DB
Step #1. Add TenantId
property to all persistent documents you want to support multi-tenancy.
/// <summary>
/// Interface for top-level entities, which belong to a tenant
/// </summary>
public interface ITenantedEntity
{
/// <summary>
/// ID of a tenant
/// </summary>
string TenantId { get; set; }
}
/// <summary>
/// Contact information [Tenanted document]
/// </summary>
public class Contact : ITenantedEntity
{
public string Id { get; set; }
public string TenantId { get; set; }
public string Name { get; set; }
}
Step #2. Implement facade for the Raven's session (IDocumentSession
or IAsyncDocumentSession
) to take care of multi-tenanted entities.
Sample code below:
/// <summary>
/// Facade for the Raven's IAsyncDocumentSession interface to take care of multi-tenanted entities
/// </summary>
public class RavenTenantedSession : IAsyncDocumentSession
{
private readonly IAsyncDocumentSession _dbSession;
private readonly string _currentTenantId;
public IAsyncAdvancedSessionOperations Advanced => _dbSession.Advanced;
public RavenTenantedSession(IAsyncDocumentSession dbSession, ICurrentTenantIdResolver tenantResolver)
{
_dbSession = dbSession;
_currentTenantId = tenantResolver.GetCurrentTenantId();
}
public void Delete<T>(T entity)
{
if (entity is ITenantedEntity tenantedEntity && tenantedEntity.TenantId != _currentTenantId)
throw new ArgumentException("Attempt to delete a record for another tenant");
_dbSession.Delete(entity);
}
public void Delete(string id)
{
throw new NotImplementedException("Deleting by ID hasn't been implemented");
}
#region SaveChanges & StoreAsync---------------------------------------
public Task SaveChangesAsync(CancellationToken token = new CancellationToken()) => _dbSession.SaveChangesAsync(token);
public Task StoreAsync(object entity, CancellationToken token = new CancellationToken())
{
SetTenantIdOnEntity(entity);
return _dbSession.StoreAsync(entity, token);
}
public Task StoreAsync(object entity, string changeVector, string id, CancellationToken token = new CancellationToken())
{
SetTenantIdOnEntity(entity);
return _dbSession.StoreAsync(entity, changeVector, id, token);
}
public Task StoreAsync(object entity, string id, CancellationToken token = new CancellationToken())
{
SetTenantIdOnEntity(entity);
return _dbSession.StoreAsync(entity, id, token);
}
private void SetTenantIdOnEntity(object entity)
{
var tenantedEntity = entity as ITenantedEntity;
if (tenantedEntity != null)
tenantedEntity.TenantId = _currentTenantId;
}
#endregion SaveChanges & StoreAsync------------------------------------
public IAsyncLoaderWithInclude<object> Include(string path)
{
throw new NotImplementedException();
}
public IAsyncLoaderWithInclude<T> Include<T>(Expression<Func<T, string>> path)
{
throw new NotImplementedException();
}
public IAsyncLoaderWithInclude<T> Include<T, TInclude>(Expression<Func<T, string>> path)
{
throw new NotImplementedException();
}
public IAsyncLoaderWithInclude<T> Include<T>(Expression<Func<T, IEnumerable<string>>> path)
{
throw new NotImplementedException();
}
public IAsyncLoaderWithInclude<T> Include<T, TInclude>(Expression<Func<T, IEnumerable<string>>> path)
{
throw new NotImplementedException();
}
#region LoadAsync -----------------------------------------------------
public async Task<T> LoadAsync<T>(string id, CancellationToken token = new CancellationToken())
{
T entity = await _dbSession.LoadAsync<T>(id, token);
if (entity == null
|| entity is ITenantedEntity tenantedEntity && tenantedEntity.TenantId == _currentTenantId)
return entity;
throw new ArgumentException("Incorrect ID");
}
public async Task<Dictionary<string, T>> LoadAsync<T>(IEnumerable<string> ids, CancellationToken token = new CancellationToken())
{
Dictionary<string, T> entities = await _dbSession.LoadAsync<T>(ids, token);
if (typeof(T).GetInterfaces().Contains(typeof(ITenantedEntity)))
return entities.Where(e => (e.Value as ITenantedEntity)?.TenantId == _currentTenantId).ToDictionary(i => i.Key, i => i.Value);
return null;
}
#endregion LoadAsync --------------------------------------------------
#region Query ---------------------------------------------------------
public IRavenQueryable<T> Query<T>(string indexName = null, string collectionName = null, bool isMapReduce = false)
{
var query = _dbSession.Query<T>(indexName, collectionName, isMapReduce);
if (typeof(T).GetInterfaces().Contains(typeof(ITenantedEntity)))
return query.Where(r => (r as ITenantedEntity).TenantId == _currentTenantId);
return query;
}
public IRavenQueryable<T> Query<T, TIndexCreator>() where TIndexCreator : AbstractIndexCreationTask, new()
{
var query = _dbSession.Query<T, TIndexCreator>();
var lastArgType = typeof(TIndexCreator).BaseType?.GenericTypeArguments?.LastOrDefault();
if (lastArgType != null && lastArgType.GetInterfaces().Contains(typeof(ITenantedEntity)))
return query.Where(r => (r as ITenantedEntity).TenantId == _currentTenantId);
return query;
}
#endregion Query ------------------------------------------------------
public void Dispose() => _dbSession.Dispose();
}
The code above may need some love if you need Include()
as well.
My final solution doesn't use listeners for RavenDb v3.x as I suggested earlier (see my comment on why) or events for RavenDb v4 (because it's hard to modify the query in there).
Of course, if you write patches of JavaScript functions you'd have have to handle multi-tenancy manually.