Skip to content

Commit 2116855

Browse files
committed
Implement moderator/admin endpoints
Allows adding/removing room-specific and global moderators/admins.
1 parent 6847ca3 commit 2116855

File tree

8 files changed

+814
-60
lines changed

8 files changed

+814
-60
lines changed

api.yaml

Lines changed: 86 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -894,12 +894,16 @@ paths:
894894
/user/{sessionId}/moderator:
895895
post:
896896
tags: [Users]
897-
summary: Adds or removes moderator powers.
897+
summary: Adds or removes moderator/admin powers.
898898
description: >
899899
Adds or removes moderator or admin permissions to a user for specific rooms, or globally on
900900
the server.
901901
902902
903+
The invoking user must have admin permissions in all of the rooms specified when adding room
904+
mods/admins, and must have global admin permissions when adding a global moderator or admin.
905+
906+
903907
Note that the given session ID does not have to exist: it is possible to grant moderator
904908
permissions preemptively for a session ID that has never visited the server or room(s).
905909
parameters:
@@ -922,22 +926,37 @@ paths:
922926
invoking user must be an admin of all of the given rooms.
923927
924928
925-
Exclusive of `global`.
929+
This may be set to the single-element list ['*'] to add or remove the moderator
930+
from all rooms in which the current user has admin permissions (the call will
931+
succeed if the calling user is an admin in at least one channel).
932+
933+
934+
Exclusive of `global`. (If you want to apply both at once use two calls, e.g.
935+
bundled in a batch request).
926936
global:
927937
type: boolean
928938
description: >
929-
If true then appoint this user as a moderator or admin of the global server.
930-
The user will receive moderator/admin ability in all rooms on the server.
931-
939+
If true then appoint this user as a global moderator or admin of the server.
940+
The user will receive moderator/admin ability in all rooms on the server (both
941+
current and future).
942+
943+
944+
The caller must be a global admin to add/remove a global moderator or admin.
932945
moderator:
933946
type: boolean
934947
description: >
935948
If `true` then this user will be granted moderator permission to either the
936-
listed room or the server globally.
949+
listed room(s) or the server globally.
950+
951+
952+
If `false` then this user will have their moderator *and admin* permissions
953+
removed from the given rooms (or server). Note that removing a global moderator
954+
only removes the global permission but does not remove individual room
955+
moderator permissions that may also be present.
937956
938957
939-
If `false` then this user will have their moderator and admin permissions
940-
removed from the given rooms (or server).
958+
See the `admin` parameter description for information on how `admin` and
959+
`moderator` parameters interact.
941960
admin:
942961
type: boolean
943962
description: >
@@ -947,53 +966,87 @@ paths:
947966
pinned messages, and changing the name/description of the room.
948967
949968
950-
If false then this user will have their admin permission removed, but will
951-
remain a moderator (if they were previously a moderator or admin). To remove
952-
both moderator and admin status you can specify simply `moderator: false` rather
953-
than needing to specify both values as false.
969+
If false then this user will have their admin permission removed, but will keep
970+
moderator permissions. To remove both moderator and admin permissions specify
971+
`moderator: false` (which implies clearing admin permissions as well).
972+
973+
974+
Note that removing a global admin only removes the global permission but does not remove
975+
individual room admin permissions that may also be present.
976+
977+
978+
The `admin`/`moderator` paramters interact as follows:
979+
- `admin=true`, `moderator` omitted: this adds admin permissions, which
980+
automatically also implies moderator permissions.
981+
- `admin=true, moderator=true`: exactly the same as above.
982+
- `admin=false, moderator=true`: removes any existing admin permissions from the
983+
rooms (or globally), if present, and adds moderator permissions to the
984+
rooms/globally (if not already present).
985+
- `admin=false`, `moderator` omitted: this removes admin permissions but leaves
986+
moderator permissions, if present. (This effectively "downgrades" an admin to
987+
a moderator). Unlike the above this does *not* add moderator permissions to
988+
matching rooms if not already present.
989+
- `moderator=true`, `admin` omitted: adds moderator permissions to the given
990+
rooms (or globally), if not already present. If the user already has admin
991+
permissions this does nothing (that is, admin permission is *not* removed,
992+
unlike the above).
993+
- `moderator=false`, `admin` omitted: this removes moderator *and* admin
994+
permissions from all given rooms (or globally).
995+
- `moderator=false, admin=false`: exactly the same as above.
996+
- `moderator=false, admin=true`: this combination is *not* *permitted* (because
997+
admin permissions imply moderator permissions) and will result in Bad Request
998+
error if given.
954999
visible:
9551000
type: boolean
9561001
description: >
957-
Whether this user should be a "visible" moderator in the server rooms. Visible
958-
moderators are identified to all room users (e.g. via a special status badge in
959-
Session clients).
1002+
Whether this user should be a "visible" moderator or admin in the specified
1003+
rooms (or globally). Visible moderators are identified to all room users (e.g.
1004+
via a special status badge in Session clients).
9601005
9611006
9621007
Invisible moderators/admins have the same permission as as visible ones, but
963-
their moderator/admin status is only visible to other moderators but not to
1008+
their moderator/admin status is only visible to other moderators, not to
9641009
ordinary room participants.
9651010
9661011
9671012
The default if this field is omitted is true for room-specific moderators/admins
9681013
and false for server-level global moderators/admins.
1014+
1015+
1016+
If an admin or moderator has both global and room-specific moderation
1017+
permissions then the visibility of the admin/mod for that room's moderator/admin
1018+
list will use the room-specific visibility value, regardless of the global
1019+
setting. (This differs from moderator/admin permissions themselves, which are
1020+
additive).
9691021
examples:
970-
tworooms:
971-
summary: "1-day mute in two rooms"
1022+
room-moderator:
1023+
summary: "Add a moderator to a pair of rooms"
9721024
value:
9731025
rooms: ["session", "lokinet"]
974-
timeout: 86400
975-
write: false
976-
allow-uploads:
977-
summary: "Allow file attachments for 1 week"
1026+
moderator: true
1027+
global-admin:
1028+
summary: "Add a global server admin, visible in all rooms"
9781029
value:
979-
rooms: ["session-help"]
980-
upload: true
981-
timeout: 604800
982-
secretroom:
983-
summary: "Grant access to a restricted room"
1030+
global: true
1031+
admin: true
1032+
visible: true
1033+
hidden-mod:
1034+
summary: "Add a hidden admin to a room"
9841035
value:
985-
rooms: ["top-secret"]
986-
read: true
987-
write: true
988-
upload: true
1036+
rooms: ["session"]
1037+
admin: true
1038+
visible: false
9891039
responses:
9901040
200:
9911041
description: Permission update applied successfully.
9921042
content: {}
9931043
403:
9941044
description: >
995-
Permission denied. The user attempting to set the permissions does not have moderator
996-
permissions for one or more of the given rooms.
1045+
Permission denied. The user attempting to set the permissions does not have admin
1046+
permissions for one or more of the given rooms and/or global admin permissions.
1047+
content: {}
1048+
404:
1049+
description: Returned if one or more specified room tokens do not exist.
9971050
content: {}
9981051
/user/{sessionId}/deleteMessages:
9991052
post:

sogs/model/room.py

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,8 @@ def set_moderator(self, user: User, *, added_by: User, admin=False, visible=True
940940
Sets `user` as a moderator or admin of this room. Replaces current admin/moderator/visible
941941
status with the new values if the user is already a moderator/admin of the room.
942942
943+
`admin` can be specified as None to not touch the current admin permission on the room.
944+
943945
added_by is the user performing the update and must have admin permission.
944946
"""
945947

@@ -951,12 +953,13 @@ def set_moderator(self, user: User, *, added_by: User, admin=False, visible=True
951953
raise BadPermission()
952954

953955
query(
954-
"""
955-
INSERT INTO user_permission_overrides (room, "user", moderator, admin, visible_mod)
956-
VALUES (:r, :u, TRUE, :admin, :visible)
956+
f"""
957+
INSERT INTO user_permission_overrides
958+
(room, "user", moderator, {'admin,' if admin is not None else ''} visible_mod)
959+
VALUES (:r, :u, TRUE, {':admin,' if admin is not None else ''} :visible)
957960
ON CONFLICT (room, "user") DO UPDATE SET
958961
moderator = excluded.moderator,
959-
admin = excluded.admin,
962+
{'admin = excluded.admin,' if admin is not None else ''}
960963
visible_mod = excluded.visible_mod
961964
""",
962965
r=self.id,
@@ -970,16 +973,22 @@ def set_moderator(self, user: User, *, added_by: User, admin=False, visible=True
970973

971974
app.logger.info(f"{added_by} set {user} as {'admin' if admin else 'moderator'} of {self}")
972975

973-
def remove_moderator(self, user: User, *, removed_by: User):
974-
"""Remove `user` as a moderator/admin of this room. Requires admin permission."""
976+
def remove_moderator(self, user: User, *, removed_by: User, remove_admin_only: bool = False):
977+
"""
978+
Remove `user` as a moderator/admin of this room. Requires admin permission.
979+
980+
If `remove_admin_only` is True then user will have admin permissions removed but will remain
981+
a room moderator if already a room moderator or admin.
982+
"""
975983

976984
if not self.check_admin(removed_by):
977985
raise BadPermission()
978986

979987
query(
980-
"""
988+
f"""
981989
UPDATE user_permission_overrides
982-
SET moderator = FALSE, admin = FALSE, visible_mod = TRUE
990+
SET admin = FALSE
991+
{', moderator = FALSE, visible_mod = TRUE' if not remove_admin_only else ''}
983992
WHERE room = :r AND "user" = :u
984993
""",
985994
r=self.id,
@@ -1321,6 +1330,63 @@ def get_rooms():
13211330
return [Room(row) for row in query("SELECT * FROM rooms ORDER BY token")]
13221331

13231332

1333+
def get_rooms_with_permission(
1334+
user: User,
1335+
*,
1336+
tokens: Optional[Union[list, tuple]] = None,
1337+
read: Optional[bool] = None,
1338+
write: Optional[bool] = None,
1339+
upload: Optional[bool] = None,
1340+
banned: Optional[bool] = None,
1341+
moderator: Optional[bool] = None,
1342+
admin: Optional[bool] = None,
1343+
):
1344+
"""
1345+
Returns a list of rooms that the given user has matching permissions for.
1346+
1347+
Parameters:
1348+
user: the user object to query permissions for. May not be None.
1349+
tokens: if non-None then this specifies a list or tuple of room tokens to filter by. When
1350+
omitted, all rooms are returned. Note that rooms are returned sorted by token, *not* in
1351+
the order specified here; duplicates are not returned; nor are entries for non-existent
1352+
tokens.
1353+
read/write/upload/banned/moderator/admin:
1354+
Any of these that are specified as non-None must match the user's permissions for the room.
1355+
For example `read=True, write=False` would return all rooms where the user has read-only
1356+
access but not rooms in which the user has both or neither read and write permissions.
1357+
At least one of these arguments must be specified as non-None.
1358+
"""
1359+
if user is None:
1360+
raise RuntimeError("user is required for get_rooms_with_permission")
1361+
if not any(arg is not None for arg in (read, write, upload, banned, moderator, admin)):
1362+
raise RuntimeError("At least one of read/write/upload/banned/moderator/admin must be given")
1363+
if tokens and (
1364+
not (isinstance(tokens, list) or isinstance(tokens, tuple))
1365+
or any(not isinstance(t, str) for t in tokens)
1366+
):
1367+
raise RuntimeError("tokens= must be a list or tuple of room token names")
1368+
1369+
return [
1370+
Room(row)
1371+
for row in query(
1372+
f"""
1373+
SELECT rooms.* FROM user_permissions perm JOIN rooms ON rooms.id = room
1374+
WHERE "user" = :u {'AND token IN :tokens' if tokens else ''}
1375+
{'' if banned is None else ('AND' if banned else 'AND NOT') + ' perm.banned'}
1376+
{'' if read is None else ('AND' if read else 'AND NOT') + ' perm.read'}
1377+
{'' if write is None else ('AND' if write else 'AND NOT') + ' perm.write'}
1378+
{'' if upload is None else ('AND' if upload else 'AND NOT') + ' perm.upload'}
1379+
{'' if moderator is None else ('AND' if moderator else 'AND NOT') + ' perm.moderator'}
1380+
{'' if admin is None else ('AND' if admin else 'AND NOT') + ' perm.admin'}
1381+
ORDER BY token
1382+
""",
1383+
u=user.id,
1384+
tokens=tokens,
1385+
bind_expanding=['tokens'] if tokens else None,
1386+
)
1387+
]
1388+
1389+
13241390
def get_readable_rooms(user: Optional[User] = None):
13251391
"""
13261392
Get a list of rooms that a user can access; if user is None then return all publicly readable
@@ -1329,14 +1395,7 @@ def get_readable_rooms(user: Optional[User] = None):
13291395
if user is None:
13301396
result = query("SELECT * FROM rooms WHERE read ORDER BY token")
13311397
else:
1332-
result = query(
1333-
"""
1334-
SELECT rooms.* FROM user_permissions perm JOIN rooms ON rooms.id = room
1335-
WHERE "user" = :u AND perm.read AND NOT perm.banned
1336-
ORDER BY token
1337-
""",
1338-
u=user.id,
1339-
)
1398+
return get_rooms_with_permission(user, read=True, banned=False)
13401399
return [Room(row) for row in result]
13411400

13421401

sogs/model/user.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,23 @@ def __init__(self, row=None, *, id=None, session_id=None, autovivify=True, touch
3434
touch - if True (default is False) then update the last_activity time of this user before
3535
returning it.
3636
"""
37+
self._touched = False
38+
self._refresh(row=row, id=id, session_id=session_id)
3739

38-
if sum(x is not None for x in (row, session_id, id)) != 1:
40+
if touch:
41+
self._touch()
42+
43+
def _refresh(self, *, row=None, id=None, session_id=None, autovivify=True):
44+
"""
45+
Internal method to (re-)fetch details from the database; this is used during construction
46+
but also in the test suite to forcibly re-fetch details.
47+
"""
48+
n_args = sum(x is not None for x in (row, session_id, id))
49+
if n_args == 0 and hasattr(self, 'id'):
50+
id = self.id
51+
elif n_args != 1:
3952
raise ValueError("User() error: exactly one of row/session_id/id is required")
4053

41-
self._touched = False
4254
if session_id is not None:
4355
row = query("SELECT * FROM users WHERE session_id = :s", s=session_id).first()
4456

@@ -62,9 +74,6 @@ def __init__(self, row=None, *, id=None, session_id=None, autovivify=True, touch
6274
bool(row[c]) for c in ('banned', 'moderator', 'admin', 'visible_mod')
6375
)
6476

65-
if touch:
66-
self._touch()
67-
6877
def __str__(self):
6978
"""Returns string representation of a user: U[050123…cdef], the id prefixed with @ or % if
7079
the user is a global admin or moderator, respectively."""
@@ -113,6 +122,8 @@ def set_moderator(self, *, added_by: User, admin=False, visible=False):
113122
"""
114123
Make this user a global moderator or admin. If the user is already a global mod/admin then
115124
their status is updated according to the given arguments (that is, this can promote/demote).
125+
126+
If `admin` is None then the current admin status is left unchanged.
116127
"""
117128

118129
if not added_by.global_admin:
@@ -123,20 +134,21 @@ def set_moderator(self, *, added_by: User, admin=False, visible=False):
123134
raise BadPermission()
124135

125136
query(
126-
"""
137+
f"""
127138
UPDATE users
128-
SET moderator = TRUE, admin = :admin, visible_mod = :visible
139+
SET moderator = TRUE, visible_mod = :visible
140+
{', admin = :admin' if admin is not None else ''}
129141
WHERE id = :u
130142
""",
131-
admin=admin,
143+
admin=bool(admin),
132144
visible=visible,
133145
u=self.id,
134146
)
135147
self.global_admin = admin
136148
self.global_moderator = True
137149
self.visible_mod = visible
138150

139-
def remove_moderator(self, *, removed_by: User):
151+
def remove_moderator(self, *, removed_by: User, remove_admin_only: bool = False):
140152
"""Removes this user's global moderator/admin status, if set."""
141153

142154
if not removed_by.global_admin:
@@ -145,7 +157,14 @@ def remove_moderator(self, *, removed_by: User):
145157
)
146158
raise BadPermission()
147159

148-
query("UPDATE users SET moderator = FALSE, admin = FALSE WHERE id = :u", u=self.id)
160+
query(
161+
f"""
162+
UPDATE users
163+
SET admin = FALSE {', moderator = FALSE' if not remove_admin_only else ''}
164+
WHERE id = :u
165+
""",
166+
u=self.id,
167+
)
149168
self.global_admin = False
150169
self.global_moderator = False
151170

0 commit comments

Comments
 (0)