Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Core/Resgrid.Config/SecurityConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ public static class SecurityConfig

};

/// <summary>
/// System-level API key used by the SMTP Relay in hosted multi-department mode.
/// When the X-Resgrid-SystemApiKey header matches this value, the request bypasses
/// OAuth 2.0 authentication and is granted full cross-department access.
/// The department for each operation is determined by the DepartmentId field in the
/// request body/query parameters, not by the auth token.
/// </summary>
public static string SystemApiKey = "";

// ── Encryption ───────────────────────────────────────────────────────────────

/// <summary>AES-256 master key used by IEncryptionService for system-wide encryption.</summary>
Expand Down
7 changes: 7 additions & 0 deletions Providers/Resgrid.Providers.Claims/ResgridClaimTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ public static class Data
public const string TimeZone = "TimeZone";
public const string DisplayName = "DisplayName";
public const string UserId = "UserId";

/// <summary>
/// Claim added to service-account tokens (client_credentials, system API keys)
/// so downstream controllers can identify them as non-user principals that bypass
/// per-user authorization checks and support cross-department operation.
/// </summary>
public const string ServiceAccount = "ServiceAccount";
}

public static class Resources
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ namespace Resgrid.Web.Services.Controllers.v4
[Route("api/v{VersionId:apiVersion}/[controller]")]
[ApiVersion("4.0")]
[ApiExplorerSettings(GroupName = "v4")]
public class CallFilesController : V4AuthenticatedApiControllerbase
[Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")]
public class CallFilesController : V4AuthenticatedApiControllerbaseSystemAuth
{
#region Members and Constructors
private readonly ICallsService _callsService;
Expand Down Expand Up @@ -205,7 +206,9 @@ public async Task<ActionResult<SaveCallFileResult>> SaveCallFile(SaveCallFileInp
return Ok(result);
}

if (call.DepartmentId != DepartmentId)
var effectiveDepartmentId = GetEffectiveDepartmentId(input.DepartmentId);

if (call.DepartmentId != effectiveDepartmentId)
return Unauthorized();

if (call.State != (int)CallStates.Active)
Expand Down
64 changes: 38 additions & 26 deletions Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ namespace Resgrid.Web.Services.Controllers.v4
[Route("api/v{VersionId:apiVersion}/[controller]")]
[ApiVersion("4.0")]
[ApiExplorerSettings(GroupName = "v4")]
public class CallsController : V4AuthenticatedApiControllerbase
[Authorize(AuthenticationSchemes = "BasicAuthentication,SystemApiKey")]
public class CallsController : V4AuthenticatedApiControllerbaseSystemAuth
{
#region Members and Constructors
private readonly ICallsService _callsService;
Expand Down Expand Up @@ -161,7 +162,7 @@ public async Task<ActionResult<ActiveCallsResult>> GetActiveCalls()
[HttpGet("GetCall")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize(Policy = ResgridResources.Call_View)]
public async Task<ActionResult<GetCallResult>> GetCall(string callId)
public async Task<ActionResult<GetCallResult>> GetCall(string callId, [FromQuery] string departmentId = null)
{
if (String.IsNullOrWhiteSpace(callId))
return BadRequest();
Expand All @@ -175,14 +176,16 @@ public async Task<ActionResult<GetCallResult>> GetCall(string callId)
return Ok(result);
}

if (c.DepartmentId != DepartmentId)
var effectiveDepartmentId = GetEffectiveDepartmentId(departmentId);

if (c.DepartmentId != effectiveDepartmentId)
return Unauthorized();

if (!await _authorizationService.CanUserViewCallAsync(UserId, int.Parse(callId)))
if (!IsSystemApiKeyRequest && !await _authorizationService.CanUserViewCallAsync(UserId, int.Parse(callId)))
return Unauthorized();

c = await _callsService.PopulateCallData(c, false, true, true, false, false, false, true, true, true);
var destinationPoi = await GetValidatedDestinationPoiAsync(c.DestinationPoiId);
var destinationPoi = await GetValidatedDestinationPoiAsync(c.DestinationPoiId, effectiveDepartmentId);

string address = "";
if (String.IsNullOrWhiteSpace(c.Address) && c.HasValidGeolocationData())
Expand All @@ -209,7 +212,7 @@ public async Task<ActionResult<GetCallResult>> GetCall(string callId)
result.Data = ConvertCall(c, protocols, address, TimeZone, destinationPoi);

// Populate UDF values
var udfValues = await _userDefinedFieldsService.GetFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Call, c.CallId.ToString());
var udfValues = await _userDefinedFieldsService.GetFieldValuesForEntityAsync(effectiveDepartmentId, (int)UdfEntityType.Call, c.CallId.ToString());
if (udfValues != null && udfValues.Any())
{
result.Data.UdfValues = udfValues.Select(v => new UdfFieldValueResultData
Expand Down Expand Up @@ -545,27 +548,35 @@ public async Task<ActionResult<SaveCallResult>> SaveCall([FromBody] NewCallInput
{
var result = new SaveCallResult();

var canDoOperation = await _authorizationService.CanUserCreateCallAsync(UserId, DepartmentId);
var effectiveDepartmentId = GetEffectiveDepartmentId(newCallInput.DepartmentId);

if (!canDoOperation)
return Unauthorized();
if (!IsSystemApiKeyRequest)
{
var canDoOperation = await _authorizationService.CanUserCreateCallAsync(UserId, effectiveDepartmentId);
if (!canDoOperation)
return Unauthorized();
}

if (!ModelState.IsValid)
return BadRequest();

var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId);
var activeUsers = await _departmentsService.GetAllMembersForDepartmentAsync(DepartmentId);
var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId);
var roles = await _personnelRolesService.GetAllRolesForDepartmentAsync(DepartmentId);
var units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId);
var destinationPoi = await GetValidatedDestinationPoiAsync(newCallInput.DestinationPoiId);
var department = await _departmentsService.GetDepartmentByIdAsync(effectiveDepartmentId);

if (department == null)
return BadRequest($"Department not found: {effectiveDepartmentId}");

var activeUsers = await _departmentsService.GetAllMembersForDepartmentAsync(effectiveDepartmentId);
var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(effectiveDepartmentId);
var roles = await _personnelRolesService.GetAllRolesForDepartmentAsync(effectiveDepartmentId);
var units = await _unitsService.GetUnitsForDepartmentAsync(effectiveDepartmentId);
var destinationPoi = await GetValidatedDestinationPoiAsync(newCallInput.DestinationPoiId, effectiveDepartmentId);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (newCallInput.DestinationPoiId.HasValue && newCallInput.DestinationPoiId.Value > 0 && destinationPoi == null)
return BadRequest();

var call = new Call
{
DepartmentId = DepartmentId,
DepartmentId = effectiveDepartmentId,
ReportingUserId = UserId,
Priority = newCallInput.Priority,
Name = newCallInput.Name,
Expand Down Expand Up @@ -635,27 +646,27 @@ public async Task<ActionResult<SaveCallResult>> SaveCall([FromBody] NewCallInput
call.CheckInTimersEnabled = newCallInput.CheckInTimersEnabled.Value;
else
{
var autoEnable = await _departmentSettingsService.GetCheckInTimersAutoEnableForNewCallsAsync(DepartmentId);
var autoEnable = await _departmentSettingsService.GetCheckInTimersAutoEnableForNewCallsAsync(effectiveDepartmentId);
call.CheckInTimersEnabled = autoEnable;
}

if (!String.IsNullOrWhiteSpace(newCallInput.Type) && newCallInput.Type != "No Type")
{
var callTypes = await _callsService.GetCallTypesForDepartmentAsync(DepartmentId);
var callTypes = await _callsService.GetCallTypesForDepartmentAsync(effectiveDepartmentId);
var type = callTypes.FirstOrDefault(x => x.Type == newCallInput.Type);

if (type != null)
{
call.Type = type.Type;
}
}
var users = await _departmentsService.GetAllUsersForDepartmentAsync(DepartmentId);
var users = await _departmentsService.GetAllUsersForDepartmentAsync(effectiveDepartmentId);
call.Dispatches = new Collection<CallDispatch>();
call.GroupDispatches = new List<CallDispatchGroup>();
call.RoleDispatches = new List<CallDispatchRole>();
call.UnitDispatches = new List<CallDispatchUnit>();

if (newCallInput.DispatchList == "0")
if (!IsSystemApiKeyRequest && (newCallInput.DispatchList == "0" || string.IsNullOrWhiteSpace(newCallInput.DispatchList)))
{
// Use case, existing clients and non-ionic2 app this will be null dispatch all users. Or we've specified everyone (0).
foreach (var u in users)
Expand All @@ -665,7 +676,7 @@ public async Task<ActionResult<SaveCallResult>> SaveCall([FromBody] NewCallInput
call.Dispatches.Add(cd);
}
}
else
else if (!string.IsNullOrWhiteSpace(newCallInput.DispatchList) && newCallInput.DispatchList != "0")
{
var dispatch = newCallInput.DispatchList.Split(char.Parse("|"));

Expand Down Expand Up @@ -751,7 +762,7 @@ public async Task<ActionResult<SaveCallResult>> SaveCall([FromBody] NewCallInput

//OutboundEventProvider handler = new OutboundEventProvider.CallAddedTopicHandler();
//OutboundEventProvider..Handle(new CallAddedEvent() { DepartmentId = DepartmentId, Call = savedCall });
_eventAggregator.SendMessage<CallAddedEvent>(new CallAddedEvent() { DepartmentId = DepartmentId, Call = savedCall });
_eventAggregator.SendMessage<CallAddedEvent>(new CallAddedEvent() { DepartmentId = effectiveDepartmentId, Call = savedCall });

if (shouldDispatchNow && ((call.GroupDispatches != null && call.GroupDispatches.Any()) || (call.UnitDispatches != null && call.UnitDispatches.Any())))
{
Expand Down Expand Up @@ -806,7 +817,7 @@ await _callDispatchStatusService.ApplyDispatchStatusesAsync(savedCall,
// Save UDF field values if supplied
if (newCallInput.UdfValues != null && newCallInput.UdfValues.Any())
{
bool isDeptAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin();
bool isDeptAdmin = IsSystemApiKeyRequest || ClaimsAuthorizationHelper.IsUserDepartmentAdmin();
bool isGroupAdmin = HttpContext.User.Claims
.Any(c => c.Type.StartsWith(ResgridClaimTypes.Resources.Group + "/", StringComparison.Ordinal)
&& c.Value == ResgridClaimTypes.Actions.Update);
Expand All @@ -816,7 +827,7 @@ await _callDispatchStatusService.ApplyDispatchStatusesAsync(savedCall,
UdfFieldId = v.UdfFieldId,
Value = v.Value
}).ToList();
await _userDefinedFieldsService.SaveFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Call, savedCall.CallId.ToString(), udfValues, UserId, isDeptAdmin, isGroupAdmin, cancellationToken);
await _userDefinedFieldsService.SaveFieldValuesForEntityAsync(effectiveDepartmentId, (int)UdfEntityType.Call, savedCall.CallId.ToString(), udfValues, UserId, isDeptAdmin, isGroupAdmin, cancellationToken);
}

result.Id = savedCall.CallId.ToString();
Expand Down Expand Up @@ -1819,12 +1830,13 @@ public static CallResultData ConvertCall(Call call, List<DispatchProtocol> proto
return callResult;
}

private async Task<Poi> GetValidatedDestinationPoiAsync(int? destinationPoiId)
private async Task<Poi> GetValidatedDestinationPoiAsync(int? destinationPoiId, int? departmentIdOverride = null)
{
if (!destinationPoiId.HasValue || destinationPoiId.Value <= 0)
return null;

return await _mappingService.GetDestinationPOIByIdAsync(DepartmentId, destinationPoiId.Value);
var deptId = departmentIdOverride ?? DepartmentId;
return await _mappingService.GetDestinationPOIByIdAsync(deptId, destinationPoiId.Value);
}
}
}
Loading
Loading