1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-24 12:35:25 +01: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:
Chad Scharf 2022-07-14 15:58:48 -04:00 committed by GitHub
parent c5852db6ed
commit 19b8d8281a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
117 changed files with 8553 additions and 169 deletions

View File

@ -70,25 +70,27 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommCore.Test", "bitwarden_
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test - Bitwarden License", "test - Bitwarden License", "{287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MySqlMigrations", "util\MySqlMigrations\MySqlMigrations.csproj", "{BDC1D592-5947-47ED-9903-7CDBB12A50C8}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MySqlMigrations", "util\MySqlMigrations\MySqlMigrations.csproj", "{BDC1D592-5947-47ED-9903-7CDBB12A50C8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostgresMigrations", "util\PostgresMigrations\PostgresMigrations.csproj", "{F72E0229-2EF7-49B3-9004-FF4C0043816E}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PostgresMigrations", "util\PostgresMigrations\PostgresMigrations.csproj", "{F72E0229-2EF7-49B3-9004-FF4C0043816E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "test\Common\Common.csproj", "{17DA09D7-0212-4009-879E-6B9CFDE5FA60}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common", "test\Common\Common.csproj", "{17DA09D7-0212-4009-879E-6B9CFDE5FA60}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper", "src\Infrastructure.Dapper\Infrastructure.Dapper.csproj", "{AD933445-27CE-4D30-A6ED-9065309464AD}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.Dapper", "src\Infrastructure.Dapper\Infrastructure.Dapper.csproj", "{AD933445-27CE-4D30-A6ED-9065309464AD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb", "src\SharedWeb\SharedWeb.csproj", "{713D44C0-1BC1-4024-96A3-A98A49F33908}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharedWeb", "src\SharedWeb\SharedWeb.csproj", "{713D44C0-1BC1-4024-96A3-A98A49F33908}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.EntityFramework", "src\Infrastructure.EntityFramework\Infrastructure.EntityFramework.csproj", "{ED880735-0250-43C7-9662-FDC7C7416E7F}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.EntityFramework", "src\Infrastructure.EntityFramework\Infrastructure.EntityFramework.csproj", "{ED880735-0250-43C7-9662-FDC7C7416E7F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Billing.Test", "test\Billing.Test\Billing.Test.csproj", "{B8639B10-2157-44BC-8CE1-D9EB4B50971F}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Billing.Test", "test\Billing.Test\Billing.Test.csproj", "{B8639B10-2157-44BC-8CE1-D9EB4B50971F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Identity.Test", "test\Identity.Test\Identity.Test.csproj", "{310A1D8E-2D3F-4FA0-84D4-FFE31FCE193E}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Identity.Test", "test\Identity.Test\Identity.Test.csproj", "{310A1D8E-2D3F-4FA0-84D4-FFE31FCE193E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Identity.IntegrationTest", "test\Identity.IntegrationTest\Identity.IntegrationTest.csproj", "{0D3B2BD2-53F3-421D-AD8F-C19B954C796B}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Identity.IntegrationTest", "test\Identity.IntegrationTest\Identity.IntegrationTest.csproj", "{0D3B2BD2-53F3-421D-AD8F-C19B954C796B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IntegrationTestCommon", "test\IntegrationTestCommon\IntegrationTestCommon.csproj", "{0923DE59-5FB1-44F2-9302-A09D2236B470}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTestCommon", "test\IntegrationTestCommon\IntegrationTestCommon.csproj", "{0923DE59-5FB1-44F2-9302-A09D2236B470}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Scim", "bitwarden_license\src\Scim\Scim.csproj", "{BC3B3F8C-621A-4CB8-9563-6EC0A2C8C747}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -214,6 +216,10 @@ Global
{0923DE59-5FB1-44F2-9302-A09D2236B470}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0923DE59-5FB1-44F2-9302-A09D2236B470}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0923DE59-5FB1-44F2-9302-A09D2236B470}.Release|Any CPU.Build.0 = Release|Any CPU
{BC3B3F8C-621A-4CB8-9563-6EC0A2C8C747}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BC3B3F8C-621A-4CB8-9563-6EC0A2C8C747}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BC3B3F8C-621A-4CB8-9563-6EC0A2C8C747}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BC3B3F8C-621A-4CB8-9563-6EC0A2C8C747}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -248,6 +254,7 @@ Global
{310A1D8E-2D3F-4FA0-84D4-FFE31FCE193E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{0D3B2BD2-53F3-421D-AD8F-C19B954C796B} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{0923DE59-5FB1-44F2-9302-A09D2236B470} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{BC3B3F8C-621A-4CB8-9563-6EC0A2C8C747} = {4FDB6543-F68B-4202-9EA6-7FEA984D2D0A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@ -0,0 +1,4 @@
*
!obj/build-output/publish/*
!obj/Docker/empty/
!entrypoint.sh

View File

@ -0,0 +1,21 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.OrganizationConnectionConfigs;
using Bit.Core.Repositories;
using Bit.Core.Settings;
namespace Bit.Scim.Context
{
public interface IScimContext
{
ScimProviderType RequestScimProvider { get; set; }
ScimConfig ScimConfiguration { get; set; }
Guid? OrganizationId { get; set; }
Organization Organization { get; set; }
Task BuildAsync(
HttpContext httpContext,
GlobalSettings globalSettings,
IOrganizationRepository organizationRepository,
IOrganizationConnectionRepository organizationConnectionRepository);
}
}

View File

@ -0,0 +1,64 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.OrganizationConnectionConfigs;
using Bit.Core.Repositories;
using Bit.Core.Settings;
namespace Bit.Scim.Context
{
public class ScimContext : IScimContext
{
private bool _builtHttpContext;
public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default;
public ScimConfig ScimConfiguration { get; set; }
public Guid? OrganizationId { get; set; }
public Organization Organization { get; set; }
public async virtual Task BuildAsync(
HttpContext httpContext,
GlobalSettings globalSettings,
IOrganizationRepository organizationRepository,
IOrganizationConnectionRepository organizationConnectionRepository)
{
if (_builtHttpContext)
{
return;
}
_builtHttpContext = true;
string orgIdString = null;
if (httpContext.Request.RouteValues.TryGetValue("organizationId", out var orgIdObject))
{
orgIdString = orgIdObject?.ToString();
}
if (Guid.TryParse(orgIdString, out var orgId))
{
OrganizationId = orgId;
Organization = await organizationRepository.GetByIdAsync(orgId);
if (Organization != null)
{
var scimConnections = await organizationConnectionRepository.GetByOrganizationIdTypeAsync(Organization.Id,
OrganizationConnectionType.Scim);
ScimConfiguration = scimConnections?.FirstOrDefault()?.GetConfig<ScimConfig>();
}
}
if (RequestScimProvider == ScimProviderType.Default &&
httpContext.Request.Headers.TryGetValue("User-Agent", out var userAgent))
{
if (userAgent.ToString().StartsWith("Okta"))
{
RequestScimProvider = ScimProviderType.Okta;
}
}
if (RequestScimProvider == ScimProviderType.Default &&
httpContext.Request.Headers.ContainsKey("Adscimversion"))
{
RequestScimProvider = ScimProviderType.AzureAd;
}
}
}
}

View File

@ -0,0 +1,21 @@
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Scim.Controllers
{
public class InfoController : Controller
{
[HttpGet("~/alive")]
[HttpGet("~/now")]
public DateTime GetAlive()
{
return DateTime.UtcNow;
}
[HttpGet("~/version")]
public JsonResult GetVersion()
{
return Json(CoreHelpers.GetVersion());
}
}
}

View File

@ -0,0 +1,290 @@
using System.Text.Json;
using Bit.Core.Repositories;
using Bit.Core.Services;
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}/groups")]
public class GroupsController : Controller
{
private readonly ScimSettings _scimSettings;
private readonly IGroupRepository _groupRepository;
private readonly IGroupService _groupService;
private readonly IScimContext _scimContext;
private readonly ILogger<GroupsController> _logger;
public GroupsController(
IGroupRepository groupRepository,
IGroupService groupService,
IOptions<ScimSettings> scimSettings,
IScimContext scimContext,
ILogger<GroupsController> logger)
{
_scimSettings = scimSettings?.Value;
_groupRepository = groupRepository;
_groupService = groupService;
_scimContext = scimContext;
_logger = logger;
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid organizationId, Guid id)
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organizationId)
{
return new NotFoundObjectResult(new ScimErrorResponseModel
{
Status = 404,
Detail = "Group not found."
});
}
return new ObjectResult(new ScimGroupResponseModel(group));
}
[HttpGet("")]
public async Task<IActionResult> Get(
Guid organizationId,
[FromQuery] string filter,
[FromQuery] int? count,
[FromQuery] int? startIndex)
{
string nameFilter = null;
string externalIdFilter = null;
if (!string.IsNullOrWhiteSpace(filter))
{
if (filter.StartsWith("displayName eq "))
{
nameFilter = filter.Substring(15).Trim('"');
}
else if (filter.StartsWith("externalId eq "))
{
externalIdFilter = filter.Substring(14).Trim('"');
}
}
var groupList = new List<ScimGroupResponseModel>();
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId);
var totalResults = 0;
if (!string.IsNullOrWhiteSpace(nameFilter))
{
var group = groups.FirstOrDefault(g => g.Name == nameFilter);
if (group != null)
{
groupList.Add(new ScimGroupResponseModel(group));
}
totalResults = groupList.Count;
}
else if (!string.IsNullOrWhiteSpace(externalIdFilter))
{
var group = groups.FirstOrDefault(ou => ou.ExternalId == externalIdFilter);
if (group != null)
{
groupList.Add(new ScimGroupResponseModel(group));
}
totalResults = groupList.Count;
}
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
{
groupList = groups.OrderBy(g => g.Name)
.Skip(startIndex.Value - 1)
.Take(count.Value)
.Select(g => new ScimGroupResponseModel(g))
.ToList();
totalResults = groups.Count;
}
var result = new ScimListResponseModel<ScimGroupResponseModel>
{
Resources = groupList,
ItemsPerPage = count.GetValueOrDefault(groupList.Count),
TotalResults = totalResults,
StartIndex = startIndex.GetValueOrDefault(1),
};
return new ObjectResult(result);
}
[HttpPost("")]
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimGroupRequestModel model)
{
if (string.IsNullOrWhiteSpace(model.DisplayName))
{
return new BadRequestResult();
}
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId);
if (!string.IsNullOrWhiteSpace(model.ExternalId) && groups.Any(g => g.ExternalId == model.ExternalId))
{
return new ConflictResult();
}
var group = model.ToGroup(organizationId);
await _groupService.SaveAsync(group, null);
var response = new ScimGroupResponseModel(group);
return new CreatedResult(Url.Action(nameof(Get), new { group.OrganizationId, group.Id }), response);
}
[HttpPut("{id}")]
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimGroupRequestModel model)
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organizationId)
{
return new NotFoundObjectResult(new ScimErrorResponseModel
{
Status = 404,
Detail = "Group not found."
});
}
group.Name = model.DisplayName;
await _groupService.SaveAsync(group);
return new ObjectResult(new ScimGroupResponseModel(group));
}
[HttpPatch("{id}")]
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organizationId)
{
return new NotFoundObjectResult(new ScimErrorResponseModel
{
Status = 404,
Detail = "Group not found."
});
}
var operationHandled = false;
var replaceOp = model.Operations?.FirstOrDefault(o => o.Op == "replace");
if (replaceOp != null)
{
// Replace a list of members
if (replaceOp.Path == "members")
{
var ids = GetOperationValueIds(replaceOp.Value);
await _groupRepository.UpdateUsersAsync(group.Id, ids);
operationHandled = true;
}
// Replace group name
else if (replaceOp.Value.TryGetProperty("displayName", out var displayNameProperty))
{
group.Name = displayNameProperty.GetString();
await _groupService.SaveAsync(group);
operationHandled = true;
}
}
// Add a single member
var addMemberOp = model.Operations?.FirstOrDefault(
o => o.Op == "add" && !string.IsNullOrWhiteSpace(o.Path) && o.Path.StartsWith("members[value eq "));
if (addMemberOp != null)
{
var addId = GetOperationPathId(addMemberOp.Path);
if (addId.HasValue)
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
orgUserIds.Add(addId.Value);
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
operationHandled = true;
}
}
// Add a list of members
var addMembersOp = model.Operations?.FirstOrDefault(o => o.Op == "add" && o.Path == "members");
if (addMembersOp != null)
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(addMembersOp.Value))
{
orgUserIds.Add(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
operationHandled = true;
}
// Remove a single member
var removeMemberOp = model.Operations?.FirstOrDefault(
o => o.Op == "remove" && !string.IsNullOrWhiteSpace(o.Path) && o.Path.StartsWith("members[value eq "));
if (removeMemberOp != null)
{
var removeId = GetOperationPathId(removeMemberOp.Path);
if (removeId.HasValue)
{
await _groupService.DeleteUserAsync(group, removeId.Value);
operationHandled = true;
}
}
// Remove a list of members
var removeMembersOp = model.Operations?.FirstOrDefault(o => o.Op == "remove" && o.Path == "members");
if (removeMembersOp != null)
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(removeMembersOp.Value))
{
orgUserIds.Remove(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
operationHandled = true;
}
if (!operationHandled)
{
_logger.LogWarning("Group patch operation not handled: {0} : ",
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
}
return new NoContentResult();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(Guid organizationId, Guid id)
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organizationId)
{
return new NotFoundObjectResult(new ScimErrorResponseModel
{
Status = 404,
Detail = "Group not found."
});
}
await _groupService.DeleteAsync(group);
return new NoContentResult();
}
private List<Guid> GetOperationValueIds(JsonElement objArray)
{
var ids = new List<Guid>();
foreach (var obj in objArray.EnumerateArray())
{
if (obj.TryGetProperty("value", out var valueProperty))
{
if (valueProperty.TryGetGuid(out var guid))
{
ids.Add(guid);
}
}
}
return ids;
}
private Guid? GetOperationPathId(string path)
{
// Parse Guid from string like: members[value eq "{GUID}"}]
if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id))
{
return id;
}
return null;
}
}
}

View 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();
}
}
}

View File

@ -0,0 +1,20 @@
FROM mcr.microsoft.com/dotnet/aspnet:6.0
LABEL com.bitwarden.product="bitwarden"
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gosu \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV ASPNETCORE_URLS http://+:5000
WORKDIR /app
EXPOSE 5000
COPY obj/build-output/publish .
COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh
HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -0,0 +1,18 @@
using Bit.Scim.Utilities;
namespace Bit.Scim.Models
{
public abstract class BaseScimGroupModel : BaseScimModel
{
public BaseScimGroupModel(bool initSchema = false)
{
if (initSchema)
{
Schemas = new List<string> { ScimConstants.Scim2SchemaGroup };
}
}
public string DisplayName { get; set; }
public string ExternalId { get; set; }
}
}

View File

@ -0,0 +1,15 @@
namespace Bit.Scim.Models
{
public abstract class BaseScimModel
{
public BaseScimModel()
{ }
public BaseScimModel(string schema)
{
Schemas = new List<string> { schema };
}
public List<string> Schemas { get; set; }
}
}

View File

@ -0,0 +1,55 @@
using Bit.Scim.Utilities;
namespace Bit.Scim.Models
{
public abstract class BaseScimUserModel : BaseScimModel
{
public BaseScimUserModel(bool initSchema = false)
{
if (initSchema)
{
Schemas = new List<string> { ScimConstants.Scim2SchemaUser };
}
}
public string UserName { get; set; }
public NameModel Name { get; set; }
public List<EmailModel> Emails { get; set; }
public string PrimaryEmail => Emails?.FirstOrDefault(e => e.Primary)?.Value;
public string DisplayName { get; set; }
public bool Active { get; set; }
public List<string> Groups { get; set; }
public string ExternalId { get; set; }
public class NameModel
{
public NameModel() { }
public NameModel(string name)
{
Formatted = name;
}
public string Formatted { get; set; }
public string GivenName { get; set; }
public string MiddleName { get; set; }
public string FamilyName { get; set; }
}
public class EmailModel
{
public EmailModel() { }
public EmailModel(string email)
{
Primary = true;
Value = email;
Type = "work";
}
public bool Primary { get; set; }
public string Value { get; set; }
public string Type { get; set; }
}
}
}

View File

@ -0,0 +1,14 @@
using Bit.Scim.Utilities;
namespace Bit.Scim.Models
{
public class ScimErrorResponseModel : BaseScimModel
{
public ScimErrorResponseModel()
: base(ScimConstants.Scim2SchemaError)
{ }
public string Detail { get; set; }
public int Status { get; set; }
}
}

View File

@ -0,0 +1,23 @@
using Bit.Core.Entities;
using Bit.Core.Utilities;
namespace Bit.Scim.Models
{
public class ScimGroupRequestModel : BaseScimGroupModel
{
public ScimGroupRequestModel()
: base(false)
{ }
public Group ToGroup(Guid organizationId)
{
var externalId = string.IsNullOrWhiteSpace(ExternalId) ? CoreHelpers.RandomString(15) : ExternalId;
return new Group
{
Name = DisplayName,
ExternalId = externalId,
OrganizationId = organizationId
};
}
}
}

View File

@ -0,0 +1,26 @@
using Bit.Core.Entities;
namespace Bit.Scim.Models
{
public class ScimGroupResponseModel : BaseScimGroupModel
{
public ScimGroupResponseModel()
: base(true)
{
Meta = new ScimMetaModel("Group");
}
public ScimGroupResponseModel(Group group)
: this()
{
Id = group.Id.ToString();
DisplayName = group.Name;
ExternalId = group.ExternalId;
Meta.Created = group.CreationDate;
Meta.LastModified = group.RevisionDate;
}
public string Id { get; set; }
public ScimMetaModel Meta { get; private set; }
}
}

View File

@ -0,0 +1,16 @@
using Bit.Scim.Utilities;
namespace Bit.Scim.Models
{
public class ScimListResponseModel<T> : BaseScimModel
{
public ScimListResponseModel()
: base(ScimConstants.Scim2SchemaListResponse)
{ }
public int TotalResults { get; set; }
public int StartIndex { get; set; }
public int ItemsPerPage { get; set; }
public List<T> Resources { get; set; }
}
}

View File

@ -0,0 +1,14 @@
namespace Bit.Scim.Models
{
public class ScimMetaModel
{
public ScimMetaModel(string resourceType)
{
ResourceType = resourceType;
}
public string ResourceType { get; set; }
public DateTime? Created { get; set; }
public DateTime? LastModified { get; set; }
}
}

View File

@ -0,0 +1,19 @@
using System.Text.Json;
namespace Bit.Scim.Models
{
public class ScimPatchModel : BaseScimModel
{
public ScimPatchModel()
: base() { }
public List<OperationModel> Operations { get; set; }
public class OperationModel
{
public string Op { get; set; }
public string Path { get; set; }
public JsonElement Value { get; set; }
}
}
}

View File

@ -0,0 +1,9 @@
namespace Bit.Scim.Models
{
public class ScimUserRequestModel : BaseScimUserModel
{
public ScimUserRequestModel()
: base(false)
{ }
}
}

View File

@ -0,0 +1,29 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
namespace Bit.Scim.Models
{
public class ScimUserResponseModel : BaseScimUserModel
{
public ScimUserResponseModel()
: base(true)
{
Meta = new ScimMetaModel("User");
Groups = new List<string>();
}
public ScimUserResponseModel(OrganizationUserUserDetails orgUser)
: this()
{
Id = orgUser.Id.ToString();
ExternalId = orgUser.ExternalId;
UserName = orgUser.Email;
DisplayName = orgUser.Name;
Emails = new List<EmailModel> { new EmailModel(orgUser.Email) };
Name = new NameModel(orgUser.Name);
Active = orgUser.Status != Core.Enums.OrganizationUserStatusType.Deactivated;
}
public string Id { get; set; }
public ScimMetaModel Meta { get; private set; }
}
}

View File

@ -0,0 +1,34 @@
using Bit.Core.Utilities;
using Serilog.Events;
namespace Bit.Scim
{
public class Program
{
public static void Main(string[] args)
{
Host
.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, e =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Properties.ContainsKey("RequestPath") &&
!string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= LogEventLevel.Warning;
}));
})
.Build()
.Run();
}
}
}

View File

@ -0,0 +1,29 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:44558/",
"sslPort": 0
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": false,
"launchUrl": "http://localhost:44558",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Scim": {
"commandName": "Project",
"launchBrowser": false,
"launchUrl": "http://localhost:44558",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:44559"
}
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>bitwarden-Scim</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="5.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Core\Core.csproj" />
<ProjectReference Include="..\..\..\src\SharedWeb\SharedWeb.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
namespace Bit.Scim
{
public class ScimSettings
{
}
}

View File

@ -0,0 +1,113 @@
using System.Globalization;
using Bit.Core.Context;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Scim.Context;
using Bit.Scim.Utilities;
using Bit.SharedWeb.Utilities;
using IdentityModel;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Stripe;
namespace Bit.Scim
{
public class Startup
{
public Startup(IWebHostEnvironment env, IConfiguration configuration)
{
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
Configuration = configuration;
Environment = env;
}
public IConfiguration Configuration { get; }
public IWebHostEnvironment Environment { get; set; }
public void ConfigureServices(IServiceCollection services)
{
// Options
services.AddOptions();
// Settings
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
services.Configure<ScimSettings>(Configuration.GetSection("ScimSettings"));
// Stripe Billing
StripeConfiguration.ApiKey = globalSettings.Stripe.ApiKey;
StripeConfiguration.MaxNetworkRetries = globalSettings.Stripe.MaxNetworkRetries;
// Repositories
services.AddSqlServerRepositories(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();
services.AddScoped<IScimContext, ScimContext>();
// Authentication
services.AddAuthentication(ApiKeyAuthenticationOptions.DefaultScheme)
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationOptions.DefaultScheme, null);
services.AddAuthorization(config =>
{
config.AddPolicy("Scim", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim(JwtClaimTypes.Scope, "api.scim");
});
});
// Identity
services.AddCustomIdentityServices(globalSettings);
// Services
services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings);
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
// Mvc
services.AddMvc(config =>
{
config.Filters.Add(new LoggingExceptionHandlerFilterAttribute());
});
services.Configure<RouteOptions>(options => options.LowercaseUrls = true);
}
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings)
{
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
// Default Middleware
app.UseDefaultMiddleware(env, globalSettings);
// Add routing
app.UseRouting();
// Add Scim context
app.UseMiddleware<ScimContextMiddleware>();
// Add authentication and authorization to the request pipeline.
app.UseAuthentication();
app.UseAuthorization();
// Add current context
app.UseMiddleware<CurrentContextMiddleware>();
// Add MVC to the request pipeline.
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
}
}
}

View File

@ -0,0 +1,83 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Scim.Context;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace Bit.Scim.Utilities
{
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
private readonly IScimContext _scimContext;
public ApiKeyAuthenticationHandler(
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
IOrganizationRepository organizationRepository,
IOrganizationApiKeyRepository organizationApiKeyRepository,
IScimContext scimContext) :
base(options, logger, encoder, clock)
{
_organizationRepository = organizationRepository;
_organizationApiKeyRepository = organizationApiKeyRepository;
_scimContext = scimContext;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!_scimContext.OrganizationId.HasValue || _scimContext.Organization == null)
{
Logger.LogWarning("No organization.");
return AuthenticateResult.Fail("Invalid parameters");
}
if (!Request.Headers.TryGetValue("Authorization", out var authHeader) || authHeader.Count != 1)
{
Logger.LogWarning("An API request was received without the Authorization header");
return AuthenticateResult.Fail("Invalid parameters");
}
var apiKey = authHeader.ToString();
if (apiKey.StartsWith("Bearer "))
{
apiKey = apiKey.Substring(7);
}
if (!_scimContext.Organization.Enabled || !_scimContext.Organization.UseScim ||
_scimContext.ScimConfiguration == null || !_scimContext.ScimConfiguration.Enabled)
{
Logger.LogInformation("Org {organizationId} not able to use Scim.", _scimContext.OrganizationId);
return AuthenticateResult.Fail("Invalid parameters");
}
var orgApiKey = (await _organizationApiKeyRepository
.GetManyByOrganizationIdTypeAsync(_scimContext.Organization.Id, OrganizationApiKeyType.Scim))
.FirstOrDefault();
if (orgApiKey?.ApiKey != apiKey)
{
Logger.LogWarning("An API request was received with an invalid API key: {apiKey}", apiKey);
return AuthenticateResult.Fail("Invalid parameters");
}
Logger.LogInformation("Org {organizationId} authenticated", _scimContext.OrganizationId);
var claims = new[]
{
new Claim(JwtClaimTypes.ClientId, $"organization.{_scimContext.OrganizationId.Value}"),
new Claim("client_sub", _scimContext.OrganizationId.Value.ToString()),
new Claim(JwtClaimTypes.Scope, "api.scim"),
};
var identity = new ClaimsIdentity(claims, nameof(ApiKeyAuthenticationHandler));
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity),
ApiKeyAuthenticationOptions.DefaultScheme);
return AuthenticateResult.Success(ticket);
}
}
}

View File

@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Authentication;
namespace Bit.Scim.Utilities
{
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
{
public const string DefaultScheme = "ScimApiKey";
}
}

View File

@ -0,0 +1,10 @@
namespace Bit.Scim.Utilities
{
public static class ScimConstants
{
public const string Scim2SchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
public const string Scim2SchemaError = "urn:ietf:params:scim:api:messages:2.0:Error";
public const string Scim2SchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User";
public const string Scim2SchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group";
}
}

View File

@ -0,0 +1,23 @@
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Scim.Context;
namespace Bit.Scim.Utilities
{
public class ScimContextMiddleware
{
private readonly RequestDelegate _next;
public ScimContextMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext httpContext, IScimContext scimContext, GlobalSettings globalSettings,
IOrganizationRepository organizationRepository, IOrganizationConnectionRepository organizationConnectionRepository)
{
await scimContext.BuildAsync(httpContext, globalSettings, organizationRepository, organizationConnectionRepository);
await _next.Invoke(httpContext);
}
}
}

View File

@ -0,0 +1,35 @@
{
"globalSettings": {
"baseServiceUri": {
"vault": "https://localhost:8080",
"api": "http://localhost:4000",
"identity": "http://localhost:33656",
"admin": "http://localhost:62911",
"notifications": "http://localhost:61840",
"sso": "http://localhost:51822",
"internalNotifications": "http://localhost:61840",
"internalAdmin": "http://localhost:62911",
"internalIdentity": "http://localhost:33656",
"internalApi": "http://localhost:4000",
"internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822",
"internalScim": "http://localhost:44559"
},
"mail": {
"smtp": {
"host": "localhost",
"port": 10250
}
},
"attachment": {
"connectionString": "UseDevelopmentStorage=true",
"baseUrl": "http://localhost:4000/attachments/"
},
"events": {
"connectionString": "UseDevelopmentStorage=true"
},
"storage": {
"connectionString": "UseDevelopmentStorage=true"
}
}
}

View File

@ -0,0 +1,42 @@
{
"globalSettings": {
"baseServiceUri": {
"vault": "https://vault.bitwarden.com",
"api": "https://api.bitwarden.com",
"identity": "https://identity.bitwarden.com",
"admin": "https://admin.bitwarden.com",
"notifications": "https://notifications.bitwarden.com",
"sso": "https://sso.bitwarden.com",
"internalNotifications": "https://notifications.bitwarden.com",
"internalAdmin": "https://admin.bitwarden.com",
"internalIdentity": "https://identity.bitwarden.com",
"internalApi": "https://api.bitwarden.com",
"internalVault": "https://vault.bitwarden.com",
"internalSso": "https://sso.bitwarden.com",
"internalScim": "https://scim.bitwarden.com"
},
"braintree": {
"production": true
},
"bitPay": {
"production": true
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
},
"Console": {
"IncludeScopes": true,
"LogLevel": {
"Default": "Warning",
"System": "Warning",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
}

View File

@ -0,0 +1,42 @@
{
"globalSettings": {
"baseServiceUri": {
"vault": "https://vault.qa.bitwarden.pw",
"api": "https://api.qa.bitwarden.pw",
"identity": "https://identity.qa.bitwarden.pw",
"admin": "https://admin.qa.bitwarden.pw",
"notifications": "https://notifications.qa.bitwarden.pw",
"sso": "https://sso.qa.bitwarden.pw",
"internalNotifications": "https://notifications.qa.bitwarden.pw",
"internalAdmin": "https://admin.qa.bitwarden.pw",
"internalIdentity": "https://identity.qa.bitwarden.pw",
"internalApi": "https://api.qa.bitwarden.pw",
"internalVault": "https://vault.qa.bitwarden.pw",
"internalSso": "https://sso.qa.bitwarden.pw",
"internalScim": "https://scim.qa.bitwarden.pw"
},
"braintree": {
"production": false
},
"bitPay": {
"production": false
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
},
"Console": {
"IncludeScopes": true,
"LogLevel": {
"Default": "Debug",
"System": "Debug",
"Microsoft": "Debug",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
}

View File

@ -0,0 +1,63 @@
{
"globalSettings": {
"selfHosted": false,
"siteName": "Bitwarden",
"projectName": "Scim",
"stripe": {
"apiKey": "SECRET"
},
"sqlServer": {
"connectionString": "SECRET"
},
"mail": {
"sendGridApiKey": "SECRET",
"amazonConfigSetName": "Email",
"replyToEmail": "no-reply@bitwarden.com"
},
"identityServer": {
"certificateThumbprint": "SECRET"
},
"dataProtection": {
"certificateThumbprint": "SECRET"
},
"storage": {
"connectionString": "SECRET"
},
"events": {
"connectionString": "SECRET"
},
"serviceBus": {
"connectionString": "SECRET",
"applicationCacheTopicName": "SECRET"
},
"documentDb": {
"uri": "SECRET",
"key": "SECRET"
},
"sentry": {
"dsn": "SECRET"
},
"notificationHub": {
"connectionString": "SECRET",
"hubName": "SECRET"
},
"braintree": {
"production": false,
"merchantId": "SECRET",
"publicKey": "SECRET",
"privateKey": "SECRET"
},
"bitPay": {
"production": false,
"token": "SECRET",
"notificationUrl": "https://bitwarden.com/SECRET"
},
"amazon": {
"accessKeyId": "SECRET",
"accessKeySecret": "SECRET",
"region": "SECRET"
}
},
"scimSettings": {
}
}

View File

@ -0,0 +1,12 @@
$dir = Split-Path -Parent $MyInvocation.MyCommand.Path
echo "`n## Building Scim"
echo "`nBuilding app"
echo ".NET Core version $(dotnet --version)"
echo "Restore"
dotnet restore $dir\Scim.csproj
echo "Clean"
dotnet clean $dir\Scim.csproj -c "Release" -o $dir\obj\Azure\publish
echo "Publish"
dotnet publish $dir\Scim.csproj -c "Release" -o $dir\obj\Azure\publish

View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -e
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && "pwd" )"
echo -e "\n## Building SCIM"
echo -e "\nBuilding app"
echo ".NET Core version $(dotnet --version)"
echo "Restore"
dotnet restore "$DIR/Scim.csproj"
echo "Clean"
dotnet clean "$DIR/Scim.csproj" -c "Release" -o "$DIR/obj/build-output/publish"
echo "Publish"
dotnet publish "$DIR/Scim.csproj" -c "Release" -o "$DIR/obj/build-output/publish"

View File

@ -0,0 +1,41 @@
#!/bin/bash
# Setup
GROUPNAME="bitwarden"
USERNAME="bitwarden"
LUID=${LOCAL_UID:-0}
LGID=${LOCAL_GID:-0}
# Step down from host root to well-known nobody/nogroup user
if [ $LUID -eq 0 ]
then
LUID=65534
fi
if [ $LGID -eq 0 ]
then
LGID=65534
fi
# Create user and group
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
mkhomedir_helper $USERNAME
# The rest...
chown -R $USERNAME:$GROUPNAME /app
mkdir -p /etc/bitwarden/core
mkdir -p /etc/bitwarden/logs
mkdir -p /etc/bitwarden/ca-certificates
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \
&& update-ca-certificates
exec gosu $USERNAME:$GROUPNAME dotnet /app/Scim.dll

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,8 @@
"internalIdentity": "http://localhost:33656",
"internalApi": "http://localhost:4000",
"internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822"
"internalSso": "http://localhost:51822",
"internalScim": "http://localhost:44559"
},
"events": {
"connectionString": "UseDevelopmentStorage=true"

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.bitwarden.com",
"internalApi": "https://api.bitwarden.com",
"internalVault": "https://vault.bitwarden.com",
"internalSso": "https://sso.bitwarden.com"
"internalSso": "https://sso.bitwarden.com",
"internalScim": "https://scim.bitwarden.com"
},
"braintree": {
"production": true

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.qa.bitwarden.pw",
"internalApi": "https://api.qa.bitwarden.pw",
"internalVault": "https://vault.qa.bitwarden.pw",
"internalSso": "https://sso.qa.bitwarden.pw"
"internalSso": "https://sso.qa.bitwarden.pw",
"internalScim": "https://scim.qa.bitwarden.pw"
},
"braintree": {
"production": false

View File

@ -12,7 +12,8 @@
"internalIdentity": null,
"internalApi": null,
"internalVault": null,
"internalSso": null
"internalSso": null,
"internalScim": null
}
}
}

View File

@ -25,6 +25,7 @@ $projects = @{
Identity = "../src/Identity"
Notifications = "../src/Notifications"
Sso = "../bitwarden_license/src/Sso"
Scim = "../bitwarden_license/src/Scim"
}
foreach ($key in $projects.keys) {

View File

@ -32,6 +32,7 @@ namespace Bit.Admin.Models
UsePolicies = org.UsePolicies;
UseSso = org.UseSso;
UseKeyConnector = org.UseKeyConnector;
UseScim = org.UseScim;
UseGroups = org.UseGroups;
UseDirectory = org.UseDirectory;
UseEvents = org.UseEvents;
@ -94,6 +95,8 @@ namespace Bit.Admin.Models
public bool UseApi { get; set; }
[Display(Name = "Reset Password")]
public bool UseResetPassword { get; set; }
[Display(Name = "SCIM")]
public bool UseScim { get; set; }
[Display(Name = "Self Host")]
public bool SelfHost { get; set; }
[Display(Name = "Users Get Premium")]
@ -126,6 +129,7 @@ namespace Bit.Admin.Models
existingOrganization.UsePolicies = UsePolicies;
existingOrganization.UseSso = UseSso;
existingOrganization.UseKeyConnector = UseKeyConnector;
existingOrganization.UseScim = UseScim;
existingOrganization.UseGroups = UseGroups;
existingOrganization.UseDirectory = UseDirectory;
existingOrganization.UseEvents = UseEvents;

View File

@ -32,6 +32,7 @@
document.getElementById('@(nameof(Model.UseApi))').checked = true;
document.getElementById('@(nameof(Model.SelfHost))').checked = false;
document.getElementById('@(nameof(Model.UseResetPassword))').checked = false;
document.getElementById('@(nameof(Model.UseScim))').checked = false;
// Licensing
document.getElementById('@(nameof(Model.LicenseKey))').value = '@Model.RandomLicenseKey';
document.getElementById('@(nameof(Model.ExpirationDate))').value = '@Model.FourteenDayExpirationDate';
@ -65,6 +66,7 @@
document.getElementById('@(nameof(Model.UseApi))').checked = true;
document.getElementById('@(nameof(Model.SelfHost))').checked = true;
document.getElementById('@(nameof(Model.UseResetPassword))').checked = true;
document.getElementById('@(nameof(Model.UseScim))').checked = true;
// Licensing
document.getElementById('@(nameof(Model.LicenseKey))').value = '@Model.RandomLicenseKey';
document.getElementById('@(nameof(Model.ExpirationDate))').value = '@Model.FourteenDayExpirationDate';
@ -219,6 +221,10 @@
<input type="checkbox" class="form-check-input" asp-for="UseKeyConnector">
<label class="form-check-label" asp-for="UseKeyConnector"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseScim">
<label class="form-check-label" asp-for="UseScim"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseDirectory">
<label class="form-check-label" asp-for="UseDirectory"></label>

View File

@ -12,7 +12,8 @@
"internalIdentity": "http://localhost:33656",
"internalApi": "http://localhost:4000",
"internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822"
"internalSso": "http://localhost:51822",
"internalScim": "http://localhost:44559"
},
"mail": {
"smtp": {

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.bitwarden.com",
"internalApi": "https://api.bitwarden.com",
"internalVault": "https://vault.bitwarden.com",
"internalSso": "https://sso.bitwarden.com"
"internalSso": "https://sso.bitwarden.com",
"internalScim": "https://scim.bitwarden.com"
},
"braintree": {
"production": true

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.qa.bitwarden.pw",
"internalApi": "https://api.qa.bitwarden.pw",
"internalVault": "https://vault.qa.bitwarden.pw",
"internalSso": "https://sso.qa.bitwarden.pw"
"internalSso": "https://sso.qa.bitwarden.pw",
"internalScim": "https://scim.qa.bitwarden.pw"
},
"braintree": {
"production": false

View File

@ -12,7 +12,8 @@
"internalIdentity": null,
"internalApi": null,
"internalVault": null,
"internalSso": null
"internalSso": null,
"internalScim": null
}
}
}

View File

@ -9,13 +9,11 @@ using Bit.Core.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Controllers
{
[SelfHosted(SelfHostedOnly = true)]
[Authorize("Application")]
[Route("organizations/connections")]
public class OrganizationConnectionsController : Controller
@ -57,10 +55,10 @@ namespace Bit.Api.Controllers
{
if (!await HasPermissionAsync(model?.OrganizationId))
{
throw new BadRequestException("Only the owner of an organization can create a connection.");
throw new BadRequestException($"You do not have permission to create a connection of type {model.Type}.");
}
if (await HasConnectionTypeAsync(model))
if (await HasConnectionTypeAsync(model, null, model.Type))
{
throw new BadRequestException($"The requested organization already has a connection of type {model.Type}. Only one of each connection type may exist per organization.");
}
@ -68,15 +66,9 @@ namespace Bit.Api.Controllers
switch (model.Type)
{
case OrganizationConnectionType.CloudBillingSync:
var typedModel = new OrganizationConnectionRequestModel<BillingSyncConfig>(model);
var license = await _licensingService.ReadOrganizationLicenseAsync(model.OrganizationId);
if (!_licensingService.VerifyLicense(license))
{
throw new BadRequestException("Cannot verify license file.");
}
typedModel.ParsedConfig.CloudOrganizationId = license.Id;
var connection = await _createOrganizationConnectionCommand.CreateAsync(typedModel.ToData());
return new OrganizationConnectionResponseModel(connection, typeof(BillingSyncConfig));
return await CreateOrUpdateOrganizationConnectionAsync<BillingSyncConfig>(null, model, ValidateBillingSyncConfig);
case OrganizationConnectionType.Scim:
return await CreateOrUpdateOrganizationConnectionAsync<ScimConfig>(null, model);
default:
throw new BadRequestException($"Unknown Organization connection Type: {model.Type}");
}
@ -91,12 +83,12 @@ namespace Bit.Api.Controllers
throw new NotFoundException();
}
if (!await HasPermissionAsync(model?.OrganizationId))
if (!await HasPermissionAsync(model?.OrganizationId, model?.Type))
{
throw new BadRequestException("Only the owner of an organization can update a connection.");
throw new BadRequestException("You do not have permission to update this connection.");
}
if (await HasConnectionTypeAsync(model, organizationConnectionId))
if (await HasConnectionTypeAsync(model, organizationConnectionId, model.Type))
{
throw new BadRequestException($"The requested organization already has a connection of type {model.Type}. Only one of each connection type may exist per organization.");
}
@ -104,11 +96,9 @@ namespace Bit.Api.Controllers
switch (model.Type)
{
case OrganizationConnectionType.CloudBillingSync:
var typedModel = new OrganizationConnectionRequestModel<BillingSyncConfig>(model);
// We don't allow overwriting or changing the CloudOrganizationId so save it from the existing connection
typedModel.ParsedConfig.CloudOrganizationId = existingOrganizationConnection.GetConfig<BillingSyncConfig>().CloudOrganizationId;
var connection = await _updateOrganizationConnectionCommand.UpdateAsync(typedModel.ToData(organizationConnectionId));
return new OrganizationConnectionResponseModel(connection, typeof(BillingSyncConfig));
return await CreateOrUpdateOrganizationConnectionAsync<BillingSyncConfig>(organizationConnectionId, model);
case OrganizationConnectionType.Scim:
return await CreateOrUpdateOrganizationConnectionAsync<ScimConfig>(organizationConnectionId, model);
default:
throw new BadRequestException($"Unkown Organization connection Type: {model.Type}");
}
@ -117,22 +107,27 @@ namespace Bit.Api.Controllers
[HttpGet("{organizationId}/{type}")]
public async Task<OrganizationConnectionResponseModel> GetConnection(Guid organizationId, OrganizationConnectionType type)
{
if (!await HasPermissionAsync(organizationId))
if (!await HasPermissionAsync(organizationId, type))
{
throw new BadRequestException("Only the owner of an organization can retrieve a connection.");
throw new BadRequestException($"You do not have permission to retrieve a connection of type {type}.");
}
var connections = await GetConnectionsAsync(organizationId);
var connections = await GetConnectionsAsync(organizationId, type);
var connection = connections.FirstOrDefault(c => c.Type == type);
switch (type)
{
case OrganizationConnectionType.CloudBillingSync:
if (!_globalSettings.SelfHosted)
{
throw new BadRequestException($"Cannot get a {type} connection outside of a self-hosted instance.");
}
return new OrganizationConnectionResponseModel(connection, typeof(BillingSyncConfig));
case OrganizationConnectionType.Scim:
return new OrganizationConnectionResponseModel(connection, typeof(ScimConfig));
default:
throw new BadRequestException($"Unkown Organization connection Type: {type}");
}
}
[HttpDelete("{organizationConnectionId}")]
@ -146,25 +141,70 @@ namespace Bit.Api.Controllers
throw new NotFoundException();
}
if (!await HasPermissionAsync(connection.OrganizationId))
if (!await HasPermissionAsync(connection.OrganizationId, connection.Type))
{
throw new BadRequestException("Only the owner of an organization can remove a connection.");
throw new BadRequestException($"You do not have permission to remove this connection of type {connection.Type}.");
}
await _deleteOrganizationConnectionCommand.DeleteAsync(connection);
}
private async Task<ICollection<OrganizationConnection>> GetConnectionsAsync(Guid organizationId) =>
await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organizationId, OrganizationConnectionType.CloudBillingSync);
private async Task<ICollection<OrganizationConnection>> GetConnectionsAsync(Guid organizationId, OrganizationConnectionType type) =>
await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organizationId, type);
private async Task<bool> HasConnectionTypeAsync(OrganizationConnectionRequestModel model, Guid? connectionId = null)
private async Task<bool> HasConnectionTypeAsync(OrganizationConnectionRequestModel model, Guid? connectionId,
OrganizationConnectionType type)
{
var existingConnections = await GetConnectionsAsync(model.OrganizationId);
var existingConnections = await GetConnectionsAsync(model.OrganizationId, type);
return existingConnections.Any(c => c.Type == model.Type && (!connectionId.HasValue || c.Id != connectionId.Value));
}
private async Task<bool> HasPermissionAsync(Guid? organizationId) =>
organizationId.HasValue && await _currentContext.OrganizationOwner(organizationId.Value);
private async Task<bool> HasPermissionAsync(Guid? organizationId, OrganizationConnectionType? type = null)
{
if (!organizationId.HasValue)
{
return false;
}
return type switch
{
OrganizationConnectionType.Scim => await _currentContext.ManageScim(organizationId.Value),
_ => await _currentContext.OrganizationOwner(organizationId.Value),
};
}
private async Task ValidateBillingSyncConfig(OrganizationConnectionRequestModel<BillingSyncConfig> typedModel)
{
if (!_globalSettings.SelfHosted)
{
throw new BadRequestException($"Cannot create a {typedModel.Type} connection outside of a self-hosted instance.");
}
var license = await _licensingService.ReadOrganizationLicenseAsync(typedModel.OrganizationId);
if (!_licensingService.VerifyLicense(license))
{
throw new BadRequestException("Cannot verify license file.");
}
typedModel.ParsedConfig.CloudOrganizationId = license.Id;
}
private async Task<OrganizationConnectionResponseModel> CreateOrUpdateOrganizationConnectionAsync<T>(
Guid? organizationConnectionId,
OrganizationConnectionRequestModel model,
Func<OrganizationConnectionRequestModel<T>, Task> validateAction = null)
where T : new()
{
var typedModel = new OrganizationConnectionRequestModel<T>(model);
if (validateAction != null)
{
await validateAction(typedModel);
}
var data = typedModel.ToData(organizationConnectionId);
var connection = organizationConnectionId.HasValue
? await _updateOrganizationConnectionCommand.UpdateAsync(data)
: await _createOrganizationConnectionCommand.CreateAsync(data);
return new OrganizationConnectionResponseModel(connection, typeof(T));
}
}
}

View File

@ -493,7 +493,7 @@ namespace Bit.Api.Controllers
public async Task<ApiKeyResponseModel> ApiKey(string id, [FromBody] OrganizationApiKeyRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.OrganizationOwner(orgIdGuid))
if (!await HasApiKeyAccessAsync(orgIdGuid, model.Type))
{
throw new NotFoundException();
}
@ -504,9 +504,9 @@ namespace Bit.Api.Controllers
throw new NotFoundException();
}
if (model.Type == OrganizationApiKeyType.BillingSync)
if (model.Type == OrganizationApiKeyType.BillingSync || model.Type == OrganizationApiKeyType.Scim)
{
// Non-enterprise orgs should not be able to create or view an apikey of billing sync key type
// Non-enterprise orgs should not be able to create or view an apikey of billing sync/scim key types
var plan = StaticStore.GetPlan(organization.PlanType);
if (plan.Product != ProductType.Enterprise)
{
@ -523,7 +523,8 @@ namespace Bit.Api.Controllers
throw new UnauthorizedAccessException();
}
if (!await _userService.VerifySecretAsync(user, model.Secret))
if (model.Type != OrganizationApiKeyType.Scim
&& !await _userService.VerifySecretAsync(user, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
@ -535,15 +536,15 @@ namespace Bit.Api.Controllers
}
}
[HttpGet("{id}/api-key-information")]
public async Task<ListResponseModel<OrganizationApiKeyInformation>> ApiKeyInformation(Guid id)
[HttpGet("{id}/api-key-information/{type?}")]
public async Task<ListResponseModel<OrganizationApiKeyInformation>> ApiKeyInformation(Guid id, OrganizationApiKeyType? type)
{
if (!await _currentContext.OrganizationOwner(id))
if (!await HasApiKeyAccessAsync(id, type))
{
throw new NotFoundException();
}
var apiKeys = await _organizationApiKeyRepository.GetManyByOrganizationIdTypeAsync(id);
var apiKeys = await _organizationApiKeyRepository.GetManyByOrganizationIdTypeAsync(id, type);
return new ListResponseModel<OrganizationApiKeyInformation>(
apiKeys.Select(k => new OrganizationApiKeyInformation(k)));
@ -553,7 +554,7 @@ namespace Bit.Api.Controllers
public async Task<ApiKeyResponseModel> RotateApiKey(string id, [FromBody] OrganizationApiKeyRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.OrganizationOwner(orgIdGuid))
if (!await HasApiKeyAccessAsync(orgIdGuid, model.Type))
{
throw new NotFoundException();
}
@ -573,7 +574,8 @@ namespace Bit.Api.Controllers
throw new UnauthorizedAccessException();
}
if (!await _userService.VerifySecretAsync(user, model.Secret))
if (model.Type != OrganizationApiKeyType.Scim
&& !await _userService.VerifySecretAsync(user, model.Secret))
{
await Task.Delay(2000);
throw new BadRequestException("MasterPasswordHash", "Invalid password.");
@ -586,6 +588,15 @@ namespace Bit.Api.Controllers
}
}
private async Task<bool> HasApiKeyAccessAsync(Guid orgId, OrganizationApiKeyType? type)
{
return type switch
{
OrganizationApiKeyType.Scim => await _currentContext.ManageScim(orgId),
_ => await _currentContext.OrganizationOwner(orgId),
};
}
[HttpGet("{id}/tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<TaxInfoResponseModel> GetTaxInfo(string id)

View File

@ -35,6 +35,7 @@ namespace Bit.Api.Models.Response.Organizations
UsePolicies = organization.UsePolicies;
UseSso = organization.UseSso;
UseKeyConnector = organization.UseKeyConnector;
UseScim = organization.UseScim;
UseGroups = organization.UseGroups;
UseDirectory = organization.UseDirectory;
UseEvents = organization.UseEvents;
@ -66,6 +67,7 @@ namespace Bit.Api.Models.Response.Organizations
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseGroups { get; set; }
public bool UseDirectory { get; set; }
public bool UseEvents { get; set; }

View File

@ -17,6 +17,7 @@ namespace Bit.Api.Models.Response
UsePolicies = organization.UsePolicies;
UseSso = organization.UseSso;
UseKeyConnector = organization.UseKeyConnector;
UseScim = organization.UseScim;
UseGroups = organization.UseGroups;
UseDirectory = organization.UseDirectory;
UseEvents = organization.UseEvents;
@ -63,6 +64,7 @@ namespace Bit.Api.Models.Response
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseGroups { get; set; }
public bool UseDirectory { get; set; }
public bool UseEvents { get; set; }

View File

@ -13,6 +13,7 @@ namespace Bit.Api.Models.Response
UsePolicies = organization.UsePolicies;
UseSso = organization.UseSso;
UseKeyConnector = organization.UseKeyConnector;
UseScim = organization.UseScim;
UseGroups = organization.UseGroups;
UseDirectory = organization.UseDirectory;
UseEvents = organization.UseEvents;

View File

@ -12,7 +12,8 @@
"internalIdentity": "http://localhost:33656",
"internalApi": "http://localhost:4000",
"internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822"
"internalSso": "http://localhost:51822",
"internalScim": "http://localhost:44559"
},
"mail": {
"smtp": {

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.bitwarden.com",
"internalApi": "https://api.bitwarden.com",
"internalVault": "https://vault.bitwarden.com",
"internalSso": "https://sso.bitwarden.com"
"internalSso": "https://sso.bitwarden.com",
"internalScim": "https://scim.bitwarden.com"
},
"braintree": {
"production": true

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.qa.bitwarden.pw",
"internalApi": "https://api.qa.bitwarden.pw",
"internalVault": "https://vault.qa.bitwarden.pw",
"internalSso": "https://sso.qa.bitwarden.pw"
"internalSso": "https://sso.qa.bitwarden.pw",
"internalScim": "https://scim.qa.bitwarden.pw"
},
"braintree": {
"production": false

View File

@ -12,7 +12,8 @@
"internalIdentity": null,
"internalApi": null,
"internalVault": null,
"internalSso": null
"internalSso": null,
"internalScim": null
}
}
}

View File

@ -12,7 +12,8 @@
"internalIdentity": "http://localhost:33656",
"internalApi": "http://localhost:4000",
"internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822"
"internalSso": "http://localhost:51822",
"internalScim": "http://localhost:44559"
},
"mail": {
"smtp": {

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.bitwarden.com",
"internalApi": "https://api.bitwarden.com",
"internalVault": "https://vault.bitwarden.com",
"internalSso": "https://sso.bitwarden.com"
"internalSso": "https://sso.bitwarden.com",
"internalScim": "https://scim.bitwarden.com"
},
"braintree": {
"production": true

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.qa.bitwarden.pw",
"internalApi": "https://api.qa.bitwarden.pw",
"internalVault": "https://vault.qa.bitwarden.pw",
"internalSso": "https://sso.qa.bitwarden.pw"
"internalSso": "https://sso.qa.bitwarden.pw",
"internalScim": "https://scim.qa.bitwarden.pw"
},
"braintree": {
"production": false

View File

@ -344,6 +344,12 @@ namespace Bit.Core.Context
&& (o.Permissions?.ManageSso ?? false)) ?? false);
}
public async Task<bool> ManageScim(Guid orgId)
{
return await OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId
&& (o.Permissions?.ManageScim ?? false)) ?? false);
}
public async Task<bool> ManageUsers(Guid orgId)
{
return await OrganizationAdmin(orgId) || (Organizations?.Any(o => o.Id == orgId
@ -469,7 +475,8 @@ namespace Bit.Core.Context
ManagePolicies = hasClaim("managepolicies"),
ManageSso = hasClaim("managesso"),
ManageUsers = hasClaim("manageusers"),
ManageResetPassword = hasClaim("manageresetpassword")
ManageResetPassword = hasClaim("manageresetpassword"),
ManageScim = hasClaim("managescim"),
};
}

View File

@ -47,6 +47,7 @@ namespace Bit.Core.Context
Task<bool> ManagePolicies(Guid orgId);
Task<bool> ManageSso(Guid orgId);
Task<bool> ManageUsers(Guid orgId);
Task<bool> ManageScim(Guid orgId);
Task<bool> ManageResetPassword(Guid orgId);
Task<bool> ManageBilling(Guid orgId);
Task<bool> ProviderUserForOrgAsync(Guid orgId);

View File

@ -37,6 +37,7 @@ namespace Bit.Core.Entities
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseGroups { get; set; }
public bool UseDirectory { get; set; }
public bool UseEvents { get; set; }

View File

@ -2,7 +2,8 @@
{
public enum OrganizationApiKeyType : byte
{
Default,
BillingSync,
Default = 0,
BillingSync = 1,
Scim = 2,
}
}

View File

@ -3,5 +3,6 @@
public enum OrganizationConnectionType : byte
{
CloudBillingSync = 1,
Scim = 2,
}
}

View File

@ -0,0 +1,13 @@
namespace Bit.Core.Enums
{
public enum ScimProviderType : byte
{
Default = 0,
AzureAd = 1,
Okta = 2,
OneLogin = 3,
JumpCloud = 4,
GoogleWorkspace = 5,
Rippling = 6,
}
}

View File

@ -34,6 +34,7 @@ namespace Bit.Core.Models.Business
UsePolicies = org.UsePolicies;
UseSso = org.UseSso;
UseKeyConnector = org.UseKeyConnector;
UseScim = org.UseScim;
UseGroups = org.UseGroups;
UseEvents = org.UseEvents;
UseDirectory = org.UseDirectory;
@ -105,6 +106,7 @@ namespace Bit.Core.Models.Business
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseGroups { get; set; }
public bool UseEvents { get; set; }
public bool UseDirectory { get; set; }
@ -129,10 +131,10 @@ namespace Bit.Core.Models.Business
/// <summary>
/// Represents the current version of the license format. Should be updated whenever new fields are added.
/// </summary>
private const int CURRENT_LICENSE_FILE_VERSION = 8;
private const int CURRENT_LICENSE_FILE_VERSION = 10;
private bool ValidLicenseVersion
{
get => Version is >= 1 and <= 9;
get => Version is >= 1 and <= 10;
}
public byte[] GetDataBytes(bool forHash = false)
@ -162,6 +164,8 @@ namespace Bit.Core.Models.Business
(Version >= 8 || !p.Name.Equals(nameof(UseResetPassword))) &&
// UseKeyConnector was added in Version 9
(Version >= 9 || !p.Name.Equals(nameof(UseKeyConnector))) &&
// UseScim was added in Version 10
(Version >= 10 || !p.Name.Equals(nameof(UseScim))) &&
(
!forHash ||
(
@ -270,6 +274,11 @@ namespace Bit.Core.Models.Business
valid = organization.UseKeyConnector == UseKeyConnector;
}
if (valid && Version >= 10)
{
valid = organization.UseScim == UseScim;
}
return valid;
}
else

View File

@ -17,6 +17,7 @@ namespace Bit.Core.Models.Data.Organizations
Enabled = organization.Enabled;
UseSso = organization.UseSso;
UseKeyConnector = organization.UseKeyConnector;
UseScim = organization.UseScim;
UseResetPassword = organization.UseResetPassword;
}
@ -28,6 +29,7 @@ namespace Bit.Core.Models.Data.Organizations
public bool Enabled { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseResetPassword { get; set; }
}
}

View File

@ -8,6 +8,7 @@
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseGroups { get; set; }
public bool UseDirectory { get; set; }
public bool UseEvents { get; set; }

View File

@ -21,6 +21,7 @@ namespace Bit.Core.Models.Data
public bool ManageSso { get; set; }
public bool ManageUsers { get; set; }
public bool ManageResetPassword { get; set; }
public bool ManageScim { get; set; }
[JsonIgnore]
public List<(bool Permission, string ClaimName)> ClaimsMap => new()
@ -38,6 +39,7 @@ namespace Bit.Core.Models.Data
(ManageSso, "managesso"),
(ManageUsers, "manageusers"),
(ManageResetPassword, "manageresetpassword"),
(ManageScim, "managescim"),
};
}
}

View File

@ -10,6 +10,7 @@ namespace Bit.Core.Models.Data
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseGroups { get; set; }
public bool UseDirectory { get; set; }
public bool UseEvents { get; set; }

View File

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
using Bit.Core.Enums;
namespace Bit.Core.Models.OrganizationConnectionConfigs
{
public class ScimConfig
{
public bool Enabled { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ScimProviderType? ScimProvider { get; set; }
}
}

View File

@ -34,6 +34,7 @@ namespace Bit.Core.Models.StaticStore
public bool HasApi { get; set; }
public bool HasSso { get; set; }
public bool HasKeyConnector { get; set; }
public bool HasScim { get; set; }
public bool HasResetPassword { get; set; }
public bool UsersGetPremium { get; set; }

View File

@ -6,6 +6,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.Policies;
using Bit.Core.Models.OrganizationConnectionConfigs;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
@ -39,6 +40,7 @@ namespace Bit.Core.Services
private readonly IGlobalSettings _globalSettings;
private readonly ITaxRateRepository _taxRateRepository;
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
private readonly IOrganizationConnectionRepository _organizationConnectionRepository;
private readonly ICurrentContext _currentContext;
private readonly ILogger<OrganizationService> _logger;
@ -66,6 +68,7 @@ namespace Bit.Core.Services
IGlobalSettings globalSettings,
ITaxRateRepository taxRateRepository,
IOrganizationApiKeyRepository organizationApiKeyRepository,
IOrganizationConnectionRepository organizationConnectionRepository,
ICurrentContext currentContext,
ILogger<OrganizationService> logger)
{
@ -91,6 +94,7 @@ namespace Bit.Core.Services
_globalSettings = globalSettings;
_taxRateRepository = taxRateRepository;
_organizationApiKeyRepository = organizationApiKeyRepository;
_organizationConnectionRepository = organizationConnectionRepository;
_currentContext = currentContext;
_logger = logger;
}
@ -266,6 +270,17 @@ namespace Bit.Core.Services
}
}
if (!newPlan.HasScim && organization.UseScim)
{
var scimConnections = await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organization.Id,
OrganizationConnectionType.Scim);
if (scimConnections != null && scimConnections.Any(c => c.GetConfig<ScimConfig>()?.Enabled == true))
{
throw new BadRequestException("Your new plan does not allow the SCIM feature. " +
"Disable your SCIM configuration.");
}
}
// TODO: Check storage?
string paymentIntentClientSecret = null;
@ -304,6 +319,7 @@ namespace Bit.Core.Services
organization.UseApi = newPlan.HasApi;
organization.UseSso = newPlan.HasSso;
organization.UseKeyConnector = newPlan.HasKeyConnector;
organization.UseScim = newPlan.HasScim;
organization.UseResetPassword = newPlan.HasResetPassword;
organization.SelfHost = newPlan.HasSelfHost;
organization.UsersGetPremium = newPlan.UsersGetPremium || upgrade.PremiumAccessAddon;
@ -702,6 +718,7 @@ namespace Bit.Core.Services
UsePolicies = license.UsePolicies,
UseSso = license.UseSso,
UseKeyConnector = license.UseKeyConnector,
UseScim = license.UseScim,
UseGroups = license.UseGroups,
UseDirectory = license.UseDirectory,
UseEvents = license.UseEvents,
@ -902,6 +919,17 @@ namespace Bit.Core.Services
}
}
if (!license.UseScim && organization.UseScim)
{
var scimConnections = await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organization.Id,
OrganizationConnectionType.Scim);
if (scimConnections != null && scimConnections.Any(c => c.GetConfig<ScimConfig>()?.Enabled == true))
{
throw new BadRequestException("Your new plan does not allow the SCIM feature. " +
"Disable your SCIM configuration.");
}
}
if (!license.UseResetPassword && organization.UseResetPassword)
{
var resetPasswordPolicy =
@ -933,6 +961,7 @@ namespace Bit.Core.Services
organization.UsePolicies = license.UsePolicies;
organization.UseSso = license.UseSso;
organization.UseKeyConnector = license.UseKeyConnector;
organization.UseScim = license.UseScim;
organization.UseResetPassword = license.UseResetPassword;
organization.SelfHost = license.SelfHost;
organization.UsersGetPremium = license.UsersGetPremium;

View File

@ -117,12 +117,14 @@
private string _admin;
private string _notifications;
private string _sso;
private string _scim;
private string _internalApi;
private string _internalIdentity;
private string _internalAdmin;
private string _internalNotifications;
private string _internalSso;
private string _internalVault;
private string _internalScim;
public BaseServiceUriSettings(GlobalSettings globalSettings)
{
@ -157,6 +159,11 @@
get => _globalSettings.BuildExternalUri(_sso, "sso");
set => _sso = value;
}
public string Scim
{
get => _globalSettings.BuildExternalUri(_scim, "scim");
set => _scim = value;
}
public string InternalNotifications
{
@ -188,6 +195,11 @@
get => _globalSettings.BuildInternalUri(_internalSso, "sso");
set => _internalSso = value;
}
public string InternalScim
{
get => _globalSettings.BuildInternalUri(_scim, "scim");
set => _internalScim = value;
}
}
public class SqlSettings

View File

@ -10,11 +10,13 @@ namespace Bit.Core.Settings
public string Admin { get; set; }
public string Notifications { get; set; }
public string Sso { get; set; }
public string Scim { get; set; }
public string InternalNotifications { get; set; }
public string InternalAdmin { get; set; }
public string InternalIdentity { get; set; }
public string InternalApi { get; set; }
public string InternalVault { get; set; }
public string InternalSso { get; set; }
public string InternalScim { get; set; }
}
}

View File

@ -414,6 +414,7 @@ namespace Bit.Core.Utilities
HasApi = true,
HasSso = true,
HasKeyConnector = true,
HasScim = true,
HasResetPassword = true,
UsersGetPremium = true,
@ -453,6 +454,7 @@ namespace Bit.Core.Utilities
HasSelfHost = true,
HasSso = true,
HasKeyConnector = true,
HasScim = true,
HasResetPassword = true,
UsersGetPremium = true,

View File

@ -12,7 +12,8 @@
"internalIdentity": "http://localhost:33656",
"internalApi": "http://localhost:4000",
"internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822"
"internalSso": "http://localhost:51822",
"internalScim": "http://localhost:44559"
},
"events": {
"connectionString": "UseDevelopmentStorage=true"

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.bitwarden.com",
"internalApi": "https://api.bitwarden.com",
"internalVault": "https://vault.bitwarden.com",
"internalSso": "https://sso.bitwarden.com"
"internalSso": "https://sso.bitwarden.com",
"internalScim": "https://scim.bitwarden.com"
}
},
"Logging": {

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.qa.bitwarden.pw",
"internalApi": "https://api.qa.bitwarden.pw",
"internalVault": "https://vault.qa.bitwarden.pw",
"internalSso": "https://sso.qa.bitwarden.pw"
"internalSso": "https://sso.qa.bitwarden.pw",
"internalScim": "https://scim.qa.bitwarden.pw"
}
},
"Logging": {

View File

@ -12,7 +12,8 @@
"internalIdentity": null,
"internalApi": null,
"internalVault": null,
"internalSso": null
"internalSso": null,
"internalScim": null
}
}
}

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.bitwarden.com",
"internalApi": "https://api.bitwarden.com",
"internalVault": "https://vault.bitwarden.com",
"internalSso": "https://sso.bitwarden.com"
"internalSso": "https://sso.bitwarden.com",
"internalScim": "https://scim.bitwarden.com"
},
"braintree": {
"production": true

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.qa.bitwarden.pw",
"internalApi": "https://api.qa.bitwarden.pw",
"internalVault": "https://vault.qa.bitwarden.pw",
"internalSso": "https://sso.qa.bitwarden.pw"
"internalSso": "https://sso.qa.bitwarden.pw",
"internalScim": "https://scim.qa.bitwarden.pw"
},
"braintree": {
"production": false

View File

@ -12,7 +12,8 @@
"internalIdentity": null,
"internalApi": null,
"internalVault": null,
"internalSso": null
"internalSso": null,
"internalScim": null
},
"captcha": {
"maximumFailedLoginAttempts": 0

View File

@ -23,6 +23,8 @@ namespace Bit.Infrastructure.EntityFramework
if (provider == SupportedDatabaseProviders.Postgres)
{
options.UseNpgsql(connectionString);
// Handle NpgSql Legacy Support for `timestamp without timezone` issue
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
}
else if (provider == SupportedDatabaseProviders.MySql)
{

View File

@ -83,6 +83,8 @@ namespace Bit.Infrastructure.EntityFramework.Repositories
Using2fa = e.Use2fa && e.TwoFactorProviders != null,
UseSso = e.UseSso,
UseKeyConnector = e.UseKeyConnector,
UseResetPassword = e.UseResetPassword,
UseScim = e.UseScim,
}).ToListAsync();
}
}

View File

@ -31,6 +31,7 @@ namespace Bit.Infrastructure.EntityFramework.Repositories.Queries
UsePolicies = x.o.UsePolicies,
UseSso = x.o.UseSso,
UseKeyConnector = x.o.UseKeyConnector,
UseScim = x.o.UseScim,
UseGroups = x.o.UseGroups,
UseDirectory = x.o.UseDirectory,
UseEvents = x.o.UseEvents,

View File

@ -20,6 +20,7 @@ namespace Bit.Infrastructure.EntityFramework.Repositories.Queries
UsePolicies = x.o.UsePolicies,
UseSso = x.o.UseSso,
UseKeyConnector = x.o.UseKeyConnector,
UseScim = x.o.UseScim,
UseGroups = x.o.UseGroups,
UseDirectory = x.o.UseDirectory,
UseEvents = x.o.UseEvents,

View File

@ -12,7 +12,8 @@
"internalIdentity": "http://localhost:33656",
"internalApi": "http://localhost:4000",
"internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822"
"internalSso": "http://localhost:51822",
"internalScim": "http://localhost:44559"
},
"notifications": {
"connectionString": "UseDevelopmentStorage=true"

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.bitwarden.com",
"internalApi": "https://api.bitwarden.com",
"internalVault": "https://vault.bitwarden.com",
"internalSso": "https://sso.bitwarden.com"
"internalSso": "https://sso.bitwarden.com",
"internalScim": "https://scim.bitwarden.com"
}
},
"Logging": {

View File

@ -12,7 +12,8 @@
"internalIdentity": "https://identity.qa.bitwarden.pw",
"internalApi": "https://api.qa.bitwarden.pw",
"internalVault": "https://vault.qa.bitwarden.pw",
"internalSso": "https://sso.qa.bitwarden.pw"
"internalSso": "https://sso.qa.bitwarden.pw",
"internalScim": "https://scim.qa.bitwarden.pw"
}
},
"Logging": {

View File

@ -12,7 +12,8 @@
"internalIdentity": null,
"internalApi": null,
"internalVault": null,
"internalSso": null
"internalSso": null,
"internalScim": null
}
}
}

View File

@ -40,7 +40,8 @@
@RevisionDate DATETIME2(7),
@OwnersNotifiedOfAutoscaling DATETIME2(7),
@MaxAutoscaleSeats INT,
@UseKeyConnector BIT = 0
@UseKeyConnector BIT = 0,
@UseScim BIT = 0
AS
BEGIN
SET NOCOUNT ON
@ -88,7 +89,8 @@ BEGIN
[RevisionDate],
[OwnersNotifiedOfAutoscaling],
[MaxAutoscaleSeats],
[UseKeyConnector]
[UseKeyConnector],
[UseScim]
)
VALUES
(
@ -133,6 +135,7 @@ BEGIN
@RevisionDate,
@OwnersNotifiedOfAutoscaling,
@MaxAutoscaleSeats,
@UseKeyConnector
@UseKeyConnector,
@UseScim
)
END

View File

@ -16,6 +16,7 @@ BEGIN
[UsersGetPremium],
[UseSso],
[UseKeyConnector],
[UseScim],
[UseResetPassword],
[Enabled]
FROM

View File

@ -40,7 +40,8 @@
@RevisionDate DATETIME2(7),
@OwnersNotifiedOfAutoscaling DATETIME2(7),
@MaxAutoscaleSeats INT,
@UseKeyConnector BIT = 0
@UseKeyConnector BIT = 0,
@UseScim BIT = 0
AS
BEGIN
SET NOCOUNT ON
@ -88,7 +89,8 @@ BEGIN
[RevisionDate] = @RevisionDate,
[OwnersNotifiedOfAutoscaling] = @OwnersNotifiedOfAutoscaling,
[MaxAutoscaleSeats] = @MaxAutoscaleSeats,
[UseKeyConnector] = @UseKeyConnector
[UseKeyConnector] = @UseKeyConnector,
[UseScim] = @UseScim
WHERE
[Id] = @Id
END

View File

@ -41,6 +41,7 @@
[OwnersNotifiedOfAutoscaling] DATETIME2(7) NULL,
[MaxAutoscaleSeats] INT NULL,
[UseKeyConnector] BIT NOT NULL,
[UseScim] BIT NOT NULL CONSTRAINT [DF_Organization_UseScim] DEFAULT (0),
CONSTRAINT [PK_Organization] PRIMARY KEY CLUSTERED ([Id] ASC)
);

View File

@ -8,6 +8,7 @@ SELECT
O.[UsePolicies],
O.[UseSso],
O.[UseKeyConnector],
O.[UseScim],
O.[UseGroups],
O.[UseDirectory],
O.[UseEvents],

View File

@ -8,6 +8,7 @@ SELECT
O.[UsePolicies],
O.[UseSso],
O.[UseKeyConnector],
O.[UseScim],
O.[UseGroups],
O.[UseDirectory],
O.[UseEvents],

View File

@ -50,11 +50,15 @@ namespace Bit.Api.Test.Controllers
[Theory]
[BitAutoData]
public async Task CreateConnection_RequiresOwnerPermissions(SutProvider<OrganizationConnectionsController> sutProvider)
public async Task CreateConnection_CloudBillingSync_RequiresOwnerPermissions(SutProvider<OrganizationConnectionsController> sutProvider)
{
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.CreateConnection(null));
var model = new OrganizationConnectionRequestModel
{
Type = OrganizationConnectionType.CloudBillingSync,
};
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.CreateConnection(model));
Assert.Contains("Only the owner of an organization can create a connection.", exception.Message);
Assert.Contains($"You do not have permission to create a connection of type", exception.Message);
}
[Theory]
@ -116,6 +120,7 @@ namespace Bit.Api.Test.Controllers
var typedModel = new OrganizationConnectionRequestModel<BillingSyncConfig>(model);
typedModel.ParsedConfig.CloudOrganizationId = cloudOrgId;
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true);
sutProvider.GetDependency<ICreateOrganizationConnectionCommand>().CreateAsync<BillingSyncConfig>(default)
.ReturnsForAnyArgs(typedModel.ToData(Guid.NewGuid()).ToEntity());
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(model.OrganizationId).Returns(true);
@ -143,17 +148,40 @@ namespace Bit.Api.Test.Controllers
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateConnection(default, null));
Assert.Contains("Only the owner of an organization can update a connection.", exception.Message);
Assert.Contains("You do not have permission to update this connection.", exception.Message);
}
[Theory]
[BitMemberAutoData(nameof(ConnectionTypes))]
public async Task UpdateConnection_OnlyOneConnectionOfEachType(OrganizationConnectionType type,
[BitAutoData(OrganizationConnectionType.CloudBillingSync)]
public async Task UpdateConnection_BillingSync_OnlyOneConnectionOfEachType(OrganizationConnectionType type,
OrganizationConnection existing1, OrganizationConnection existing2, BillingSyncConfig config,
SutProvider<OrganizationConnectionsController> sutProvider)
{
existing1.Type = existing2.Type = type;
existing1.Config = JsonSerializer.Serialize(config);
var typedModel = RequestModelFromEntity(existing1);
var typedModel = RequestModelFromEntity<BillingSyncConfig>(existing1);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(typedModel.OrganizationId).Returns(true);
var orgConnectionRepository = sutProvider.GetDependency<IOrganizationConnectionRepository>();
orgConnectionRepository.GetByIdAsync(existing1.Id).Returns(existing1);
orgConnectionRepository.GetByIdAsync(existing2.Id).Returns(existing2);
orgConnectionRepository.GetByOrganizationIdTypeAsync(typedModel.OrganizationId, type).Returns(new[] { existing1, existing2 });
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateConnection(existing1.Id, typedModel));
Assert.Contains($"The requested organization already has a connection of type {typedModel.Type}. Only one of each connection type may exist per organization.", exception.Message);
}
[Theory]
[BitAutoData(OrganizationConnectionType.Scim)]
public async Task UpdateConnection_Scim_OnlyOneConnectionOfEachType(OrganizationConnectionType type,
OrganizationConnection existing1, OrganizationConnection existing2, ScimConfig config,
SutProvider<OrganizationConnectionsController> sutProvider)
{
existing1.Type = existing2.Type = type;
existing1.Config = JsonSerializer.Serialize(config);
var typedModel = RequestModelFromEntity<ScimConfig>(existing1);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(typedModel.OrganizationId).Returns(true);
@ -161,7 +189,11 @@ namespace Bit.Api.Test.Controllers
.GetByIdAsync(existing1.Id)
.Returns(existing1);
sutProvider.GetDependency<IOrganizationConnectionRepository>().GetByOrganizationIdTypeAsync(typedModel.OrganizationId, type).Returns(new[] { existing1, existing2 });
sutProvider.GetDependency<ICurrentContext>().ManageScim(typedModel.OrganizationId).Returns(true);
sutProvider.GetDependency<IOrganizationConnectionRepository>()
.GetByOrganizationIdTypeAsync(typedModel.OrganizationId, type)
.Returns(new[] { existing1, existing2 });
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.UpdateConnection(existing1.Id, typedModel));
@ -180,11 +212,16 @@ namespace Bit.Api.Test.Controllers
});
updated.Config = JsonSerializer.Serialize(config);
updated.Id = existing.Id;
var model = RequestModelFromEntity(updated);
updated.Type = OrganizationConnectionType.CloudBillingSync;
var model = RequestModelFromEntity<BillingSyncConfig>(updated);
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(model.OrganizationId).Returns(true);
sutProvider.GetDependency<IOrganizationConnectionRepository>().GetByOrganizationIdTypeAsync(model.OrganizationId, model.Type).Returns(new[] { existing });
sutProvider.GetDependency<IUpdateOrganizationConnectionCommand>().UpdateAsync<BillingSyncConfig>(default).ReturnsForAnyArgs(updated);
sutProvider.GetDependency<IOrganizationConnectionRepository>()
.GetByOrganizationIdTypeAsync(model.OrganizationId, model.Type)
.Returns(new[] { existing });
sutProvider.GetDependency<IUpdateOrganizationConnectionCommand>()
.UpdateAsync<BillingSyncConfig>(default)
.ReturnsForAnyArgs(updated);
sutProvider.GetDependency<IOrganizationConnectionRepository>()
.GetByIdAsync(existing.Id)
.Returns(existing);
@ -211,7 +248,7 @@ namespace Bit.Api.Test.Controllers
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.GetConnection(connectionId, OrganizationConnectionType.CloudBillingSync));
Assert.Contains("Only the owner of an organization can retrieve a connection.", exception.Message);
Assert.Contains("You do not have permission to retrieve a connection of type", exception.Message);
}
[Theory]
@ -221,7 +258,10 @@ namespace Bit.Api.Test.Controllers
{
connection.Config = JsonSerializer.Serialize(config);
sutProvider.GetDependency<IOrganizationConnectionRepository>().GetByOrganizationIdTypeAsync(connection.OrganizationId, connection.Type).Returns(new[] { connection });
sutProvider.GetDependency<IGlobalSettings>().SelfHosted.Returns(true);
sutProvider.GetDependency<IOrganizationConnectionRepository>()
.GetByOrganizationIdTypeAsync(connection.OrganizationId, connection.Type)
.Returns(new[] { connection });
sutProvider.GetDependency<ICurrentContext>().OrganizationOwner(connection.OrganizationId).Returns(true);
var expected = new OrganizationConnectionResponseModel(connection, typeof(BillingSyncConfig));
@ -247,7 +287,7 @@ namespace Bit.Api.Test.Controllers
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.DeleteConnection(connection.Id));
Assert.Contains("Only the owner of an organization can remove a connection.", exception.Message);
Assert.Contains("You do not have permission to remove this connection of type", exception.Message);
}
[Theory]
@ -263,7 +303,8 @@ namespace Bit.Api.Test.Controllers
await sutProvider.GetDependency<IDeleteOrganizationConnectionCommand>().DeleteAsync(connection);
}
private static OrganizationConnectionRequestModel<BillingSyncConfig> RequestModelFromEntity(OrganizationConnection entity)
private static OrganizationConnectionRequestModel<T> RequestModelFromEntity<T>(OrganizationConnection entity)
where T : new()
{
return new(new OrganizationConnectionRequestModel()
{

View File

@ -13,6 +13,7 @@ namespace Bit.Core.Test.Helpers.Factories
var globalSettings = GlobalSettingsFactory.GlobalSettings;
if (!string.IsNullOrWhiteSpace(GlobalSettingsFactory.GlobalSettings.PostgreSql?.ConnectionString))
{
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
Options.Add(new DbContextOptionsBuilder<DatabaseContext>().UseNpgsql(globalSettings.PostgreSql.ConnectionString).Options);
}
if (!string.IsNullOrWhiteSpace(GlobalSettingsFactory.GlobalSettings.MySql?.ConnectionString))

View File

@ -23,7 +23,8 @@ namespace Bit.Core.Test.Models
"\"managePolicies\": false,",
"\"manageSso\": false,",
"\"manageUsers\": false,",
"\"manageResetPassword\": false",
"\"manageResetPassword\": false,",
"\"manageScim\": false",
"}");
[Fact]
@ -44,6 +45,7 @@ namespace Bit.Core.Test.Models
ManageSso = false,
ManageUsers = false,
ManageResetPassword = false,
ManageScim = false,
};
// minify expected json

Some files were not shown because too many files have changed in this diff Show More