Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
using Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers;
using System.Security.Claims;
using Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers;
using Bit.Services.Pam.AccessConnector.Api.Models.Request;
using Microsoft.AspNetCore.Mvc;

namespace Bit.Services.Pam.AccessConnector.Api.Endpoints;

/// <summary>
/// The <c>organizations/{orgId}/access-connectors</c> resource: fleet registration, enable/disable, deletion, and
/// target assignment. <c>orgId</c> is bound from the group's route prefix.
/// </summary>
/// <summary>The <c>organizations/{orgId}/access-connectors</c> resource.</summary>
internal static class AccessConnectorEndpoints
{
public static RouteGroupBuilder MapAccessConnectorEndpoints(this RouteGroupBuilder group)
Expand All @@ -23,25 +21,25 @@ public static RouteGroupBuilder MapAccessConnectorEndpoints(this RouteGroupBuild
"Returns one access connector with its recent rotation activity -- the jobs it has worked and " +
"the attempts it recorded against them, newest first.");

group.MapPost("", ([FromRoute] Guid orgId, RegisterAccessConnectorRequestModel model, AccessConnectorEndpointsHandler handler) => handler.Post(orgId, model))
group.MapPost("", ([FromRoute] Guid orgId, RegisterAccessConnectorRequestModel model, AccessConnectorEndpointsHandler handler, ClaimsPrincipal user) => handler.Post(user, orgId, model))
.WithName("Pam_AccessConnectors_Post")
.WithDescription(
"Registers an access connector and returns its client secret. The secret is shown exactly once here " +
"-- the server hashes it for storage and cannot return it again.");

group.MapPost("{id:guid}/enable",
async ([FromRoute] Guid orgId, Guid id, AccessConnectorEndpointsHandler handler) =>
async ([FromRoute] Guid orgId, Guid id, AccessConnectorEndpointsHandler handler, ClaimsPrincipal user) =>
{
await handler.Enable(orgId, id);
await handler.Enable(user, orgId, id);
return TypedResults.NoContent();
})
.WithName("Pam_AccessConnectors_Enable")
.WithDescription("Re-enables a disabled access connector so it can authenticate and claim jobs again.");

group.MapPost("{id:guid}/disable",
async ([FromRoute] Guid orgId, Guid id, AccessConnectorEndpointsHandler handler) =>
async ([FromRoute] Guid orgId, Guid id, AccessConnectorEndpointsHandler handler, ClaimsPrincipal user) =>
{
await handler.Disable(orgId, id);
await handler.Disable(user, orgId, id);
return TypedResults.NoContent();
})
.WithName("Pam_AccessConnectors_Disable")
Expand All @@ -50,9 +48,9 @@ public static RouteGroupBuilder MapAccessConnectorEndpoints(this RouteGroupBuild
"are released, but its credential is retained so it can be re-enabled later.");

group.MapDelete("{id:guid}",
async ([FromRoute] Guid orgId, Guid id, AccessConnectorEndpointsHandler handler) =>
async ([FromRoute] Guid orgId, Guid id, AccessConnectorEndpointsHandler handler, ClaimsPrincipal user) =>
{
await handler.Delete(orgId, id);
await handler.Delete(user, orgId, id);
return TypedResults.NoContent();
})
.WithName("Pam_AccessConnectors_Delete")
Expand All @@ -62,17 +60,17 @@ public static RouteGroupBuilder MapAccessConnectorEndpoints(this RouteGroupBuild
"remediation for a suspected compromise.");

group.MapPost("{id:guid}/assignments",
async ([FromRoute] Guid orgId, Guid id, AssignAccessConnectorTargetRequestModel model, AccessConnectorEndpointsHandler handler) =>
async ([FromRoute] Guid orgId, Guid id, AssignAccessConnectorTargetRequestModel model, AccessConnectorEndpointsHandler handler, ClaimsPrincipal user) =>
{
await handler.AssignTarget(orgId, id, model);
await handler.AssignTarget(user, orgId, id, model);
return TypedResults.NoContent();
})
.WithName("Pam_AccessConnectors_AssignTarget");

group.MapDelete("{id:guid}/assignments/{targetSystemId:guid}",
async ([FromRoute] Guid orgId, Guid id, Guid targetSystemId, AccessConnectorEndpointsHandler handler) =>
async ([FromRoute] Guid orgId, Guid id, Guid targetSystemId, AccessConnectorEndpointsHandler handler, ClaimsPrincipal user) =>
{
await handler.UnassignTarget(orgId, id, targetSystemId);
await handler.UnassignTarget(user, orgId, id, targetSystemId);
return TypedResults.NoContent();
})
.WithName("Pam_AccessConnectors_UnassignTarget");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Pam.Entities;
using Bit.Pam.Repositories;
using Microsoft.Extensions.Options;

namespace Bit.Services.Pam.AccessConnector.Api.Endpoints.Filters;

/// <summary>
/// Bumps <see cref="PamAccessConnector.LastHeartbeatDate"/> on every connector-facing route, at most once per
/// <c>HeartbeatMinInterval</c> since the repository write is conditional. Authorizes nothing; token issuance and the
/// work queries check eligibility.
/// </summary>
public class AccessConnectorHeartbeatEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var services = context.HttpContext.RequestServices;
var currentContext = services.GetRequiredService<ICurrentContext>();
var accessConnectorRepository = services.GetRequiredService<IPamAccessConnectorRepository>();
var options = services.GetRequiredService<IOptions<PamRotationOptions>>();
var timeProvider = services.GetRequiredService<TimeProvider>();

var accessConnectorId = currentContext.PamAccessConnectorId ?? throw new NotFoundException();

var now = timeProvider.GetUtcNow().UtcDateTime;
await accessConnectorRepository.UpdateHeartbeatAsync(
accessConnectorId, now, options.Value.HeartbeatMinInterval);

return await next(context);
}
}
Original file line number Diff line number Diff line change
@@ -1,42 +1,75 @@
using Bit.HttpExtensions;
using System.Security.Claims;
using Bit.Core.Services;
using Bit.HttpExtensions;
using Bit.Services.Pam.AccessConnector.Api.Models.Request;
using Bit.Services.Pam.AccessConnector.Api.Models.Response;
using Bit.Services.Pam.AccessConnector.Commands.Interfaces;
using Bit.Services.Pam.AccessConnector.Queries.Interfaces;

namespace Bit.Services.Pam.AccessConnector.Api.Endpoints.Handlers;

/// <summary>
/// Handler for the <c>organizations/{orgId}/access-connectors</c> resource: fleet registration, enable/disable,
/// deletion, and target assignment. The Minimal API endpoints (see <c>AccessConnectorEndpoints</c>) resolve this
/// handler from DI.
/// Handler for the <c>organizations/{orgId}/access-connectors</c> resource, authorized in the middleware by
/// <c>ManageAccessConnectorRequirement</c>. The commands check the plan and edit access.
/// </summary>
/// <remarks>
/// Scaffold only: the method signatures define the wire contract (request/response models, status codes) that the
/// generated OpenAPI spec and client bindings are built from. The bodies are intentionally unimplemented — the
/// behavior lands with the rest of the rotation feature.
/// </remarks>
public class AccessConnectorEndpointsHandler
public class AccessConnectorEndpointsHandler(
IUserService userService,
IListAccessConnectorsQuery listAccessConnectorsQuery,
IGetAccessConnectorDetailsQuery getAccessConnectorDetailsQuery,
IRegisterAccessConnectorCommand registerAccessConnectorCommand,
ISetAccessConnectorStatusCommand setAccessConnectorStatusCommand,
IDeleteAccessConnectorCommand deleteAccessConnectorCommand,
IAssignAccessConnectorToTargetCommand assignAccessConnectorToTargetCommand,
IUnassignAccessConnectorFromTargetCommand unassignAccessConnectorFromTargetCommand)
{
public Task<ListResponseModel<PamAccessConnectorResponseModel>> GetAll(Guid orgId)
=> throw new NotImplementedException();
public async Task<ListResponseModel<PamAccessConnectorResponseModel>> GetAll(Guid orgId)
{
var connectors = await listAccessConnectorsQuery.ListAsync(orgId);
return new ListResponseModel<PamAccessConnectorResponseModel>(
connectors.Select(connector => new PamAccessConnectorResponseModel(connector)));
}

public Task<PamAccessConnectorDetailResponseModel> Get(Guid orgId, Guid id)
=> throw new NotImplementedException();
public async Task<PamAccessConnectorDetailResponseModel> Get(Guid orgId, Guid id)
{
var history = await getAccessConnectorDetailsQuery.GetAsync(orgId, id);
return new PamAccessConnectorDetailResponseModel(history);
}

public Task<RegisterAccessConnectorResponseModel> Post(Guid orgId, RegisterAccessConnectorRequestModel model)
=> throw new NotImplementedException();
public async Task<RegisterAccessConnectorResponseModel> Post(
ClaimsPrincipal user, Guid orgId, RegisterAccessConnectorRequestModel model)
{
var result = await registerAccessConnectorCommand.RegisterAsync(
orgId, userService.GetProperUserId(user)!.Value, model.Name, model.EncryptedPayload, model.Key);
return new RegisterAccessConnectorResponseModel(result);
}

public Task Enable(Guid orgId, Guid id)
=> throw new NotImplementedException();
public async Task Enable(ClaimsPrincipal user, Guid orgId, Guid id)
{
await setAccessConnectorStatusCommand.SetStatusAsync(
orgId, userService.GetProperUserId(user)!.Value, id, enable: true);
}

public Task Disable(Guid orgId, Guid id)
=> throw new NotImplementedException();
public async Task Disable(ClaimsPrincipal user, Guid orgId, Guid id)
{
await setAccessConnectorStatusCommand.SetStatusAsync(
orgId, userService.GetProperUserId(user)!.Value, id, enable: false);
}

public Task Delete(Guid orgId, Guid id)
=> throw new NotImplementedException();
public async Task Delete(ClaimsPrincipal user, Guid orgId, Guid id)
{
await deleteAccessConnectorCommand.DeleteAsync(orgId, userService.GetProperUserId(user)!.Value, id);
}

public Task AssignTarget(Guid orgId, Guid id, AssignAccessConnectorTargetRequestModel model)
=> throw new NotImplementedException();
public async Task AssignTarget(
ClaimsPrincipal user, Guid orgId, Guid id, AssignAccessConnectorTargetRequestModel model)
{
await assignAccessConnectorToTargetCommand.AssignAsync(
orgId, userService.GetProperUserId(user)!.Value, id, model.TargetSystemId);
}

public Task UnassignTarget(Guid orgId, Guid id, Guid targetSystemId)
=> throw new NotImplementedException();
public async Task UnassignTarget(ClaimsPrincipal user, Guid orgId, Guid id, Guid targetSystemId)
{
await unassignAccessConnectorFromTargetCommand.UnassignAsync(
orgId, userService.GetProperUserId(user)!.Value, id, targetSystemId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@

namespace Bit.Services.Pam.AccessConnector.Api.Models.Request;

/// <summary>The body of <c>POST access-connectors/{id}/assignments</c>: the target system to assign the access
/// connector to.</summary>
public class AssignAccessConnectorTargetRequestModel
{
/// <summary>
/// The target system the access connector should work. Assignment is what makes the target's rotation jobs visible
/// to the access connector -- an unassigned access connector sees no work, and a manual target has no access
/// connector to assign.
/// Assignment makes the target's rotation jobs visible to the access connector. A manual target cannot be
/// assigned.
/// </summary>
[Required]
public Guid TargetSystemId { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,26 @@
namespace Bit.Services.Pam.AccessConnector.Api.Models.Request;

/// <summary>
/// Registers a new access connector (spec <c>ConnectorRegistration</c>). <see cref="EncryptedPayload"/> and
/// <see cref="Key"/> carry the organization key wrapped client-side, so the server holds ciphertext only and never
/// sees the plaintext key. <see cref="Name"/> is a plaintext display label.
/// Registers a new access connector (spec <c>ConnectorRegistration</c>). The organization key arrives wrapped
/// client-side in <see cref="EncryptedPayload"/> and <see cref="Key"/>, so the server never sees it in plaintext.
/// </summary>
public class RegisterAccessConnectorRequestModel
{
/// <summary>The access connector's plaintext display label, shown wherever access connectors are listed and
/// managed.</summary>
/// <summary>The access connector's display label, sent in plaintext.</summary>
[Required]
[StringLength(200)]
public string Name { get; set; } = null!;

/// <summary>
/// The organization key, wrapped client-side with the encryption-key half of the access connector's credential.
/// Stored verbatim and handed back on every token response so the access connector can unwrap it locally; the
/// server only ever holds the ciphertext.
/// Returned on every token response so the access connector can unwrap it locally.
/// </summary>
[Required]
[EncryptedString]
[EncryptedStringLength(4000)]
public string EncryptedPayload { get; set; } = null!;

/// <summary>
/// The key protecting <see cref="EncryptedPayload"/>, itself uploaded wrapped -- opaque ciphertext the server
/// stores but can never use, recoverable only client-side.
/// </summary>
/// <summary>The key protecting <see cref="EncryptedPayload"/>, wrapped with the organization key.</summary>
[Required]
[EncryptedString]
public string Key { get; set; } = null!;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
using Bit.Services.Pam.AccessConnector.Rotation.Api.Models.Response;
using Bit.Services.Pam.AccessConnector.Models;
using Bit.Services.Pam.AccessConnector.Rotation.Api.Models.Response;

namespace Bit.Services.Pam.AccessConnector.Api.Models.Response;

/// <summary>
/// An access connector's detail view for <c>GET access-connectors/{id}</c>: the list shape flattened onto the same
/// object, plus the access connector's recent rotation activity. The fleet surface renders the header from the access
/// connector fields and the activity section from <see cref="Jobs"/>, so both arrive in one response.
/// </summary>
/// <summary>An access connector with its recent rotation activity.</summary>
public class PamAccessConnectorDetailResponseModel : PamAccessConnectorResponseModel
{
public PamAccessConnectorDetailResponseModel()
: base("pamAccessConnectorDetails")
public PamAccessConnectorDetailResponseModel(PamAccessConnectorHistory history)
: base(
history?.AccessConnector ?? throw new ArgumentNullException(nameof(history)), "pamAccessConnectorDetails")
{
Jobs = history.Jobs.Select(job => new PamRotationJobResponseModel(job)).ToList();
}

/// <summary>
/// The access connector's recent jobs, newest first, each carrying only the attempts this access connector recorded
/// -- capped by <c>GetAccessConnectorDetailsQuery</c> rather than being the access connector's whole history.
/// The access connector's most recent jobs (capped), newest first, each with only the attempts it recorded.
/// </summary>
public IReadOnlyList<PamRotationJobResponseModel> Jobs { get; set; } = [];
}
Original file line number Diff line number Diff line change
@@ -1,65 +1,54 @@
using Bit.HttpExtensions;
using Bit.Pam.Enums;
using Bit.Services.Pam.AccessConnector.Models;
using Bit.Services.Pam.Api.Models.Response;

namespace Bit.Services.Pam.AccessConnector.Api.Models.Response;

/// <summary>
/// An access connector as the fleet-admin surface renders it: its derived liveness (spec <c>ConnectorConnection</c>)
/// and the target systems it is assigned to. The list view model for <c>GET access-connectors</c>.
/// </summary>
/// <summary>An access connector with its derived liveness and target assignments.</summary>
public class PamAccessConnectorResponseModel : ResponseModel
{
public PamAccessConnectorResponseModel(string obj = "pamAccessConnector")
public PamAccessConnectorResponseModel(PamAccessConnectorListItem item, string obj = "pamAccessConnector")
: base(obj)
{
ArgumentNullException.ThrowIfNull(item);

Id = item.AccessConnector.Id;
OrganizationId = item.AccessConnector.OrganizationId;
Name = item.AccessConnector.Name;
Status = item.AccessConnector.Status;
IsConnected = item.IsConnected;
LastHeartbeatAt = item.AccessConnector.LastHeartbeatDate.AsUtc();
AssignedTargetSystemIds = item.AssignedTargetSystemIds;
CreationDate = item.AccessConnector.CreationDate.AsUtc();
RevisionDate = item.AccessConnector.RevisionDate.AsUtc();
}

/// <summary>
/// The access connector's unique identifier.
/// </summary>
public Guid Id { get; set; }

/// <summary>
/// The organization this access connector belongs to.
/// </summary>
public Guid OrganizationId { get; set; }

/// <summary>
/// The access connector's display label, shown wherever access connectors are listed and managed.
/// </summary>
public string Name { get; set; } = null!;

/// <summary>
/// Whether the access connector may authenticate, poll, and claim jobs -- see
/// <see cref="PamAccessConnectorStatus"/>. A disabled access connector keeps its credential and can be re-enabled.
/// Whether the access connector may authenticate and claim jobs. A disabled one keeps its credential and can be
/// re-enabled.
/// </summary>
public PamAccessConnectorStatus Status { get; set; }

/// <summary>
/// Derived from <see cref="LastHeartbeatAt"/> against <c>PamRotationOptions.ConnectorOfflineAfter</c> -- spec
/// <c>ConnectorConnection</c>.
/// Whether <see cref="LastHeartbeatAt"/> is within <c>AccessConnectorOfflineAfter</c> of now (spec
/// <c>ConnectorConnection</c>).
/// </summary>
public bool IsConnected { get; set; }

/// <summary>
/// The last time the access connector polled or reported (UTC). Null until its first request; bumped only by the
/// access connector's own requests, never by a sweep.
/// </summary>
/// <summary>The last time the access connector polled or reported. Null until its first request.</summary>
public DateTime? LastHeartbeatAt { get; set; }

/// <summary>
/// The target systems this access connector is assigned to work. An access connector is offered rotation jobs only
/// for the targets it is assigned.
/// </summary>
/// <summary>The only target systems the access connector is offered rotation jobs for.</summary>
public IReadOnlyList<Guid> AssignedTargetSystemIds { get; set; } = [];

/// <summary>
/// When the access connector was registered (UTC).
/// </summary>
public DateTime CreationDate { get; set; }

/// <summary>
/// When the access connector was last modified (UTC).
/// </summary>
public DateTime RevisionDate { get; set; }
}
Loading
Loading