mirror of
https://github.com/bitwarden/server
synced 2025-12-20 02:03:46 +00:00
[EC-261] SCIM (#2105)
* scim project stub * some scim models and v2 controllers * implement some v2 scim endpoints * fix spacing * api key auth * EC-261 - SCIM Org API Key and connection type config * EC-261 - Fix lint errors/formatting * updates for okta implementation testing * fix var ref * updates from testing with Okta * implement scim context via provider parsing * support single and list of ids for add/remove groups * log ops not handled * touch up scim context * group list filtering * EC-261 - Additional SCIM provider types * EC-265 - UseScim flag and license update * EC-265 - SCIM provider type of default (0) * EC-265 - Add Scim URL and update connection validation * EC-265 - Model validation and cleanup for SCIM keys * implement scim org connection * EC-265 - Ensure ServiceUrl is not persisted to DB * EC-265 - Exclude provider type from DB if not configured * EC-261 - EF Migrations for SCIM * add docker builds for scim * EC-261 - Fix failing permissions tests * EC-261 - Fix unit tests and pgsql migrations * Formatting fixes from linter * EC-265 - Remove service URL from scim config * EC-265 - Fix unit tests, removed wayward validation * EC-265 - Require self-hosted for billing sync org conn * EC-265 - Fix formatting issues - whitespace * EC-261 - PR feedback and cleanup * scim constants rename * no scim settings right now * update project name * delete package lock * update appsettings configs for scim * use default scim provider for context Co-authored-by: Kyle Spearrin <kyle.spearrin@gmail.com>
This commit is contained in:
267
bitwarden_license/src/Scim/Controllers/v2/UsersController.cs
Normal file
267
bitwarden_license/src/Scim/Controllers/v2/UsersController.cs
Normal file
@@ -0,0 +1,267 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Scim.Context;
|
||||
using Bit.Scim.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bit.Scim.Controllers.v2
|
||||
{
|
||||
[Authorize("Scim")]
|
||||
[Route("v2/{organizationId}/users")]
|
||||
public class UsersController : Controller
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IScimContext _scimContext;
|
||||
private readonly ScimSettings _scimSettings;
|
||||
private readonly ILogger<UsersController> _logger;
|
||||
|
||||
public UsersController(
|
||||
IUserService userService,
|
||||
IUserRepository userRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IScimContext scimContext,
|
||||
IOptions<ScimSettings> scimSettings,
|
||||
ILogger<UsersController> logger)
|
||||
{
|
||||
_userService = userService;
|
||||
_userRepository = userRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_scimContext = scimContext;
|
||||
_scimSettings = scimSettings?.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Get(Guid organizationId, Guid id)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
return new ObjectResult(new ScimUserResponseModel(orgUser));
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Get(
|
||||
Guid organizationId,
|
||||
[FromQuery] string filter,
|
||||
[FromQuery] int? count,
|
||||
[FromQuery] int? startIndex)
|
||||
{
|
||||
string emailFilter = null;
|
||||
string usernameFilter = null;
|
||||
string externalIdFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
if (filter.StartsWith("userName eq "))
|
||||
{
|
||||
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant();
|
||||
if (usernameFilter.Contains("@"))
|
||||
{
|
||||
emailFilter = usernameFilter;
|
||||
}
|
||||
}
|
||||
else if (filter.StartsWith("externalId eq "))
|
||||
{
|
||||
externalIdFilter = filter.Substring(14).Trim('"');
|
||||
}
|
||||
}
|
||||
|
||||
var userList = new List<ScimUserResponseModel> { };
|
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
var totalResults = 0;
|
||||
if (!string.IsNullOrWhiteSpace(emailFilter))
|
||||
{
|
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.Email.ToLowerInvariant() == emailFilter);
|
||||
if (orgUser != null)
|
||||
{
|
||||
userList.Add(new ScimUserResponseModel(orgUser));
|
||||
}
|
||||
totalResults = userList.Count;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(externalIdFilter))
|
||||
{
|
||||
var orgUser = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalIdFilter);
|
||||
if (orgUser != null)
|
||||
{
|
||||
userList.Add(new ScimUserResponseModel(orgUser));
|
||||
}
|
||||
totalResults = userList.Count;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
|
||||
{
|
||||
userList = orgUsers.OrderBy(ou => ou.Email)
|
||||
.Skip(startIndex.Value - 1)
|
||||
.Take(count.Value)
|
||||
.Select(ou => new ScimUserResponseModel(ou))
|
||||
.ToList();
|
||||
totalResults = orgUsers.Count;
|
||||
}
|
||||
|
||||
var result = new ScimListResponseModel<ScimUserResponseModel>
|
||||
{
|
||||
Resources = userList,
|
||||
ItemsPerPage = count.GetValueOrDefault(userList.Count),
|
||||
TotalResults = totalResults,
|
||||
StartIndex = startIndex.GetValueOrDefault(1),
|
||||
};
|
||||
return new ObjectResult(result);
|
||||
}
|
||||
|
||||
[HttpPost("")]
|
||||
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimUserRequestModel model)
|
||||
{
|
||||
var email = model.PrimaryEmail?.ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
switch (_scimContext.RequestScimProvider)
|
||||
{
|
||||
case ScimProviderType.AzureAd:
|
||||
email = model.UserName?.ToLowerInvariant();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) || !model.Active)
|
||||
{
|
||||
return new BadRequestResult();
|
||||
}
|
||||
|
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
||||
if (orgUserByEmail != null)
|
||||
{
|
||||
return new ConflictResult();
|
||||
}
|
||||
|
||||
string externalId = null;
|
||||
if (!string.IsNullOrWhiteSpace(model.ExternalId))
|
||||
{
|
||||
externalId = model.ExternalId;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(model.UserName))
|
||||
{
|
||||
externalId = model.UserName;
|
||||
}
|
||||
else
|
||||
{
|
||||
externalId = CoreHelpers.RandomString(15);
|
||||
}
|
||||
|
||||
var orgUserByExternalId = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalId);
|
||||
if (orgUserByExternalId != null)
|
||||
{
|
||||
return new ConflictResult();
|
||||
}
|
||||
|
||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, null, email,
|
||||
OrganizationUserType.User, false, externalId, new List<SelectionReadOnly>());
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||
var response = new ScimUserResponseModel(orgUser);
|
||||
return new CreatedResult(Url.Action(nameof(Get), new { orgUser.OrganizationId, orgUser.Id }), response);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimUserRequestModel model)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
|
||||
if (model.Active && orgUser.Status == OrganizationUserStatusType.Deactivated)
|
||||
{
|
||||
await _organizationService.ActivateUserAsync(orgUser, null);
|
||||
}
|
||||
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Deactivated)
|
||||
{
|
||||
await _organizationService.DeactivateUserAsync(orgUser, null);
|
||||
}
|
||||
|
||||
// Have to get full details object for response model
|
||||
var orgUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id);
|
||||
return new ObjectResult(new ScimUserResponseModel(orgUserDetails));
|
||||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
|
||||
var operationHandled = false;
|
||||
|
||||
var replaceOp = model.Operations?.FirstOrDefault(o => o.Op == "replace");
|
||||
if (replaceOp != null)
|
||||
{
|
||||
if (replaceOp.Value.TryGetProperty("active", out var activeProperty))
|
||||
{
|
||||
var active = activeProperty.GetBoolean();
|
||||
if (active && orgUser.Status == OrganizationUserStatusType.Deactivated)
|
||||
{
|
||||
await _organizationService.ActivateUserAsync(orgUser, null);
|
||||
operationHandled = true;
|
||||
}
|
||||
else if (!active && orgUser.Status != OrganizationUserStatusType.Deactivated)
|
||||
{
|
||||
await _organizationService.DeactivateUserAsync(orgUser, null);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!operationHandled)
|
||||
{
|
||||
_logger.LogWarning("User patch operation not handled: {operation} : ",
|
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
|
||||
}
|
||||
|
||||
return new NoContentResult();
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> Delete(Guid organizationId, Guid id, [FromBody] ScimUserRequestModel model)
|
||||
{
|
||||
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||
if (orgUser == null || orgUser.OrganizationId != organizationId)
|
||||
{
|
||||
return new NotFoundObjectResult(new ScimErrorResponseModel
|
||||
{
|
||||
Status = 404,
|
||||
Detail = "User not found."
|
||||
});
|
||||
}
|
||||
await _organizationService.DeleteUserAsync(organizationId, id, null);
|
||||
return new NoContentResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user