feat(server,admin): private channels via per-role permission overrides

The read side of channel visibility was already complete — channel_overrides
rows deny ReadMessages and every list/focus/send/voice path enforces them —
but nothing could write those rows. Add the missing write path and UI:

- db: UpsertChannelOverride / DeleteChannelOverride / ListChannelRoleOverrides
  (roles LEFT JOIN overrides so the UI gets everything in one call)
- admin API: GET/PUT/DELETE /admin/api/channels/{id}/permissions[/{roleId}]
  with unknown permission bits masked via the new permissions.AllPerms,
  audit logging, and immediate permission-cache invalidation
- ws: Hub.RefreshChannelVisibility sends targeted channel_create /
  channel_delete to connected clients after an override change, unsubscribes
  hidden clients from the channel topic, and clears their focus. Sent outside
  the sequenced replay path on purpose: a replayed channel_delete would be
  filtered by the post-change allowed-channel set, inverting its audience.
- admin panel: per-channel Access modal (lock icon) with per-role
  "Can access" checkboxes; unchecking writes deny = ReadMessages|ConnectVoice

Known limits (follow-ups): users offline during a revoke keep a stale
sidebar entry until their next fresh connect (server still denies access),
and users already in a voice channel are not kicked when it goes private.

Closes #93

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwtnpHAoSFr1ZibQgQkNQK
This commit is contained in:
Claude
2026-07-19 10:50:11 +00:00
parent b9180bdbeb
commit 9e6ff47194
13 changed files with 819 additions and 11 deletions
@@ -83,9 +83,7 @@ describe("screen share FPS", () => {
it("scales bitrate up for 60 and 120 fps", () => {
expect(getScreenShareMaxBitrate("high", 60)).toBe(SCREENSHARE_PUBLISH_BITRATES.high * 1.5);
expect(getScreenShareMaxBitrate("source", 120)).toBe(
SCREENSHARE_PUBLISH_BITRATES.source * 2,
);
expect(getScreenShareMaxBitrate("source", 120)).toBe(SCREENSHARE_PUBLISH_BITRATES.source * 2);
});
});
+3
View File
@@ -46,6 +46,9 @@ func NewAdminAPI(database *db.DB, version string, hub HubBroadcaster, u *updater
r.Post("/channels", handleCreateChannel(database, hub))
r.Patch("/channels/{id}", handlePatchChannel(database, hub))
r.Delete("/channels/{id}", handleDeleteChannel(database, hub))
r.Get("/channels/{id}/permissions", handleGetChannelPermissions(database))
r.Put("/channels/{id}/permissions/{roleId}", handlePutChannelPermission(database, hub, permInvalidator))
r.Delete("/channels/{id}/permissions/{roleId}", handleDeleteChannelPermission(database, hub, permInvalidator))
r.Get("/audit-log", handleGetAuditLog(database))
r.Get("/settings", handleGetSettings(database))
r.Patch("/settings", handlePatchSettings(database))
+21 -7
View File
@@ -87,6 +87,15 @@ CREATE TABLE IF NOT EXISTS channels (
voice_max_video INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS channel_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
allow INTEGER NOT NULL DEFAULT 0,
deny INTEGER NOT NULL DEFAULT 0,
UNIQUE(channel_id, role_id)
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
@@ -1107,13 +1116,14 @@ func TestAdminAPI_PatchUser_NoTOTPSecret(t *testing.T) {
// mockHub records which broadcast methods were called and with what arguments.
type mockHub struct {
restartCalls []restartCall
channelCreates []*db.Channel
channelUpdates []*db.Channel
channelDeleteIDs []int64
memberBanIDs []int64
memberUpdates []memberUpdateCall
clientCount int
restartCalls []restartCall
channelCreates []*db.Channel
channelUpdates []*db.Channel
channelDeleteIDs []int64
memberBanIDs []int64
memberUpdates []memberUpdateCall
visibilityRefreshes []*db.Channel
clientCount int
}
type memberUpdateCall struct {
@@ -1150,6 +1160,10 @@ func (m *mockHub) BroadcastMemberUpdate(userID int64, roleName string) {
m.memberUpdates = append(m.memberUpdates, memberUpdateCall{userID, roleName})
}
func (m *mockHub) RefreshChannelVisibility(ch *db.Channel) {
m.visibilityRefreshes = append(m.visibilityRefreshes, ch)
}
func (m *mockHub) ClientCount() int {
return m.clientCount
}
+161
View File
@@ -0,0 +1,161 @@
package admin
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
)
// ─── Channel Permission Override Handlers ────────────────────────────────────
//
// These endpoints manage per-role allow/deny permission overrides on a
// channel (the channel_overrides table). Denying ReadMessages hides the
// channel from a role entirely ("private channel"); the read side is already
// enforced by ListVisibleChannels, the WS ready payload, and the per-message
// permission checks.
// getPermChannel loads the channel for an override request and writes the
// appropriate error response when it is missing or a DM. Returns nil when a
// response has already been written.
func getPermChannel(database *db.DB, w http.ResponseWriter, r *http.Request) *db.Channel {
id, err := pathInt64(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid channel id")
return nil
}
ch, err := database.GetChannel(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel")
return nil
}
if ch == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "channel not found")
return nil
}
if ch.Type == "dm" {
writeErr(w, http.StatusBadRequest, "INVALID_INPUT", "DM channels do not support permission overrides")
return nil
}
return ch
}
// channelPermissionsResponse is the JSON shape for GET .../permissions.
type channelPermissionsResponse struct {
ChannelID int64 `json:"channel_id"`
Roles []db.ChannelRoleOverride `json:"roles"`
}
func handleGetChannelPermissions(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ch := getPermChannel(database, w, r)
if ch == nil {
return
}
overrides, err := database.ListChannelRoleOverrides(ch.ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to list channel permissions")
return
}
writeJSON(w, http.StatusOK, channelPermissionsResponse{ChannelID: ch.ID, Roles: overrides})
}
}
// putChannelPermissionRequest is the JSON body for PUT .../permissions/{roleId}.
type putChannelPermissionRequest struct {
Allow int64 `json:"allow"`
Deny int64 `json:"deny"`
}
func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ch := getPermChannel(database, w, r)
if ch == nil {
return
}
roleID, err := pathInt64(r, "roleId")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id")
return
}
role, err := database.GetRoleByID(roleID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch role")
return
}
if role == nil {
writeErr(w, http.StatusNotFound, "NOT_FOUND", "role not found")
return
}
var req putChannelPermissionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
// Drop unknown bits so garbage input cannot persist undefined perms.
allow := req.Allow & permissions.AllPerms
deny := req.Deny & permissions.AllPerms
if err := database.UpsertChannelOverride(ch.ID, roleID, allow, deny); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save channel permission")
return
}
actor := actorFromContext(r)
slog.Info("channel permissions updated", "actor_id", actor, "channel_id", ch.ID,
"role_id", roleID, "allow", allow, "deny", deny)
_ = database.LogAudit(actor, "channel_perms_update", "channel", ch.ID,
fmt.Sprintf("set overrides for role %s on #%s (allow=%#x deny=%#x)", role.Name, ch.Name, allow, deny))
if permInvalidator != nil {
permInvalidator.InvalidateAll()
}
if hub != nil {
hub.RefreshChannelVisibility(ch)
}
writeJSON(w, http.StatusOK, db.ChannelRoleOverride{
RoleID: role.ID,
RoleName: role.Name,
Position: role.Position,
Permissions: role.Permissions,
Allow: allow,
Deny: deny,
})
}
}
func handleDeleteChannelPermission(database *db.DB, hub HubBroadcaster, permInvalidator PermissionInvalidator) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ch := getPermChannel(database, w, r)
if ch == nil {
return
}
roleID, err := pathInt64(r, "roleId")
if err != nil {
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "invalid role id")
return
}
if err := database.DeleteChannelOverride(ch.ID, roleID); err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete channel permission")
return
}
actor := actorFromContext(r)
slog.Info("channel permissions cleared", "actor_id", actor, "channel_id", ch.ID, "role_id", roleID)
_ = database.LogAudit(actor, "channel_perms_clear", "channel", ch.ID,
fmt.Sprintf("cleared overrides for role %d on #%s", roleID, ch.Name))
if permInvalidator != nil {
permInvalidator.InvalidateAll()
}
if hub != nil {
hub.RefreshChannelVisibility(ch)
}
w.WriteHeader(http.StatusNoContent)
}
}
+255
View File
@@ -0,0 +1,255 @@
package admin_test
import (
"encoding/json"
"net/http"
"testing"
"github.com/owncord/server/admin"
"github.com/owncord/server/db"
"github.com/owncord/server/permissions"
)
// mockPermInvalidator records permission-cache invalidation calls.
type mockPermInvalidator struct {
invalidateUserIDs []int64
invalidateAllN int
}
func (m *mockPermInvalidator) InvalidateUser(userID int64) {
m.invalidateUserIDs = append(m.invalidateUserIDs, userID)
}
func (m *mockPermInvalidator) InvalidateAll() {
m.invalidateAllN++
}
// ─── GET /channels/{id}/permissions ──────────────────────────────────────────
func TestGetChannelPermissions_ReturnsAllRoles(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel("secret", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
w := doRequest(t, handler, http.MethodGet, "/channels/1/permissions", token, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
var resp struct {
ChannelID int64 `json:"channel_id"`
Roles []db.ChannelRoleOverride `json:"roles"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.ChannelID != chID && resp.ChannelID != 1 {
t.Errorf("channel_id = %d", resp.ChannelID)
}
if len(resp.Roles) != 3 {
t.Fatalf("expected 3 roles, got %d", len(resp.Roles))
}
if resp.Roles[0].RoleName != "Owner" {
t.Errorf("first role = %q, want Owner (position desc)", resp.Roles[0].RoleName)
}
for _, role := range resp.Roles {
if role.Allow != 0 || role.Deny != 0 {
t.Errorf("role %d: expected zero overrides, got (%#x, %#x)", role.RoleID, role.Allow, role.Deny)
}
}
}
func TestGetChannelPermissions_NotFound(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database))
token := createAdminUser(t, database)
w := doRequest(t, handler, http.MethodGet, "/channels/9999/permissions", token, nil)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestGetChannelPermissions_DMRejected(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel("dm-chan", "dm", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel dm: %v", err)
}
w := doRequest(t, handler, http.MethodGet,
"/channels/"+itoa(chID)+"/permissions", token, nil)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String())
}
}
// ─── PUT /channels/{id}/permissions/{roleId} ─────────────────────────────────
func TestPutChannelPermission_PersistsAndPropagates(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
inv := &mockPermInvalidator{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel("secret", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
denyPrivate := permissions.ReadMessages | permissions.ConnectVoice
body := map[string]any{"allow": 0, "deny": denyPrivate}
w := doRequest(t, handler, http.MethodPut,
"/channels/"+itoa(chID)+"/permissions/3", token, body)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
allow, deny, err := database.GetChannelPermissions(chID, 3)
if err != nil {
t.Fatalf("GetChannelPermissions: %v", err)
}
if allow != 0 || deny != denyPrivate {
t.Errorf("persisted override = (%#x, %#x), want (0, %#x)", allow, deny, denyPrivate)
}
if inv.invalidateAllN != 1 {
t.Errorf("InvalidateAll calls = %d, want 1", inv.invalidateAllN)
}
if len(hub.visibilityRefreshes) != 1 || hub.visibilityRefreshes[0].ID != chID {
t.Errorf("RefreshChannelVisibility not called for channel %d", chID)
}
entries, err := database.GetAuditLog(10, 0)
if err != nil {
t.Fatalf("GetAuditLog: %v", err)
}
found := false
for _, e := range entries {
if e.Action == "channel_perms_update" {
found = true
}
}
if !found {
t.Error("expected channel_perms_update audit entry")
}
}
func TestPutChannelPermission_MasksUnknownBits(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel("secret2", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
// 0x4 and 0x8 are undefined bits — they must be dropped.
body := map[string]any{"allow": 0x4 | int64(permissions.SendMessages), "deny": 0x8}
w := doRequest(t, handler, http.MethodPut,
"/channels/"+itoa(chID)+"/permissions/3", token, body)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}
allow, deny, err := database.GetChannelPermissions(chID, 3)
if err != nil {
t.Fatalf("GetChannelPermissions: %v", err)
}
if allow != permissions.SendMessages {
t.Errorf("allow = %#x, want %#x (unknown bits dropped)", allow, permissions.SendMessages)
}
if deny != 0 {
t.Errorf("deny = %#x, want 0 (unknown bits dropped)", deny)
}
}
func TestPutChannelPermission_UnknownRole(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel("secret3", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
w := doRequest(t, handler, http.MethodPut,
"/channels/"+itoa(chID)+"/permissions/999", token, map[string]any{"allow": 0, "deny": 2})
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404; body: %s", w.Code, w.Body.String())
}
}
func TestPutChannelPermission_NonAdminForbidden(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database))
_ = createAdminUser(t, database)
memberToken := createMemberUser(t, database)
chID, err := database.CreateChannel("secret4", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
w := doRequest(t, handler, http.MethodPut,
"/channels/"+itoa(chID)+"/permissions/3", memberToken, map[string]any{"allow": 0, "deny": 2})
if w.Code != http.StatusForbidden && w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 403/401; body: %s", w.Code, w.Body.String())
}
}
// ─── DELETE /channels/{id}/permissions/{roleId} ──────────────────────────────
func TestDeleteChannelPermission_ClearsOverride(t *testing.T) {
database := openAdminTestDB(t)
hub := &mockHub{}
inv := &mockPermInvalidator{}
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, inv, newTestModService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel("secret5", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
if err := database.UpsertChannelOverride(chID, 3, 0, permissions.ReadMessages); err != nil {
t.Fatalf("UpsertChannelOverride: %v", err)
}
w := doRequest(t, handler, http.MethodDelete,
"/channels/"+itoa(chID)+"/permissions/3", token, nil)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String())
}
allow, deny, err := database.GetChannelPermissions(chID, 3)
if err != nil {
t.Fatalf("GetChannelPermissions: %v", err)
}
if allow != 0 || deny != 0 {
t.Errorf("override still present: (%#x, %#x)", allow, deny)
}
if inv.invalidateAllN != 1 {
t.Errorf("InvalidateAll calls = %d, want 1", inv.invalidateAllN)
}
if len(hub.visibilityRefreshes) != 1 {
t.Errorf("RefreshChannelVisibility calls = %d, want 1", len(hub.visibilityRefreshes))
}
// Deleting again is idempotent.
w = doRequest(t, handler, http.MethodDelete,
"/channels/"+itoa(chID)+"/permissions/3", token, nil)
if w.Code != http.StatusNoContent {
t.Errorf("second delete status = %d, want 204", w.Code)
}
}
@@ -402,6 +402,7 @@ func (m *mockHubWB) BroadcastChannelUpdate(ch *db.Channel) {}
func (m *mockHubWB) BroadcastChannelDelete(channelID int64) {}
func (m *mockHubWB) BroadcastMemberBan(userID int64) {}
func (m *mockHubWB) BroadcastMemberUpdate(userID int64, roleName string) {}
func (m *mockHubWB) RefreshChannelVisibility(ch *db.Channel) {}
func (m *mockHubWB) ClientCount() int { return 0 }
// TestSpawnDetached_ValidExecutable verifies that spawnDetached can start a
+44 -1
View File
@@ -277,6 +277,7 @@ const I={
voice:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>',
megaphone:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
logs:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
lock:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
};
/* ═══ State ═══ */
@@ -527,7 +528,8 @@ async function renderChannels(){
html+='<td><span class="badge '+(type==='voice'?'badge-yellow':type==='announcement'?'badge-accent':'badge-muted')+'">'+esc(type)+'</span></td>';
html+='<td style="font-size:12px;color:var(--text-faint)">'+esc(cat)+'</td>';
html+='<td>'+(archived?'<span class="badge badge-muted">Yes</span>':'<span class="badge badge-green">No</span>')+'</td>';
html+='<td><div class="act-group" style="justify-content:flex-end"><button class="act-btn" title="Edit" onclick="openChannelEditModal('+id+',\''+esc(name)+'\')">'+I.edit+'</button><button class="act-btn danger" title="Delete" onclick="openDeleteChannel('+id+',\''+esc(name)+'\')">'+I.trash+'</button></div></td></tr>';
const lockBtn=type==='dm'?'':'<button class="act-btn" title="Access (private channel)" onclick="openChannelPermsModal('+id+',\''+esc(name)+'\')">'+I.lock+'</button>';
html+='<td><div class="act-group" style="justify-content:flex-end"><button class="act-btn" title="Edit" onclick="openChannelEditModal('+id+',\''+esc(name)+'\')">'+I.edit+'</button>'+lockBtn+'<button class="act-btn danger" title="Delete" onclick="openDeleteChannel('+id+',\''+esc(name)+'\')">'+I.trash+'</button></div></td></tr>';
});
html+='</tbody></table></div></div>';
return html;
@@ -560,6 +562,47 @@ async function confirmDeleteChannel(id){
try{await api('DELETE','/channels/'+id);closeModal();showToast('Channel deleted');renderContent()}catch(e){showToast(e.message,'error')}
}
/* ═══ Channel Access (private channels) ═══ */
const DENY_PRIVATE=0x202; /* READ_MESSAGES | CONNECT_VOICE */
const ADMIN_BIT=0x40000000;
async function openChannelPermsModal(id,name){
let data;
try{data=await api('GET','/channels/'+id+'/permissions')}catch(e){showToast(e.message,'error');return}
const roles=data.roles||[];
state.permChannelRoles=roles;
let rows='';
roles.forEach(role=>{
const isAdmin=(role.permissions&ADMIN_BIT)!==0;
const canAccess=isAdmin||((role.deny&0x2)===0);
rows+='<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--bg-active)">'
+'<span style="color:'+roleColor(role.role_id)+';font-weight:600">'+esc(role.role_name)+'</span>'
+(isAdmin
?'<span style="font-size:12px;color:var(--text-faint)">always has access</span>'
:'<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text-muted);cursor:pointer"><input type="checkbox" id="permRole'+role.role_id+'" '+(canAccess?'checked':'')+'> Can access</label>')
+'</div>';
});
openModal('<div class="modal-header"><h3>Channel Access — #'+esc(name)+'</h3><button class="modal-close" onclick="closeModal()">&times;</button></div>'
+'<div class="modal-body"><p style="color:var(--text-muted);font-size:13px;margin-bottom:12px">Uncheck a role to hide this channel from it (private channel). Changes apply to connected users immediately; users already in the voice channel are not disconnected.</p>'
+rows+'</div>'
+'<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="saveChannelPerms('+id+')">Save</button></div>');
}
async function saveChannelPerms(id){
const roles=state.permChannelRoles||[];
try{
for(const role of roles){
if((role.permissions&ADMIN_BIT)!==0)continue;
const box=document.getElementById('permRole'+role.role_id);
if(!box)continue;
const wasHidden=(role.deny&0x2)!==0;
if(!box.checked)await api('PUT','/channels/'+id+'/permissions/'+role.role_id,{allow:0,deny:DENY_PRIVATE});
else if(wasHidden)await api('DELETE','/channels/'+id+'/permissions/'+role.role_id);
}
closeModal();showToast('Channel access updated');renderContent();
}catch(e){showToast(e.message,'error')}
}
/* ═══ Audit Log ═══ */
async function renderAudit(){
const offset=(state.auditPage-1)*PAGE_SIZE;
+4
View File
@@ -40,6 +40,10 @@ type HubBroadcaster interface {
BroadcastChannelDelete(channelID int64)
BroadcastMemberBan(userID int64)
BroadcastMemberUpdate(userID int64, roleName string)
// RefreshChannelVisibility sends targeted channel_create/channel_delete
// messages after a channel permission override change so each connected
// client's sidebar reflects its new visibility without a reconnect.
RefreshChannelVisibility(ch *db.Channel)
ClientCount() int
}
+75
View File
@@ -174,6 +174,81 @@ func (d *DB) GetAllChannelPermissionsForRole(roleID int64) (map[int64]ChannelOve
return result, nil
}
// UpsertChannelOverride inserts or updates the allow/deny permission override
// for a role on a channel.
func (d *DB) UpsertChannelOverride(channelID, roleID, allow, deny int64) error {
_, err := d.sqlDB.Exec(
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny)
VALUES (?, ?, ?, ?)
ON CONFLICT(channel_id, role_id)
DO UPDATE SET allow = excluded.allow, deny = excluded.deny`,
channelID, roleID, allow, deny,
)
if err != nil {
return fmt.Errorf("UpsertChannelOverride: %w", err)
}
return nil
}
// DeleteChannelOverride removes the permission override for a role on a
// channel. Deleting a non-existent override is a no-op.
func (d *DB) DeleteChannelOverride(channelID, roleID int64) error {
_, err := d.sqlDB.Exec(
`DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = ?`,
channelID, roleID,
)
if err != nil {
return fmt.Errorf("DeleteChannelOverride: %w", err)
}
return nil
}
// ChannelRoleOverride pairs a role with its (possibly zero) permission
// override on a specific channel. Permissions carries the role's base bits so
// callers can tell which roles bypass overrides via Administrator.
type ChannelRoleOverride struct {
RoleID int64 `json:"role_id"`
RoleName string `json:"role_name"`
Position int `json:"position"`
Permissions int64 `json:"permissions"`
Allow int64 `json:"allow"`
Deny int64 `json:"deny"`
}
// ListChannelRoleOverrides returns every role together with its override bits
// on the given channel (zero allow/deny when no override row exists), ordered
// by role position descending.
func (d *DB) ListChannelRoleOverrides(channelID int64) ([]ChannelRoleOverride, error) {
rows, err := d.sqlDB.Query(
`SELECT r.id, r.name, r.position, r.permissions,
COALESCE(o.allow, 0), COALESCE(o.deny, 0)
FROM roles r
LEFT JOIN channel_overrides o ON o.role_id = r.id AND o.channel_id = ?
ORDER BY r.position DESC, r.id ASC`,
channelID,
)
if err != nil {
return nil, fmt.Errorf("ListChannelRoleOverrides: %w", err)
}
defer rows.Close() //nolint:errcheck
var result []ChannelRoleOverride
for rows.Next() {
var o ChannelRoleOverride
if scanErr := rows.Scan(&o.RoleID, &o.RoleName, &o.Position, &o.Permissions, &o.Allow, &o.Deny); scanErr != nil {
return nil, fmt.Errorf("ListChannelRoleOverrides scan: %w", scanErr)
}
result = append(result, o)
}
if rows.Err() != nil {
return nil, fmt.Errorf("ListChannelRoleOverrides rows: %w", rows.Err())
}
if result == nil {
result = []ChannelRoleOverride{}
}
return result, nil
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// scanChannel scans a single channel row from *sql.Rows.
+90
View File
@@ -234,6 +234,96 @@ func TestGetChannelPermissions_WithOverride(t *testing.T) {
}
}
// ─── Channel override write path ─────────────────────────────────────────────
func TestUpsertChannelOverride_InsertAndUpdate(t *testing.T) {
database := openMigratedMemory(t)
chID, _ := database.CreateChannel("private", "text", "", "", 0)
if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil {
t.Fatalf("UpsertChannelOverride insert: %v", err)
}
allow, deny, err := database.GetChannelPermissions(chID, 4)
if err != nil {
t.Fatalf("GetChannelPermissions: %v", err)
}
if allow != 0 || deny != 0x202 {
t.Errorf("after insert: got (%#x, %#x), want (0, 0x202)", allow, deny)
}
// Upsert again with different bits — must update, not duplicate.
if err := database.UpsertChannelOverride(chID, 4, 0x2, 0x200); err != nil {
t.Fatalf("UpsertChannelOverride update: %v", err)
}
allow, deny, err = database.GetChannelPermissions(chID, 4)
if err != nil {
t.Fatalf("GetChannelPermissions: %v", err)
}
if allow != 0x2 || deny != 0x200 {
t.Errorf("after update: got (%#x, %#x), want (0x2, 0x200)", allow, deny)
}
}
func TestDeleteChannelOverride(t *testing.T) {
database := openMigratedMemory(t)
chID, _ := database.CreateChannel("private2", "text", "", "", 0)
if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil {
t.Fatalf("UpsertChannelOverride: %v", err)
}
if err := database.DeleteChannelOverride(chID, 4); err != nil {
t.Fatalf("DeleteChannelOverride: %v", err)
}
allow, deny, err := database.GetChannelPermissions(chID, 4)
if err != nil {
t.Fatalf("GetChannelPermissions: %v", err)
}
if allow != 0 || deny != 0 {
t.Errorf("after delete: got (%#x, %#x), want (0, 0)", allow, deny)
}
// Deleting again is a no-op.
if err := database.DeleteChannelOverride(chID, 4); err != nil {
t.Errorf("DeleteChannelOverride non-existent should not error: %v", err)
}
}
func TestListChannelRoleOverrides(t *testing.T) {
database := openMigratedMemory(t)
chID, _ := database.CreateChannel("private3", "text", "", "", 0)
if err := database.UpsertChannelOverride(chID, 4, 0, 0x202); err != nil {
t.Fatalf("UpsertChannelOverride: %v", err)
}
overrides, err := database.ListChannelRoleOverrides(chID)
if err != nil {
t.Fatalf("ListChannelRoleOverrides: %v", err)
}
// All four seeded roles must be present, position descending (Owner first).
if len(overrides) != 4 {
t.Fatalf("expected 4 roles, got %d", len(overrides))
}
if overrides[0].RoleID != 1 || overrides[0].RoleName != "Owner" {
t.Errorf("first role = (%d, %q), want (1, Owner)", overrides[0].RoleID, overrides[0].RoleName)
}
for _, o := range overrides {
switch o.RoleID {
case 4:
if o.Deny != 0x202 || o.Allow != 0 {
t.Errorf("member override = (%#x, %#x), want (0, 0x202)", o.Allow, o.Deny)
}
default:
if o.Allow != 0 || o.Deny != 0 {
t.Errorf("role %d override = (%#x, %#x), want zeros", o.RoleID, o.Allow, o.Deny)
}
}
if o.Permissions == 0 {
t.Errorf("role %d permissions should be non-zero", o.RoleID)
}
}
}
// ─── SetChannelSlowMode ─────────────────────────────────────────────────────
func TestSetChannelSlowMode(t *testing.T) {
+7
View File
@@ -26,6 +26,13 @@ const (
Administrator = int64(0x40000000) // bit 30 — bypasses all permission checks
)
// AllPerms is the union of every defined permission bit. Use it to mask
// externally supplied permission values so unknown bits are dropped.
const AllPerms = SendMessages | ReadMessages | AttachFiles | AddReactions |
ConnectVoice | SpeakVoice | UseVideo | ShareScreen |
ManageMessages | ManageChannels | KickMembers | BanMembers | MuteMembers |
ManageRoles | ManageServer | ManageInvites | ViewAuditLog | Administrator
// ─── Role ID constants (default roles inserted on first run) ─────────────────
const (
+63
View File
@@ -509,6 +509,69 @@ func (h *Hub) BroadcastChannelDelete(channelID int64) {
h.BroadcastToAll(buildChannelDelete(channelID))
}
// RefreshChannelVisibility re-evaluates which connected clients may see ch
// after a channel_overrides change and sends targeted channel_create /
// channel_delete messages so sidebars converge without a reconnect. Clients
// that lose visibility are also unsubscribed from the channel topic and have
// their focused channel cleared so live messages stop flowing.
//
// The sends deliberately bypass the sequenced broadcast/replay path: a
// replayed channel_delete would be filtered by the allowed-channel set
// computed at replay time, which after an override change is exactly the
// inverse of the intended audience. Clients tolerate seq-less messages.
func (h *Hub) RefreshChannelVisibility(ch *db.Channel) {
if ch == nil {
return
}
h.mu.RLock()
clients := make([]*Client, 0, len(h.clients))
for _, c := range h.clients {
clients = append(clients, c)
}
h.mu.RUnlock()
// Visibility is a function of the role, so resolve each role once.
visibleByRole := make(map[int64]bool)
roleVisible := func(roleID int64) bool {
if v, ok := visibleByRole[roleID]; ok {
return v
}
visible := false
role, err := h.db.GetRoleByID(roleID)
if err == nil && role != nil {
if permissions.HasAdmin(role.Permissions) {
visible = true
} else {
allow, deny, permErr := h.db.GetChannelPermissions(ch.ID, roleID)
// Fail closed: an error hides the channel rather than leaking it.
visible = permErr == nil &&
permissions.EffectivePerms(role.Permissions, allow, deny)&permissions.ReadMessages != 0
}
}
visibleByRole[roleID] = visible
return visible
}
for _, c := range clients {
if c.user == nil {
continue
}
if roleVisible(c.user.RoleID) {
// Idempotent add on the client; also refreshes channel metadata.
c.sendMsg(buildChannelCreate(ch))
continue
}
c.sendMsg(buildChannelDelete(ch.ID))
h.pubsub.Unsubscribe(c, ChannelTopic(ch.ID))
c.mu.Lock()
if c.channelID == ch.ID {
c.channelID = 0
}
c.mu.Unlock()
}
}
// BroadcastMemberBan sends a member_ban message to all connected clients
// and immediately disconnects the banned user's WebSocket connection (BUG-113).
func (h *Hub) BroadcastMemberBan(userID int64) {
+94
View File
@@ -860,6 +860,100 @@ func TestHub_VoiceSessionCount(t *testing.T) {
}
}
// ─── RefreshChannelVisibility ─────────────────────────────────────────────────
// drainForMsgType reads messages from send until one with the given type
// arrives or the timeout expires. Returns the decoded payload-bearing message.
func drainForMsgType(t *testing.T, send chan []byte, msgType string) map[string]any {
t.Helper()
deadline := time.After(500 * time.Millisecond)
for {
select {
case raw := <-send:
var msg map[string]any
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
if msg["type"] == msgType {
return msg
}
case <-deadline:
t.Fatalf("timed out waiting for %q message", msgType)
return nil
}
}
}
// assertNoMsgType asserts that no message of the given type is buffered.
func assertNoMsgType(t *testing.T, send chan []byte, msgType string) {
t.Helper()
for {
select {
case raw := <-send:
var msg map[string]any
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
if msg["type"] == msgType {
t.Fatalf("unexpected %q message", msgType)
}
default:
return
}
}
}
func TestRefreshChannelVisibility_TargetedSends(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
chID := seedTestChannel(t, database, "secret-room")
ch, err := database.GetChannel(chID)
if err != nil || ch == nil {
t.Fatalf("GetChannel: %v", err)
}
owner := seedOwnerUser(t, database, "vis-owner")
memberID := seedTestUser(t, database, "vis-member")
member, err := database.GetUserByID(memberID)
if err != nil || member == nil {
t.Fatalf("GetUserByID: %v", err)
}
ownerSend := make(chan []byte, 16)
memberSend := make(chan []byte, 16)
ownerClient := ws.NewTestClientWithUser(hub, owner, chID, ownerSend)
memberClient := ws.NewTestClientWithUser(hub, member, chID, memberSend)
hub.Register(ownerClient)
hub.Register(memberClient)
time.Sleep(30 * time.Millisecond)
// Hide the channel from the Member role (deny ReadMessages).
if _, err := database.Exec(
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`,
chID,
); err != nil {
t.Fatalf("insert override: %v", err)
}
hub.RefreshChannelVisibility(ch)
// Member loses the channel; owner (admin bit) keeps it.
drainForMsgType(t, memberSend, "channel_delete")
drainForMsgType(t, ownerSend, "channel_create")
// Restore visibility — the member gets the channel back.
if _, err := database.Exec(
`DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = 4`, chID,
); err != nil {
t.Fatalf("delete override: %v", err)
}
hub.RefreshChannelVisibility(ch)
drainForMsgType(t, memberSend, "channel_create")
assertNoMsgType(t, memberSend, "channel_delete")
}
// hubTestSchema is the minimal schema needed for hub tests.
var hubTestSchema = []byte(`
CREATE TABLE IF NOT EXISTS roles (