1
0
mirror of https://github.com/bitwarden/server synced 2026-01-19 00:43:47 +00:00

[PM-28485] Move organization events domain to DIRT code ownership (#6685)

This commit is contained in:
Thomas Rittson
2025-12-20 07:32:51 +10:00
committed by GitHub
parent bc800a788e
commit 69d72c2ad3
52 changed files with 15 additions and 13 deletions

View File

@@ -1,348 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Controllers;
[Route("events")]
[Authorize("Application")]
public class EventsController : Controller
{
private readonly IUserService _userService;
private readonly ICipherRepository _cipherRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IEventRepository _eventRepository;
private readonly ICurrentContext _currentContext;
private readonly ISecretRepository _secretRepository;
private readonly IProjectRepository _projectRepository;
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly ILogger<EventsController> _logger;
private readonly IFeatureService _featureService;
public EventsController(IUserService userService,
ICipherRepository cipherRepository,
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository,
IEventRepository eventRepository,
ICurrentContext currentContext,
ISecretRepository secretRepository,
IProjectRepository projectRepository,
IServiceAccountRepository serviceAccountRepository,
ILogger<EventsController> logger,
IFeatureService featureService)
{
_userService = userService;
_cipherRepository = cipherRepository;
_organizationUserRepository = organizationUserRepository;
_providerUserRepository = providerUserRepository;
_eventRepository = eventRepository;
_currentContext = currentContext;
_secretRepository = secretRepository;
_projectRepository = projectRepository;
_serviceAccountRepository = serviceAccountRepository;
_logger = logger;
_featureService = featureService;
}
[HttpGet("")]
public async Task<ListResponseModel<EventResponseModel>> GetUser(
[FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null)
{
var dateRange = ApiHelpers.GetDateRange(start, end);
var userId = _userService.GetProperUserId(User).Value;
var result = await _eventRepository.GetManyByUserAsync(userId, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[HttpGet("~/ciphers/{id}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetCipher(string id,
[FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null)
{
var cipher = await _cipherRepository.GetByIdAsync(new Guid(id));
if (cipher == null)
{
throw new NotFoundException();
}
var canView = false;
if (cipher.OrganizationId.HasValue)
{
canView = await _currentContext.AccessEventLogs(cipher.OrganizationId.Value);
}
else if (cipher.UserId.HasValue)
{
var userId = _userService.GetProperUserId(User).Value;
canView = userId == cipher.UserId.Value;
}
if (!canView)
{
throw new NotFoundException();
}
var dateRange = ApiHelpers.GetDateRange(start, end);
var result = await _eventRepository.GetManyByCipherAsync(cipher, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[HttpGet("~/organizations/{id}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetOrganization(string id,
[FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null)
{
var orgId = new Guid(id);
if (!await _currentContext.AccessEventLogs(orgId))
{
throw new NotFoundException();
}
var dateRange = ApiHelpers.GetDateRange(start, end);
var result = await _eventRepository.GetManyByOrganizationAsync(orgId, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
_logger.LogAggregateData(_featureService, orgId, responses, continuationToken, start, end);
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[HttpGet("~/organization/{orgId}/secrets/{id}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetSecrets(
Guid id, Guid orgId,
[FromQuery] DateTime? start = null,
[FromQuery] DateTime? end = null,
[FromQuery] string continuationToken = null)
{
if (id == Guid.Empty || orgId == Guid.Empty)
{
throw new NotFoundException();
}
var secret = await _secretRepository.GetByIdAsync(id);
var orgIdForVerification = secret?.OrganizationId ?? orgId;
var secretOrg = _currentContext.GetOrganization(orgIdForVerification);
if (secretOrg == null || !await _currentContext.AccessEventLogs(secretOrg.Id))
{
throw new NotFoundException();
}
bool canViewLogs = false;
if (secret == null)
{
secret = new Core.SecretsManager.Entities.Secret { Id = id, OrganizationId = orgId };
canViewLogs = secretOrg.Type is Core.Enums.OrganizationUserType.Admin or Core.Enums.OrganizationUserType.Owner;
}
else
{
canViewLogs = await CanViewSecretsLogs(secret);
}
if (!canViewLogs)
{
throw new NotFoundException();
}
var (fromDate, toDate) = ApiHelpers.GetDateRange(start, end);
var result = await _eventRepository.GetManyBySecretAsync(secret, fromDate, toDate, new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[HttpGet("~/organization/{orgId}/projects/{id}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetProjects(
Guid id,
Guid orgId,
[FromQuery] DateTime? start = null,
[FromQuery] DateTime? end = null,
[FromQuery] string continuationToken = null)
{
if (id == Guid.Empty || orgId == Guid.Empty)
{
throw new NotFoundException();
}
var project = await GetProject(id, orgId);
await ValidateOrganization(project);
var (fromDate, toDate) = ApiHelpers.GetDateRange(start, end);
var result = await _eventRepository.GetManyByProjectAsync(
project,
fromDate,
toDate,
new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[HttpGet("~/organization/{orgId}/service-account/{id}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetServiceAccounts(
Guid orgId,
Guid id,
[FromQuery] DateTime? start = null,
[FromQuery] DateTime? end = null,
[FromQuery] string continuationToken = null)
{
if (id == Guid.Empty || orgId == Guid.Empty)
{
throw new NotFoundException();
}
var serviceAccount = await GetServiceAccount(id, orgId);
var org = _currentContext.GetOrganization(orgId);
if (org == null || !await _currentContext.AccessEventLogs(org.Id))
{
throw new NotFoundException();
}
var (fromDate, toDate) = ApiHelpers.GetDateRange(start, end);
var result = await _eventRepository.GetManyByOrganizationServiceAccountAsync(
serviceAccount.OrganizationId,
serviceAccount.Id,
fromDate,
toDate,
new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[ApiExplorerSettings(IgnoreApi = true)]
private async Task<ServiceAccount> GetServiceAccount(Guid serviceAccountId, Guid orgId)
{
var serviceAccount = await _serviceAccountRepository.GetByIdAsync(serviceAccountId);
if (serviceAccount != null)
{
return serviceAccount;
}
var fallbackServiceAccount = new ServiceAccount
{
Id = serviceAccountId,
OrganizationId = orgId
};
return fallbackServiceAccount;
}
[HttpGet("~/organizations/{orgId}/users/{id}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetOrganizationUser(string orgId, string id,
[FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null)
{
var organizationUser = await _organizationUserRepository.GetByIdAsync(new Guid(id));
if (organizationUser == null || !organizationUser.UserId.HasValue ||
!await _currentContext.AccessEventLogs(organizationUser.OrganizationId))
{
throw new NotFoundException();
}
var dateRange = ApiHelpers.GetDateRange(start, end);
var result = await _eventRepository.GetManyByOrganizationActingUserAsync(organizationUser.OrganizationId,
organizationUser.UserId.Value, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[HttpGet("~/providers/{providerId:guid}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetProvider(Guid providerId,
[FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null)
{
if (!_currentContext.ProviderAccessEventLogs(providerId))
{
throw new NotFoundException();
}
var dateRange = ApiHelpers.GetDateRange(start, end);
var result = await _eventRepository.GetManyByProviderAsync(providerId, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[HttpGet("~/providers/{providerId:guid}/users/{id:guid}/events")]
public async Task<ListResponseModel<EventResponseModel>> GetProviderUser(Guid providerId, Guid id,
[FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null)
{
var providerUser = await _providerUserRepository.GetByIdAsync(id);
if (providerUser == null || !providerUser.UserId.HasValue ||
!_currentContext.ProviderAccessEventLogs(providerUser.ProviderId))
{
throw new NotFoundException();
}
var dateRange = ApiHelpers.GetDateRange(start, end);
var result = await _eventRepository.GetManyByProviderActingUserAsync(providerUser.ProviderId,
providerUser.UserId.Value, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
}
[ApiExplorerSettings(IgnoreApi = true)]
private async Task ValidateOrganization(Project project)
{
var org = _currentContext.GetOrganization(project.OrganizationId);
if (org == null || !await _currentContext.AccessEventLogs(org.Id))
{
throw new NotFoundException();
}
}
[ApiExplorerSettings(IgnoreApi = true)]
private async Task<Project> GetProject(Guid projectGuid, Guid orgGuid)
{
var project = await _projectRepository.GetByIdAsync(projectGuid);
if (project != null)
{
return project;
}
var fallbackProject = new Project
{
Id = projectGuid,
OrganizationId = orgGuid
};
return fallbackProject;
}
[ApiExplorerSettings(IgnoreApi = true)]
private async Task<bool> CanViewSecretsLogs(Secret secret)
{
if (!_currentContext.AccessSecretsManager(secret.OrganizationId))
{
throw new NotFoundException();
}
var userId = _userService.GetProperUserId(User)!.Value;
var isAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, isAdmin);
var access = await _secretRepository.AccessToSecretAsync(secret.Id, userId, accessClient);
return access.Read;
}
}

View File

@@ -1,63 +0,0 @@
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Models.Data;
namespace Bit.Api.Models.Response;
public class EventResponseModel : ResponseModel
{
public EventResponseModel(IEvent ev)
: base("event")
{
if (ev == null)
{
throw new ArgumentNullException(nameof(ev));
}
Type = ev.Type;
UserId = ev.UserId;
OrganizationId = ev.OrganizationId;
ProviderId = ev.ProviderId;
CipherId = ev.CipherId;
CollectionId = ev.CollectionId;
GroupId = ev.GroupId;
PolicyId = ev.PolicyId;
OrganizationUserId = ev.OrganizationUserId;
ProviderUserId = ev.ProviderUserId;
ProviderOrganizationId = ev.ProviderOrganizationId;
ActingUserId = ev.ActingUserId;
Date = ev.Date;
DeviceType = ev.DeviceType;
IpAddress = ev.IpAddress;
InstallationId = ev.InstallationId;
SystemUser = ev.SystemUser;
DomainName = ev.DomainName;
SecretId = ev.SecretId;
ProjectId = ev.ProjectId;
ServiceAccountId = ev.ServiceAccountId;
GrantedServiceAccountId = ev.GrantedServiceAccountId;
}
public EventType Type { get; set; }
public Guid? UserId { get; set; }
public Guid? OrganizationId { get; set; }
public Guid? ProviderId { get; set; }
public Guid? CipherId { get; set; }
public Guid? CollectionId { get; set; }
public Guid? GroupId { get; set; }
public Guid? PolicyId { get; set; }
public Guid? OrganizationUserId { get; set; }
public Guid? ProviderUserId { get; set; }
public Guid? ProviderOrganizationId { get; set; }
public Guid? ActingUserId { get; set; }
public Guid? InstallationId { get; set; }
public DateTime Date { get; set; }
public DeviceType? DeviceType { get; set; }
public string IpAddress { get; set; }
public EventSystemUser? SystemUser { get; set; }
public string DomainName { get; set; }
public Guid? SecretId { get; set; }
public Guid? ProjectId { get; set; }
public Guid? ServiceAccountId { get; set; }
public Guid? GrantedServiceAccountId { get; set; }
}

View File

@@ -1,133 +0,0 @@

using System.Net;
using Bit.Api.Models.Public.Request;
using Bit.Api.Models.Public.Response;
using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.Context;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Public.Controllers;
[Route("public/events")]
[Authorize("Organization")]
public class EventsController : Controller
{
private readonly IEventRepository _eventRepository;
private readonly ICipherRepository _cipherRepository;
private readonly ICurrentContext _currentContext;
private readonly ISecretRepository _secretRepository;
private readonly IProjectRepository _projectRepository;
private readonly IUserService _userService;
private readonly ILogger<EventsController> _logger;
private readonly IFeatureService _featureService;
public EventsController(
IEventRepository eventRepository,
ICipherRepository cipherRepository,
ICurrentContext currentContext,
ISecretRepository secretRepository,
IProjectRepository projectRepository,
IUserService userService,
ILogger<EventsController> logger,
IFeatureService featureService)
{
_eventRepository = eventRepository;
_cipherRepository = cipherRepository;
_currentContext = currentContext;
_secretRepository = secretRepository;
_projectRepository = projectRepository;
_userService = userService;
_logger = logger;
_featureService = featureService;
}
/// <summary>
/// List all events.
/// </summary>
/// <remarks>
/// Returns a filtered list of your organization's event logs, paged by a continuation token.
/// If no filters are provided, it will return the last 30 days of event for the organization.
/// </remarks>
[HttpGet]
[ProducesResponseType(typeof(PagedListResponseModel<EventResponseModel>), (int)HttpStatusCode.OK)]
public async Task<IActionResult> List([FromQuery] EventFilterRequestModel request)
{
if (!_currentContext.OrganizationId.HasValue)
{
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
}
var organizationId = _currentContext.OrganizationId.Value;
var dateRange = request.ToDateRange();
var result = new PagedResult<IEvent>();
if (request.ActingUserId.HasValue)
{
result = await _eventRepository.GetManyByOrganizationActingUserAsync(
organizationId, request.ActingUserId.Value, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
else if (request.ItemId.HasValue)
{
var cipher = await _cipherRepository.GetByIdAsync(request.ItemId.Value);
if (cipher != null && cipher.OrganizationId == organizationId)
{
result = await _eventRepository.GetManyByCipherAsync(
cipher, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
}
else if (request.SecretId.HasValue)
{
var secret = await _secretRepository.GetByIdAsync(request.SecretId.Value);
if (secret == null)
{
secret = new Core.SecretsManager.Entities.Secret { Id = request.SecretId.Value, OrganizationId = organizationId };
}
if (secret.OrganizationId == organizationId)
{
result = await _eventRepository.GetManyBySecretAsync(
secret, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
else
{
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
}
}
else if (request.ProjectId.HasValue)
{
var project = await _projectRepository.GetByIdAsync(request.ProjectId.Value);
if (project != null && project.OrganizationId == organizationId)
{
result = await _eventRepository.GetManyByProjectAsync(
project, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
else
{
return new JsonResult(new PagedListResponseModel<EventResponseModel>([], ""));
}
}
else
{
result = await _eventRepository.GetManyByOrganizationAsync(
organizationId, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = request.ContinuationToken });
}
var eventResponses = result.Data.Select(e => new EventResponseModel(e));
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken ?? "");
_logger.LogAggregateData(_featureService, organizationId, response, request);
return new JsonResult(response);
}
}

View File

@@ -1,60 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Exceptions;
namespace Bit.Api.Models.Public.Request;
public class EventFilterRequestModel
{
/// <summary>
/// The start date. Must be less than the end date.
/// </summary>
public DateTime? Start { get; set; }
/// <summary>
/// The end date. Must be greater than the start date.
/// </summary>
public DateTime? End { get; set; }
/// <summary>
/// The unique identifier of the user that performed the event.
/// </summary>
public Guid? ActingUserId { get; set; }
/// <summary>
/// The unique identifier of the related item that the event describes.
/// </summary>
public Guid? ItemId { get; set; }
/// <summary>
/// The unique identifier of the related secret that the event describes.
/// </summary>
public Guid? SecretId { get; set; }
/// <summary>
/// The unique identifier of the related project that the event describes.
/// </summary>
public Guid? ProjectId { get; set; }
/// <summary>
/// A cursor for use in pagination.
/// </summary>
public string ContinuationToken { get; set; }
public Tuple<DateTime, DateTime> ToDateRange()
{
if (!End.HasValue || !Start.HasValue)
{
End = DateTime.UtcNow.Date.AddDays(1).AddMilliseconds(-1);
Start = DateTime.UtcNow.Date.AddDays(-30);
}
else if (Start.Value > End.Value)
{
var newEnd = Start;
Start = End;
End = newEnd;
}
if ((End.Value - Start.Value) > TimeSpan.FromDays(367))
{
throw new BadRequestException("Date range must be < 367 days.");
}
return new Tuple<DateTime, DateTime>(Start.Value, End.Value);
}
}

View File

@@ -1,110 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
namespace Bit.Api.Models.Public.Response;
/// <summary>
/// An event log.
/// </summary>
public class EventResponseModel : IResponseModel
{
public EventResponseModel(IEvent ev)
{
if (ev == null)
{
throw new ArgumentNullException(nameof(ev));
}
Type = ev.Type;
ItemId = ev.CipherId;
CollectionId = ev.CollectionId;
GroupId = ev.GroupId;
PolicyId = ev.PolicyId;
MemberId = ev.OrganizationUserId;
ActingUserId = ev.ActingUserId;
Date = ev.Date;
Device = ev.DeviceType;
IpAddress = ev.IpAddress;
InstallationId = ev.InstallationId;
SecretId = ev.SecretId;
ProjectId = ev.ProjectId;
ServiceAccountId = ev.ServiceAccountId;
}
/// <summary>
/// String representing the object's type. Objects of the same type share the same properties.
/// </summary>
/// <example>event</example>
[Required]
public string Object => "event";
/// <summary>
/// The type of event.
/// </summary>
[Required]
public EventType Type { get; set; }
/// <summary>
/// The unique identifier of the related item that the event describes.
/// </summary>
/// <example>3767a302-8208-4dc6-b842-030428a1cfad</example>
public Guid? ItemId { get; set; }
/// <summary>
/// The unique identifier of the related collection that the event describes.
/// </summary>
/// <example>bce212a4-25f3-4888-8a0a-4c5736d851e0</example>
public Guid? CollectionId { get; set; }
/// <summary>
/// The unique identifier of the related group that the event describes.
/// </summary>
/// <example>f29a2515-91d2-4452-b49b-5e8040e6b0f4</example>
public Guid? GroupId { get; set; }
/// <summary>
/// The unique identifier of the related policy that the event describes.
/// </summary>
/// <example>f29a2515-91d2-4452-b49b-5e8040e6b0f4</example>
public Guid? PolicyId { get; set; }
/// <summary>
/// The unique identifier of the related member that the event describes.
/// </summary>
/// <example>e68b8629-85eb-4929-92c0-b84464976ba4</example>
public Guid? MemberId { get; set; }
/// <summary>
/// The unique identifier of the user that performed the event.
/// </summary>
/// <example>a2549f79-a71f-4eb9-9234-eb7247333f94</example>
public Guid? ActingUserId { get; set; }
/// <summary>
/// The Unique identifier of the Installation that performed the event.
/// </summary>
/// <value></value>
public Guid? InstallationId { get; set; }
/// <summary>
/// The date/timestamp when the event occurred.
/// </summary>
[Required]
public DateTime Date { get; set; }
/// <summary>
/// The type of device used by the acting user when the event occurred.
/// </summary>
public DeviceType? Device { get; set; }
/// <summary>
/// The IP address of the acting user.
/// </summary>
/// <example>172.16.254.1</example>
public string IpAddress { get; set; }
/// <summary>
/// The unique identifier of the related secret that the event describes.
/// </summary>
/// <example>e68b8629-85eb-4929-92c0-b84464976ba4</example>
public Guid? SecretId { get; set; }
/// <summary>
/// The unique identifier of the related project that the event describes.
/// </summary>
/// <example>e68b8629-85eb-4929-92c0-b84464976ba4</example>
public Guid? ProjectId { get; set; }
/// <summary>
/// The unique identifier of the related service account that the event describes.
/// </summary>
/// <example>e68b8629-85eb-4929-92c0-b84464976ba4</example>
public Guid? ServiceAccountId { get; set; }
}