Skip to content

Commit ccde77c

Browse files
committed
feat(tenant): implement multi-tenancy support and secure authentication
- implement TenantEntity and TenantSettings - update Template and Notification to support TenantId - add ITenantProvider and TenantResolver with JWT signature and lifetime validation - update repositories to filter by TenantId and resolve global templates - fix compiler warnings and logic bugs in TemplateRepository null checks
1 parent b5a20c8 commit ccde77c

50 files changed

Lines changed: 587 additions & 101 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using DotCruz.Notifications.Api.Controllers.Base;
2+
using DotCruz.Notifications.Application.DTOs.Base;
3+
using DotCruz.Notifications.Application.UseCases.Tenants.ConfigureTenantSmtp;
4+
using MediatR;
5+
using Microsoft.AspNetCore.Mvc;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
9+
namespace DotCruz.Notifications.Api.Controllers.Tenants
10+
{
11+
public class TenantController : DotCruzNotificationBaseController
12+
{
13+
public TenantController(IMediator mediator) : base(mediator) { }
14+
15+
[HttpPost("smtp")]
16+
[ProducesResponseType(StatusCodes.Status204NoContent)]
17+
[ProducesResponseType(typeof(ErrorResponseDto), StatusCodes.Status400BadRequest)]
18+
public async Task<IActionResult> ConfigureSmtp([FromBody] ConfigureTenantSmtpCommand request, CancellationToken cancellationToken)
19+
{
20+
await _mediator.Send(request, cancellationToken);
21+
return NoContent();
22+
}
23+
}
24+
}

src/DotCruz.Notifications.Api/DotCruz.Notifications.Api.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
1111
<PackageReference Include="Scalar.AspNetCore" Version="2.14.14" />
1212
<PackageReference Include="SharpCompress" Version="0.48.1" />
13+
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
1314
</ItemGroup>
1415

1516
<ItemGroup>

src/DotCruz.Notifications.Api/Program.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using System.Text.Json.Serialization;
22
using DotCruz.Notifications.Api.Filters;
33
using DotCruz.Notifications.Api.Middlewares;
4+
using DotCruz.Notifications.Api.Security;
45
using DotCruz.Notifications.Application;
56
using DotCruz.Notifications.CrossCutting;
7+
using DotCruz.Notifications.Domain.Interfaces;
68
using DotCruz.Notifications.Infrastructure;
79
using Scalar.AspNetCore;
810

@@ -23,6 +25,9 @@
2325
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
2426
builder.Services.AddOpenApi();
2527

28+
builder.Services.AddHttpContextAccessor();
29+
builder.Services.AddScoped<ITenantProvider, TenantResolver>();
30+
2631
builder.Services.AddCrossCutting(builder.Configuration);
2732
builder.Services.AddApplication();
2833
builder.Services.AddInfrastructure(builder.Configuration);
@@ -31,7 +36,6 @@
3136

3237
var app = builder.Build();
3338

34-
// Configure the HTTP request pipeline.
3539
if (app.Environment.IsDevelopment())
3640
{
3741
app.MapOpenApi();
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
using DotCruz.Notifications.Domain.Exceptions.BaseExceptions;
2+
using DotCruz.Notifications.Domain.Exceptions.Resources;
3+
using DotCruz.Notifications.Domain.Interfaces;
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.IdentityModel.Tokens;
6+
using System.IdentityModel.Tokens.Jwt;
7+
using System.Security.Claims;
8+
using System.Text;
9+
10+
namespace DotCruz.Notifications.Api.Security
11+
{
12+
public class TenantResolver : ITenantProvider
13+
{
14+
private const string AUTHENTICATION_TYPE = "Bearer";
15+
private const string SUPER_ADMIN_ROLE = "SuperAdmin";
16+
private const string TENANT_ID_CLAIM = "tenant_id";
17+
18+
private readonly IHttpContextAccessor _httpContextAccessor;
19+
private readonly IConfiguration _configuration;
20+
21+
public TenantResolver(IHttpContextAccessor httpContextAccessor, IConfiguration configuration)
22+
{
23+
_httpContextAccessor = httpContextAccessor;
24+
_configuration = configuration;
25+
}
26+
27+
public Guid? TenantId => ResolveTenantId();
28+
29+
private Guid? ResolveTenantId()
30+
{
31+
var context = _httpContextAccessor.HttpContext;
32+
if (context == null)
33+
return null;
34+
35+
if (context.Request.Headers.TryGetValue("X-Api-Key", out var apiKeyHeader))
36+
{
37+
if (context.Request.Headers.TryGetValue("X-Tenant-ID", out var apiKeyTenantHeader) && Guid.TryParse(apiKeyTenantHeader, out var apiKeyTenantId))
38+
{
39+
return apiKeyTenantId;
40+
}
41+
return null;
42+
}
43+
44+
var authHeader = context.Request.Headers.Authorization.FirstOrDefault();
45+
46+
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith($"{AUTHENTICATION_TYPE} ", StringComparison.OrdinalIgnoreCase))
47+
throw new UnauthorizedException(ResourceMessagesException.NO_TOKEN);
48+
49+
var token = authHeader[$"{AUTHENTICATION_TYPE} ".Length..].Trim();
50+
51+
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
52+
53+
if (!jwtSecurityTokenHandler.CanReadToken(token))
54+
throw new UnauthorizedException(ResourceMessagesException.NO_TOKEN);
55+
56+
var signingKey = _configuration["Settings:Jwt:SigningKey"];
57+
if (string.IsNullOrEmpty(signingKey))
58+
throw new UnauthorizedException(ResourceMessagesException.TOKEN_INVALID);
59+
60+
var key = Encoding.UTF8.GetBytes(signingKey);
61+
62+
var validationParameters = new TokenValidationParameters
63+
{
64+
ValidateIssuerSigningKey = true,
65+
IssuerSigningKey = new SymmetricSecurityKey(key),
66+
ValidateIssuer = false,
67+
ValidateAudience = false,
68+
ValidateLifetime = true,
69+
ClockSkew = TimeSpan.Zero
70+
};
71+
72+
try
73+
{
74+
var principal = jwtSecurityTokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
75+
var user = principal;
76+
77+
if (user == null || user.Identity == null || !user.Identity.IsAuthenticated)
78+
throw new UnauthorizedException(ResourceMessagesException.TOKEN_INVALID);
79+
80+
var isSuperAdmin = user.IsInRole(SUPER_ADMIN_ROLE) || user.HasClaim(ClaimTypes.Role, SUPER_ADMIN_ROLE);
81+
82+
if (isSuperAdmin && context.Request.Headers.TryGetValue("X-Tenant-ID", out var tenantHeader) && Guid.TryParse(tenantHeader, out var impersonatedId))
83+
return impersonatedId;
84+
85+
var tenantClaim = user.FindFirst(TENANT_ID_CLAIM)?.Value;
86+
if (!Guid.TryParse(tenantClaim, out var tenantId))
87+
return null;
88+
89+
return tenantId;
90+
}
91+
catch (Exception)
92+
{
93+
throw new UnauthorizedException(ResourceMessagesException.TOKEN_INVALID);
94+
}
95+
}
96+
}
97+
}

src/DotCruz.Notifications.Api/appsettings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
},
1212
"Settings": {
1313
"ApiKey": "",
14+
"Jwt": {
15+
"SigningKey": "a-very-long-signing-key-that-is-at-least-32-characters-long-for-security-validation"
16+
},
1417
"EmailSettings": {
1518
"Host": "",
1619
"Port": 587,

src/DotCruz.Notifications.Application/Factories/Notifications/EmailFactoryStrategy.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ public Notification Create(
1616
string? title,
1717
Guid? templateId,
1818
Dictionary<string, object>? templateData,
19-
DateTimeOffset? scheduledFor)
19+
DateTimeOffset? scheduledFor,
20+
Guid tenantId)
2021
{
2122
return new EmailNotification(
2223
serviceId,
@@ -26,6 +27,7 @@ public Notification Create(
2627
body,
2728
templateId,
2829
templateData,
29-
scheduledFor);
30+
scheduledFor,
31+
tenantId);
3032
}
3133
}

src/DotCruz.Notifications.Application/Factories/Notifications/PushFactoryStrategy.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ public Notification Create(
1616
string? title,
1717
Guid? templateId,
1818
Dictionary<string, object>? templateData,
19-
DateTimeOffset? scheduledFor)
19+
DateTimeOffset? scheduledFor,
20+
Guid tenantId)
2021
{
2122
return new PushNotification(
2223
serviceId,
@@ -26,6 +27,7 @@ public Notification Create(
2627
body,
2728
templateId,
2829
templateData,
29-
scheduledFor);
30+
scheduledFor,
31+
tenantId);
3032
}
3133
}

src/DotCruz.Notifications.Application/Factories/Notifications/SmsFactoryStrategy.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ public Notification Create(
1616
string? title,
1717
Guid? templateId,
1818
Dictionary<string, object>? templateData,
19-
DateTimeOffset? scheduledFor)
19+
DateTimeOffset? scheduledFor,
20+
Guid tenantId)
2021
{
2122
return new SmsNotification(
2223
serviceId,
@@ -25,6 +26,7 @@ public Notification Create(
2526
body,
2627
templateId,
2728
templateData,
28-
scheduledFor);
29+
scheduledFor,
30+
tenantId);
2931
}
3032
}

src/DotCruz.Notifications.Application/UseCases/Notifications/CreateNotification/CreateNotificationCommandHandler.cs

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,31 @@ public class CreateNotificationCommandHandler : IRequestHandler<CreateNotificati
1616
{
1717
private readonly INotificationRepository _notificationRepository;
1818
private readonly ITemplateRepository _templateRepository;
19+
private readonly ITenantSettingsRepository _tenantSettingsRepository;
1920
private readonly IEnumerable<INotificationFactoryStrategy> _factories;
2021
private readonly IPublishNotificationService _publishService;
2122
private readonly ITemplateEngine _templateEngine;
2223
private readonly INotificationScheduler _notificationScheduler;
24+
private readonly ITenantProvider _tenantProvider;
2325

2426
public CreateNotificationCommandHandler(
2527
INotificationRepository notificationRepository,
2628
ITemplateRepository templateRepository,
29+
ITenantSettingsRepository tenantSettingsRepository,
2730
IEnumerable<INotificationFactoryStrategy> factories,
2831
IPublishNotificationService publishService,
2932
ITemplateEngine templateEngine,
30-
INotificationScheduler notificationScheduler)
33+
INotificationScheduler notificationScheduler,
34+
ITenantProvider tenantProvider)
3135
{
3236
_notificationRepository = notificationRepository;
3337
_templateRepository = templateRepository;
38+
_tenantSettingsRepository = tenantSettingsRepository;
3439
_factories = factories;
3540
_publishService = publishService;
3641
_templateEngine = templateEngine;
3742
_notificationScheduler = notificationScheduler;
43+
_tenantProvider = tenantProvider;
3844
}
3945

4046
public async Task<Guid> Handle(CreateNotificationCommand request, CancellationToken cancellationToken)
@@ -48,6 +54,10 @@ public async Task<Guid> Handle(CreateNotificationCommand request, CancellationTo
4854
var factory = _factories.FirstOrDefault(f => f.Type == domainType)
4955
?? throw new NotificationTypeNotSupportedException();
5056

57+
var tenantId = _tenantProvider.TenantId;
58+
if (!tenantId.HasValue)
59+
throw new UnauthorizedException(ResourceMessagesException.TENANT_ID_REQUIRED);
60+
5161
var notification = factory.Create(
5262
message.ServiceId,
5363
message.Recipient,
@@ -56,7 +66,8 @@ public async Task<Guid> Handle(CreateNotificationCommand request, CancellationTo
5666
message.Title,
5767
resolvedTemplateId,
5868
message.TemplateData,
59-
message.ScheduledFor);
69+
message.ScheduledFor,
70+
tenantId.Value);
6071

6172
await ProcessTemplateAsync(notification, cancellationToken);
6273

@@ -82,7 +93,21 @@ private async Task ProcessTemplateAsync(Notification notification, CancellationT
8293
var renderedBody = _templateEngine.Render(rawBody, notification.TemplateData);
8394

8495
if (notification.Type == NotificationType.Email)
85-
renderedBody = EmailTemplateWrapper.Wrap(renderedBody);
96+
{
97+
var wrapped = false;
98+
if (notification.TenantId != Guid.Empty)
99+
{
100+
var tenantSettings = await _tenantSettingsRepository.GetByTenantIdAsync(notification.TenantId, cancellationToken);
101+
if (tenantSettings != null && (!string.IsNullOrEmpty(tenantSettings.HeaderHtml) || !string.IsNullOrEmpty(tenantSettings.FooterHtml)))
102+
{
103+
renderedBody = $"{tenantSettings.HeaderHtml}{renderedBody}{tenantSettings.FooterHtml}";
104+
wrapped = true;
105+
}
106+
}
107+
108+
if (!wrapped)
109+
renderedBody = EmailTemplateWrapper.Wrap(renderedBody);
110+
}
86111

87112
notification.SetRenderedBody(renderedBody);
88113
}
@@ -113,12 +138,22 @@ private async Task ProcessTemplateAsync(Notification notification, CancellationT
113138
return null;
114139

115140
var template = await _templateRepository.GetByCodeAsync(code, culture ?? "pt-BR", cancellationToken);
141+
if (template == null && _tenantProvider.TenantId.HasValue)
142+
template = await _templateRepository.GetGlobalByCodeAsync(code, culture ?? "pt-BR", cancellationToken);
116143

117144
if (template == null && culture != "pt-BR")
145+
{
118146
template = await _templateRepository.GetByCodeAsync(code, "pt-BR", cancellationToken);
147+
if (template == null && _tenantProvider.TenantId.HasValue)
148+
template = await _templateRepository.GetGlobalByCodeAsync(code, "pt-BR", cancellationToken);
149+
}
119150

120151
if (template == null && culture != "en" && culture != "pt-BR")
152+
{
121153
template = await _templateRepository.GetByCodeAsync(code, "en", cancellationToken);
154+
if (template == null && _tenantProvider.TenantId.HasValue)
155+
template = await _templateRepository.GetGlobalByCodeAsync(code, "en", cancellationToken);
156+
}
122157

123158
if (template == null)
124159
throw new NotFoundException(ResourceMessagesException.TEMPLATE_NOT_FOUND);
@@ -160,6 +195,6 @@ private static SendNotificationMessage BuildNotificationMessage(Notification not
160195
_ => null
161196
};
162197

163-
return new SendNotificationMessage(notification.Id, type, notification.Recipient, notification.Body!, title);
198+
return new SendNotificationMessage(notification.Id, type, notification.Recipient, notification.Body!, title, notification.TenantId);
164199
}
165200
}

src/DotCruz.Notifications.Application/UseCases/Notifications/PollScheduledNotifications/PollScheduledNotificationsCommand.cs

Lines changed: 0 additions & 5 deletions
This file was deleted.

0 commit comments

Comments
 (0)