1
0
mirror of https://github.com/bitwarden/server.git synced 2025-02-03 23:51:21 +01:00

[SM-501] Add support for revoking access tokens (#2692)

* Add support for revoking access tokens
This commit is contained in:
Oscar Hinton 2023-02-16 10:51:02 +01:00 committed by GitHub
parent e6635ff590
commit 7a209aa3bb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 315 additions and 5 deletions

View File

@ -0,0 +1,24 @@
using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
public class RevokeAccessTokensCommand : IRevokeAccessTokensCommand
{
private readonly IApiKeyRepository _apiKeyRepository;
public RevokeAccessTokensCommand(IApiKeyRepository apiKeyRepository)
{
_apiKeyRepository = apiKeyRepository;
}
public async Task RevokeAsync(ServiceAccount serviceAccount, IEnumerable<Guid> Ids)
{
var accessTokens = await _apiKeyRepository.GetManyByServiceAccountIdAsync(serviceAccount.Id);
var tokensToDelete = accessTokens.Where(at => Ids.Contains(at.Id));
await _apiKeyRepository.DeleteManyAsync(tokensToDelete);
}
}

View File

@ -26,6 +26,7 @@ public static class SecretsManagerCollectionExtensions
services.AddScoped<IDeleteProjectCommand, DeleteProjectCommand>();
services.AddScoped<ICreateServiceAccountCommand, CreateServiceAccountCommand>();
services.AddScoped<IUpdateServiceAccountCommand, UpdateServiceAccountCommand>();
services.AddScoped<IRevokeAccessTokensCommand, RevokeAccessTokensCommand>();
services.AddScoped<ICreateAccessTokenCommand, CreateAccessTokenCommand>();
services.AddScoped<ICreateAccessPoliciesCommand, CreateAccessPoliciesCommand>();
services.AddScoped<IUpdateAccessPolicyCommand, UpdateAccessPolicyCommand>();

View File

@ -0,0 +1,39 @@
using Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Xunit;
namespace Bit.Commercial.Core.Test.SecretsManager.ServiceAccounts;
[SutProviderCustomize]
public class RevokeAccessTokenCommandTests
{
[Theory]
[BitAutoData]
public async Task RevokeAsyncAsync_Success(ServiceAccount serviceAccount, SutProvider<RevokeAccessTokensCommand> sutProvider)
{
var apiKey1 = new ApiKey
{
Id = Guid.NewGuid(),
ServiceAccountId = serviceAccount.Id
};
var apiKey2 = new ApiKey
{
Id = Guid.NewGuid(),
ServiceAccountId = serviceAccount.Id
};
sutProvider.GetDependency<IApiKeyRepository>()
.GetManyByServiceAccountIdAsync(serviceAccount.Id)
.Returns(new List<ApiKey> { apiKey1, apiKey2 });
await sutProvider.Sut.RevokeAsync(serviceAccount, new List<Guid> { apiKey1.Id });
await sutProvider.GetDependency<IApiKeyRepository>().Received(1)
.DeleteManyAsync(Arg.Is<IEnumerable<ApiKey>>(arg => arg.SequenceEqual(new List<ApiKey> { apiKey1 })));
}
}

View File

@ -19,20 +19,23 @@ namespace Bit.Api.SecretsManager.Controllers;
public class ServiceAccountsController : Controller
{
private readonly ICurrentContext _currentContext;
private readonly IUserService _userService;
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IApiKeyRepository _apiKeyRepository;
private readonly ICreateAccessTokenCommand _createAccessTokenCommand;
private readonly ICreateServiceAccountCommand _createServiceAccountCommand;
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IUpdateServiceAccountCommand _updateServiceAccountCommand;
private readonly IUserService _userService;
private readonly IRevokeAccessTokensCommand _revokeAccessTokensCommand;
public ServiceAccountsController(
ICurrentContext currentContext,
IUserService userService,
IServiceAccountRepository serviceAccountRepository,
IApiKeyRepository apiKeyRepository,
ICreateAccessTokenCommand createAccessTokenCommand,
IApiKeyRepository apiKeyRepository, ICreateServiceAccountCommand createServiceAccountCommand,
IUpdateServiceAccountCommand updateServiceAccountCommand)
ICreateServiceAccountCommand createServiceAccountCommand,
IUpdateServiceAccountCommand updateServiceAccountCommand,
IRevokeAccessTokensCommand revokeAccessTokensCommand)
{
_currentContext = currentContext;
_userService = userService;
@ -40,6 +43,7 @@ public class ServiceAccountsController : Controller
_apiKeyRepository = apiKeyRepository;
_createServiceAccountCommand = createServiceAccountCommand;
_updateServiceAccountCommand = updateServiceAccountCommand;
_revokeAccessTokensCommand = revokeAccessTokensCommand;
_createAccessTokenCommand = createAccessTokenCommand;
}
@ -129,4 +133,37 @@ public class ServiceAccountsController : Controller
var result = await _createAccessTokenCommand.CreateAsync(request.ToApiKey(id), userId);
return new AccessTokenCreationResponseModel(result);
}
[HttpPost("{id}/access-tokens/revoke")]
public async Task RevokeAccessTokensAsync(Guid id, [FromBody] RevokeAccessTokensRequest request)
{
var userId = _userService.GetProperUserId(User).Value;
var serviceAccount = await _serviceAccountRepository.GetByIdAsync(id);
if (serviceAccount == null)
{
throw new NotFoundException();
}
if (!_currentContext.AccessSecretsManager(serviceAccount.OrganizationId))
{
throw new NotFoundException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(serviceAccount.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin);
var hasAccess = accessClient switch
{
AccessClientType.NoAccessCheck => true,
AccessClientType.User => await _serviceAccountRepository.UserHasWriteAccessToServiceAccount(id, userId),
_ => false,
};
if (!hasAccess)
{
throw new NotFoundException();
}
await _revokeAccessTokensCommand.RevokeAsync(serviceAccount, request.Ids);
}
}

View File

@ -0,0 +1,7 @@
using System.ComponentModel.DataAnnotations;
public class RevokeAccessTokensRequest
{
[Required]
public Guid[] Ids { get; set; }
}

View File

@ -0,0 +1,8 @@
using Bit.Core.SecretsManager.Entities;
namespace Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces;
public interface IRevokeAccessTokensCommand
{
Task RevokeAsync(ServiceAccount serviceAccount, IEnumerable<Guid> ids);
}

View File

@ -8,4 +8,5 @@ public interface IApiKeyRepository : IRepository<ApiKey, Guid>
{
Task<ApiKeyDetails> GetDetailsByIdAsync(Guid id);
Task<ICollection<ApiKey>> GetManyByServiceAccountIdAsync(Guid id);
Task DeleteManyAsync(IEnumerable<ApiKey> objs);
}

View File

@ -42,4 +42,13 @@ public class ApiKeyRepository : Repository<ApiKey, Guid>, IApiKeyRepository
return results.ToList();
}
public async Task DeleteManyAsync(IEnumerable<ApiKey> objs)
{
using var connection = new SqlConnection(ConnectionString);
await connection.QueryAsync<ApiKey>(
$"[{Schema}].[ApiKey_DeleteByIds]",
new { Ids = objs.Select(obj => obj.Id).ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure);
}
}

View File

@ -36,4 +36,13 @@ public class ApiKeyRepository : Repository<Core.SecretsManager.Entities.ApiKey,
return Mapper.Map<List<Core.SecretsManager.Entities.ApiKey>>(apiKeys);
}
public async Task DeleteManyAsync(IEnumerable<Core.SecretsManager.Entities.ApiKey> objs)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var entities = objs.Select(obj => Mapper.Map<ApiKey>(obj));
dbContext.RemoveRange(entities);
await dbContext.SaveChangesAsync();
}
}

View File

@ -0,0 +1,23 @@
CREATE PROCEDURE [dbo].[ApiKey_DeleteByIds]
@Ids [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
DECLARE @BatchSize INT = 100
WHILE @BatchSize > 0
BEGIN
BEGIN TRANSACTION ApiKey_DeleteMany
DELETE TOP(@BatchSize) AK
FROM
[dbo].[ApiKey] AK
INNER JOIN
@Ids I ON I.Id = AK.Id
SET @BatchSize = @@ROWCOUNT
COMMIT TRANSACTION ApiKey_DeleteMany
END
END

View File

@ -444,6 +444,7 @@
<Build Include="dbo\Views\UserView.sql" />
<Build Include="SecretsManager\dbo\Stored Procedures\ApiKey\ApiKeyDetails_ReadById.sql" />
<Build Include="SecretsManager\dbo\Stored Procedures\ApiKey\ApiKey_Create.sql" />
<Build Include="SecretsManager\dbo\Stored Procedures\ApiKey\ApiKey_DeleteByIds.sql" />
<Build Include="SecretsManager\dbo\Stored Procedures\ApiKey\ApiKey_ReadByServiceAccountId.sql" />
<Build Include="SecretsManager\dbo\Tables\AccessPolicy.sql" />
<Build Include="SecretsManager\dbo\Tables\ApiKey.sql" />

View File

@ -1,6 +1,7 @@
using System.Net;
using System.Net.Http.Headers;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.SecretsManager.Enums;
using Bit.Api.Models.Response;
using Bit.Api.SecretsManager.Models.Request;
using Bit.Api.SecretsManager.Models.Response;
@ -21,9 +22,11 @@ public class ServiceAccountsControllerTest : IClassFixture<ApiApplicationFactory
private const string _mockNewName =
"2.3AZ+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98xy4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg=";
private readonly IAccessPolicyRepository _accessPolicyRepository;
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly IAccessPolicyRepository _accessPolicyRepository;
private readonly IApiKeyRepository _apiKeyRepository;
private readonly IServiceAccountRepository _serviceAccountRepository;
private string _email = null!;
@ -35,6 +38,7 @@ public class ServiceAccountsControllerTest : IClassFixture<ApiApplicationFactory
_client = _factory.CreateClient();
_serviceAccountRepository = _factory.GetService<IServiceAccountRepository>();
_accessPolicyRepository = _factory.GetService<IAccessPolicyRepository>();
_apiKeyRepository = _factory.GetService<IApiKeyRepository>();
}
public async Task InitializeAsync()
@ -426,6 +430,130 @@ public class ServiceAccountsControllerTest : IClassFixture<ApiApplicationFactory
AssertHelper.AssertRecent(result.CreationDate);
}
[Theory]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task RevokeAccessToken_SmNotEnabled_NotFound(bool useSecrets, bool accessSecrets)
{
var (org, _) = await _organizationHelper.Initialize(useSecrets, accessSecrets);
await LoginAsync(_email);
var serviceAccount = await _serviceAccountRepository.CreateAsync(new ServiceAccount
{
OrganizationId = org.Id,
Name = _mockEncryptedString,
});
var accessToken = await _apiKeyRepository.CreateAsync(new ApiKey
{
ServiceAccountId = org.Id,
Name = _mockEncryptedString,
ExpireAt = DateTime.UtcNow.AddDays(30),
});
var request = new RevokeAccessTokensRequest
{
Ids = new[] { accessToken.Id },
};
var response = await _client.PostAsJsonAsync($"/service-accounts/{serviceAccount.Id}/access-tokens/revoke", request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task RevokeAccessToken_User_NoPermission(bool hasReadAccess)
{
var (org, _) = await _organizationHelper.Initialize(true, true);
var (email, orgUser) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true);
await LoginAsync(email);
var serviceAccount = await _serviceAccountRepository.CreateAsync(new ServiceAccount
{
OrganizationId = org.Id,
Name = _mockEncryptedString,
});
if (hasReadAccess)
{
await _accessPolicyRepository.CreateManyAsync(new List<BaseAccessPolicy> {
new UserServiceAccountAccessPolicy
{
GrantedServiceAccountId = serviceAccount.Id,
OrganizationUserId = orgUser.Id,
Write = false,
Read = true,
},
});
}
var accessToken = await _apiKeyRepository.CreateAsync(new ApiKey
{
ServiceAccountId = org.Id,
Name = _mockEncryptedString,
ExpireAt = DateTime.UtcNow.AddDays(30),
});
var request = new RevokeAccessTokensRequest
{
Ids = new[] { accessToken.Id },
};
var response = await _client.PostAsJsonAsync($"/service-accounts/{serviceAccount.Id}/access-tokens/revoke", request);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Theory]
[InlineData(PermissionType.RunAsAdmin)]
[InlineData(PermissionType.RunAsUserWithPermission)]
public async Task RevokeAccessToken_Success(PermissionType permissionType)
{
var (org, _) = await _organizationHelper.Initialize(true, true);
var serviceAccount = await _serviceAccountRepository.CreateAsync(new ServiceAccount
{
OrganizationId = org.Id,
Name = _mockEncryptedString,
});
if (permissionType == PermissionType.RunAsAdmin)
{
await LoginAsync(_email);
}
else
{
var (email, orgUser) = await _organizationHelper.CreateNewUser(OrganizationUserType.User, true);
await LoginAsync(email);
await _accessPolicyRepository.CreateManyAsync(new List<BaseAccessPolicy> {
new UserServiceAccountAccessPolicy
{
GrantedServiceAccountId = serviceAccount.Id,
OrganizationUserId = orgUser.Id,
Write = true,
Read = true,
},
});
}
var accessToken = await _apiKeyRepository.CreateAsync(new ApiKey
{
ServiceAccountId = org.Id,
Name = _mockEncryptedString,
ExpireAt = DateTime.UtcNow.AddDays(30),
});
var request = new RevokeAccessTokensRequest
{
Ids = new[] { accessToken.Id },
};
var response = await _client.PostAsJsonAsync($"/service-accounts/{serviceAccount.Id}/access-tokens/revoke", request);
response.EnsureSuccessStatusCode();
}
private async Task CreateUserPolicyAsync(Guid userId, Guid serviceAccountId, bool read, bool write)
{
var policy = new UserServiceAccountAccessPolicy

View File

@ -0,0 +1,23 @@
CREATE OR ALTER PROCEDURE [dbo].[ApiKey_DeleteByIds]
@Ids [dbo].[GuidIdArray] READONLY
AS
BEGIN
SET NOCOUNT ON
DECLARE @BatchSize INT = 100
WHILE @BatchSize > 0
BEGIN
BEGIN TRANSACTION ApiKey_DeleteMany
DELETE TOP(@BatchSize) AK
FROM
[dbo].[ApiKey] AK
INNER JOIN
@Ids I ON I.Id = AK.Id
SET @BatchSize = @@ROWCOUNT
COMMIT TRANSACTION ApiKey_DeleteMany
END
END