Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SM-923] Add project service accounts access policies management endpoints #3993

Merged
merged 56 commits into from May 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
9d448e6
Add the ability to get multi projects access
Thomas-Avery Feb 2, 2024
39ca17c
Add access policy helper + tests
Thomas-Avery Feb 2, 2024
8f85f65
Add new data/request models
Thomas-Avery Feb 2, 2024
6bffa3c
Add access policy operations to repo
Thomas-Avery Feb 2, 2024
c666eb1
Add authz handler for new operations
Thomas-Avery Feb 2, 2024
1a5895e
Add new controller endpoints
Thomas-Avery Feb 2, 2024
3ffb56b
Merge branch 'main' into sm/sm-910
Thomas-Avery Feb 2, 2024
e8d136e
Merge branch 'main' into sm/sm-910
Thomas-Avery Mar 1, 2024
4d0c812
fix tests
Thomas-Avery Mar 1, 2024
caa214a
Merge branch 'main' into sm/sm-910
Thomas-Avery Mar 4, 2024
b19d72b
Merge branch 'main' into sm/sm-910
Thomas-Avery Mar 25, 2024
147e256
Change PUT processing flow for granted policies
Thomas-Avery Mar 27, 2024
06d228f
Merge branch 'main' into sm/sm-910
Thomas-Avery Mar 27, 2024
1ccdce5
Merge branch 'main' into sm/sm-910
Thomas-Avery Apr 1, 2024
e9ebaba
add updating service account revision
Thomas-Avery Apr 2, 2024
b4a8cea
Add unit tests for query
Thomas-Avery Apr 3, 2024
5df96a0
Add command, remove old endpoint
Thomas-Avery Apr 3, 2024
35dc93a
Merge branch 'main' into sm/sm-910
Thomas-Avery Apr 3, 2024
3e6adcf
Tweak authz handler
Thomas-Avery Apr 8, 2024
60b7efd
Merge branch 'main' into sm/sm-910
Thomas-Avery Apr 8, 2024
6fa058c
move model tests
Thomas-Avery Apr 15, 2024
77d4b4f
Add new models
Thomas-Avery Apr 15, 2024
54d1ddb
Update repositories
Thomas-Avery Apr 15, 2024
0b52e64
Add new authz handler
Thomas-Avery Apr 15, 2024
fc7a1ba
Add new query
Thomas-Avery Apr 15, 2024
52804e0
Add new command
Thomas-Avery Apr 15, 2024
45f7440
Add authz, command, and query to DI
Thomas-Avery Apr 15, 2024
1695630
Add new endpoint to controller
Thomas-Avery Apr 15, 2024
ef3f3e6
Add query unit tests
Thomas-Avery Apr 15, 2024
dc53f25
Merge branch 'sm/sm-910' into sm/sm-923
Thomas-Avery Apr 15, 2024
083ef53
Add api unit tests
Thomas-Avery Apr 15, 2024
627d2ab
Add api unit tests
Thomas-Avery Apr 16, 2024
96c4f0a
Fix api unit tests
Thomas-Avery Apr 16, 2024
0bafd0c
Merge branch 'main' into sm/sm-910
Thomas-Avery Apr 16, 2024
8d81deb
Merge branch 'sm/sm-910' into sm/sm-923
Thomas-Avery Apr 16, 2024
cf74ace
Fix ServiceAccountsAreInOrganization
Thomas-Avery Apr 16, 2024
6a10c90
Add api integration tests
Thomas-Avery Apr 16, 2024
be5c1e3
Update tests
Thomas-Avery Apr 17, 2024
3773fb0
Swap to non permission details responses
Thomas-Avery Apr 17, 2024
4841ce0
Keep consistent naming
Thomas-Avery Apr 17, 2024
4b32109
General renaming and cleanup
Thomas-Avery Apr 17, 2024
22973a8
Fix ap repo
Thomas-Avery Apr 17, 2024
a800323
Handle no updates at command level
Thomas-Avery Apr 18, 2024
4803c81
Handle no updates at command level
Thomas-Avery Apr 18, 2024
6491b23
Merge branch 'sm/sm-910' into sm/sm-923
Thomas-Avery Apr 18, 2024
69bfcf6
Move fetching the organization ID to constructor
Thomas-Avery Apr 19, 2024
d41fefd
Set org id in the constructor
Thomas-Avery Apr 19, 2024
b007fa6
Merge branch 'sm/sm-910' into sm/sm-923
Thomas-Avery Apr 19, 2024
2805525
Merge branch 'main' into sm/sm-910
Thomas-Avery Apr 24, 2024
ab3d855
Merge branch 'sm/sm-910' into sm/sm-923
Thomas-Avery Apr 24, 2024
e6c148c
Merge branch 'main' into sm/sm-910
Thomas-Avery Apr 25, 2024
776eec0
Merge branch 'sm/sm-910' into sm/sm-923
Thomas-Avery Apr 25, 2024
f43270c
Merge branch 'main' into sm/sm-910
Thomas-Avery Apr 29, 2024
2ce120a
Merge branch 'sm/sm-910' into sm/sm-923
Thomas-Avery Apr 29, 2024
005e474
Merge branch 'main' into sm/sm-923
Thomas-Avery May 1, 2024
e3e389d
Merge branch 'main' into sm/sm-923
Thomas-Avery May 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -0,0 +1,107 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.AuthorizationRequirements;
using Bit.Core.SecretsManager.Enums.AccessPolicies;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Queries.Interfaces;
using Bit.Core.SecretsManager.Repositories;
using Microsoft.AspNetCore.Authorization;

namespace Bit.Commercial.Core.SecretsManager.AuthorizationHandlers.AccessPolicies;

public class ProjectServiceAccountsAccessPoliciesAuthorizationHandler : AuthorizationHandler<
ProjectServiceAccountsAccessPoliciesOperationRequirement,
ProjectServiceAccountsAccessPoliciesUpdates>
{
private readonly IAccessClientQuery _accessClientQuery;
private readonly ICurrentContext _currentContext;
private readonly IProjectRepository _projectRepository;
private readonly IServiceAccountRepository _serviceAccountRepository;

public ProjectServiceAccountsAccessPoliciesAuthorizationHandler(ICurrentContext currentContext,
IAccessClientQuery accessClientQuery,
IProjectRepository projectRepository,
IServiceAccountRepository serviceAccountRepository)
{
_currentContext = currentContext;
_accessClientQuery = accessClientQuery;
_serviceAccountRepository = serviceAccountRepository;
_projectRepository = projectRepository;
}

protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
ProjectServiceAccountsAccessPoliciesOperationRequirement requirement,
ProjectServiceAccountsAccessPoliciesUpdates resource)
{
if (!_currentContext.AccessSecretsManager(resource.OrganizationId))
{
return;
}

// Only users and admins should be able to manipulate access policies
var (accessClient, userId) =
await _accessClientQuery.GetAccessClientAsync(context.User, resource.OrganizationId);
if (accessClient != AccessClientType.User && accessClient != AccessClientType.NoAccessCheck)
{
return;
}

switch (requirement)
{
case not null when requirement == ProjectServiceAccountsAccessPoliciesOperations.Updates:
await CanUpdateAsync(context, requirement, resource, accessClient,
userId);
break;
default:
throw new ArgumentException("Unsupported operation requirement type provided.",
nameof(requirement));
}
}

private async Task CanUpdateAsync(AuthorizationHandlerContext context,
ProjectServiceAccountsAccessPoliciesOperationRequirement requirement,
ProjectServiceAccountsAccessPoliciesUpdates resource,
AccessClientType accessClient, Guid userId)
{
var access =
await _projectRepository.AccessToProjectAsync(resource.ProjectId, userId,
accessClient);
if (!access.Write)
{
return;
}

var serviceAccountIds = resource.ServiceAccountAccessPolicyUpdates.Select(update =>
update.AccessPolicy.ServiceAccountId!.Value).ToList();

var inSameOrganization =
await _serviceAccountRepository.ServiceAccountsAreInOrganizationAsync(serviceAccountIds,
resource.OrganizationId);
if (!inSameOrganization)
{
return;
}

// Users can only create access policies for service accounts they have access to.
// User can delete and update any service account access policy if they have write access to the project.
var serviceAccountIdsToCheck = resource.ServiceAccountAccessPolicyUpdates
.Where(update => update.Operation == AccessPolicyOperation.Create).Select(update =>
update.AccessPolicy.ServiceAccountId!.Value).ToList();

if (serviceAccountIdsToCheck.Count == 0)
{
context.Succeed(requirement);
return;
}

var serviceAccountsAccess =
await _serviceAccountRepository.AccessToServiceAccountsAsync(serviceAccountIdsToCheck, userId,
accessClient);
if (serviceAccountsAccess.Count == serviceAccountIdsToCheck.Count &&
serviceAccountsAccess.All(a => a.Value.Write))
{
context.Succeed(requirement);
}
}
}
@@ -0,0 +1,26 @@
#nullable enable
using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Repositories;

namespace Bit.Commercial.Core.SecretsManager.Commands.AccessPolicies;

public class UpdateProjectServiceAccountsAccessPoliciesCommand : IUpdateProjectServiceAccountsAccessPoliciesCommand
{
private readonly IAccessPolicyRepository _accessPolicyRepository;

public UpdateProjectServiceAccountsAccessPoliciesCommand(IAccessPolicyRepository accessPolicyRepository)
{
_accessPolicyRepository = accessPolicyRepository;
}

public async Task UpdateAsync(ProjectServiceAccountsAccessPoliciesUpdates accessPoliciesUpdates)
{
if (!accessPoliciesUpdates.ServiceAccountAccessPolicyUpdates.Any())
{
return;
}

await _accessPolicyRepository.UpdateProjectServiceAccountsAccessPoliciesAsync(accessPoliciesUpdates);
}
}
@@ -0,0 +1,44 @@
#nullable enable
using Bit.Core.SecretsManager.Enums.AccessPolicies;
using Bit.Core.SecretsManager.Models.Data;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Queries.AccessPolicies.Interfaces;
using Bit.Core.SecretsManager.Repositories;

namespace Bit.Commercial.Core.SecretsManager.Queries.AccessPolicies;

public class ProjectServiceAccountsAccessPoliciesUpdatesQuery : IProjectServiceAccountsAccessPoliciesUpdatesQuery
{
private readonly IAccessPolicyRepository _accessPolicyRepository;

public ProjectServiceAccountsAccessPoliciesUpdatesQuery(IAccessPolicyRepository accessPolicyRepository)
{
_accessPolicyRepository = accessPolicyRepository;
}

public async Task<ProjectServiceAccountsAccessPoliciesUpdates> GetAsync(
ProjectServiceAccountsAccessPolicies projectServiceAccountsAccessPolicies)
{
var currentPolicies =
await _accessPolicyRepository.GetProjectServiceAccountsAccessPoliciesAsync(
projectServiceAccountsAccessPolicies.ProjectId);

if (currentPolicies == null)
{
return new ProjectServiceAccountsAccessPoliciesUpdates
{
ProjectId = projectServiceAccountsAccessPolicies.ProjectId,
OrganizationId = projectServiceAccountsAccessPolicies.OrganizationId,
ServiceAccountAccessPolicyUpdates =
projectServiceAccountsAccessPolicies.ServiceAccountAccessPolicies.Select(p =>
new ServiceAccountProjectAccessPolicyUpdate
{
Operation = AccessPolicyOperation.Create,
AccessPolicy = p
})
};
}

return currentPolicies.GetPolicyUpdates(projectServiceAccountsAccessPolicies);
}
}
Expand Up @@ -42,12 +42,14 @@ public static void AddSecretsManagerServices(this IServiceCollection services)
services.AddScoped<IAuthorizationHandler, ProjectPeopleAccessPoliciesAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, ServiceAccountPeopleAccessPoliciesAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, ServiceAccountGrantedPoliciesAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, ProjectServiceAccountsAccessPoliciesAuthorizationHandler>();
services.AddScoped<IAccessClientQuery, AccessClientQuery>();
services.AddScoped<IMaxProjectsQuery, MaxProjectsQuery>();
services.AddScoped<ISameOrganizationQuery, SameOrganizationQuery>();
services.AddScoped<IServiceAccountSecretsDetailsQuery, ServiceAccountSecretsDetailsQuery>();
services.AddScoped<IServiceAccountGrantedPolicyUpdatesQuery, ServiceAccountGrantedPolicyUpdatesQuery>();
services.AddScoped<ISecretsSyncQuery, SecretsSyncQuery>();
services.AddScoped<IProjectServiceAccountsAccessPoliciesUpdatesQuery, ProjectServiceAccountsAccessPoliciesUpdatesQuery>();
services.AddScoped<ICreateSecretCommand, CreateSecretCommand>();
services.AddScoped<IUpdateSecretCommand, UpdateSecretCommand>();
services.AddScoped<IDeleteSecretCommand, DeleteSecretCommand>();
Expand All @@ -67,5 +69,6 @@ public static void AddSecretsManagerServices(this IServiceCollection services)
services.AddScoped<IEmptyTrashCommand, EmptyTrashCommand>();
services.AddScoped<IRestoreTrashCommand, RestoreTrashCommand>();
services.AddScoped<IUpdateServiceAccountGrantedPoliciesCommand, UpdateServiceAccountGrantedPoliciesCommand>();
services.AddScoped<IUpdateProjectServiceAccountsAccessPoliciesCommand, UpdateProjectServiceAccountsAccessPoliciesCommand>();
}
}
Expand Up @@ -465,12 +465,68 @@ public async Task UpdateServiceAccountGrantedPoliciesAsync(ServiceAccountGranted
dbContext.RemoveRange(policiesToDelete);
}

await UpsertServiceAccountGrantedPoliciesAsync(dbContext, currentAccessPolicies,
await UpsertServiceAccountProjectPoliciesAsync(dbContext, currentAccessPolicies,
updates.ProjectGrantedPolicyUpdates.Where(pu => pu.Operation != AccessPolicyOperation.Delete).ToList());
await UpdateServiceAccountRevisionAsync(dbContext, updates.ServiceAccountId);
await dbContext.SaveChangesAsync();
}

public async Task<ProjectServiceAccountsAccessPolicies?> GetProjectServiceAccountsAccessPoliciesAsync(Guid projectId)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var entities = await dbContext.ServiceAccountProjectAccessPolicy
.Where(ap => ap.GrantedProjectId == projectId)
.Include(ap => ap.ServiceAccount)
.Include(ap => ap.GrantedProject)
.ToListAsync();

if (entities.Count == 0)
{
return null;
}

return new ProjectServiceAccountsAccessPolicies(projectId, entities.Select(MapToCore).ToList());
}

public async Task UpdateProjectServiceAccountsAccessPoliciesAsync(
ProjectServiceAccountsAccessPoliciesUpdates updates)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
await using var transaction = await dbContext.Database.BeginTransactionAsync();

var currentAccessPolicies = await dbContext.ServiceAccountProjectAccessPolicy
.Where(ap => ap.GrantedProjectId == updates.ProjectId)
.ToListAsync();

if (currentAccessPolicies.Count != 0)
{
var serviceAccountIdsToDelete = updates.ServiceAccountAccessPolicyUpdates
.Where(pu => pu.Operation == AccessPolicyOperation.Delete)
.Select(pu => pu.AccessPolicy.ServiceAccountId!.Value)
.ToList();

var accessPolicyIdsToDelete = currentAccessPolicies
.Where(entity => serviceAccountIdsToDelete.Contains(entity.ServiceAccountId!.Value))
.Select(ap => ap.Id)
.ToList();

await dbContext.ServiceAccountProjectAccessPolicy
.Where(ap => accessPolicyIdsToDelete.Contains(ap.Id))
.ExecuteDeleteAsync();
}

await UpsertServiceAccountProjectPoliciesAsync(dbContext, currentAccessPolicies,
updates.ServiceAccountAccessPolicyUpdates.Where(update => update.Operation != AccessPolicyOperation.Delete)
.ToList());
var effectedServiceAccountIds = updates.ServiceAccountAccessPolicyUpdates
.Select(sa => sa.AccessPolicy.ServiceAccountId!.Value).ToList();
await UpdateServiceAccountsRevisionAsync(dbContext, effectedServiceAccountIds);
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}

private static async Task UpsertPeoplePoliciesAsync(DatabaseContext dbContext,
List<BaseAccessPolicy> policies, IReadOnlyCollection<AccessPolicy> userPolicyEntities,
IReadOnlyCollection<AccessPolicy> groupPolicyEntities)
Expand Down Expand Up @@ -506,7 +562,7 @@ public async Task UpdateServiceAccountGrantedPoliciesAsync(ServiceAccountGranted
}
}

private async Task UpsertServiceAccountGrantedPoliciesAsync(DatabaseContext dbContext,
private async Task UpsertServiceAccountProjectPoliciesAsync(DatabaseContext dbContext,
IReadOnlyCollection<ServiceAccountProjectAccessPolicy> currentPolices,
List<ServiceAccountProjectAccessPolicyUpdate> policyUpdates)
{
Expand All @@ -515,7 +571,8 @@ public async Task UpdateServiceAccountGrantedPoliciesAsync(ServiceAccountGranted
{
var updatedEntity = MapToEntity(policyUpdate.AccessPolicy);
var currentEntity = currentPolices.FirstOrDefault(e =>
e.GrantedProjectId == policyUpdate.AccessPolicy.GrantedProjectId!.Value);
e.GrantedProjectId == policyUpdate.AccessPolicy.GrantedProjectId!.Value &&
e.ServiceAccountId == policyUpdate.AccessPolicy.ServiceAccountId!.Value);

switch (policyUpdate.Operation)
{
Expand Down Expand Up @@ -628,4 +685,13 @@ private static async Task UpdateServiceAccountRevisionAsync(DatabaseContext dbCo
entity.RevisionDate = DateTime.UtcNow;
}
}

private static async Task UpdateServiceAccountsRevisionAsync(DatabaseContext dbContext, List<Guid> serviceAccountIds)
{
var utcNow = DateTime.UtcNow;
await dbContext.ServiceAccount
.Where(sa => serviceAccountIds.Contains(sa.Id))
.ExecuteUpdateAsync(setters =>
setters.SetProperty(sa => sa.RevisionDate, utcNow));
}
}
Expand Up @@ -112,30 +112,29 @@
public async Task<(bool Read, bool Write)> AccessToServiceAccountAsync(Guid id, Guid userId,
AccessClientType accessType)
{
using var scope = ServiceScopeFactory.CreateScope();
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);

var serviceAccount = dbContext.ServiceAccount.Where(sa => sa.Id == id);
var serviceAccountQuery = dbContext.ServiceAccount.Where(sa => sa.Id == id);

var query = accessType switch
{
AccessClientType.NoAccessCheck => serviceAccount.Select(_ => new { Read = true, Write = true }),
AccessClientType.User => serviceAccount.Select(sa => new
{
Read = sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
sa.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read)),
Write = sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
sa.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write)),
}),
AccessClientType.ServiceAccount => serviceAccount.Select(_ => new { Read = false, Write = false }),
_ => serviceAccount.Select(_ => new { Read = false, Write = false }),
};
var accessQuery = BuildServiceAccountAccessQuery(serviceAccountQuery, userId, accessType);
var access = await accessQuery.FirstOrDefaultAsync();

return access == null ? (false, false) : (access.Read, access.Write);
}

public async Task<Dictionary<Guid, (bool Read, bool Write)>> AccessToServiceAccountsAsync(
IEnumerable<Guid> ids,
Guid userId,
AccessClientType accessType)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);

var policy = await query.FirstOrDefaultAsync();
var serviceAccountsQuery = dbContext.ServiceAccount.Where(p => ids.Contains(p.Id));
var accessQuery = BuildServiceAccountAccessQuery(serviceAccountsQuery, userId, accessType);

return policy == null ? (false, false) : (policy.Read, policy.Write);
return await accessQuery.ToDictionaryAsync(access => access.Id, access => (access.Read, access.Write));
}

public async Task<int> GetServiceAccountCountByOrganizationIdAsync(Guid organizationId)
Expand All @@ -148,6 +147,15 @@
}
}

public async Task<bool> ServiceAccountsAreInOrganizationAsync(List<Guid> serviceAccountIds, Guid organizationId)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var result = await dbContext.ServiceAccount.CountAsync(sa =>
sa.OrganizationId == organizationId && serviceAccountIds.Contains(sa.Id));
return serviceAccountIds.Count == result;
}

public async Task<IEnumerable<ServiceAccountSecretsDetails>> GetManyByOrganizationIdWithSecretsDetailsAsync(
Guid organizationId, Guid userId, AccessClientType accessType)
{
Expand Down Expand Up @@ -186,6 +194,27 @@
return results;
}

private record ServiceAccountAccess(Guid Id, bool Read, bool Write);

private static IQueryable<ServiceAccountAccess> BuildServiceAccountAccessQuery(IQueryable<ServiceAccount> serviceAccountQuery, Guid userId,
AccessClientType accessType) =>
accessType switch
{
AccessClientType.NoAccessCheck => serviceAccountQuery.Select(sa => new ServiceAccountAccess(sa.Id, true, true)),
AccessClientType.User => serviceAccountQuery.Select(sa => new ServiceAccountAccess
(
sa.Id,
sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
sa.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read)),
sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
sa.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write))
)),
AccessClientType.ServiceAccount => serviceAccountQuery.Select(sa => new ServiceAccountAccess(sa.Id, false, false)),
_ => serviceAccountQuery.Select(sa => new ServiceAccountAccess(sa.Id, false, false))

Check warning on line 215 in bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs

View check run for this annotation

Codecov / codecov/patch

bitwarden_license/src/Commercial.Infrastructure.EntityFramework/SecretsManager/Repositories/ServiceAccountRepository.cs#L214-L215

Added lines #L214 - L215 were not covered by tests
};

private static Expression<Func<ServiceAccount, bool>> UserHasReadAccessToServiceAccount(Guid userId) => sa =>
sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
sa.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read));
Expand Down