Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Allow admins to require a manual approval process before new accounts can be used (using MSC3866) #13556

Merged
merged 24 commits into from
Sep 29, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f7c9743
Add experimental config options and constant for MSC3866
babolivier Aug 18, 2022
1eaff08
Add storage support for checking and updating a user's approval status
babolivier Aug 18, 2022
685f76f
Block new accounts after registering if configured to do so
babolivier Aug 18, 2022
5d08fe2
Block login if a user requires approval and the server is configured …
babolivier Aug 18, 2022
7b532a9
Change admin APIs to support checking and updating the approval statu…
babolivier Aug 18, 2022
eedaed1
Changelog
babolivier Aug 18, 2022
ffaea1e
Use a boolean in the database schema
babolivier Aug 30, 2022
0230200
Incorporate review
babolivier Aug 31, 2022
562aa7a
Merge branch 'develop' of github.com:matrix-org/synapse into babolivi…
babolivier Sep 21, 2022
868ab64
Incorporate review
babolivier Sep 21, 2022
836aa32
Merge branch 'develop' of github.com:matrix-org/synapse into babolivi…
babolivier Sep 21, 2022
8d091b4
Correctly filter on bools, not ints
babolivier Sep 22, 2022
116fc53
Merge branch 'develop' of github.com:matrix-org/synapse into babolivi…
babolivier Sep 22, 2022
a87d2f7
Don't create a new device if the new user needs approval
babolivier Sep 22, 2022
08d85f5
Test that we raise the error on SSO logins
babolivier Sep 22, 2022
7585098
Test that we don't register devices for users needing approval
babolivier Sep 22, 2022
75cf999
Lint
babolivier Sep 22, 2022
f4a7f16
Merge branch 'develop' of github.com:matrix-org/synapse into babolivi…
babolivier Sep 26, 2022
df0c887
Incorporate review
babolivier Sep 27, 2022
3f93dda
Fix test
babolivier Sep 29, 2022
577967c
Lint
babolivier Sep 29, 2022
7a5425a
Incorporate review
babolivier Sep 29, 2022
560e160
Incorporate latest change in the MSC
babolivier Sep 29, 2022
7d71712
Add comment to try to catch bool()ing NULLs in the future
babolivier Sep 29, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions synapse/storage/databases/main/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ async def get_user_by_id(self, user_id: str) -> Optional[Dict[str, Any]]:
"user_type",
"deactivated",
"shadow_banned",
"approved",
babolivier marked this conversation as resolved.
Show resolved Hide resolved
],
allow_none=True,
desc="get_user_by_id",
Expand Down Expand Up @@ -1778,6 +1779,26 @@ async def is_guest(self, user_id: str) -> bool:

return res if res else False

@cached()
async def is_user_approved(self, user_id: str) -> bool:
"""Checks if a user is approved and therefore can be allowed to log in.

Args:
user_id: the user to check the approval status of.

Returns:
A boolean that is True if the user is approved, False otherwise.
"""
ret = await self.db_pool.simple_select_one_onecol(
table="users",
keyvalues={"name": user_id},
retcol="approved",
allow_none=True,
desc="is_user_pending_approval",
)

return bool(ret)


class RegistrationBackgroundUpdateStore(RegistrationWorkerStore):
def __init__(
Expand Down Expand Up @@ -1817,6 +1838,10 @@ def __init__(
unique=False,
)

self.db_pool.updates.register_background_update_handler(
"users_set_approved_flag", self._background_update_set_approved_flag
)

async def _background_update_set_deactivated_flag(
self, progress: JsonDict, batch_size: int
) -> int:
Expand Down Expand Up @@ -1915,6 +1940,75 @@ def set_user_deactivated_status_txn(
self._invalidate_cache_and_stream(txn, self.get_user_by_id, (user_id,))
txn.call_after(self.is_guest.invalidate, (user_id,))

async def _background_update_set_approved_flag(
self, progress: JsonDict, batch_size: int
) -> int:
"""Set the 'approved' flag for all already existing users. We want to set it to
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to have a background job to rewrite when we could set it as a database default, either because NULL means approved (and new users are inserted with approved = false)
or because there's a default value for the column*?
It feels like it would be less complicated. Note that any new users will already need approved = false upon registration because otherwise there's a race where a user can register before your migration is finished and then slip through as approved. Your background job might even be substantially delayed in starting because an earlier one is blocking it.

*This involves rewriting the rows in table, except in Postgres 11+. Postgres 10 has an EOL in 2 months so we're close to being able to require it!
We probably don't need to worry about SQLite's behaviour even if it is rewriting rows (which it may not be actually), because those will be small databases.

that said ... Even on matrix.org-size servers, rewriting the rows in one go is probably not so bad, because:

  1. the alternative (this background job) is doing that, just slower
  2. the users table isn't that large
  3. we can now update the schema before restarting workers, so there's no actual downtime involved

That said I would still be tempted to just leave NULL meaning no approval required.

Copy link
Contributor Author

@babolivier babolivier Aug 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using NULL as approved is an interesting idea, though I'm a bit on the fence with this because imo it's very easy to read NULL on a boolean column as a false value, and it also means it needs a bit of special casing when handling it (because then we need to explicitly make it true) which can end up a bit fiddly. I'd rather not use a default value since, as you point out, it involves rewriting the table in Postgres 10. That version reaching EOL in a couple of months is good news but I'd rather not block this PR on that.

It's also worth noting that this background update is basically a copy of the one we already have for setting the deactivation flag.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually since writing this PR a similar question came up in #13799, to which the decision was to treat NULL as true, which isn't that much of a faff in the end. So let's go with that.

true systematically because we don't want to suddenly prevent already existing
users from logging in if the option to block registration on approval is turned
on.
"""
last_user = progress.get("user_id", "")

def _background_update_set_approved_flag_txn(txn: LoggingTransaction) -> int:
txn.execute(
"""
SELECT name
FROM users
WHERE
approved IS NULL
AND name > ?
ORDER BY name ASC
LIMIT ?
""",
(last_user, batch_size),
)
rows = self.db_pool.cursor_to_dict(txn)

if len(rows) == 0:
return 0

for user in rows:
self.update_user_approval_status_txn(txn, user["name"], True)

self.db_pool.updates._background_update_progress_txn(
txn, "users_set_approved_flag", {"user_id": rows[-1]["name"]}
)

return len(rows)

nb_processed = await self.db_pool.runInteraction(
"users_set_approved_flag", _background_update_set_approved_flag_txn
)

if nb_processed < batch_size:
await self.db_pool.updates._end_background_update("users_set_approved_flag")

return nb_processed

def update_user_approval_status_txn(
self, txn: LoggingTransaction, user_id: str, approved: bool
) -> None:
"""Set the user's 'approved' flag to the given value.

The boolean is turned into an int because the column is a smallint.
babolivier marked this conversation as resolved.
Show resolved Hide resolved

Args:
txn: the current database transaction.
user_id: the user to update the flag for.
approved: the value to set the flag to.
"""
self.db_pool.simple_update_one_txn(
txn=txn,
table="users",
keyvalues={"name": user_id},
updatevalues={"approved": int(approved)},
)

# Invalidate the caches of methods that read the value of the 'approved' flag.
self._invalidate_cache_and_stream(txn, self.get_user_by_id, (user_id,))
self._invalidate_cache_and_stream(txn, self.is_user_approved, (user_id,))


class RegistrationStore(StatsStore, RegistrationBackgroundUpdateStore):
def __init__(
Expand All @@ -1932,6 +2026,13 @@ def __init__(
self._access_tokens_id_gen = IdGenerator(db_conn, "access_tokens", "id")
self._refresh_tokens_id_gen = IdGenerator(db_conn, "refresh_tokens", "id")

# If support for MSC3866 is enabled and configured to require approval for new
# account, we will create new users with an 'approved' flag set to false.
self._require_approval = (
hs.config.experimental.msc3866.enabled
and hs.config.experimental.msc3866.require_approval_for_new_accounts
)

async def add_access_token_to_user(
self,
user_id: str,
Expand Down Expand Up @@ -2064,6 +2165,7 @@ async def register_user(
admin: bool = False,
user_type: Optional[str] = None,
shadow_banned: bool = False,
approved: bool = False,
) -> None:
"""Attempts to register an account.

Expand All @@ -2082,6 +2184,8 @@ async def register_user(
or None for a normal user.
shadow_banned: Whether the user is shadow-banned, i.e. they may be
told their requests succeeded but we ignore them.
approved: Whether to consider the user has already been approved by an
administrator.

Raises:
StoreError if the user_id could not be registered.
Expand All @@ -2098,6 +2202,7 @@ async def register_user(
admin,
user_type,
shadow_banned,
approved,
)

def _register_user(
Expand All @@ -2112,11 +2217,14 @@ def _register_user(
admin: bool,
user_type: Optional[str],
shadow_banned: bool,
approved: bool,
) -> None:
user_id_obj = UserID.from_string(user_id)

now = int(self._clock.time())

pending_approval = self._require_approval and not approved

try:
if was_guest:
# Ensure that the guest user actually exists
Expand All @@ -2142,6 +2250,7 @@ def _register_user(
"admin": 1 if admin else 0,
"user_type": user_type,
"shadow_banned": shadow_banned,
"approved": 0 if pending_approval else 1,
},
)
else:
Expand All @@ -2157,6 +2266,7 @@ def _register_user(
"admin": 1 if admin else 0,
"user_type": user_type,
"shadow_banned": shadow_banned,
"approved": 0 if pending_approval else 1,
},
)

Expand Down Expand Up @@ -2499,6 +2609,25 @@ def start_or_continue_validation_session_txn(txn: LoggingTransaction) -> None:
start_or_continue_validation_session_txn,
)

async def update_user_approval_status(
self, user_id: UserID, approved: bool
) -> None:
"""Set the user's 'approved' flag to the given value.

The boolean will be turned into an int (in update_user_approval_status_txn)
because the column is a smallint.

Args:
user_id: the user to update the flag for.
approved: the value to set the flag to.
"""
await self.db_pool.runInteraction(
"update_user_approval_status",
self.update_user_approval_status_txn,
user_id.to_string(),
approved,
)


def find_max_generated_user_id_localpart(cur: Cursor) -> int:
"""
Expand Down
22 changes: 22 additions & 0 deletions synapse/storage/schema/main/delta/72/04users_approved_column.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/* Copyright 2022 The Matrix.org Foundation C.I.C
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

-- Add a column to the users table to track whether the user needs to be approved by an
-- administrator.
ALTER TABLE users ADD COLUMN approved SMALLINT;

-- Run a background update to set the approved flag on already existing users.
INSERT INTO background_updates (ordering, update_name, progress_json) VALUES
(7204, 'users_set_approved_flag', '{}');
105 changes: 104 additions & 1 deletion tests/storage/test_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from twisted.test.proto_helpers import MemoryReactor

from synapse.api.constants import UserTypes
from synapse.api.errors import ThreepidValidationError
from synapse.server import HomeServer
from synapse.types import JsonDict, UserID
from synapse.util import Clock

from tests.unittest import HomeserverTestCase
from tests.unittest import HomeserverTestCase, override_config


class RegistrationStoreTestCase(HomeserverTestCase):
Expand Down Expand Up @@ -44,6 +48,7 @@ def test_register(self):
"user_type": None,
"deactivated": 0,
"shadow_banned": 0,
"approved": 1,
},
(self.get_success(self.store.get_user_by_id(self.user_id))),
)
Expand Down Expand Up @@ -147,3 +152,101 @@ def test_3pid_inhibit_invalid_validation_session_error(self):
ThreepidValidationError,
)
self.assertEqual(e.value.msg, "Validation token not found or has expired", e)


class ApprovalRequiredRegistrationTestCase(HomeserverTestCase):
def default_config(self) -> JsonDict:
config = super().default_config()

# If there's already some config for this feature in the default config, it
# means we're overriding it with @override_config. In this case we don't want
# to do anything more with it.
msc3866_config = config.get("experimental_features", {}).get("msc3866")
if msc3866_config is not None:
return config

# Require approval for all new accounts.
config["experimental_features"] = {
"msc3866": {
"enabled": True,
"require_approval_for_new_accounts": True,
}
}
return config

def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.store = hs.get_datastores().main
self.user_id = "@my-user:test"
self.pwhash = "{xx1}123456789"

@override_config(
{
"experimental_features": {
"msc3866": {
"enabled": True,
"require_approval_for_new_accounts": False,
}
}
}
)
def test_approval_not_required(self) -> None:
"""Tests that if we don't require approval for new accounts, newly created
accounts are automatically marked as approved.
"""
self.get_success(self.store.register_user(self.user_id, self.pwhash))

user = self.get_success(self.store.get_user_by_id(self.user_id))
babolivier marked this conversation as resolved.
Show resolved Hide resolved
assert user is not None
self.assertEqual(user["approved"], 1)
babolivier marked this conversation as resolved.
Show resolved Hide resolved

approved = self.get_success(self.store.is_user_approved(self.user_id))
self.assertTrue(approved)

def test_approval_required(self) -> None:
"""Tests that if we require approval for new accounts, newly created accounts
are not automatically marked as approved.
"""
self.get_success(self.store.register_user(self.user_id, self.pwhash))

user = self.get_success(self.store.get_user_by_id(self.user_id))
assert user is not None
self.assertEqual(user["approved"], 0)

approved = self.get_success(self.store.is_user_approved(self.user_id))
self.assertFalse(approved)

def test_override(self) -> None:
"""Tests that if we require approval for new accounts, but we explicitly say the
new user should be considered approved, they're marked as approved.
"""
self.get_success(
self.store.register_user(
self.user_id,
self.pwhash,
approved=True,
)
)

user = self.get_success(self.store.get_user_by_id(self.user_id))
self.assertIsNotNone(user)
assert user is not None
self.assertEqual(user["approved"], 1)

approved = self.get_success(self.store.is_user_approved(self.user_id))
self.assertTrue(approved)

def test_approve_user(self) -> None:
"""Tests that approving the user updates their approval status."""
self.get_success(self.store.register_user(self.user_id, self.pwhash))

approved = self.get_success(self.store.is_user_approved(self.user_id))
self.assertFalse(approved)

self.get_success(
self.store.update_user_approval_status(
UserID.from_string(self.user_id), True
)
)

approved = self.get_success(self.store.is_user_approved(self.user_id))
self.assertTrue(approved)