fullstackhero/dotnet-starter-kit · error · ForbiddenException
Only channel admins can add members to private channels.
Error message
Only channel admins can add members to private channels.
What it means
AddChannelMembersCommandHandler throws ForbiddenException when the target channel is private and the calling user's ChannelMemberRole is not Admin. Members can invite into public channels, but private channels restrict membership changes to admins.
Solutions
- Have a channel admin perform the invite, or promote the caller to ChannelMemberRole.Admin
- Hide/disable the add-members UI for non-admins on private channels
- Check the caller's role via channel state before calling the endpoint
Example fix
// before
await api.addChannelMembers(channelId, userIds); // caller is Member on private channel
// after
if (channel.isPrivate && myRole !== 'Admin') {
throw new Error('Only channel admins can add members to private channels.');
}
await api.addChannelMembers(channelId, userIds); Defensive patterns
Strategy: validation
Validate before calling
var caller = channel.RequireMember(currentUserId); if (channel.IsPrivate && caller.Role != ChannelMemberRole.Admin) return 403; // before calling
Type guard
bool mayInvite = !channel.IsPrivate || myRoleInChannel === 'Admin';
Try / catch
try { await addMembers(channelId, userIds); }
catch (ForbiddenException) { /* show 'admin required' message */ } Prevention
- Gate invite UI on the caller's per-channel role
- Re-fetch role before sensitive actions
- Document that private channels require Admin
When it happens
Trigger: A non-admin member of a private channel invokes AddChannelMembers; a member of a public channel whose role in THAT channel is Member tries to add users to a private channel they belong to.
Common situations: Frontend showing 'invite' button without checking caller role; role downgraded to Member after an admin removal; user assuming channel-wide admin rights apply per-channel.
Understand the failure class
Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.
Related errors
- Channel admin role required.
- Cross-tenant audit summary requires…
- Cross-tenant audit access requires…
- Cannot start a DM with yourself.
- Group DM requires at least 3 distinct members.
AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15).
Data as JSON: /api/errors/e1189499f8818101.
Report an issue: GitHub.
Appendix: source
Thrown at src/Modules/Chat/Modules.Chat/Features/v1/Channels/AddChannelMembers/AddChannelMembersCommandHandler.cs:35
IHubContext<AppHub> hub)
: ICommandHandler<AddChannelMembersCommand, Unit>
{
public async ValueTask<Unit> Handle(AddChannelMembersCommand cmd, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(cmd);
var userId = currentUser.GetUserId();
if (userId == Guid.Empty) throw new UnauthorizedException("no current user");
var currentUserId = userId.ToString();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Id == cmd.ChannelId, cancellationToken)
.ConfigureAwait(false)
?? throw new NotFoundException("Channel not found.");
// Members can invite to public channels they belong to; private channels require Admin.
var caller = channel.RequireMember(currentUserId);
if (channel.IsPrivate && caller.Role != ChannelMemberRole.Admin)
{
throw new ForbiddenException("Only channel admins can add members to private channels.");
}
var newlyAdded = new List<string>();
foreach (var uid in cmd.UserIds.Distinct(StringComparer.Ordinal))
{
// Skip duplicates silently — endpoint is idempotent for already-members.
if (channel.Members.Any(m => string.Equals(m.UserId, uid, StringComparison.Ordinal))) continue;
channel.AddMember(uid, currentUserId);
newlyAdded.Add(uid);
}
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
foreach (var uid in newlyAdded)
{
// Notify existing members. The new member isn't in the channel:{id} group yet; they pick
// it up on next reconnect (OnConnectedAsync pre-joins all their channels).
await hub.Clients.Group($"channel:{channel.Id}")View on GitHub (pinned to 3f2959e683)