Files
Website/BlazorApp/Services/ProvisioningService.cs
SysAdmin cd2994d7eb
All checks were successful
Build and Deploy / deploy (push) Successful in 18s
fix(developers): add Mattermost team membership and role-aware Gitea provisioning
New users are now added to the SilverLABS Mattermost team after account
creation. Gitea provisioning is skipped for Testers (only Developers get
repo access). Role is parsed from ticket description and threaded through
the entire approval/confirmation flow. Gitea API token is now configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:10:45 +00:00

546 lines
21 KiB
C#

using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace SilverLabs.Website.Services;
public record PendingDeployment(
string Token,
string Username,
string Email,
string FullName,
string TicketId,
string? Role,
DateTime CreatedAt,
DateTime ExpiresAt);
public class ProvisioningService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<ProvisioningService> _logger;
private readonly IConfiguration _configuration;
private static readonly ConcurrentDictionary<string, PendingDeployment> _pendingDeployments = new();
public ProvisioningService(
IHttpClientFactory httpClientFactory,
ILogger<ProvisioningService> logger,
IConfiguration configuration)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
_configuration = configuration;
}
// --- Token management ---
public PendingDeployment CreatePendingDeployment(string username, string email, string fullName, string ticketId, string? role = null)
{
CleanupExpiredTokens();
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
var deployment = new PendingDeployment(
token, username, email, fullName, ticketId, role,
DateTime.UtcNow, DateTime.UtcNow.AddHours(48));
_pendingDeployments[token] = deployment;
_logger.LogInformation("Created pending deployment for {Username} (ticket {TicketId}), token expires {ExpiresAt}",
username, ticketId, deployment.ExpiresAt);
return deployment;
}
public PendingDeployment? GetPendingDeployment(string token)
{
CleanupExpiredTokens();
if (_pendingDeployments.TryGetValue(token, out var deployment))
{
if (deployment.ExpiresAt > DateTime.UtcNow)
return deployment;
_pendingDeployments.TryRemove(token, out _);
}
return null;
}
public void RemovePendingDeployment(string token)
{
_pendingDeployments.TryRemove(token, out _);
}
private void CleanupExpiredTokens()
{
var expired = _pendingDeployments
.Where(kvp => kvp.Value.ExpiresAt <= DateTime.UtcNow)
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in expired)
_pendingDeployments.TryRemove(key, out _);
}
// --- Authentication ---
public async Task<bool> ValidateSilverDeskCredentialsAsync(string username, string password)
{
try
{
var client = _httpClientFactory.CreateClient("SilverDesk");
var payload = new { username, password };
var response = await client.PostAsJsonAsync("/api/auth/login", payload);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("SilverDESK credential validation succeeded for {Username}", username);
return true;
}
_logger.LogWarning("SilverDESK credential validation failed for {Username}: {Status}", username, response.StatusCode);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error validating SilverDESK credentials for {Username}", username);
return false;
}
}
// --- Full provisioning with password ---
public async Task<(bool Success, string Message)> ProvisionWithPasswordAsync(
string ticketId, string username, string email, string fullName, string password, string? role = null)
{
var results = new List<string>();
var allSuccess = true;
// 1. Create Mattermost user
var (mmOk, mmMsg) = await CreateMattermostUserAsync(username, email, fullName, password);
results.Add($"Mattermost: {mmMsg}");
if (!mmOk) allSuccess = false;
// 1b. Add to SilverLABS team (only if user was created)
if (mmOk)
{
var (teamOk, teamMsg) = await AddMattermostUserToTeamAsync(username);
results.Add($"Mattermost Team: {teamMsg}");
if (!teamOk) allSuccess = false;
}
// 2. Create Mailcow mailbox
var (mailOk, mailMsg) = await CreateMailcowMailboxAsync(username, fullName, password);
results.Add($"Mailcow: {mailMsg}");
if (!mailOk) allSuccess = false;
// 3. Create Gitea user (Developers only)
var giteaOk = false;
if (string.Equals(role, "Developer", StringComparison.OrdinalIgnoreCase))
{
var (gOk, giteaMsg) = await CreateGiteaUserAsync(username, email, fullName, password);
giteaOk = gOk;
results.Add($"Gitea: {giteaMsg}");
if (!giteaOk) allSuccess = false;
}
else
{
results.Add("Gitea: Skipped (not required for Tester role)");
}
// 4. Update the DeveloperApplication record in SilverDESK
var (updateOk, updateMsg) = await UpdateApplicationStatusAsync(ticketId, mmOk, mailOk, giteaOk);
results.Add($"Application record: {updateMsg}");
if (!updateOk) allSuccess = false;
var summary = string.Join("; ", results);
_logger.LogInformation("Provisioning for {Username} (ticket {TicketId}): {Summary}", username, ticketId, summary);
return (allSuccess, summary);
}
// --- Ticket replies ---
public async Task<(bool Success, string Message)> SendTicketReplyAsync(string ticketId, string content, string action = "waitingcustomer")
{
try
{
var client = _httpClientFactory.CreateClient("SilverDesk");
var payload = new { content, action };
var response = await client.PostAsJsonAsync($"/api/tickets/{ticketId}/reply", payload);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("Sent ticket reply to {TicketId} with action {Action}", ticketId, action);
return (true, "Reply sent");
}
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Failed to send ticket reply to {TicketId}: {Status} {Body}", ticketId, response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending ticket reply to {TicketId}", ticketId);
return (false, $"Error: {ex.Message}");
}
}
// --- Password sync ---
public async Task<(bool Success, string Message)> SyncPasswordAsync(string username, string newPassword)
{
var results = new List<string>();
var allSuccess = true;
// 1. Mattermost - need to look up user ID first
var (mmOk, mmMsg) = await UpdateMattermostPasswordAsync(username, newPassword);
results.Add($"Mattermost: {mmMsg}");
if (!mmOk) allSuccess = false;
// 2. Mailcow
var (mailOk, mailMsg) = await UpdateMailcowPasswordAsync(username, newPassword);
results.Add($"Mailcow: {mailMsg}");
if (!mailOk) allSuccess = false;
// 3. Gitea
var (giteaOk, giteaMsg) = await UpdateGiteaPasswordAsync(username, newPassword);
results.Add($"Gitea: {giteaMsg}");
if (!giteaOk) allSuccess = false;
var summary = string.Join("; ", results);
_logger.LogInformation("Password sync for {Username}: {Summary}", username, summary);
return (allSuccess, summary);
}
// --- Application status update ---
private async Task<(bool Success, string Message)> UpdateApplicationStatusAsync(
string ticketId, bool mattermostProvisioned, bool mailcowProvisioned, bool giteaProvisioned = false)
{
try
{
var client = _httpClientFactory.CreateClient("SilverDesk");
var lookupResponse = await client.GetAsync($"/api/developer-program/applications?ticketId={ticketId}");
if (!lookupResponse.IsSuccessStatusCode)
{
var body = await lookupResponse.Content.ReadAsStringAsync();
_logger.LogWarning("Failed to look up application by ticket {TicketId}: {Status} {Body}",
ticketId, lookupResponse.StatusCode, body);
return (false, $"Lookup failed ({lookupResponse.StatusCode})");
}
var apps = await lookupResponse.Content.ReadFromJsonAsync<JsonElement>();
if (apps.GetArrayLength() == 0)
{
_logger.LogWarning("No application found for ticket {TicketId}", ticketId);
return (false, "No application found for ticket");
}
var appId = apps[0].GetProperty("id").GetString();
var updatePayload = new
{
status = 1, // ApplicationStatus.Approved
mattermostProvisioned,
mailcowProvisioned,
giteaProvisioned
};
var updateResponse = await client.PutAsJsonAsync($"/api/developer-program/applications/{appId}", updatePayload);
if (updateResponse.IsSuccessStatusCode)
{
_logger.LogInformation("Application {AppId} updated to Approved for ticket {TicketId}", appId, ticketId);
return (true, "Updated to Approved");
}
var updateBody = await updateResponse.Content.ReadAsStringAsync();
_logger.LogWarning("Failed to update application {AppId}: {Status} {Body}",
appId, updateResponse.StatusCode, updateBody);
return (false, $"Update failed ({updateResponse.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating application status for ticket {TicketId}", ticketId);
return (false, $"Error: {ex.Message}");
}
}
// --- Service account creation ---
public async Task<(bool Success, string Message)> CreateSilverDeskUserAsync(string username, string email, string fullName)
{
try
{
var client = _httpClientFactory.CreateClient("SilverDesk");
var payload = new
{
username,
email,
fullName,
password = Guid.NewGuid().ToString("N")[..16] + "!A1"
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/users", content);
if (response.IsSuccessStatusCode)
return (true, "User created");
var body = await response.Content.ReadAsStringAsync();
_logger.LogError("SilverDESK user creation failed: {Status} {Body}", response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "SilverDESK user creation error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
private async Task<(bool Success, string Message)> CreateMattermostUserAsync(
string username, string email, string fullName, string password)
{
try
{
var client = _httpClientFactory.CreateClient("Mattermost");
var nameParts = fullName.Split(' ', 2);
var payload = new
{
email,
username,
first_name = nameParts[0],
last_name = nameParts.Length > 1 ? nameParts[1] : "",
password
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v4/users", content);
if (response.IsSuccessStatusCode)
return (true, "User created");
var body = await response.Content.ReadAsStringAsync();
_logger.LogError("Mattermost user creation failed: {Status} {Body}", response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Mattermost user creation error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
private async Task<(bool Success, string Message)> AddMattermostUserToTeamAsync(string username)
{
try
{
var client = _httpClientFactory.CreateClient("Mattermost");
// Look up user ID by username
var userResponse = await client.GetAsync($"/api/v4/users/username/{username}");
if (!userResponse.IsSuccessStatusCode)
return (false, $"User lookup failed ({userResponse.StatusCode})");
var userData = await userResponse.Content.ReadFromJsonAsync<JsonElement>();
var userId = userData.GetProperty("id").GetString();
// Add to SilverLABS team
var teamId = _configuration["Mattermost:TeamId"] ?? "ear83bc7nprzpe878ey7hxza7h";
var payload = new { team_id = teamId, user_id = userId };
var response = await client.PostAsJsonAsync($"/api/v4/teams/{teamId}/members", payload);
if (response.IsSuccessStatusCode)
return (true, "Added to team");
var body = await response.Content.ReadAsStringAsync();
_logger.LogError("Mattermost team join failed: {Status} {Body}", response.StatusCode, body);
return (false, $"Team join failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Mattermost team join error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
private async Task<(bool Success, string Message)> CreateMailcowMailboxAsync(
string username, string fullName, string password)
{
try
{
var client = _httpClientFactory.CreateClient("Mailcow");
var payload = new
{
local_part = username,
domain = "silverlabs.uk",
name = fullName,
password,
password2 = password,
quota = 1024, // 1GB
active = 1,
force_pw_update = 0
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/add/mailbox", content);
if (response.IsSuccessStatusCode)
return (true, "Mailbox created");
var body = await response.Content.ReadAsStringAsync();
_logger.LogError("Mailcow mailbox creation failed: {Status} {Body}", response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Mailcow mailbox creation error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
private async Task<(bool Success, string Message)> CreateGiteaUserAsync(
string username, string email, string fullName, string password)
{
try
{
var client = _httpClientFactory.CreateClient("Gitea");
var payload = new
{
email,
full_name = fullName,
login_name = username,
must_change_password = false,
password,
send_notify = false,
username,
visibility = "public"
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/admin/users", content);
if (response.IsSuccessStatusCode)
return (true, "User created");
var body = await response.Content.ReadAsStringAsync();
_logger.LogError("Gitea user creation failed: {Status} {Body}", response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Gitea user creation error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
// --- Password update methods ---
private async Task<(bool Success, string Message)> UpdateMattermostPasswordAsync(string username, string newPassword)
{
try
{
var client = _httpClientFactory.CreateClient("Mattermost");
// Look up user ID by username
var userResponse = await client.GetAsync($"/api/v4/users/username/{username}");
if (!userResponse.IsSuccessStatusCode)
{
_logger.LogWarning("Mattermost user lookup failed for {Username}: {Status}", username, userResponse.StatusCode);
return (false, $"User not found ({userResponse.StatusCode})");
}
var userData = await userResponse.Content.ReadFromJsonAsync<JsonElement>();
var userId = userData.GetProperty("id").GetString();
// Update password (admin reset — no old password needed with bot token)
var payload = new { new_password = newPassword };
var response = await client.PutAsJsonAsync($"/api/v4/users/{userId}/password", payload);
if (response.IsSuccessStatusCode)
return (true, "Password updated");
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Mattermost password update failed for {Username}: {Status} {Body}", username, response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Mattermost password update error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
private async Task<(bool Success, string Message)> UpdateMailcowPasswordAsync(string username, string newPassword)
{
try
{
var client = _httpClientFactory.CreateClient("Mailcow");
var payload = new
{
items = new[] { $"{username}@silverlabs.uk" },
attr = new
{
password = newPassword,
password2 = newPassword
}
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await client.PostAsync("/api/v1/edit/mailbox", content);
if (response.IsSuccessStatusCode)
return (true, "Password updated");
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Mailcow password update failed for {Username}: {Status} {Body}", username, response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Mailcow password update error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
private async Task<(bool Success, string Message)> UpdateGiteaPasswordAsync(string username, string newPassword)
{
try
{
var client = _httpClientFactory.CreateClient("Gitea");
var payload = new
{
login_name = username,
password = newPassword,
must_change_password = false,
source_id = 0
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(HttpMethod.Patch, $"/api/v1/admin/users/{username}")
{
Content = content
};
var response = await client.SendAsync(request);
if (response.IsSuccessStatusCode)
return (true, "Password updated");
var body = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Gitea password update failed for {Username}: {Status} {Body}", username, response.StatusCode, body);
return (false, $"Failed ({response.StatusCode})");
}
catch (Exception ex)
{
_logger.LogError(ex, "Gitea password update error for {Username}", username);
return (false, $"Error: {ex.Message}");
}
}
}