Skip to content

Commit 5dece45

Browse files
author
CIS Guru
committed
Fix workflow crashes, auth guards, security issues, and logo switching
- UserContextService: catch InvalidOperationException outside Blazor circuit - BaseWorkflowService: optional organizationId param in LogTransitionAsync - LeaseWorkflowService: pass organizationId to LogTransitionAsync in ExpireOverdueLeaseAsync - ApplicationWorkflowService: bypass user-context when organizationId provided in ExpireLeaseOfferAsync - ScheduledTaskService: pass offer.OrganizationId to ExpireLeaseOfferAsync - DatabaseService: add OrderBy to fix EF non-deterministic FirstOrDefault warning - NotificationService: explicit senderUserId param; require auth or SystemUser.Id; set CreatedBy/LastModifiedBy - BaseService: explicit opt-in system user guard in CreateAsync/UpdateAsync; no silent auth escalation - LinuxKeychainService: remove plaintext password from console output (security fix) - WindowsKeychainService: remove plaintext credential from console output (security fix) - NavMenu: switch logo based on brand theme + light/dark mode (Obsidian=always inverted; Bootstrap/Teal=inverted in dark only) - ThemeService/theme.js: related theme handling consistency fixes
1 parent f5dbb00 commit 5dece45

14 files changed

Lines changed: 98 additions & 71 deletions

File tree

1-Nine.Infrastructure/Services/LinuxKeychainService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ public bool StoreKey(string keyHex, string label = "Nine Database Encryption Key
102102
process.WaitForExit(5000);
103103

104104
Console.WriteLine($"[LinuxKeychainService] secret-tool exit code: {process.ExitCode}");
105-
Console.WriteLine($"[LinuxKeychainService] secret-tool output: '{output}'");
105+
Console.WriteLine($"[LinuxKeychainService] secret-tool output: [{(string.IsNullOrWhiteSpace(output) ? "empty" : "received")}]");
106106
if (!string.IsNullOrWhiteSpace(error))
107107
{
108108
Console.WriteLine($"[LinuxKeychainService] secret-tool error: {error}");

1-Nine.Infrastructure/Services/WindowsKeychainService.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ public class WindowsKeychainService : IKeychainService
2323
public WindowsKeychainService(string appName = "Nine-Electron")
2424
{
2525
var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
26-
var aquiisDir = Path.Combine(appDataPath, "Nine");
27-
Directory.CreateDirectory(aquiisDir);
26+
var nineDir = Path.Combine(appDataPath, "Nine");
27+
Directory.CreateDirectory(nineDir);
2828

2929
// Sanitize appName for use as a filename component
3030
var safeAppName = new string(appName.Where(c => char.IsLetterOrDigit(c) || c == '-' || c == '_').ToArray());
31-
_keyFilePath = Path.Combine(aquiisDir, $"aquiis_{safeAppName}.key");
31+
_keyFilePath = Path.Combine(nineDir, $"aquiis_{safeAppName}.key");
3232

3333
Console.WriteLine($"[WindowsKeychainService] Initialized with key file: {_keyFilePath}");
3434
}
@@ -73,7 +73,7 @@ public bool StoreKey(string password, string label = "Nine Database Encryption K
7373
var encryptedBytes = File.ReadAllBytes(_keyFilePath);
7474
var plainBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.CurrentUser);
7575
var password = Encoding.UTF8.GetString(plainBytes);
76-
Console.WriteLine($"[WindowsKeychainService] Password retrieved successfully using DPAPI (length: {password.Length})");
76+
Console.WriteLine($"[WindowsKeychainService] Password retrieved successfully using DPAPI. Password status: {(string.IsNullOrEmpty(password) ? "empty" : "received")}");
7777
return password;
7878
}
7979
catch (CryptographicException ex)

2-Nine.Application/Services/BaseService.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,10 @@ public virtual async Task<TEntity> CreateAsync(TEntity entity)
133133
var userId = await _userContext.GetUserIdAsync();
134134
if (string.IsNullOrEmpty(userId))
135135
{
136-
throw new UnauthorizedAccessException("User is not authenticated.");
136+
if (entity.CreatedBy == ApplicationConstants.SystemUser.Id)
137+
userId = entity.CreatedBy; // Allow system-created records to specify "System" as creator
138+
else
139+
throw new UnauthorizedAccessException("User is not authenticated.");
137140
}
138141

139142
var organizationId = await _userContext.GetActiveOrganizationIdAsync();
@@ -188,7 +191,10 @@ public virtual async Task<TEntity> UpdateAsync(TEntity entity)
188191
var userId = await _userContext.GetUserIdAsync();
189192
if (string.IsNullOrEmpty(userId))
190193
{
191-
throw new UnauthorizedAccessException("User is not authenticated.");
194+
if (entity.LastModifiedBy == ApplicationConstants.SystemUser.Id)
195+
userId = ApplicationConstants.SystemUser.Id;
196+
else
197+
throw new UnauthorizedAccessException("User is not authenticated.");
192198
}
193199

194200
var organizationId = await _userContext.GetActiveOrganizationIdAsync();

2-Nine.Application/Services/DatabaseService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public async Task<int> GetIdentityPendingMigrationsCountAsync()
9393
/// </summary>
9494
public async Task<Nine.Core.Entities.DatabaseSettings> GetDatabaseSettingsAsync()
9595
{
96-
var settings = await _businessContext.DatabaseSettings.FirstOrDefaultAsync();
96+
var settings = await _businessContext.DatabaseSettings.OrderBy(s => s.Id).FirstOrDefaultAsync();
9797

9898
if (settings == null)
9999
{

2-Nine.Application/Services/NotificationService.cs

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,29 @@ public async Task<Notification> SendNotificationAsync(
4040
string type,
4141
string category,
4242
Guid? relatedEntityId = null,
43-
string? relatedEntityType = null)
43+
string? relatedEntityType = null,
44+
Guid? organizationId = null,
45+
string? senderUserId = null)
4446
{
45-
var organizationId = await _userContext.GetActiveOrganizationIdAsync();
47+
organizationId ??= await _userContext.GetActiveOrganizationIdAsync();
4648

4749
// Get user preferences
48-
var preferences = await GetNotificationPreferencesAsync(recipientUserId);
50+
var preferences = await GetNotificationPreferencesAsync(recipientUserId, organizationId);
51+
52+
// Resolve the sender: explicit caller-provided ID (e.g. SystemUser for background jobs),
53+
// otherwise require an authenticated user.
54+
string resolvedSenderUserId;
55+
if (!string.IsNullOrEmpty(senderUserId))
56+
{
57+
resolvedSenderUserId = senderUserId;
58+
}
59+
else
60+
{
61+
var authenticatedUserId = await _userContext.GetUserIdAsync();
62+
if (string.IsNullOrEmpty(authenticatedUserId))
63+
throw new UnauthorizedAccessException("User is not authenticated.");
64+
resolvedSenderUserId = authenticatedUserId;
65+
}
4966

5067
var notification = new Notification
5168
{
@@ -62,7 +79,8 @@ public async Task<Notification> SendNotificationAsync(
6279
IsRead = false,
6380
SendInApp = preferences.EnableInAppNotifications,
6481
SendEmail = preferences.EnableEmailNotifications && ShouldSendEmail(category, preferences),
65-
SendSMS = preferences.EnableSMSNotifications && ShouldSendSMS(category, preferences)
82+
SendSMS = preferences.EnableSMSNotifications && ShouldSendSMS(category, preferences),
83+
CreatedBy = resolvedSenderUserId
6684
};
6785

6886
// Save in-app notification
@@ -107,6 +125,7 @@ await _smsService.SendSMSAsync(
107125
}
108126
}
109127

128+
notification.LastModifiedBy = resolvedSenderUserId;
110129
await UpdateAsync(notification);
111130

112131
// Broadcast new notification via SignalR
@@ -141,7 +160,9 @@ public async Task<Notification> NotifyAllUsersAsync(
141160
type,
142161
category,
143162
relatedEntityId,
144-
relatedEntityType);
163+
relatedEntityType,
164+
organizationId,
165+
senderUserId: ApplicationConstants.SystemUser.Id);
145166
}
146167

147168
return lastNotification!;
@@ -281,9 +302,9 @@ public async Task<NotificationPreferences> UpdateUserPreferencesAsync(Notificati
281302
/// <summary>
282303
/// Get or create notification preferences for user
283304
/// </summary>
284-
private async Task<NotificationPreferences> GetNotificationPreferencesAsync(string userId)
305+
private async Task<NotificationPreferences> GetNotificationPreferencesAsync(string userId, Guid? organizationId = null)
285306
{
286-
var organizationId = await _userContext.GetActiveOrganizationIdAsync();
307+
organizationId ??= await _userContext.GetActiveOrganizationIdAsync();
287308

288309
var preferences = await _context.NotificationPreferences
289310
.FirstOrDefaultAsync(p => p.OrganizationId == organizationId

2-Nine.Application/Services/ScheduledTaskService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
7979
timeUntilMonday,
8080
TimeSpan.FromDays(7));
8181

82-
_logger.LogInformation("Scheduled Task Service started. Daily tasks will run at midnight, hourly tasks every hour, weekly tasks every Monday at 6 AM.");
82+
_logger.LogInformation("Scheduled Task Service started. Daily tasks will on each application start-up or when explicityly triggered in Application");
8383

8484
// Keep the service running
8585
while (!stoppingToken.IsCancellationRequested)
@@ -568,7 +568,7 @@ private async Task<int> ExpireOldLeaseOffers(IServiceScope scope)
568568
{
569569
try
570570
{
571-
var result = await workflowService.ExpireLeaseOfferAsync(offer.Id);
571+
var result = await workflowService.ExpireLeaseOfferAsync(offer.Id, offer.OrganizationId);
572572

573573
if (result.Success)
574574
{

2-Nine.Application/Services/Workflows/ApplicationWorkflowService.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,12 +1059,13 @@ await _notificationService.NotifyAllUsersAsync(
10591059
/// Expires a lease offer (called by scheduled task).
10601060
/// Similar to decline but automated.
10611061
/// </summary>
1062-
public async Task<WorkflowResult> ExpireLeaseOfferAsync(Guid leaseOfferId)
1062+
public async Task<WorkflowResult> ExpireLeaseOfferAsync(Guid leaseOfferId, Guid? organizationId = null)
10631063
{
10641064
return await ExecuteWorkflowAsync(async () =>
10651065
{
1066-
var orgId = await GetActiveOrganizationIdAsync();
1066+
var orgId = organizationId ?? await GetActiveOrganizationIdAsync();
10671067
var userId = await GetCurrentUserIdAsync();
1068+
if (string.IsNullOrEmpty(userId)) userId = "System";
10681069

10691070
var leaseOffer = await _context.LeaseOffers
10701071
.Include(lo => lo.RentalApplication)
@@ -1118,7 +1119,8 @@ await LogTransitionAsync(
11181119
"Pending",
11191120
"Expired",
11201121
"ExpireLeaseOffer",
1121-
"Offer expired after 30 days");
1122+
"Offer expired after 30 days",
1123+
organizationId: orgId);
11221124

11231125
// send notification to leasing agents
11241126
await _notificationService.SendNotificationAsync(

2-Nine.Application/Services/Workflows/BaseWorkflowService.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ protected async Task<WorkflowResult> ExecuteWorkflowAsync(
119119

120120
/// <summary>
121121
/// Logs a workflow state transition to the audit log.
122+
/// Pass <paramref name="organizationId"/> when calling from background services where the
123+
/// user context has no Blazor circuit (e.g., ScheduledTaskService).
122124
/// </summary>
123125
protected async Task LogTransitionAsync(
124126
string entityType,
@@ -127,10 +129,11 @@ protected async Task LogTransitionAsync(
127129
string toStatus,
128130
string action,
129131
string? reason = null,
130-
Dictionary<string, object>? metadata = null)
132+
Dictionary<string, object>? metadata = null,
133+
Guid? organizationId = null)
131134
{
132135
var userId = await _userContext.GetUserIdAsync() ?? string.Empty;
133-
var activeOrgId = await _userContext.GetActiveOrganizationIdAsync();
136+
var activeOrgId = organizationId ?? await _userContext.GetActiveOrganizationIdAsync();
134137

135138
var auditLog = new WorkflowAuditLog
136139
{
@@ -141,12 +144,12 @@ protected async Task LogTransitionAsync(
141144
ToStatus = toStatus,
142145
Action = action,
143146
Reason = reason,
144-
PerformedBy = userId,
147+
PerformedBy = string.IsNullOrEmpty(userId) ? "System" : userId,
145148
PerformedOn = DateTime.UtcNow,
146149
OrganizationId = activeOrgId.HasValue ? activeOrgId.Value : Guid.Empty,
147150
Metadata = metadata != null ? JsonSerializer.Serialize(metadata) : null,
148151
CreatedOn = DateTime.UtcNow,
149-
CreatedBy = userId
152+
CreatedBy = string.IsNullOrEmpty(userId) ? "System" : userId
150153
};
151154

152155
_context.WorkflowAuditLogs.Add(auditLog);

2-Nine.Application/Services/Workflows/LeaseWorkflowService.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ public async Task<WorkflowResult<int>> ExpireOverdueLeaseAsync(Guid organization
588588
{
589589
return await ExecuteWorkflowAsync<int>(async () =>
590590
{
591+
// Called from background service — no Blazor circuit, use "System" as the actor.
591592
var userId = await _userContext.GetUserIdAsync() ?? "System";
592593

593594
// Find active leases past their end date
@@ -615,7 +616,8 @@ await LogTransitionAsync(
615616
oldStatus,
616617
lease.Status,
617618
"AutoExpire",
618-
"Lease end date passed without renewal");
619+
"Lease end date passed without renewal",
620+
organizationId: organizationId);
619621

620622
addresses += $"- {lease.Property?.Address} (Tenant: {lease.Tenant?.FullName})\n";
621623

3-Nine.Shared.UI/wwwroot/js/theme.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,15 @@ window.themeManager = {
4747
},
4848

4949
getBrandTheme: function () {
50-
const brandTheme = localStorage.getItem("brandTheme") || "bootstrap";
50+
const brandTheme = localStorage.getItem("brandTheme") || "obsidian";
5151
return brandTheme;
5252
},
5353
};
5454

5555
// Initialize theme IMMEDIATELY (before DOMContentLoaded) to prevent flash
5656
if (typeof localStorage !== "undefined") {
5757
const savedTheme = localStorage.getItem("theme") || "light";
58-
const savedBrandTheme = localStorage.getItem("brandTheme") || "bootstrap";
58+
const savedBrandTheme = localStorage.getItem("brandTheme") || "obsidian";
5959
console.log("Initial theme load:", savedTheme, "Brand:", savedBrandTheme);
6060
document.documentElement.setAttribute("data-bs-theme", savedTheme);
6161
document.documentElement.setAttribute("data-brand-theme", savedBrandTheme);
@@ -98,7 +98,7 @@ if (typeof localStorage !== "undefined") {
9898
}
9999

100100
if (!currentBrandTheme) {
101-
currentBrandTheme = localStorage.getItem("brandTheme") || "bootstrap";
101+
currentBrandTheme = localStorage.getItem("brandTheme") || "obsidian";
102102
document.documentElement.setAttribute(
103103
"data-brand-theme",
104104
currentBrandTheme,
@@ -131,7 +131,7 @@ if (typeof localStorage !== "undefined") {
131131
const currentBrandTheme =
132132
document.documentElement.getAttribute("data-brand-theme") ||
133133
localStorage.getItem("brandTheme") ||
134-
"bootstrap";
134+
"obsidian";
135135

136136
document.documentElement.setAttribute("data-bs-theme", currentTheme);
137137
document.documentElement.setAttribute(

0 commit comments

Comments
 (0)