diff --git a/src/SelfService/Application/IMemberApplicationService.cs b/src/SelfService/Application/IMemberApplicationService.cs index e952fb2a..c598c065 100644 --- a/src/SelfService/Application/IMemberApplicationService.cs +++ b/src/SelfService/Application/IMemberApplicationService.cs @@ -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); diff --git a/src/SelfService/Infrastructure/Api/RBAC/Dto/CapabilityMembershipApiResource.cs b/src/SelfService/Infrastructure/Api/RBAC/Dto/CapabilityMembershipApiResource.cs new file mode 100644 index 00000000..b67cebd6 --- /dev/null +++ b/src/SelfService/Infrastructure/Api/RBAC/Dto/CapabilityMembershipApiResource.cs @@ -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; +} diff --git a/src/SelfService/Infrastructure/Api/RBAC/Dto/RegisterServicePrincipalRequest.cs b/src/SelfService/Infrastructure/Api/RBAC/Dto/RegisterServicePrincipalRequest.cs index 367010f0..2179b554 100644 --- a/src/SelfService/Infrastructure/Api/RBAC/Dto/RegisterServicePrincipalRequest.cs +++ b/src/SelfService/Infrastructure/Api/RBAC/Dto/RegisterServicePrincipalRequest.cs @@ -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; } +} diff --git a/src/SelfService/Infrastructure/Api/RBAC/RbacController.cs b/src/SelfService/Infrastructure/Api/RBAC/RbacController.cs index 2e27db7b..fe865386 100644 --- a/src/SelfService/Infrastructure/Api/RBAC/RbacController.cs +++ b/src/SelfService/Infrastructure/Api/RBAC/RbacController.cs @@ -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; @@ -34,6 +35,7 @@ public RbacController( IMemberRepository memberRepository, IMemberApplicationService memberApplicationService, IMembershipApplicationService membershipApplicationService, + IMembershipRepository membershipRepository, ApiResourceFactory apiResourceFactory, IAuthorizationService authorizationService ) @@ -44,6 +46,7 @@ IAuthorizationService authorizationService _memberRepository = memberRepository; _memberApplicationService = memberApplicationService; _membershipApplicationService = membershipApplicationService; + _membershipRepository = membershipRepository; _apiResourceFactory = apiResourceFactory; _authorizationService = authorizationService; } @@ -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 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)] @@ -452,6 +495,61 @@ public async Task GetMemberGroups(string id) return Ok(payload); } + [HttpGet("members/{id:required}/memberships")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [RequiresPermission("rbac", "read")] + public async Task 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 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 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)] @@ -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 }) { @@ -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