mirror of
https://github.com/bitwarden/server.git
synced 2024-11-27 13:05:23 +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:
parent
c5852db6ed
commit
19b8d8281a
@ -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}
|
||||
|
4
bitwarden_license/src/Scim/.dockerignore
Normal file
4
bitwarden_license/src/Scim/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
||||
*
|
||||
!obj/build-output/publish/*
|
||||
!obj/Docker/empty/
|
||||
!entrypoint.sh
|
21
bitwarden_license/src/Scim/Context/IScimContext.cs
Normal file
21
bitwarden_license/src/Scim/Context/IScimContext.cs
Normal 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);
|
||||
}
|
||||
}
|
64
bitwarden_license/src/Scim/Context/ScimContext.cs
Normal file
64
bitwarden_license/src/Scim/Context/ScimContext.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
bitwarden_license/src/Scim/Controllers/InfoController.cs
Normal file
21
bitwarden_license/src/Scim/Controllers/InfoController.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
290
bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs
Normal file
290
bitwarden_license/src/Scim/Controllers/v2/GroupsController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
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();
|
||||
}
|
||||
}
|
||||
}
|
20
bitwarden_license/src/Scim/Dockerfile
Normal file
20
bitwarden_license/src/Scim/Dockerfile
Normal 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"]
|
18
bitwarden_license/src/Scim/Models/BaseScimGroupModel.cs
Normal file
18
bitwarden_license/src/Scim/Models/BaseScimGroupModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
15
bitwarden_license/src/Scim/Models/BaseScimModel.cs
Normal file
15
bitwarden_license/src/Scim/Models/BaseScimModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
55
bitwarden_license/src/Scim/Models/BaseScimUserModel.cs
Normal file
55
bitwarden_license/src/Scim/Models/BaseScimUserModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
14
bitwarden_license/src/Scim/Models/ScimErrorResponseModel.cs
Normal file
14
bitwarden_license/src/Scim/Models/ScimErrorResponseModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
23
bitwarden_license/src/Scim/Models/ScimGroupRequestModel.cs
Normal file
23
bitwarden_license/src/Scim/Models/ScimGroupRequestModel.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
26
bitwarden_license/src/Scim/Models/ScimGroupResponseModel.cs
Normal file
26
bitwarden_license/src/Scim/Models/ScimGroupResponseModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
16
bitwarden_license/src/Scim/Models/ScimListResponseModel.cs
Normal file
16
bitwarden_license/src/Scim/Models/ScimListResponseModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
14
bitwarden_license/src/Scim/Models/ScimMetaModel.cs
Normal file
14
bitwarden_license/src/Scim/Models/ScimMetaModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
19
bitwarden_license/src/Scim/Models/ScimPatchModel.cs
Normal file
19
bitwarden_license/src/Scim/Models/ScimPatchModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
namespace Bit.Scim.Models
|
||||
{
|
||||
public class ScimUserRequestModel : BaseScimUserModel
|
||||
{
|
||||
public ScimUserRequestModel()
|
||||
: base(false)
|
||||
{ }
|
||||
}
|
||||
}
|
29
bitwarden_license/src/Scim/Models/ScimUserResponseModel.cs
Normal file
29
bitwarden_license/src/Scim/Models/ScimUserResponseModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
34
bitwarden_license/src/Scim/Program.cs
Normal file
34
bitwarden_license/src/Scim/Program.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
29
bitwarden_license/src/Scim/Properties/launchSettings.json
Normal file
29
bitwarden_license/src/Scim/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
17
bitwarden_license/src/Scim/Scim.csproj
Normal file
17
bitwarden_license/src/Scim/Scim.csproj
Normal 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>
|
6
bitwarden_license/src/Scim/ScimSettings.cs
Normal file
6
bitwarden_license/src/Scim/ScimSettings.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace Bit.Scim
|
||||
{
|
||||
public class ScimSettings
|
||||
{
|
||||
}
|
||||
}
|
113
bitwarden_license/src/Scim/Startup.cs
Normal file
113
bitwarden_license/src/Scim/Startup.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace Bit.Scim.Utilities
|
||||
{
|
||||
public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
|
||||
{
|
||||
public const string DefaultScheme = "ScimApiKey";
|
||||
}
|
||||
}
|
10
bitwarden_license/src/Scim/Utilities/ScimConstants.cs
Normal file
10
bitwarden_license/src/Scim/Utilities/ScimConstants.cs
Normal 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";
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
35
bitwarden_license/src/Scim/appsettings.Development.json
Normal file
35
bitwarden_license/src/Scim/appsettings.Development.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
42
bitwarden_license/src/Scim/appsettings.Production.json
Normal file
42
bitwarden_license/src/Scim/appsettings.Production.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
42
bitwarden_license/src/Scim/appsettings.QA.json
Normal file
42
bitwarden_license/src/Scim/appsettings.QA.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
63
bitwarden_license/src/Scim/appsettings.json
Normal file
63
bitwarden_license/src/Scim/appsettings.json
Normal 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": {
|
||||
}
|
||||
}
|
12
bitwarden_license/src/Scim/build.ps1
Normal file
12
bitwarden_license/src/Scim/build.ps1
Normal 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
|
15
bitwarden_license/src/Scim/build.sh
Normal file
15
bitwarden_license/src/Scim/build.sh
Normal 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"
|
41
bitwarden_license/src/Scim/entrypoint.sh
Normal file
41
bitwarden_license/src/Scim/entrypoint.sh
Normal 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
|
2930
bitwarden_license/src/Scim/packages.lock.json
Normal file
2930
bitwarden_license/src/Scim/packages.lock.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -12,7 +12,8 @@
|
||||
"internalIdentity": null,
|
||||
"internalApi": null,
|
||||
"internalVault": null,
|
||||
"internalSso": null
|
||||
"internalSso": null,
|
||||
"internalScim": null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -12,7 +12,8 @@
|
||||
"internalIdentity": null,
|
||||
"internalApi": null,
|
||||
"internalVault": null,
|
||||
"internalSso": null
|
||||
"internalSso": null,
|
||||
"internalScim": null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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; }
|
||||
|
@ -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; }
|
||||
|
@ -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;
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -12,7 +12,8 @@
|
||||
"internalIdentity": null,
|
||||
"internalApi": null,
|
||||
"internalVault": null,
|
||||
"internalSso": null
|
||||
"internalSso": null,
|
||||
"internalScim": null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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; }
|
||||
|
@ -2,7 +2,8 @@
|
||||
{
|
||||
public enum OrganizationApiKeyType : byte
|
||||
{
|
||||
Default,
|
||||
BillingSync,
|
||||
Default = 0,
|
||||
BillingSync = 1,
|
||||
Scim = 2,
|
||||
}
|
||||
}
|
||||
|
@ -3,5 +3,6 @@
|
||||
public enum OrganizationConnectionType : byte
|
||||
{
|
||||
CloudBillingSync = 1,
|
||||
Scim = 2,
|
||||
}
|
||||
}
|
||||
|
13
src/Core/Enums/ScimProviderType.cs
Normal file
13
src/Core/Enums/ScimProviderType.cs
Normal 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,
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
|
@ -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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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; }
|
||||
|
12
src/Core/Models/OrganizationConnectionConfigs/ScimConfig.cs
Normal file
12
src/Core/Models/OrganizationConnectionConfigs/ScimConfig.cs
Normal 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; }
|
||||
}
|
||||
}
|
@ -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; }
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -12,7 +12,8 @@
|
||||
"internalIdentity": null,
|
||||
"internalApi": null,
|
||||
"internalVault": null,
|
||||
"internalSso": null
|
||||
"internalSso": null,
|
||||
"internalScim": null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -12,7 +12,8 @@
|
||||
"internalIdentity": null,
|
||||
"internalApi": null,
|
||||
"internalVault": null,
|
||||
"internalSso": null
|
||||
"internalSso": null,
|
||||
"internalScim": null
|
||||
},
|
||||
"captcha": {
|
||||
"maximumFailedLoginAttempts": 0
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
|
@ -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": {
|
||||
|
@ -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": {
|
||||
|
@ -12,7 +12,8 @@
|
||||
"internalIdentity": null,
|
||||
"internalApi": null,
|
||||
"internalVault": null,
|
||||
"internalSso": null
|
||||
"internalSso": null,
|
||||
"internalScim": null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
@ -16,6 +16,7 @@ BEGIN
|
||||
[UsersGetPremium],
|
||||
[UseSso],
|
||||
[UseKeyConnector],
|
||||
[UseScim],
|
||||
[UseResetPassword],
|
||||
[Enabled]
|
||||
FROM
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
);
|
||||
|
||||
|
@ -8,6 +8,7 @@ SELECT
|
||||
O.[UsePolicies],
|
||||
O.[UseSso],
|
||||
O.[UseKeyConnector],
|
||||
O.[UseScim],
|
||||
O.[UseGroups],
|
||||
O.[UseDirectory],
|
||||
O.[UseEvents],
|
||||
|
@ -8,6 +8,7 @@ SELECT
|
||||
O.[UsePolicies],
|
||||
O.[UseSso],
|
||||
O.[UseKeyConnector],
|
||||
O.[UseScim],
|
||||
O.[UseGroups],
|
||||
O.[UseDirectory],
|
||||
O.[UseEvents],
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -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))
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user