1
0
mirror of https://github.com/bitwarden/server.git synced 2025-02-10 00:51:22 +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<IDeleteProjectCommand, DeleteProjectCommand>();
services.AddScoped<ICreateServiceAccountCommand, CreateServiceAccountCommand>(); services.AddScoped<ICreateServiceAccountCommand, CreateServiceAccountCommand>();
services.AddScoped<IUpdateServiceAccountCommand, UpdateServiceAccountCommand>(); services.AddScoped<IUpdateServiceAccountCommand, UpdateServiceAccountCommand>();
services.AddScoped<IRevokeAccessTokensCommand, RevokeAccessTokensCommand>();
services.AddScoped<ICreateAccessTokenCommand, CreateAccessTokenCommand>(); services.AddScoped<ICreateAccessTokenCommand, CreateAccessTokenCommand>();
services.AddScoped<ICreateAccessPoliciesCommand, CreateAccessPoliciesCommand>(); services.AddScoped<ICreateAccessPoliciesCommand, CreateAccessPoliciesCommand>();
services.AddScoped<IUpdateAccessPolicyCommand, UpdateAccessPolicyCommand>(); 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 public class ServiceAccountsController : Controller
{ {
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IUserService _userService;
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IApiKeyRepository _apiKeyRepository; private readonly IApiKeyRepository _apiKeyRepository;
private readonly ICreateAccessTokenCommand _createAccessTokenCommand; private readonly ICreateAccessTokenCommand _createAccessTokenCommand;
private readonly ICreateServiceAccountCommand _createServiceAccountCommand; private readonly ICreateServiceAccountCommand _createServiceAccountCommand;
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IUpdateServiceAccountCommand _updateServiceAccountCommand; private readonly IUpdateServiceAccountCommand _updateServiceAccountCommand;
private readonly IUserService _userService; private readonly IRevokeAccessTokensCommand _revokeAccessTokensCommand;
public ServiceAccountsController( public ServiceAccountsController(
ICurrentContext currentContext, ICurrentContext currentContext,
IUserService userService, IUserService userService,
IServiceAccountRepository serviceAccountRepository, IServiceAccountRepository serviceAccountRepository,
IApiKeyRepository apiKeyRepository,
ICreateAccessTokenCommand createAccessTokenCommand, ICreateAccessTokenCommand createAccessTokenCommand,
IApiKeyRepository apiKeyRepository, ICreateServiceAccountCommand createServiceAccountCommand, ICreateServiceAccountCommand createServiceAccountCommand,
IUpdateServiceAccountCommand updateServiceAccountCommand) IUpdateServiceAccountCommand updateServiceAccountCommand,
IRevokeAccessTokensCommand revokeAccessTokensCommand)
{ {
_currentContext = currentContext; _currentContext = currentContext;
_userService = userService; _userService = userService;
@ -40,6 +43,7 @@ public class ServiceAccountsController : Controller
_apiKeyRepository = apiKeyRepository; _apiKeyRepository = apiKeyRepository;
_createServiceAccountCommand = createServiceAccountCommand; _createServiceAccountCommand = createServiceAccountCommand;
_updateServiceAccountCommand = updateServiceAccountCommand; _updateServiceAccountCommand = updateServiceAccountCommand;
_revokeAccessTokensCommand = revokeAccessTokensCommand;
_createAccessTokenCommand = createAccessTokenCommand; _createAccessTokenCommand = createAccessTokenCommand;
} }
@ -129,4 +133,37 @@ public class ServiceAccountsController : Controller
var result = await _createAccessTokenCommand.CreateAsync(request.ToApiKey(id), userId); var result = await _createAccessTokenCommand.CreateAsync(request.ToApiKey(id), userId);
return new AccessTokenCreationResponseModel(result); 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<ApiKeyDetails> GetDetailsByIdAsync(Guid id);
Task<ICollection<ApiKey>> GetManyByServiceAccountIdAsync(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(); 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); 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="dbo\Views\UserView.sql" />
<Build Include="SecretsManager\dbo\Stored Procedures\ApiKey\ApiKeyDetails_ReadById.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_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\Stored Procedures\ApiKey\ApiKey_ReadByServiceAccountId.sql" />
<Build Include="SecretsManager\dbo\Tables\AccessPolicy.sql" /> <Build Include="SecretsManager\dbo\Tables\AccessPolicy.sql" />
<Build Include="SecretsManager\dbo\Tables\ApiKey.sql" /> <Build Include="SecretsManager\dbo\Tables\ApiKey.sql" />

View File

@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.SecretsManager.Enums;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Request;
using Bit.Api.SecretsManager.Models.Response; using Bit.Api.SecretsManager.Models.Response;
@ -21,9 +22,11 @@ public class ServiceAccountsControllerTest : IClassFixture<ApiApplicationFactory
private const string _mockNewName = private const string _mockNewName =
"2.3AZ+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98xy4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg="; "2.3AZ+WNBIoU5xzmVFNcoWzz==|1MsPIYuRfdOHfu/0uY6H2Q==|/98xy4wb6pHP1VTZ9JcNCYgQjEUMFPlqJgCwRk1YXKg=";
private readonly IAccessPolicyRepository _accessPolicyRepository;
private readonly HttpClient _client; private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory; private readonly ApiApplicationFactory _factory;
private readonly IAccessPolicyRepository _accessPolicyRepository;
private readonly IApiKeyRepository _apiKeyRepository;
private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IServiceAccountRepository _serviceAccountRepository;
private string _email = null!; private string _email = null!;
@ -35,6 +38,7 @@ public class ServiceAccountsControllerTest : IClassFixture<ApiApplicationFactory
_client = _factory.CreateClient(); _client = _factory.CreateClient();
_serviceAccountRepository = _factory.GetService<IServiceAccountRepository>(); _serviceAccountRepository = _factory.GetService<IServiceAccountRepository>();
_accessPolicyRepository = _factory.GetService<IAccessPolicyRepository>(); _accessPolicyRepository = _factory.GetService<IAccessPolicyRepository>();
_apiKeyRepository = _factory.GetService<IApiKeyRepository>();
} }
public async Task InitializeAsync() public async Task InitializeAsync()
@ -426,6 +430,130 @@ public class ServiceAccountsControllerTest : IClassFixture<ApiApplicationFactory
AssertHelper.AssertRecent(result.CreationDate); 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) private async Task CreateUserPolicyAsync(Guid userId, Guid serviceAccountId, bool read, bool write)
{ {
var policy = new UserServiceAccountAccessPolicy 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