{"record":{"id":"fcccdb1407868570","repo":"block/buzz","slug":"only-owners-admins-may-change-an-active-member-s-r","errorCode":null,"errorMessage":"only owners/admins may change an active member's role","messagePattern":"only owners/admins may change an active member's role","errorType":"validation","errorClass":"IngestError::Rejected","httpStatus":null,"severity":"error","filePath":"crates/buzz-relay/src/handlers/side_effects.rs","lineNumber":398,"sourceCode":"            // directions, on every visibility. `get_members` filters\n            // `removed_at IS NULL`, so a soft-removed row is deliberately not an\n            // \"existing member\" here: its stored role is history, not live\n            // authority, and reactivation is governed by the elevated-granter\n            // check above rather than by the role the row remembers.\n            //\n            // `add_member` is the authority (it also covers the desktop/admin\n            // callers that skip this validator); rejecting here too means the\n            // client gets a real error instead of an OK for an event whose side\n            // effect then fails. Re-adding at the same role stays idempotent —\n            // the huddle bot-add path relies on that.\n            if let Some((target, role)) = members\n                .iter()\n                .find(|m| m.pubkey == target_pubkey)\n                .zip(requested_role)\n                .filter(|(m, role)| m.role != role.as_str())\n            {\n                if !actor_role.is_some_and(|r| r.is_elevated()) {\n                    return Err(anyhow::anyhow!(\n                        \"only owners/admins may change an active member's role\"\n                    ));\n                }\n                if target.role == \"owner\"\n                    && role != buzz_db::channel::MemberRole::Owner\n                    && members.iter().filter(|m| m.role == \"owner\").count() <= 1\n                {\n                    return Err(anyhow::anyhow!(\n                        \"cannot demote the last owner — transfer ownership first\"\n                    ));\n                }\n            }\n\n            // Self-add: always allowed regardless of policy.\n            if target_pubkey == actor_bytes {\n                return Ok(());\n            }\n","sourceCodeStart":380,"sourceCodeEnd":416,"githubUrl":"https://github.com/block/buzz/blob/f956e6fe06a76e50cbd8fba1a162482e752e7f1a/crates/buzz-relay/src/handlers/side_effects.rs#L380-L416","documentation":"The 9000 event changes the role of an existing ACTIVE member (stored role differs from requested role) and the actor is not channel owner/admin. Unlike first-time adds, role changes of active members are privileged in both directions on every channel visibility — open channels too — because a demotion or promotion mutates live authority. This validator intentionally mirrors add_member so the client gets a real error instead of an OK whose side effect later fails.","triggerScenarios":"On an open channel, any authenticated non-elevated user sends 9000 with a role tag that differs from the target's current role (e.g. flipping someone from member to guest); a member 're-adds' an existing admin at role member (a disguised demotion); a stale client re-sends an add with the original role after the member was promoted, which counts as a change if the tags differ.","commonSituations":"Developers assume open-channel adds are fully permissionless — true only for brand-new members or same-role re-adds (idempotent); retry logic replaying old payloads after a role was updated elsewhere; huddle bot-add paths relying on idempotent same-role re-adds suddenly changing the role value.","solutions":["Route role changes through a channel owner/admin signer","Omit the role tag when re-adding an existing member — absent role means 'keep current role', which is idempotent and needs no elevation","Before publishing, fetch members and only include a role tag when you intend a change you are authorized to make"],"exampleFix":"// before: member re-adds an existing admin with role member (silent demotion attempt)\nput_user(ch, admin_pk).with_role(\"member\").publish_as(member_keys)\n// → only owners/admins may change an active member's role\n\n// after: idempotent re-add without role tag\nput_user(ch, admin_pk).publish_as(member_keys); // keeps current role\n// or a real role change signed by an admin:\nput_user(ch, admin_pk).with_role(\"member\").publish_as(channel_admin_keys);","handlingStrategy":"validation","validationCode":"// Only include a role tag when it actually changes an active member's role AND you are elevated\nlet existing = members.iter().find(|m| m.pubkey == target_pubkey);\nlet changes_role = existing.zip(requested_role).is_some_and(|(m, r)| m.role != r.as_str());\nif changes_role {\n    anyhow::ensure!(\n        actor_role.is_some_and(|r| r.is_elevated()),\n        \"role changes of active members need owner/admin\"\n    );\n}","typeGuard":"const isRoleChange = (\n  current: string | undefined,\n  requested: string | null,\n): boolean => current !== undefined && requested !== null && current !== requested;","tryCatchPattern":"match validate_admin_event(&tenant, 9000, &event, &state).await {\n    Err(e) if e.to_string().contains(\"may change an active member's role\") => {\n        // re-issue without the role tag (idempotent no-op add), or escalate to an admin signer\n        retry_without_role_or_escalate(event).await\n    }\n    other => other,\n}","preventionTips":["Omit the role tag when re-adding existing members — absent means keep-current and is always safe","Fetch the member list before sending so you know whether your 9000 is a new add or a role change","Remember this applies on open channels too: first adds are free, role changes never are"],"tags":["nostr","nip-29","group-admin","authorization","roles","demotion"],"backgroundTag":"insufficient-permissions","analyzedSha":"f956e6fe06a76e50cbd8fba1a162482e752e7f1a","analyzedAt":"2026-08-16T22:11:40.750Z","schemaVersion":2},"datasetVersion":"2026-08-16T23:17:17.608Z"}