Skip to content

Commit 7315f6d

Browse files
committed
Test and fix ban timeouts, and allow global timeouts
Adds more tests for ban timeouts, which revealed a permission futures bug that wouldn't properly apply updates when `banned` was NULL. Fixing this required splitting permission updates and bans into separate tables, which makes things easier to manage. Moreover this allows the new user_ban_futures table to accept a NULL room so that we can use it to also have global ban timeouts, which is implemented here.
1 parent 9f79c49 commit 7315f6d

File tree

9 files changed

+351
-54
lines changed

9 files changed

+351
-54
lines changed

api.yaml

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -743,7 +743,8 @@ paths:
743743
user must be a server-level moderator or admin.
744744
745745
746-
The ban applies immediately to all server requests as early as possible.
746+
The ban applies immediately to all server requests initiated by the user (not
747+
just room access).
747748
748749
749750
Exclusive of `rooms`.
@@ -753,13 +754,9 @@ paths:
753754
nullable: true
754755
example: 86400
755756
description: >
756-
How long the ban should apply, in seconds. If there is an existing ban on the
757-
user in the given rooms this updates the existing expiry to the given value. If
758-
omitted or `null` then the ban does not expire.
759-
760-
761-
May only be used with the `rooms` argument; timeouts are not supported on global
762-
bans.
757+
How long the ban should apply, in seconds. If omitted (or `null`) then the ban
758+
applies forever. If an unban was previously scheduled then it is replaced (if a
759+
timeout is given) or deleted (if no timeout is provided).
763760
examples:
764761
tworooms:
765762
summary: "1-day ban from two rooms"

sogs/cleanup.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,24 +94,53 @@ def expire_nonce_history():
9494
def apply_permission_updates():
9595
with db.transaction():
9696
now = time.time()
97-
num_applied = query(
97+
num_perms = query(
9898
"""
99-
INSERT INTO user_permission_overrides (room, "user", read, write, upload, banned)
100-
SELECT room, "user", read, write, upload, banned FROM user_permission_futures
99+
INSERT INTO user_permission_overrides (room, "user", read, write, upload)
100+
SELECT room, "user", read, write, upload
101+
FROM user_permission_futures
101102
WHERE at <= :now
103+
ORDER BY at
102104
ON CONFLICT (room, "user") DO UPDATE SET
103105
read = COALESCE(excluded.read, user_permission_overrides.read),
104106
write = COALESCE(excluded.write, user_permission_overrides.write),
105-
upload = COALESCE(excluded.upload, user_permission_overrides.upload),
106-
banned = COALESCE(excluded.banned, user_permission_overrides.banned)
107+
upload = COALESCE(excluded.upload, user_permission_overrides.upload)
107108
""",
108109
now=now,
109110
).rowcount
110-
if not num_applied:
111-
return 0
112111

113-
query("DELETE FROM user_permission_futures WHERE at <= :now", now=now)
112+
if num_perms > 0:
113+
query("DELETE FROM user_permission_futures WHERE at <= :now", now=now)
114+
115+
num_room_bans, num_user_bans = 0, 0
116+
for uid, rid, banned in query(
117+
'SELECT "user", room, banned FROM user_ban_futures WHERE at <= :now ORDER BY at',
118+
now=now,
119+
):
120+
if rid is None:
121+
query("UPDATE users SET banned = :b WHERE id = :u", u=uid, b=banned)
122+
num_user_bans += 1
123+
else:
124+
query(
125+
"""
126+
INSERT INTO user_permission_overrides (room, "user", banned)
127+
VALUES (:r, :u, :b)
128+
ON CONFLICT (room, "user") DO UPDATE SET banned = excluded.banned
129+
""",
130+
r=rid,
131+
u=uid,
132+
b=banned,
133+
)
134+
num_room_bans += 1
135+
136+
if num_room_bans > 0 or num_user_bans > 0:
137+
query("DELETE FROM user_ban_futures WHERE at <= :now", now=now)
138+
139+
num_applied = num_perms + num_room_bans + num_user_bans
114140

115141
if num_applied > 0:
116-
app.logger.info("Applied {} scheduled user permission updates".format(num_applied))
142+
app.logger.info(
143+
f"Applied {num_applied} permission updates ({num_perms} perms; "
144+
f"{num_room_bans} room (un)bans; {num_user_bans} global (un)bans)"
145+
)
117146
return num_applied

sogs/db.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ def database_init():
128128
create_message_details_deleter,
129129
check_for_hacks,
130130
seqno_etc_updates,
131+
user_perm_future_updates,
131132
):
132133
with transaction(conn):
133134
if migrate(conn):
@@ -479,6 +480,76 @@ def seqno_etc_updates(conn):
479480
return True
480481

481482

483+
def user_perm_future_updates(conn):
484+
"""
485+
Break up user_permission_futures to not be (room,user) unique, and to move ban futures to a
486+
separate table.
487+
"""
488+
489+
if 'user_ban_futures' in metadata.tables:
490+
return False
491+
492+
logging.warning("Updating user_permission_futures")
493+
494+
if engine.name == 'sqlite':
495+
# Under sqlite we have to drop and recreate the whole thing. (Since we didn't have a
496+
# release out that was using futures yet, we don't bother trying to migrate data).
497+
conn.execute("DROP TABLE user_permission_futures")
498+
conn.execute(
499+
"""
500+
CREATE TABLE user_permission_futures (
501+
room INTEGER NOT NULL REFERENCES rooms ON DELETE CASCADE,
502+
user INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
503+
at FLOAT NOT NULL, /* when the change should take effect (unix epoch) */
504+
read BOOLEAN, /* Set this value @ at, if non-null */
505+
write BOOLEAN, /* Set this value @ at, if non-null */
506+
upload BOOLEAN /* Set this value @ at, if non-null */
507+
)
508+
"""
509+
)
510+
conn.execute("CREATE INDEX user_permission_futures_at ON user_permission_futures(at)")
511+
conn.execute(
512+
"""
513+
CREATE INDEX user_permission_futures_room_user ON user_permission_futures(room, user)
514+
"""
515+
)
516+
517+
conn.execute(
518+
"""
519+
CREATE TABLE user_ban_futures (
520+
room INTEGER REFERENCES rooms ON DELETE CASCADE,
521+
user INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
522+
at FLOAT NOT NULL, /* when the change should take effect (unix epoch) */
523+
banned BOOLEAN NOT NULL /* if true then ban at `at`, if false then unban */
524+
);
525+
"""
526+
)
527+
conn.execute("CREATE INDEX user_ban_futures_at ON user_ban_futures(at)")
528+
conn.execute("CREATE INDEX user_ban_futures_room_user ON user_ban_futures(room, user)")
529+
530+
else: # postgresql
531+
conn.execute(
532+
"""
533+
CREATE TABLE user_ban_futures (
534+
room INTEGER NOT NULL REFERENCES rooms ON DELETE CASCADE,
535+
"user" INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
536+
at FLOAT NOT NULL, /* when the change should take effect (unix epoch) */
537+
banned BOOLEAN NOT NULL /* if true then ban at `at`, if false then unban */
538+
);
539+
CREATE INDEX user_ban_futures_at ON user_ban_futures(at);
540+
CREATE INDEX user_ban_futures_room_user ON user_ban_futures(room, "user");
541+
542+
INSERT INTO user_ban_futures (room, "user", at, banned)
543+
SELECT room, "user", at, banned FROM user_permission_futures WHERE banned is NOT NULL;
544+
545+
ALTER TABLE user_permission_futures DROP PRIMARY KEY;
546+
ALTER TABLE user_permission_futures DROP COLUMN banned;
547+
"""
548+
)
549+
550+
return True
551+
552+
482553
def create_admin_user(dbconn):
483554
"""
484555
We create a dummy user (with id 0) for system tasks such as changing moderators from

sogs/model/room.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,28 +1035,36 @@ def ban_user(self, to_ban: User, *, mod: User, timeout: Optional[float] = None):
10351035
"""
10361036
INSERT INTO user_permission_overrides (room, "user", banned, moderator, admin)
10371037
VALUES (:r, :ban, TRUE, FALSE, FALSE)
1038-
ON CONFLICT (room, user) DO
1038+
ON CONFLICT (room, "user") DO
10391039
UPDATE SET banned = TRUE, moderator = FALSE, admin = FALSE
10401040
""",
10411041
r=self.id,
10421042
ban=to_ban.id,
10431043
)
10441044

1045+
# Replace (or remove) an existing scheduled unban:
1046+
query(
1047+
'DELETE FROM user_ban_futures WHERE room = :r AND "user" = :u AND NOT banned',
1048+
r=self.id,
1049+
u=to_ban.id,
1050+
)
10451051
if timeout:
10461052
query(
10471053
"""
1048-
INSERT INTO user_permission_futures (room, "user", at, banned)
1049-
VALUES (:r, :banned, :at, FALSE)
1054+
INSERT INTO user_ban_futures
1055+
(room, "user", banned, at) VALUES (:r, :u, FALSE, :at)
10501056
""",
10511057
r=self.id,
1052-
banned=to_ban.id,
1058+
u=to_ban.id,
10531059
at=time.time() + timeout,
10541060
)
10551061

10561062
if to_ban.id in self._perm_cache:
10571063
del self._perm_cache[to_ban.id]
10581064

1059-
app.logger.debug(f"Banned {to_ban} from {self} (banned by {mod})")
1065+
app.logger.debug(
1066+
f"Banned {to_ban} from {self} {f'for {timeout}s ' if timeout else ''}(banned by {mod})"
1067+
)
10601068

10611069
def unban_user(self, to_unban: User, *, mod: User):
10621070
"""

sogs/model/user.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from ..web import app
66
from .exc import NoSuchUser, BadPermission
77

8+
from typing import Optional
89
import time
910

1011

@@ -168,11 +169,18 @@ def remove_moderator(self, *, removed_by: User, remove_admin_only: bool = False)
168169
self.global_admin = False
169170
self.global_moderator = False
170171

171-
def ban(self, *, banned_by: User):
172-
"""Globally bans this user from the server; can only be applied by a global moderator or
173-
global admin, and cannot be applied to another global moderator or admin (to prevent
174-
accidental mod/admin banning; to ban them, first explicitly remove them as moderator/admin
175-
and then ban)."""
172+
def ban(self, *, banned_by: User, timeout: Optional[float] = None):
173+
"""
174+
Globally bans this user from the server; can only be applied by a global moderator or global
175+
admin, and cannot be applied to another global moderator or admin (to prevent accidental
176+
mod/admin banning; to ban them, first explicitly remove them as moderator/admin and then
177+
ban).
178+
179+
timeout should be None for a non-expiring ban and otherwise should be the duration of the
180+
ban, in seconds; an unban will be scheduled to occur after the interval. In either case,
181+
any existing scheduled global unbans for this user will be deleted (and replaced, if a new
182+
timeout is provided).
183+
"""
176184

177185
if not banned_by.global_moderator:
178186
app.logger.warning(f"Cannot ban {self}: {banned_by} is not a global mod/admin")
@@ -182,17 +190,43 @@ def ban(self, *, banned_by: User):
182190
app.logger.warning(f"Cannot ban {self}: user is a global moderator/admin")
183191
raise BadPermission()
184192

185-
query("UPDATE users SET banned = TRUE WHERE id = :u", u=self.id)
186-
app.logger.debug(f"{banned_by} globally banned {self}")
193+
with db.transaction():
194+
query("UPDATE users SET banned = TRUE WHERE id = :u", u=self.id)
195+
query(
196+
'DELETE FROM user_ban_futures WHERE room IS NULL AND "user" = :u AND NOT banned',
197+
u=self.id,
198+
)
199+
200+
if timeout:
201+
query(
202+
"""
203+
INSERT INTO user_ban_futures
204+
("user", room, banned, at) VALUES (:u, NULL, FALSE, :at)
205+
""",
206+
u=self.id,
207+
at=time.time() + timeout,
208+
)
209+
210+
app.logger.debug(
211+
f"{banned_by} globally banned {self}{f' for {timeout}s' if timeout else ''}"
212+
)
187213
self.banned = True
188214

189215
def unban(self, *, unbanned_by: User):
190-
"""Undoes a global ban. `unbanned_by` must be a global mod/admin."""
216+
"""
217+
Undoes a global ban. `unbanned_by` must be a global mod/admin.
218+
219+
Any currently scheduled global ban futures for this user will be removed as well.
220+
"""
191221
if not unbanned_by.global_moderator:
192222
app.logger.warning(f"Cannot unban {self}: {unbanned_by} is not a global mod/admin")
193223
raise BadPermission()
194224

195225
query("UPDATE users SET banned = FALSE WHERE id = :u", u=self.id)
226+
query(
227+
'DELETE FROM user_ban_futures WHERE room IS NULL AND "user" = :u AND banned', u=self.id
228+
)
229+
196230
app.logger.debug(f"{unbanned_by} removed global ban on {self}")
197231
self.banned = False
198232

sogs/routes/users.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,16 +156,12 @@ def ban_user(sid):
156156
app.logger.warning("Invalid ban request: timeout must be numeric")
157157
abort(http.BAD_REQUEST)
158158

159-
if timeout and global_ban:
160-
app.logger.warning("Invalid ban request: global server bans do not support timeouts")
161-
abort(http.BAD_REQUEST)
162-
163159
if rooms:
164160
with db.transaction():
165161
for room in rooms:
166162
room.ban_user(to_ban=user, mod=g.user, timeout=timeout)
167163
else:
168-
user.ban(banned_by=g.user)
164+
user.ban(banned_by=g.user, timeout=timeout)
169165

170166
return {}
171167

sogs/schema.pgsql

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -367,11 +367,22 @@ CREATE TABLE user_permission_futures (
367367
at FLOAT NOT NULL, /* when the change should take effect (unix epoch) */
368368
read BOOLEAN, /* Set this value @ at, if non-null */
369369
write BOOLEAN, /* Set this value @ at, if non-null */
370-
upload BOOLEAN, /* Set this value @ at, if non-null */
371-
banned BOOLEAN, /* Set this value @ at, if non-null */
372-
PRIMARY KEY(room, "user")
370+
upload BOOLEAN /* Set this value @ at, if non-null */
373371
);
374372
CREATE INDEX user_permissions_future_at ON user_permission_futures(at);
373+
CREATE INDEX user_permissions_future_room_user ON user_permission_futures(room, "user");
374+
375+
-- Similar to the above, but for ban/unbans. For example to implement a 2-day ban you would set
376+
-- their user_permissions.banned to TRUE then add a row here with banned = FALSE to schedule the
377+
-- unban. (You can also schedule a future *ban* here, but the utility of that is less clear).
378+
CREATE TABLE user_ban_futures (
379+
room INTEGER NOT NULL REFERENCES rooms ON DELETE CASCADE,
380+
"user" INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
381+
at FLOAT NOT NULL, /* when the change should take effect (unix epoch) */
382+
banned BOOLEAN NOT NULL /* if true then ban at `at`, if false then unban */
383+
);
384+
CREATE INDEX user_ban_futures_at ON user_ban_futures(at);
385+
CREATE INDEX user_ban_futures_room_user ON user_ban_futures(room, "user");
375386

376387

377388
-- Nonce tracking to prohibit request signature nonce reuse (thus prevent replay attacks)

sogs/schema.sqlite

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -322,16 +322,27 @@ FROM
322322
-- Or to implement a join delay you could set room defaults to false then insert a value here to be
323323
-- applied after a delay.
324324
CREATE TABLE user_permission_futures (
325-
room INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
326-
user INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
325+
room INTEGER NOT NULL REFERENCES rooms ON DELETE CASCADE,
326+
user INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
327327
at FLOAT NOT NULL, /* when the change should take effect (unix epoch) */
328328
read BOOLEAN, /* Set this value @ at, if non-null */
329329
write BOOLEAN, /* Set this value @ at, if non-null */
330-
upload BOOLEAN, /* Set this value @ at, if non-null */
331-
banned BOOLEAN, /* Set this value @ at, if non-null */
332-
PRIMARY KEY(room, user)
333-
) WITHOUT ROWID;
334-
CREATE INDEX user_permissions_future_at ON user_permission_futures(at);
330+
upload BOOLEAN /* Set this value @ at, if non-null */
331+
);
332+
CREATE INDEX user_permission_futures_at ON user_permission_futures(at);
333+
CREATE INDEX user_permission_futures_room_user ON user_permission_futures(room, "user");
334+
335+
-- Similar to the above, but for ban/unbans. For example to implement a 2-day ban you would set
336+
-- their user_permissions.banned to TRUE then add a row here with banned = FALSE to schedule the
337+
-- unban. (You can also schedule a future *ban* here, but the utility of that is less clear).
338+
CREATE TABLE user_ban_futures (
339+
room INTEGER REFERENCES rooms ON DELETE CASCADE,
340+
user INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
341+
at FLOAT NOT NULL, /* when the change should take effect (unix epoch) */
342+
banned BOOLEAN NOT NULL /* if true then ban at `at`, if false then unban */
343+
);
344+
CREATE INDEX user_ban_futures_at ON user_ban_futures(at);
345+
CREATE INDEX user_ban_futures_room_user ON user_ban_futures(room, user);
335346

336347

337348
-- Nonce tracking to prohibit request signature nonce reuse (thus prevent replay attacks)

0 commit comments

Comments
 (0)