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
6 changes: 5 additions & 1 deletion src/SelfService/Application/IMemberApplicationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ public async Task RegisterUserProfile(UserId userId, string name, string email)
var user = await _memberRepository.FindBy(userId);
if (user == null)
{
user = Member.Register(userId, name, email, new UserSettings());
// Member.Register is (id, email, displayName) — keep the same arg order as the Update
// path below so create and update agree. (Previously this passed (name, email), which
// stored Email/DisplayName swapped until the next profile update healed it; members
// provisioned from the RBAC admin page never log in, so the swap was permanent.)
user = Member.Register(userId, email, name, new UserSettings());
await _memberRepository.Add(user);

_logger.LogInformation("User {UserId} has been registered", userId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace SelfService.Infrastructure.Api.RBAC.Dto;

public class CapabilityMembershipApiResource
{
public string CapabilityId { get; set; } = "";
public DateTime CreatedAt { get; set; }
}

public class GrantCapabilityMembershipRequest
{
public string CapabilityId { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,12 @@ public class RegisterServicePrincipalRequest
public string Id { get; set; } = string.Empty;
public string? DisplayName { get; set; }
}

// Provisions a regular (User) Member from the RBAC admin page after an admin picks an Azure AD
// tenant user that is not yet in selfservice. Id is the user's email/UPN.
public class ProvisionMemberRequest
{
public string Id { get; set; } = string.Empty;
public string? DisplayName { get; set; }
public string? Email { get; set; }
}
104 changes: 103 additions & 1 deletion src/SelfService/Infrastructure/Api/RBAC/RbacController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public class RbacController : ControllerBase
private readonly IMemberRepository _memberRepository;
private readonly IMemberApplicationService _memberApplicationService;
private readonly IMembershipApplicationService _membershipApplicationService;
private readonly IMembershipRepository _membershipRepository;
private readonly ApiResourceFactory _apiResourceFactory;
private readonly IAuthorizationService _authorizationService;

Expand All @@ -34,6 +35,7 @@ public RbacController(
IMemberRepository memberRepository,
IMemberApplicationService memberApplicationService,
IMembershipApplicationService membershipApplicationService,
IMembershipRepository membershipRepository,
ApiResourceFactory apiResourceFactory,
IAuthorizationService authorizationService
)
Expand All @@ -44,6 +46,7 @@ IAuthorizationService authorizationService
_memberRepository = memberRepository;
_memberApplicationService = memberApplicationService;
_membershipApplicationService = membershipApplicationService;
_membershipRepository = membershipRepository;
_apiResourceFactory = apiResourceFactory;
_authorizationService = authorizationService;
}
Expand Down Expand Up @@ -423,6 +426,46 @@ await _memberApplicationService.RegisterServicePrincipal(
return existing == null ? StatusCode(StatusCodes.Status201Created, resource) : Ok(resource);
}

[HttpPost("members")]
[ProducesResponseType(typeof(MemberSummaryApiResource), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(MemberSummaryApiResource), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[RequiresPermission("rbac", "create")]
public async Task<IActionResult> ProvisionMember([FromBody] ProvisionMemberRequest request)
{
if (request == null || string.IsNullOrWhiteSpace(request.Id))
return BadRequest("id is required");

if (!UserId.TryParse(request.Id, out var userId))
return BadRequest("id is not a valid user id");

var existing = await _memberRepository.FindBy(userId);
if (existing != null && existing.Type == MemberType.ServicePrincipal)
{
return Conflict(
new ProblemDetails
{
Title = "Identifier already taken by a service principal",
Detail =
$"Member \"{userId}\" already exists as a service principal and cannot be registered as a user.",
Status = StatusCodes.Status409Conflict,
}
);
}

var email = string.IsNullOrWhiteSpace(request.Email) ? userId.ToString() : request.Email!;
await _memberApplicationService.RegisterUserProfile(userId, request.DisplayName ?? email, email);

var member = await _memberRepository.FindBy(userId);
if (member == null)
return StatusCode(StatusCodes.Status500InternalServerError);

var resource = MapMemberSummary(member, includeLinks: true);
return existing == null ? StatusCode(StatusCodes.Status201Created, resource) : Ok(resource);
}

[HttpGet("members/{id:required}")]
[ProducesResponseType(typeof(MemberSummaryApiResource), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
Expand Down Expand Up @@ -452,6 +495,61 @@ public async Task<IActionResult> GetMemberGroups(string id)
return Ok(payload);
}

[HttpGet("members/{id:required}/memberships")]
[ProducesResponseType(typeof(List<CapabilityMembershipApiResource>), StatusCodes.Status200OK)]
[RequiresPermission("rbac", "read")]
public async Task<IActionResult> GetMemberMemberships(string id)
{
if (!UserId.TryParse(id, out var memberId))
return BadRequest("Invalid member id");

var memberships = await _membershipRepository.GetAllMembershipsForUserId(memberId);
var payload = memberships
.Select(m => new CapabilityMembershipApiResource
{
CapabilityId = m.CapabilityId.ToString(),
CreatedAt = m.CreatedAt,
})
.ToList();
return Ok(payload);
}

[HttpPost("members/{id:required}/memberships")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[RequiresPermission("rbac", "create")]
public async Task<IActionResult> AddMemberMembership(string id, [FromBody] GrantCapabilityMembershipRequest request)
{
if (!User.TryGetUserId(out _))
return Unauthorized();
if (!UserId.TryParse(id, out var memberId))
return BadRequest("Invalid member id");
if (request?.CapabilityId == null || !CapabilityId.TryParse(request.CapabilityId, out var capabilityId))
return BadRequest("Invalid capability id");

await EnsureCapabilityMembership(capabilityId, memberId);
return Created();
}

[HttpDelete("members/{id:required}/memberships/{capabilityId:required}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[RequiresPermission("rbac", "delete")]
public async Task<IActionResult> RemoveMemberMembership(string id, string capabilityId)
{
if (!User.TryGetUserId(out _))
return Unauthorized();
if (!UserId.TryParse(id, out var memberId))
return BadRequest("Invalid member id");
if (!CapabilityId.TryParse(capabilityId, out var parsedCapabilityId))
return BadRequest("Invalid capability id");

await _membershipApplicationService.RemoveMemberFromCapability(parsedCapabilityId, memberId);
return NoContent();
}

[HttpPost("permission/grant-bulk")]
[ProducesResponseType(typeof(BulkPermissionGrantResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
Expand Down Expand Up @@ -527,6 +625,11 @@ private async Task SyncMembershipOnCapabilityGrant(Domain.Models.RbacRoleGrant g
if (!UserId.TryParse(grant.AssignedEntityId, out var entityId))
return;

await EnsureCapabilityMembership(capabilityId, entityId);
}

private async Task EnsureCapabilityMembership(CapabilityId capabilityId, UserId entityId)
{
var member = await _memberRepository.FindBy(entityId);
if (member is { Type: MemberType.ServicePrincipal })
{
Expand All @@ -537,7 +640,6 @@ private async Task SyncMembershipOnCapabilityGrant(Domain.Models.RbacRoleGrant g
{
await _membershipApplicationService.JoinCapability(capabilityId, entityId);
}
// Both membership methods swallow AlreadyHasActiveMembershipException, so re-granting is idempotent.
}

// Counterpart to the grant: when the last capability-scoped role for a user or service principal
Expand Down
Loading