using MemberCenter.Application.Abstractions; using MemberCenter.Domain.Entities; using MemberCenter.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using OpenIddict.Abstractions; namespace MemberCenter.Infrastructure.Services; public sealed class AuthResourceRegistryService : IAuthResourceRegistryService { private const string MemberCenterResourceName = "member_center_api"; private readonly MemberCenterDbContext _dbContext; private readonly IConfiguration _configuration; public AuthResourceRegistryService(MemberCenterDbContext dbContext, IConfiguration configuration) { _dbContext = dbContext; _configuration = configuration; } public async Task EnsureDefaultsAsync(CancellationToken cancellationToken = default) { var memberCenter = await EnsureResourceAsync( MemberCenterResourceName, ResolveAudience("MemberCenter", "Auth:MemberCenterAudience", "member_center_api"), "Member Center API", requireTenant: false, allowDelegatedToken: true, cancellationToken); var sendEngine = await EnsureResourceAsync( "send_engine_api", ResolveAudience("SendEngine", "Auth:SendEngineAudience", "send_engine_api"), "Send Engine API", requireTenant: true, allowDelegatedToken: false, cancellationToken); var fileAccess = await EnsureResourceAsync( "file_access_api", ResolveAudience("FileAccess", null, "file_access_api"), "File upload/download API", requireTenant: true, allowDelegatedToken: true, cancellationToken); await EnsureScopesAsync(memberCenter.Id, [ OpenIddictConstants.Scopes.OpenId, OpenIddictConstants.Scopes.Email, OpenIddictConstants.Scopes.Profile, "profile:basic.read", "profile:basic.write", "profile:addresses.read", "profile:addresses.write", "profile:subscriptions.read", "profile:subscriptions.write", "newsletter:list.read", "newsletter:events.read", "newsletter:events.write", "newsletter:events.write.global" ], cancellationToken); await EnsureScopesAsync(sendEngine.Id, [ "newsletter:send.write", "newsletter:send.read" ], cancellationToken); await EnsureScopesAsync(fileAccess.Id, [ "files:upload.write", "files:download.read", "files:download.delegate", "files:metadata.read", "files:delete" ], cancellationToken); await EnsureUsagePermissionsAsync("web_login", [ OpenIddictConstants.Scopes.OpenId, OpenIddictConstants.Scopes.Email, OpenIddictConstants.Scopes.Profile, "profile:basic.read", "profile:basic.write", "profile:addresses.read", "profile:addresses.write", "profile:subscriptions.read", "profile:subscriptions.write" ], cancellationToken); await EnsureUsagePermissionsAsync("webhook_outbound", [ OpenIddictConstants.Scopes.OpenId, OpenIddictConstants.Scopes.Email, OpenIddictConstants.Scopes.Profile, "newsletter:events.write" ], cancellationToken); await EnsureUsagePermissionsAsync("tenant_api", [ "newsletter:events.write", "newsletter:list.read", "profile:basic.read", "profile:basic.write", "profile:addresses.read", "profile:addresses.write", "profile:subscriptions.read", "profile:subscriptions.write" ], cancellationToken); await EnsureUsagePermissionsAsync("platform_service", [ "newsletter:events.write.global", "newsletter:list.read", "profile:basic.read", "profile:basic.write", "profile:addresses.read", "profile:addresses.write", "profile:subscriptions.read", "profile:subscriptions.write" ], cancellationToken); await EnsureUsagePermissionsAsync("send_api", [ "newsletter:send.write", "newsletter:send.read" ], cancellationToken); await EnsureUsagePermissionsAsync("file_api", [ "files:upload.write", "files:download.read", "files:download.delegate", "files:metadata.read", "files:delete" ], cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken); } public async Task> ResolveAudiencesAsync( IEnumerable scopes, CancellationToken cancellationToken = default) { var requestedScopes = scopes .Where(scope => !string.IsNullOrWhiteSpace(scope)) .Select(scope => scope.Trim()) .Distinct(StringComparer.Ordinal) .ToList(); if (requestedScopes.Count == 0) { var defaultAudience = await _dbContext.AuthResources .Where(resource => resource.Name == MemberCenterResourceName && resource.IsEnabled) .Select(resource => resource.Audience) .SingleOrDefaultAsync(cancellationToken); return string.IsNullOrWhiteSpace(defaultAudience) ? ["member_center_api"] : [defaultAudience]; } var resourceScopes = await _dbContext.AuthResourceScopes .AsNoTracking() .Include(resourceScope => resourceScope.Resource) .Where(resourceScope => resourceScope.IsEnabled && requestedScopes.Contains(resourceScope.Scope) && resourceScope.Resource != null && resourceScope.Resource.IsEnabled) .ToListAsync(cancellationToken); var resolvedScopes = resourceScopes .Select(resourceScope => resourceScope.Scope) .ToHashSet(StringComparer.Ordinal); var unknownScopes = requestedScopes .Where(scope => !resolvedScopes.Contains(scope)) .ToList(); if (unknownScopes.Count > 0) { throw new InvalidOperationException($"Unknown auth scope(s): {string.Join(", ", unknownScopes)}"); } return resourceScopes .Select(resourceScope => resourceScope.Resource!.Audience) .Distinct(StringComparer.Ordinal) .ToList(); } public async Task> GetAllowedScopesForUsageAsync( string usage, CancellationToken cancellationToken = default) { var normalizedUsage = usage.Trim(); return await _dbContext.AuthClientUsagePermissions .AsNoTracking() .Where(permission => permission.Usage == normalizedUsage && permission.IsEnabled) .OrderBy(permission => permission.Scope) .Select(permission => permission.Scope) .ToListAsync(cancellationToken); } private async Task EnsureResourceAsync( string name, string audience, string description, bool requireTenant, bool allowDelegatedToken, CancellationToken cancellationToken) { var resource = await _dbContext.AuthResources .SingleOrDefaultAsync(item => item.Name == name, cancellationToken); if (resource is null) { resource = new AuthResource { Id = Guid.NewGuid(), Name = name, Audience = audience }; _dbContext.AuthResources.Add(resource); } resource.Audience = audience; resource.Description = description; resource.RequireTenant = requireTenant; resource.AllowDelegatedToken = allowDelegatedToken; resource.IsEnabled = true; resource.UpdatedAt = DateTimeOffset.UtcNow; return resource; } private string ResolveAudience(string resourceKey, string? legacyKey, string defaultValue) { var configured = _configuration[$"Auth:Resources:{resourceKey}:Audience"]; if (string.IsNullOrWhiteSpace(configured) && !string.IsNullOrWhiteSpace(legacyKey)) { configured = _configuration[legacyKey]; } return string.IsNullOrWhiteSpace(configured) ? defaultValue : configured; } private async Task EnsureScopesAsync( Guid resourceId, IEnumerable scopes, CancellationToken cancellationToken) { var existingScopes = await _dbContext.AuthResourceScopes .Where(scope => scope.ResourceId == resourceId) .ToDictionaryAsync(scope => scope.Scope, StringComparer.Ordinal, cancellationToken); foreach (var scope in scopes.Distinct(StringComparer.Ordinal)) { if (existingScopes.TryGetValue(scope, out var existing)) { existing.IsEnabled = true; continue; } _dbContext.AuthResourceScopes.Add(new AuthResourceScope { Id = Guid.NewGuid(), ResourceId = resourceId, Scope = scope, IsEnabled = true }); } } private async Task EnsureUsagePermissionsAsync( string usage, IEnumerable scopes, CancellationToken cancellationToken) { var existingPermissions = await _dbContext.AuthClientUsagePermissions .Where(permission => permission.Usage == usage) .ToDictionaryAsync(permission => permission.Scope, StringComparer.Ordinal, cancellationToken); foreach (var scope in scopes.Distinct(StringComparer.Ordinal)) { if (existingPermissions.TryGetValue(scope, out var existing)) { existing.IsEnabled = true; continue; } _dbContext.AuthClientUsagePermissions.Add(new AuthClientUsagePermission { Id = Guid.NewGuid(), Usage = usage, Scope = scope, IsEnabled = true }); } } }