1
0
mirror of https://github.com/bitwarden/server synced 2025-12-27 13:43:18 +00:00

Merge branch 'main' into billing/PM-24964/msp-unable-verfy-bank-account

This commit is contained in:
Alex Morask
2025-09-08 10:02:04 -05:00
25 changed files with 453 additions and 124 deletions

View File

@@ -208,7 +208,6 @@ public class OrganizationSponsorshipsController : Controller
[Authorize("Application")]
[HttpDelete("{sponsoringOrganizationId}")]
[HttpPost("{sponsoringOrganizationId}/delete")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task RevokeSponsorship(Guid sponsoringOrganizationId)
{
@@ -225,6 +224,15 @@ public class OrganizationSponsorshipsController : Controller
await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship);
}
[Authorize("Application")]
[HttpPost("{sponsoringOrganizationId}/delete")]
[Obsolete("This endpoint is deprecated. Use DELETE /{sponsoringOrganizationId} instead.")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostRevokeSponsorship(Guid sponsoringOrganizationId)
{
await RevokeSponsorship(sponsoringOrganizationId);
}
[Authorize("Application")]
[HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")]
[SelfHosted(NotSelfHostedOnly = true)]
@@ -241,7 +249,6 @@ public class OrganizationSponsorshipsController : Controller
[Authorize("Application")]
[HttpDelete("sponsored/{sponsoredOrgId}")]
[HttpPost("sponsored/{sponsoredOrgId}/remove")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task RemoveSponsorship(Guid sponsoredOrgId)
{
@@ -257,6 +264,15 @@ public class OrganizationSponsorshipsController : Controller
await _removeSponsorshipCommand.RemoveSponsorshipAsync(existingOrgSponsorship);
}
[Authorize("Application")]
[HttpPost("sponsored/{sponsoredOrgId}/remove")]
[Obsolete("This endpoint is deprecated. Use DELETE /sponsored/{sponsoredOrgId} instead.")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostRemoveSponsorship(Guid sponsoredOrgId)
{
await RemoveSponsorship(sponsoredOrgId);
}
[HttpGet("{sponsoringOrgId}/sync-status")]
public async Task<object> GetSyncStatus(Guid sponsoringOrgId)
{

View File

@@ -4,7 +4,7 @@ using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Responses;
public record PaymentMethodResponse(
long AccountCredit,
decimal AccountCredit,
PaymentSource PaymentSource,
string SubscriptionStatus,
TaxInformation TaxInformation)

View File

@@ -53,7 +53,7 @@ public class SelfHostedOrganizationLicensesController : Controller
}
[HttpPost("")]
public async Task<OrganizationResponseModel> PostLicenseAsync(OrganizationCreateLicenseRequestModel model)
public async Task<OrganizationResponseModel> CreateLicenseAsync(OrganizationCreateLicenseRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
@@ -74,7 +74,7 @@ public class SelfHostedOrganizationLicensesController : Controller
}
[HttpPost("{id}")]
public async Task PostLicenseAsync(string id, LicenseRequestModel model)
public async Task UpdateLicenseAsync(string id, LicenseRequestModel model)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.OrganizationOwner(orgIdGuid))

View File

@@ -79,7 +79,6 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
}
[HttpDelete("{sponsoringOrgId}")]
[HttpPost("{sponsoringOrgId}/delete")]
public async Task RevokeSponsorship(Guid sponsoringOrgId)
{
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default);
@@ -95,6 +94,13 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship);
}
[HttpPost("{sponsoringOrgId}/delete")]
[Obsolete("This endpoint is deprecated. Use DELETE /{sponsoringOrgId} instead.")]
public async Task PostRevokeSponsorship(Guid sponsoringOrgId)
{
await RevokeSponsorship(sponsoringOrgId);
}
[HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")]
public async Task AdminInitiatedRevokeSponsorshipAsync(Guid sponsoringOrgId, string sponsoredFriendlyName)
{

View File

@@ -33,6 +33,7 @@ using Bit.Core.Auth.Models.Api.Request;
using Bit.Core.Dirt.Reports.ReportFeatures;
using Bit.Core.Tools.SendFeatures;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.Auth.Identity;
#if !OSS
@@ -145,6 +146,12 @@ public class Startup
(c.Value.Contains(ApiScopes.Api) || c.Value.Contains(ApiScopes.ApiSecrets))
));
});
config.AddPolicy(Policies.Send, configurePolicy: policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiSendAccess);
policy.RequireClaim(Claims.SendAccessClaims.SendId);
});
});
services.AddScoped<AuthenticatorTokenProvider>();

View File

@@ -28,12 +28,12 @@ public abstract class EventLoggingListenerService : BackgroundService
if (root.ValueKind == JsonValueKind.Array)
{
var eventMessages = root.Deserialize<IEnumerable<EventMessage>>();
await _handler.HandleManyEventsAsync(eventMessages);
await _handler.HandleManyEventsAsync(eventMessages ?? throw new JsonException("Deserialize returned null"));
}
else if (root.ValueKind == JsonValueKind.Object)
{
var eventMessage = root.Deserialize<EventMessage>();
await _handler.HandleEventAsync(eventMessage);
await _handler.HandleEventAsync(eventMessage ?? throw new JsonException("Deserialize returned null"));
}
else
{

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Models.Data;
using Bit.Core.Models.Data;
namespace Bit.Core.Services;

View File

@@ -1,6 +1,5 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Globalization;
using System.Net;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
namespace Bit.Core.Services;
@@ -20,8 +19,56 @@ public abstract class IntegrationHandlerBase<T> : IIntegrationHandler<T>
public async Task<IntegrationHandlerResult> HandleAsync(string json)
{
var message = IntegrationMessage<T>.FromJson(json);
return await HandleAsync(message);
return await HandleAsync(message ?? throw new ArgumentException("IntegrationMessage was null when created from the provided JSON"));
}
public abstract Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<T> message);
protected IntegrationHandlerResult ResultFromHttpResponse(
HttpResponseMessage response,
IntegrationMessage<T> message,
TimeProvider timeProvider)
{
var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message);
if (response.IsSuccessStatusCode) return result;
switch (response.StatusCode)
{
case HttpStatusCode.TooManyRequests:
case HttpStatusCode.RequestTimeout:
case HttpStatusCode.InternalServerError:
case HttpStatusCode.BadGateway:
case HttpStatusCode.ServiceUnavailable:
case HttpStatusCode.GatewayTimeout:
result.Retryable = true;
result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}";
if (response.Headers.TryGetValues("Retry-After", out var values))
{
var value = values.FirstOrDefault();
if (int.TryParse(value, out var seconds))
{
// Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds.
result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime;
}
else if (DateTimeOffset.TryParseExact(value,
"r", // "r" is the round-trip format: RFC1123
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var retryDate))
{
// Retry-after was specified as a date. Adjust DelayUntilDate to the specified date.
result.DelayUntilDate = retryDate.UtcDateTime;
}
}
break;
default:
result.Retryable = false;
result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}";
break;
}
return result;
}
}

View File

@@ -418,13 +418,21 @@ dependencies and integrations. For instance, `SlackIntegrationHandler` needs a `
`AddEventIntegrationServices` has a call to `AddSlackService`. Same thing for webhooks when it
comes to defining a custom HttpClient by name.
1. In `AddEventIntegrationServices` create the listener configuration:
In `AddEventIntegrationServices`:
1. Create the singleton for the handler:
``` csharp
services.TryAddSingleton<IIntegrationHandler<ExampleIntegrationConfigurationDetails>, ExampleIntegrationHandler>();
```
2. Create the listener configuration:
``` csharp
var exampleConfiguration = new ExampleListenerConfiguration(globalSettings);
```
2. Add the integration to both the RabbitMQ and ASB specific declarations:
3. Add the integration to both the RabbitMQ and ASB specific declarations:
``` csharp
services.AddRabbitMqIntegration<ExampleIntegrationConfigurationDetails, ExampleListenerConfiguration>(exampleConfiguration);

View File

@@ -1,7 +1,5 @@
#nullable enable
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
@@ -17,7 +15,8 @@ public class WebhookIntegrationHandler(
public const string HttpClientName = "WebhookIntegrationHandlerHttpClient";
public override async Task<IntegrationHandlerResult> HandleAsync(IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
public override async Task<IntegrationHandlerResult> HandleAsync(
IntegrationMessage<WebhookIntegrationConfigurationDetails> message)
{
var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Uri);
request.Content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json");
@@ -28,45 +27,8 @@ public class WebhookIntegrationHandler(
parameter: message.Configuration.Token
);
}
var response = await _httpClient.SendAsync(request);
var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message);
switch (response.StatusCode)
{
case HttpStatusCode.TooManyRequests:
case HttpStatusCode.RequestTimeout:
case HttpStatusCode.InternalServerError:
case HttpStatusCode.BadGateway:
case HttpStatusCode.ServiceUnavailable:
case HttpStatusCode.GatewayTimeout:
result.Retryable = true;
result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}";
if (response.Headers.TryGetValues("Retry-After", out var values))
{
var value = values.FirstOrDefault();
if (int.TryParse(value, out var seconds))
{
// Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds.
result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime;
}
else if (DateTimeOffset.TryParseExact(value,
"r", // "r" is the round-trip format: RFC1123
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out var retryDate))
{
// Retry-after was specified as a date. Adjust DelayUntilDate to the specified date.
result.DelayUntilDate = retryDate.UtcDateTime;
}
}
break;
default:
result.Retryable = false;
result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}";
break;
}
return result;
return ResultFromHttpResponse(response, message, timeProvider);
}
}

View File

@@ -0,0 +1,10 @@
namespace Bit.Core.Auth.Identity;
public static class Policies
{
/// <summary>
/// Policy for managing access to the Send feature.
/// </summary>
public const string Send = "Send"; // [Authorize(Policy = Policies.Send)]
// TODO: migrate other existing policies to use this class
}

View File

@@ -6,7 +6,7 @@ using Bit.Core.Billing.Tax.Models;
namespace Bit.Core.Billing.Models;
public record PaymentMethod(
long AccountCredit,
decimal AccountCredit,
PaymentSource PaymentSource,
string SubscriptionStatus,
TaxInformation TaxInformation)

View File

@@ -345,7 +345,7 @@ public class SubscriberService(
return PaymentMethod.Empty;
}
var accountCredit = customer.Balance * -1 / 100;
var accountCredit = customer.Balance * -1 / 100M;
var paymentMethod = await GetPaymentSourceAsync(subscriber.Id, customer);

View File

@@ -199,7 +199,6 @@ public static class FeatureFlagKeys
public const string SendAccess = "pm-19394-send-access-control";
/* Platform Team */
public const string PersistPopupView = "persist-popup-view";
public const string IpcChannelFramework = "ipc-channel-framework";
public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked";
public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users";

View File

@@ -5,7 +5,7 @@ using Microsoft.Extensions.Caching.Memory;
namespace Bit.Icons.Controllers;
[Route("change-password-uri")]
[Route("~/change-password-uri")]
public class ChangePasswordUriController : Controller
{
private readonly IMemoryCache _memoryCache;

View File

@@ -92,6 +92,9 @@ public class Startup
await next();
});
app.UseCors(policy => policy.SetIsOriginAllowed(o => CoreHelpers.IsCorsOriginAllowed(o, globalSettings))
.AllowAnyMethod().AllowAnyHeader().AllowCredentials());
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
}

View File

@@ -5,6 +5,8 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess;
/// <summary>
/// String constants for the Send Access user feature
/// Most of these need to be synced with the `bitwarden-auth` crate in the SDK.
/// There is snapshot testing to help ensure this.
/// </summary>
public static class SendAccessConstants
{
@@ -41,7 +43,7 @@ public static class SendAccessConstants
/// <summary>
/// The sendId is missing from the request.
/// </summary>
public const string MissingSendId = "send_id_required";
public const string SendIdRequired = "send_id_required";
/// <summary>
/// The sendId is invalid, does not match a known send.
/// </summary>

View File

@@ -23,7 +23,7 @@ public class SendAccessGrantValidator(
private static readonly Dictionary<string, string>
_sendGrantValidatorErrorDescriptions = new()
{
{ SendAccessConstants.GrantValidatorResults.MissingSendId, $"{SendAccessConstants.TokenRequest.SendId} is required." },
{ SendAccessConstants.GrantValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." },
{ SendAccessConstants.GrantValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." }
};
@@ -90,7 +90,7 @@ public class SendAccessGrantValidator(
// if the sendId is null then the request is the wrong shape and the request is invalid
if (sendId == null)
{
return (Guid.Empty, SendAccessConstants.GrantValidatorResults.MissingSendId);
return (Guid.Empty, SendAccessConstants.GrantValidatorResults.SendIdRequired);
}
// the send_id is not null so the request is the correct shape, so we will attempt to parse it
try
@@ -125,7 +125,7 @@ public class SendAccessGrantValidator(
return error switch
{
// Request is the wrong shape
SendAccessConstants.GrantValidatorResults.MissingSendId => new GrantValidationResult(
SendAccessConstants.GrantValidatorResults.SendIdRequired => new GrantValidationResult(
TokenRequestErrors.InvalidRequest,
errorDescription: _sendGrantValidatorErrorDescriptions[error],
customResponse),

View File

@@ -0,0 +1,66 @@
Send Access Request Validation
===
This feature supports the ability of Tools to require specific claims for access to sends.
In order to access Send data a user must meet the requirements laid out in these request validators.
# ***Important: String Constants***
The string constants contained herein are used in conjunction with the Auth module in the SDK. Any change to these string values _must_ be intentional and _must_ have a corresponding change in the SDK.
There is snapshot testing that will fail if the strings change to help detect unintended changes to the string constants.
# Custom Claims
Send access tokens contain custom claims specific to the Send the Send grant type.
1. `send_id` - is always included in the issued access token. This is the `GUID` of the request Send.
1. `send_email` - only set when the Send requires `EmailOtp` authentication type.
1. `type` - this will always be `Send`
# Authentication methods
## `NeverAuthenticate`
For a Send to be in this state two things can be true:
1. The Send has been modified and no longer allows access.
2. The Send does not exist.
## `NotAuthenticated`
In this scenario the Send is not protected by any added authentication or authorization and the access token is issued to the requesting user.
## `ResourcePassword`
In this scenario the Send is password protected and a user must supply the correct password hash to be issued an access token.
## `EmailOtp`
In this scenario the Send is only accessible to owners of specific email addresses. The user must submit a correct email. Once the email has been entered then ownership of the email must be established via OTP. The Otp is sent to the aforementioned email and must be supplied, along with the email, to be issued an access token.
# Send Access Request Validation
## Required Parameters
### All Requests
- `send_id` - Base64 URL-encoded GUID of the send being accessed
### Password Protected Sends
- `password_hash_b64` - client hashed Base64-encoded password.
### Email OTP Protected Sends
- `email` - Email address associated with the send
- `otp` - One-time password (optional - if missing, OTP is generated and sent)
## Error Responses
All errors include a custom response field:
```json
{
"error": "invalid_request|invalid_grant",
"error_description": "Human readable description",
"send_access_error_type": "specific_error_code"
}
```

View File

@@ -1,13 +1,10 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using AutoMapper;
using AutoMapper;
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models;
public class OrganizationIntegration : Core.AdminConsole.Entities.OrganizationIntegration
{
public virtual Organization Organization { get; set; }
public virtual required Organization Organization { get; set; }
}
public class OrganizationIntegrationMapperProfile : Profile

View File

@@ -1,13 +1,10 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using AutoMapper;
using AutoMapper;
namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models;
public class OrganizationIntegrationConfiguration : Core.AdminConsole.Entities.OrganizationIntegrationConfiguration
{
public virtual OrganizationIntegration OrganizationIntegration { get; set; }
public virtual required OrganizationIntegration OrganizationIntegration { get; set; }
}
public class OrganizationIntegrationConfigurationMapperProfile : Profile