Files
flatrender/services/identity/FlatRender.IdentitySvc/Application/Services/TenantService.cs
T
soroush.asadi 90ac0b81d1 feat: V2 microservices stack — backend services, gateway, JWT auth
Add full V2 architecture: identity, content, studio (.NET 10) and file,
render, notification, gateway (Go) services with vendored deps, plus DB
migrations, event/API contracts, and an init-db script.

Wire the Next.js frontend to the gateway: server-side JWT auth routes
(login/register/refresh/logout/me), gateway fetch helper, and session/
cookie/jwt helpers under src/lib.

Containerize the stack via docker-compose.v2.yml and per-service
Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and
MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via
next/font/local to avoid Google Fonts (geo-blocked).

Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:29:31 +03:30

295 lines
13 KiB
C#

using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.IdentitySvc.Application.Services;
public class TenantService(IdentityDbContext db, ITokenService tokenService) : ITenantService
{
public async Task<PagedResponse<TenantResponse>> ListAsync(int page, int pageSize)
{
var total = await db.Tenants.LongCountAsync(t => t.DeletedAt == null);
var tenants = await db.Tenants
.Where(t => t.DeletedAt == null)
.OrderBy(t => t.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResponse<TenantResponse>(
tenants.Select(AuthService.MapTenantResponse).ToList(),
new PaginationMeta(page, pageSize, total, total > (long)page * pageSize)
);
}
public async Task<TenantResponse> CreateAsync(CreateTenantRequest request)
{
var existing = await db.Tenants.AnyAsync(t => t.Slug == request.Slug && t.DeletedAt == null);
if (existing) throw new InvalidOperationException("Slug already taken");
var kind = Enum.TryParse<TenantKind>(request.Kind, true, out var k) ? k : TenantKind.Reseller;
var tenant = new Tenant
{
Slug = request.Slug.ToLower(),
Name = request.Name,
Kind = kind,
ContactName = request.ContactName,
ContactEmail = request.ContactEmail,
ContactPhone = request.ContactPhone,
};
db.Tenants.Add(tenant);
await db.SaveChangesAsync();
return AuthService.MapTenantResponse(tenant);
}
public async Task<TenantResponse> GetByIdAsync(Guid tenantId)
{
var tenant = await db.Tenants.FindAsync(tenantId)
?? throw new KeyNotFoundException("Tenant not found");
return AuthService.MapTenantResponse(tenant);
}
public async Task<TenantResponse> GetBySlugAsync(string slug)
{
var tenant = await db.Tenants.FirstOrDefaultAsync(t => t.Slug == slug && t.DeletedAt == null)
?? throw new KeyNotFoundException("Tenant not found");
return AuthService.MapTenantResponse(tenant);
}
public async Task<TenantResponse> UpdateAsync(Guid tenantId, UpdateTenantRequest request)
{
var tenant = await db.Tenants.FindAsync(tenantId)
?? throw new KeyNotFoundException("Tenant not found");
if (request.Name != null) tenant.Name = request.Name;
if (request.ContactName != null) tenant.ContactName = request.ContactName;
if (request.ContactEmail != null) tenant.ContactEmail = request.ContactEmail;
if (request.ContactPhone != null) tenant.ContactPhone = request.ContactPhone;
if (request.BillingEmail != null) tenant.BillingEmail = request.BillingEmail;
if (request.AllowedOrigins != null) tenant.AllowedOrigins = request.AllowedOrigins;
tenant.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return AuthService.MapTenantResponse(tenant);
}
public async Task<TenantBrandingResponse> GetBrandingAsync(Guid tenantId)
{
var branding = await db.TenantBrandings.FirstOrDefaultAsync(b => b.TenantId == tenantId)
?? new TenantBranding { TenantId = tenantId };
return MapBrandingResponse(branding);
}
public async Task<TenantBrandingResponse> UpsertBrandingAsync(Guid tenantId, TenantBrandingRequest request)
{
var branding = await db.TenantBrandings.FirstOrDefaultAsync(b => b.TenantId == tenantId);
if (branding == null)
{
branding = new TenantBranding { TenantId = tenantId };
db.TenantBrandings.Add(branding);
}
if (request.DisplayName != null) branding.DisplayName = request.DisplayName;
if (request.LogoUrl != null) branding.LogoUrl = request.LogoUrl;
if (request.LogoDarkUrl != null) branding.LogoDarkUrl = request.LogoDarkUrl;
if (request.FaviconUrl != null) branding.FaviconUrl = request.FaviconUrl;
if (request.OgImageUrl != null) branding.OgImageUrl = request.OgImageUrl;
if (request.PrimaryColor != null) branding.PrimaryColor = request.PrimaryColor;
if (request.SecondaryColor != null) branding.SecondaryColor = request.SecondaryColor;
if (request.AccentColor != null) branding.AccentColor = request.AccentColor;
if (request.BackgroundColor != null) branding.BackgroundColor = request.BackgroundColor;
if (request.FontFamily != null) branding.FontFamily = request.FontFamily;
if (request.EmailFromName != null) branding.EmailFromName = request.EmailFromName;
if (request.EmailFromAddress != null) branding.EmailFromAddress = request.EmailFromAddress;
if (request.EmailReplyTo != null) branding.EmailReplyTo = request.EmailReplyTo;
if (request.EmailFooterHtml != null) branding.EmailFooterHtml = request.EmailFooterHtml;
if (request.SupportUrl != null) branding.SupportUrl = request.SupportUrl;
if (request.TermsUrl != null) branding.TermsUrl = request.TermsUrl;
if (request.PrivacyUrl != null) branding.PrivacyUrl = request.PrivacyUrl;
if (request.EmbedEnabled.HasValue) branding.EmbedEnabled = request.EmbedEnabled.Value;
if (request.EmbedAllowedHosts != null) branding.EmbedAllowedHosts = request.EmbedAllowedHosts;
if (request.WatermarkText != null) branding.WatermarkText = request.WatermarkText;
if (request.WatermarkImageUrl != null) branding.WatermarkImageUrl = request.WatermarkImageUrl;
if (request.WatermarkEnabled.HasValue) branding.WatermarkEnabled = request.WatermarkEnabled.Value;
branding.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return MapBrandingResponse(branding);
}
public async Task<DomainVerificationResponse> StartDomainVerificationAsync(Guid tenantId, string domain, string method)
{
var tenant = await db.Tenants.FindAsync(tenantId)
?? throw new KeyNotFoundException("Tenant not found");
// Generate a unique verification challenge
var challenge = $"flatrender-verify={tokenService.HashToken(Guid.NewGuid().ToString())[..32]}";
// For now just return the challenge — actual DNS checking would be via a background job
return new DomainVerificationResponse(
Guid.NewGuid(),
challenge,
DateTime.UtcNow.AddDays(7)
);
}
public async Task<List<TenantUsageDayResponse>> GetUsageAsync(Guid tenantId, DateOnly from, DateOnly to)
{
var rows = await db.TenantUsageDailies
.Where(u => u.TenantId == tenantId && u.UsageDate >= from && u.UsageDate <= to)
.OrderBy(u => u.UsageDate)
.ToListAsync();
return rows.Select(r => new TenantUsageDayResponse(
r.UsageDate, r.RendersCompleted, r.RenderSeconds,
r.StorageBytes, r.ApiCalls, r.ActiveUsers,
r.AmountBilledMinor, r.BillingCurrency, r.BillingStatus
)).ToList();
}
// ── API Keys ─────────────────────────────────────────────────────────
public async Task<List<ApiKeyResponse>> GetApiKeysAsync(Guid tenantId)
{
var keys = await db.TenantApiKeys
.Where(k => k.TenantId == tenantId && k.RevokedAt == null)
.OrderByDescending(k => k.CreatedAt)
.ToListAsync();
return keys.Select(MapApiKeyResponse).ToList();
}
public async Task<ApiKeyCreatedResponse> CreateApiKeyAsync(Guid tenantId, Guid createdByUserId, CreateApiKeyRequest request)
{
var rawSecret = $"fr_{request.Environment.ToLower()[..4]}_{Guid.NewGuid():N}{Guid.NewGuid():N}";
var prefix = rawSecret[..16];
var last4 = rawSecret[^4..];
var hash = tokenService.HashToken(rawSecret);
var key = new TenantApiKey
{
TenantId = tenantId,
CreatedByUserId = createdByUserId,
Name = request.Name,
Environment = request.Environment,
KeyPrefix = prefix,
KeyHash = hash,
Last4 = last4,
Scopes = request.Scopes,
AllowedIps = request.AllowedIps ?? [],
RateLimitRpm = request.RateLimitRpm,
ExpiresAt = request.ExpiresAt,
};
db.TenantApiKeys.Add(key);
await db.SaveChangesAsync();
return new ApiKeyCreatedResponse(key.Id, tenantId, key.Name, key.Environment, prefix, last4, key.Scopes, rawSecret, key.CreatedAt);
}
public async Task RevokeApiKeyAsync(Guid tenantId, Guid apiKeyId, string? reason)
{
var key = await db.TenantApiKeys.FirstOrDefaultAsync(k => k.Id == apiKeyId && k.TenantId == tenantId)
?? throw new KeyNotFoundException("API key not found");
key.RevokedAt = DateTime.UtcNow;
key.RevokeReason = reason;
key.IsActive = false;
await db.SaveChangesAsync();
}
public async Task<ApiKeyValidateResponse> ValidateApiKeyAsync(string keyPrefix, string keyHash, string? ipAddress)
{
var key = await db.TenantApiKeys
.FirstOrDefaultAsync(k => k.KeyPrefix == keyPrefix && k.IsActive && k.RevokedAt == null &&
(k.ExpiresAt == null || k.ExpiresAt > DateTime.UtcNow));
if (key == null || key.KeyHash != keyHash)
return new ApiKeyValidateResponse(false, null, null, null);
if (key.AllowedIps.Length > 0 && !string.IsNullOrEmpty(ipAddress) && !key.AllowedIps.Contains(ipAddress))
return new ApiKeyValidateResponse(false, null, null, null);
key.UsageCount++;
key.LastUsedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new ApiKeyValidateResponse(true, key.TenantId, key.Scopes, key.RateLimitRpm);
}
// ── Webhooks ──────────────────────────────────────────────────────────
public async Task<List<WebhookResponse>> GetWebhooksAsync(Guid tenantId)
{
var hooks = await db.TenantWebhooks
.Where(w => w.TenantId == tenantId)
.OrderByDescending(w => w.CreatedAt)
.ToListAsync();
return hooks.Select(w => new WebhookResponse(
w.Id, w.Name, w.Url, w.Events, w.IsActive,
w.LastTriggeredAt, w.LastStatusCode, w.ConsecutiveFailures, w.CreatedAt
)).ToList();
}
public async Task<WebhookResponse> CreateWebhookAsync(Guid tenantId, CreateWebhookRequest request)
{
_ = await db.Tenants.FindAsync(tenantId) ?? throw new KeyNotFoundException("Tenant not found");
var hook = new TenantWebhook
{
TenantId = tenantId,
Name = request.Name,
Url = request.Url,
Events = request.Events,
SecretHash = tokenService.HashToken(Guid.NewGuid().ToString()),
};
db.TenantWebhooks.Add(hook);
await db.SaveChangesAsync();
return new WebhookResponse(hook.Id, hook.Name, hook.Url, hook.Events, hook.IsActive,
hook.LastTriggeredAt, hook.LastStatusCode, hook.ConsecutiveFailures, hook.CreatedAt);
}
public async Task DeleteWebhookAsync(Guid tenantId, Guid webhookId)
{
var hook = await db.TenantWebhooks.FirstOrDefaultAsync(w => w.Id == webhookId && w.TenantId == tenantId)
?? throw new KeyNotFoundException("Webhook not found");
db.TenantWebhooks.Remove(hook);
await db.SaveChangesAsync();
}
public async Task<List<WebhookDeliveryResponse>> GetWebhookDeliveriesAsync(Guid tenantId, Guid webhookId)
{
_ = await db.TenantWebhooks.FirstOrDefaultAsync(w => w.Id == webhookId && w.TenantId == tenantId)
?? throw new KeyNotFoundException("Webhook not found");
var deliveries = await db.TenantWebhookDeliveries
.Where(d => d.WebhookId == webhookId)
.OrderByDescending(d => d.CreatedAt)
.Take(50)
.ToListAsync();
return deliveries.Select(d => new WebhookDeliveryResponse(
d.Id, d.EventType, d.RequestUrl, d.ResponseStatus, d.ResponseBody,
d.DurationMs, d.Attempt, d.Succeeded, d.ErrorMessage, d.DeliveredAt, d.CreatedAt
)).ToList();
}
// ── Helpers ───────────────────────────────────────────────────────────
private static ApiKeyResponse MapApiKeyResponse(TenantApiKey k) => new(
k.Id, k.TenantId, k.Name, k.Environment, k.KeyPrefix, k.Last4,
k.Scopes, k.AllowedIps, k.RateLimitRpm, k.IsActive,
k.ExpiresAt, k.LastUsedAt, k.UsageCount, k.CreatedAt
);
private static TenantBrandingResponse MapBrandingResponse(TenantBranding b) => new(
b.TenantId, b.DisplayName, b.LogoUrl, b.LogoDarkUrl,
b.PrimaryColor, b.SecondaryColor, b.AccentColor,
b.EmbedEnabled, b.WatermarkEnabled
);
}