feat(phase2): channel permission overrides + expanded role UI + audit log cleanup
This commit is contained in:
@@ -27,6 +27,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/{channelID}", h.Get)
|
||||
r.Patch("/{channelID}", h.Update)
|
||||
r.Delete("/{channelID}", h.Delete)
|
||||
h.registerOverrideRoutes(r)
|
||||
}
|
||||
|
||||
type createChannelRequest struct {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/permissions"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type overrideRequest struct {
|
||||
Allow int64 `json:"allow"`
|
||||
Deny int64 `json:"deny"`
|
||||
}
|
||||
|
||||
type overrideResponse struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Allow int64 `json:"allow_bitflags"`
|
||||
Deny int64 `json:"deny_bitflags"`
|
||||
}
|
||||
|
||||
func (h *Handler) registerOverrideRoutes(r chi.Router) {
|
||||
r.Put("/{channelID}/permissions/{targetType}/{targetID}", h.SetOverride)
|
||||
r.Get("/{channelID}/permissions", h.ListOverrides)
|
||||
r.Delete("/{channelID}/permissions/{targetType}/{targetID}", h.DeleteOverride)
|
||||
}
|
||||
|
||||
func (h *Handler) requireChannelManager(w http.ResponseWriter, r *http.Request, channelID string) (userID, serverID string, ok bool) {
|
||||
userID, authOK := middleware.UserIDFromContext(r.Context())
|
||||
if !authOK {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return "", "", false
|
||||
}
|
||||
serverID, err := h.serverIDForChannel(r.Context(), channelID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||
return "", "", false
|
||||
}
|
||||
allowed, err := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MANAGE_CHANNELS)
|
||||
if err != nil || !allowed {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return "", "", false
|
||||
}
|
||||
return userID, serverID, true
|
||||
}
|
||||
|
||||
func (h *Handler) SetOverride(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
_, _, ok := h.requireChannelManager(w, r, channelID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetType := chi.URLParam(r, "targetType")
|
||||
targetID := chi.URLParam(r, "targetID")
|
||||
if targetType != "role" && targetType != "user" {
|
||||
http.Error(w, `{"error":"invalid target_type"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var req overrideRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var res overrideResponse
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO channel_overrides (channel_id, target_type, target_id, allow_bitflags, deny_bitflags)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (channel_id, target_type, target_id)
|
||||
DO UPDATE SET allow_bitflags = EXCLUDED.allow_bitflags, deny_bitflags = EXCLUDED.deny_bitflags, updated_at = NOW()
|
||||
RETURNING id, channel_id, target_type, target_id, allow_bitflags, deny_bitflags
|
||||
`, channelID, targetType, targetID, req.Allow, req.Deny).Scan(&res.ID, &res.ChannelID, &res.TargetType, &res.TargetID, &res.Allow, &res.Deny)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to set override"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
func (h *Handler) ListOverrides(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
_, _, ok := h.requireChannelManager(w, r, channelID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := h.db.QueryContext(r.Context(), `
|
||||
SELECT id, channel_id, target_type, target_id, allow_bitflags, deny_bitflags
|
||||
FROM channel_overrides
|
||||
WHERE channel_id = $1
|
||||
ORDER BY target_type, target_id
|
||||
`, channelID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
overrides := make([]overrideResponse, 0)
|
||||
for rows.Next() {
|
||||
var o overrideResponse
|
||||
if err := rows.Scan(&o.ID, &o.ChannelID, &o.TargetType, &o.TargetID, &o.Allow, &o.Deny); err != nil {
|
||||
continue
|
||||
}
|
||||
overrides = append(overrides, o)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(overrides)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteOverride(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
_, _, ok := h.requireChannelManager(w, r, channelID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetType := chi.URLParam(r, "targetType")
|
||||
targetID := chi.URLParam(r, "targetID")
|
||||
_, err := h.db.ExecContext(r.Context(), `
|
||||
DELETE FROM channel_overrides WHERE channel_id = $1 AND target_type = $2 AND target_id = $3
|
||||
`, channelID, targetType, targetID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to delete override"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package permissions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// ComputeChannelPermissions returns effective permissions for a user in a specific channel,
|
||||
// layering channel overrides on top of server-wide role permissions.
|
||||
func (c *Checker) ComputeChannelPermissions(ctx context.Context, serverID, channelID, userID string) (int64, error) {
|
||||
base, err := c.GetUserPermissions(ctx, serverID, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if Has(base, ADMINISTRATOR) {
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// Everyone role override applies first.
|
||||
var everyoneID string
|
||||
_ = c.db.QueryRowContext(ctx, `SELECT id FROM roles WHERE server_id = $1 AND is_default = TRUE LIMIT 1`, serverID).Scan(&everyoneID)
|
||||
if everyoneID != "" {
|
||||
allow, deny, err := c.getOverride(ctx, channelID, "role", everyoneID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
base |= allow
|
||||
base &^= deny
|
||||
}
|
||||
|
||||
// Member-specific role overrides.
|
||||
rows, err := c.db.QueryContext(ctx, `
|
||||
SELECT r.id FROM roles r
|
||||
INNER JOIN member_roles mr ON mr.role_id = r.id
|
||||
WHERE mr.user_id = $1 AND mr.server_id = $2
|
||||
`, userID, serverID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var roleID string
|
||||
if err := rows.Scan(&roleID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
allow, deny, err := c.getOverride(ctx, channelID, "role", roleID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
base |= allow
|
||||
base &^= deny
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// User-specific override wins last.
|
||||
allow, deny, err := c.getOverride(ctx, channelID, "user", userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
base |= allow
|
||||
base &^= deny
|
||||
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func (c *Checker) getOverride(ctx context.Context, channelID, targetType, targetID string) (allow, deny int64, err error) {
|
||||
err = c.db.QueryRowContext(ctx, `
|
||||
SELECT allow_bitflags, deny_bitflags FROM channel_overrides
|
||||
WHERE channel_id = $1 AND target_type = $2 AND target_id = $3
|
||||
`, channelID, targetType, targetID).Scan(&allow, &deny)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, 0, nil
|
||||
}
|
||||
return allow, deny, err
|
||||
}
|
||||
@@ -26,6 +26,35 @@ const (
|
||||
MENTION_EVERYONE int64 = 1 << 21
|
||||
)
|
||||
|
||||
// AllPermissionLabels maps flags to human-friendly names.
|
||||
var AllPermissionLabels = []struct {
|
||||
Key string
|
||||
Flag int64
|
||||
}{
|
||||
{"VIEW_CHANNEL", VIEW_CHANNEL},
|
||||
{"SEND_MESSAGES", SEND_MESSAGES},
|
||||
{"MANAGE_MESSAGES", MANAGE_MESSAGES},
|
||||
{"KICK_MEMBERS", KICK_MEMBERS},
|
||||
{"BAN_MEMBERS", BAN_MEMBERS},
|
||||
{"MANAGE_SERVER", MANAGE_SERVER},
|
||||
{"MANAGE_CHANNELS", MANAGE_CHANNELS},
|
||||
{"ADMINISTRATOR", ADMINISTRATOR},
|
||||
{"CONNECT_VOICE", CONNECT_VOICE},
|
||||
{"SPEAK_VOICE", SPEAK_VOICE},
|
||||
{"SHARE_SCREEN", SHARE_SCREEN},
|
||||
{"MUTE_MEMBERS", MUTE_MEMBERS},
|
||||
{"CREATE_INSTANT_INVITE", CREATE_INSTANT_INVITE},
|
||||
{"CHANGE_NICKNAME", CHANGE_NICKNAME},
|
||||
{"MANAGE_NICKNAMES", MANAGE_NICKNAMES},
|
||||
{"MANAGE_ROLES", MANAGE_ROLES},
|
||||
{"MANAGE_WEBHOOKS", MANAGE_WEBHOOKS},
|
||||
{"EMBED_LINKS", EMBED_LINKS},
|
||||
{"ATTACH_FILES", ATTACH_FILES},
|
||||
{"ADD_REACTIONS", ADD_REACTIONS},
|
||||
{"USE_EXTERNAL_EMOJIS", USE_EXTERNAL_EMOJIS},
|
||||
{"MENTION_EVERYONE", MENTION_EVERYONE},
|
||||
}
|
||||
|
||||
// DefaultEveryonePermissions is granted to the @everyone role when a server is created.
|
||||
// VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS = bits 0,1,8,9,10,12,17,18,19.
|
||||
const DefaultEveryonePermissions int64 = VIEW_CHANNEL | SEND_MESSAGES | CONNECT_VOICE | SPEAK_VOICE | SHARE_SCREEN | CREATE_INSTANT_INVITE | EMBED_LINKS | ATTACH_FILES | ADD_REACTIONS
|
||||
|
||||
Reference in New Issue
Block a user