diff --git a/CHANGELOG.md b/CHANGELOG.md index 32c6515bf..ab5644082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Hosts can have a color and an icon, which sync to the desktop and mobile apps - A sidebar setting to turn off alternating row colors - TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF, TERMIX_NGINX_PID and TERMIX_APP_DIR let an install outside Docker, like the Proxmox LXC script, switch nginx to HTTPS when a certificate is added +- Removing a user or role from a shared folder can also remove their access from every host in the folder and its subfolders ### Fixed diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index 2f88ccc04..b1f372fd0 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -11,6 +11,7 @@ import { import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js"; import { assertHostsUnlocked, + hostLock, hasHostLocks, requireUnlockedHost, sendHostLocked, @@ -722,14 +723,40 @@ router.get( }, ); +/** The host grants that went to the same user or role as a folder rule. */ +export function grantsForFolderRule< + T extends { userId?: string | null; roleId?: number | null }, +>(entries: T[], rule: { userId?: string | null; roleId?: number | null }): T[] { + return entries.filter((entry) => + rule.userId + ? entry.userId === rule.userId + : rule.roleId != null && entry.roleId === rule.roleId, + ); +} + /** * @openapi * /rbac/folder/access/{id}: * delete: * summary: Stop a folder share from applying to hosts added later - * description: Grants already fanned out to hosts stay; revoke those per host. + * description: Grants already fanned out to hosts stay, unless fromHosts=true, which also removes the same user's or role's access from every host in the folder and its subfolders that the caller may manage sharing on. * tags: * - RBAC + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * - in: query + * name: fromHosts + * schema: + * type: boolean + * responses: + * 200: + * description: Removed. removedFromHosts counts the host grants removed. + * 404: + * description: No such rule. */ router.delete( "/folder/access/:id", @@ -741,11 +768,35 @@ router.delete( } try { const repository = createCurrentFolderAccessRepository(); - if (!(await repository.findById(ruleId, req.userId!))) { + const rule = await repository.findById(ruleId, req.userId!); + if (!rule) { return res.status(404).json({ error: "Rule not found" }); } await repository.deleteById(ruleId, req.userId!); - res.json({ success: true }); + + let removedFromHosts = 0; + if (req.query.fromHosts === "true") { + const hosts = + await createCurrentHostFolderRepository().listHostsInFolder( + req.userId!, + rule.folder, + ); + const access = createCurrentRbacAccessRepository(); + for (const host of hosts) { + if (hostLock(host.id)) continue; + if (!(await canManageHostSharing(req.userId!, host.id)).allowed) + continue; + let changed = false; + const entries = await access.listHostAccess(host.id); + for (const entry of grantsForFolderRule(entries, rule)) { + await access.revokeHostAccess(entry.id, host.id); + removedFromHosts++; + changed = true; + } + if (changed) await pruneHostGrants(host.id); + } + } + res.json({ success: true, removedFromHosts }); } catch (error) { databaseLogger.error("Failed to delete folder access rule", error, { operation: "delete_folder_access", diff --git a/src/backend/tests/database/routes/rbac-share-prune.test.ts b/src/backend/tests/database/routes/rbac-share-prune.test.ts index 00da97cc0..d3d20100f 100644 --- a/src/backend/tests/database/routes/rbac-share-prune.test.ts +++ b/src/backend/tests/database/routes/rbac-share-prune.test.ts @@ -69,7 +69,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({ }), })); -const { pruneHostGrants, pruneCredentialGrants } = +const { pruneHostGrants, pruneCredentialGrants, grantsForFolderRule } = await import("../../../database/routes/rbac.js"); const chain = (): Grant[] => [ @@ -105,3 +105,27 @@ describe("share pruning", () => { expect(state.credentialGrants.map((g) => g.userId)).toEqual(["dave"]); }); }); + +describe("grantsForFolderRule", () => { + const entries = [ + { id: 1, userId: "bob", roleId: null }, + { id: 2, userId: null, roleId: 7 }, + { id: 3, userId: "carol", roleId: null }, + ]; + + it("picks the grants of the rule's user or role", () => { + expect( + grantsForFolderRule(entries, { userId: "bob", roleId: null }).map( + (e) => e.id, + ), + ).toEqual([1]); + expect( + grantsForFolderRule(entries, { userId: null, roleId: 7 }).map( + (e) => e.id, + ), + ).toEqual([2]); + expect( + grantsForFolderRule(entries, { userId: null, roleId: null }), + ).toEqual([]); + }); +}); diff --git a/src/ui/api/rbac-api.ts b/src/ui/api/rbac-api.ts index af2bce016..22a46af11 100644 --- a/src/ui/api/rbac-api.ts +++ b/src/ui/api/rbac-api.ts @@ -238,9 +238,14 @@ export async function getFolderAccess( } } -export async function revokeFolderAccess(ruleId: number): Promise { +export async function revokeFolderAccess( + ruleId: number, + options: { fromHosts?: boolean } = {}, +): Promise { try { - await rbacApi.delete(`/rbac/folder/access/${ruleId}`); + await rbacApi.delete(`/rbac/folder/access/${ruleId}`, { + params: options.fromHosts ? { fromHosts: "true" } : undefined, + }); } catch (error) { throw handleApiError(error, "revoke folder access"); } diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 220c8c025..1a6c275a0 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -777,6 +777,7 @@ "sharedBadge": "Shared", "sharedBadgeTooltip": "Shared by {{owner}} ({{level}} access)", "viewOnlyBanner": "This host is shared with you by {{owner}} with view access. The configuration is read-only.", + "folderRuleRemoveFromHosts": "Also remove this access from every host in the folder and its subfolders", "sharedCopyBanner": "This host is shared with you by {{owner}}. The desktop keeps a read-only copy, so change it on the server and it syncs here.", "sharedEditBanner": "This host is shared with you by {{owner}} with edit access. Changes apply to the real host; authentication references can only be changed by the owner.", "ownerOnlyControl": "Only the host owner can change this",