From 94c72e3f6c82540e9e67cb519bc93ef809996717 Mon Sep 17 00:00:00 2001 From: Chris Addams Date: Thu, 4 Jun 2026 02:00:34 +0100 Subject: [PATCH] fix(roles): support system permissions in roles permissions add/list and add a remove command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit System permissions (such as anythink_subscription_plans:*) are defined globally without a corresponding entity row — their entity_id is null. Three related gaps showed up when trying to manage them via the CLI: - roles permissions add hard-failed when the named entity didn't exist, so granting anythink_subscription_plans:read was impossible. - roles permissions list filtered out any permission whose entity_id was null, so even when a system permission was assigned through the admin UI it was invisible to the CLI. - There was no roles permissions remove, so toggling off was impossible without dropping into the API directly. This change makes the entity lookup tolerant of a 404, falls back to matching permissions by exact name + entity_id == null when the entity isn't found (so we only grant true system permissions, not anything that happens to share a name), drops the entity_id filter from list, and adds a parallel remove command. --- src/Commands/RolesCommand.cs | 99 ++++++++++++++++++++++++++++++++++-- src/Program.cs | 4 ++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/Commands/RolesCommand.cs b/src/Commands/RolesCommand.cs index f8a6fe9..3045ab2 100644 --- a/src/Commands/RolesCommand.cs +++ b/src/Commands/RolesCommand.cs @@ -183,7 +183,7 @@ await AnsiConsole.Status() var perms = role.Permissions ?? []; var entityPerms = new Dictionary>(); - foreach (var p in perms.Where(p => p.EntityId.HasValue)) + foreach (var p in perms) { var parts = p.Name.Split(':'); if (parts.Length == 2) @@ -236,7 +236,7 @@ public override async Task ExecuteAsync(CommandContext context, RolePermiss { var client = GetClient(); - var entity = await client.GetEntityAsync(settings.Entity); + int? entityId = await TryResolveEntityIdAsync(client, settings.Entity); var role = await client.GetRoleAsync(settings.RoleId) ?? throw new AnythinkCli.Client.AnythinkException($"Role {settings.RoleId} not found.", 404); @@ -250,7 +250,7 @@ public override async Task ExecuteAsync(CommandContext context, RolePermiss foreach (var action in actions) { var permName = $"{settings.Entity}:{action}"; - var existing = allPerms.FirstOrDefault(p => p.Name == permName && p.EntityId == entity.Id); + var existing = FindPermission(allPerms, permName, entityId); if (existing != null) { @@ -290,4 +290,97 @@ await client.UpdateRoleWithPermissionsAsync(settings.RoleId, return 1; } } + + internal static async Task TryResolveEntityIdAsync(AnythinkCli.Client.AnythinkClient client, string entityName) + { + try + { + var entity = await client.GetEntityAsync(entityName); + return entity.Id; + } + catch (AnythinkCli.Client.AnythinkException ex) when (ex.StatusCode == 404) + { + return null; + } + } + + internal static Permission? FindPermission(List allPerms, string permName, int? entityId) + { + return allPerms.FirstOrDefault(p => + p.Name == permName && + (entityId.HasValue ? p.EntityId == entityId.Value : p.EntityId == null)); + } +} + +// ── roles permissions remove ───────────────────────────────────────────────── + +public class RolePermissionsRemoveSettings : CommandSettings +{ + [CommandArgument(0, "")] + [Description("Role ID")] + public int RoleId { get; set; } + + [CommandArgument(1, "")] + [Description("Entity name")] + public string Entity { get; set; } = ""; + + [CommandOption("--actions ")] + [Description("Comma-separated actions: read,create,update,delete (default: read)")] + public string Actions { get; set; } = "read"; +} + +public class RolesPermissionsRemoveCommand : BaseCommand +{ + public override async Task ExecuteAsync(CommandContext context, RolePermissionsRemoveSettings settings) + { + try + { + var client = GetClient(); + + int? entityId = await RolesPermissionsAddCommand.TryResolveEntityIdAsync(client, settings.Entity); + + var role = await client.GetRoleAsync(settings.RoleId) + ?? throw new AnythinkCli.Client.AnythinkException($"Role {settings.RoleId} not found.", 404); + + var permIds = (role.Permissions ?? []).Select(p => p.Id).ToList(); + + var allPerms = await client.GetPermissionsAsync(); + var actions = settings.Actions.Split(',').Select(a => a.Trim().ToLower()).ToList(); + + var removed = new List(); + foreach (var action in actions) + { + var permName = $"{settings.Entity}:{action}"; + var existing = RolesPermissionsAddCommand.FindPermission(allPerms, permName, entityId); + + if (existing != null && permIds.Remove(existing.Id)) + { + removed.Add(action); + } + } + + if (removed.Count == 0) + { + Renderer.Info("No matching permissions to remove (not assigned or not found)."); + return 0; + } + + await AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .StartAsync("Updating role permissions...", async _ => + { + await client.UpdateRoleWithPermissionsAsync(settings.RoleId, + new UpdateRolePermissionsRequest( + role.Name, role.Description, role.IsActive, role.AnyApiAccess, permIds)); + }); + + Renderer.Success($"Removed {Markup.Escape(string.Join(", ", removed))} on [#F97316]{Markup.Escape(settings.Entity)}[/] from role {settings.RoleId}."); + return 0; + } + catch (Exception ex) + { + HandleError(ex); + return 1; + } + } } diff --git a/src/Program.cs b/src/Program.cs index 91056db..c34029f 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -424,6 +424,10 @@ perms.AddCommand("add") .WithDescription("Add entity permissions to a role") .WithExample("roles", "permissions", "add", "239", "badges", "--actions", "read,create"); + + perms.AddCommand("remove") + .WithDescription("Remove entity permissions from a role") + .WithExample("roles", "permissions", "remove", "239", "badges", "--actions", "create"); }); });