├── .github ├── CODEOWNERS └── workflows │ └── ci.yml ├── .gitignore ├── .gitlab-ci.yml ├── LICENSE ├── README.md ├── mypy.ini ├── pyproject.toml ├── scripts-dev └── lint.sh ├── setup.cfg ├── synapse_user_restrictions ├── __init__.py ├── config.py └── module.py ├── tests ├── __init__.py ├── test_config.py └── test_rules.py └── tox.ini /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | 2 | # Automatically request reviews from the synapse-core team when a pull request comes in. 3 | * @matrix-org/synapse-core 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Linting and Tests 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | 7 | jobs: 8 | check-code-style: 9 | name: Check code style 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | with: 15 | python-version: "3.x" 16 | - run: python -m pip install tox 17 | - run: tox -e check_codestyle 18 | 19 | check-types: 20 | name: Check types with Mypy 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: actions/setup-python@v2 25 | with: 26 | python-version: "3.x" 27 | - run: python -m pip install tox 28 | - run: tox -e check_types 29 | 30 | unit-tests: 31 | name: Unit tests 32 | runs-on: ubuntu-latest 33 | strategy: 34 | matrix: 35 | # Run the unit tests both against our oldest supported Python version 36 | # and the newest stable. 37 | python_version: [ "3.7", "3.x" ] 38 | steps: 39 | - uses: actions/checkout@v2 40 | - uses: actions/setup-python@v2 41 | with: 42 | python-version: ${{ matrix.python_version }} 43 | - run: python -m pip install tox 44 | - run: tox -e py 45 | 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /.venv 3 | /*.egg-info 4 | /.envrc 5 | /.tox 6 | /_trial_temp 7 | __pycache__ 8 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | 2 | variables: 3 | GIT_DEPTH: 0 4 | 5 | 6 | check_code_style: 7 | image: python:3 8 | tags: ['docker'] 9 | script: 10 | - "pip install tox" 11 | - "tox -e check_codestyle" 12 | rules: 13 | - if: '$CI_PIPELINE_SOURCE == "push"' 14 | - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' 15 | 16 | check_types: 17 | image: python:3 18 | tags: ['docker'] 19 | script: 20 | - "pip install tox" 21 | - "tox -e check_types" 22 | rules: 23 | - if: '$CI_PIPELINE_SOURCE == "push"' 24 | - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' 25 | 26 | .unit_tests_template: &unit_tests 27 | tags: ['docker'] 28 | script: 29 | - "pip install tox" 30 | - "tox -e py" 31 | rules: 32 | - if: '$CI_PIPELINE_SOURCE == "push"' 33 | - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' 34 | 35 | unit_tests_latest: 36 | <<: *unit_tests 37 | image: python:3 38 | 39 | unit_tests_oldest: 40 | <<: *unit_tests 41 | image: python:3.7 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Synapse User Restrictions Module 2 | 3 | This module allows restricting users, that match given regular expressions, 4 | from performing actions such as creating rooms or sending invites. 5 | 6 | Note that server administrators are not bound by these rules, as the code 7 | within Synapse exempts server administrators from some 'spam checks', 8 | including those used in this module (creating rooms and inviting users). 9 | 10 | 11 | ## Installation 12 | 13 | From the virtual environment that you use for Synapse, install this module with: 14 | ```shell 15 | pip install path/to/synapse-user-restrictions 16 | ``` 17 | (If you run into issues, you may need to upgrade `pip` first, e.g. by running 18 | `pip install --upgrade pip`) 19 | 20 | Then alter your homeserver configuration, adding to your `modules` configuration: 21 | ```yaml 22 | modules: 23 | - module: synapse_user_restrictions.UserRestrictionsModule 24 | config: 25 | # List of rules. Earlier rules have a higher priority than later rules. 26 | rules: 27 | - match: "@admin.*:example.org" 28 | allow: [invite, create_room] 29 | 30 | - match: "@assistant.*:example.org" 31 | allow: [invite] 32 | 33 | # If no rules match, then these permissions are denied. 34 | # All other permissions are allowed by default. 35 | default_deny: [invite, create_room] 36 | ``` 37 | 38 | In this example: 39 | - `@adminalice:example.org` could create rooms and invite users to 40 | rooms; 41 | - `@assistantbob:example.org` could invite users to rooms but NOT create rooms; 42 | and 43 | - `@plainoldjoe:example.org` could neither create rooms nor invite users. 44 | 45 | ### Configuration 46 | 47 | Rules are applied top-to-bottom, with the first matching rule being used. 48 | 49 | A rule matches if the regular expression (written in `match`) fully matches the 50 | user's Matrix ID, and the permission being sought is either in the `allow` list 51 | or the `deny` list. 52 | The regular expression must match the full Matrix ID and not just a portion of it. 53 | 54 | Valid permissions (as at the time of writing) are: 55 | 56 | - `invite`: the user is trying to invite another user to a room 57 | - `create_room`: the user is trying to create a room 58 | 59 | If no rules match, then `default_deny` is consulted; 60 | `default_deny` is useful for only allowing a select few listed user patterns to 61 | be allowed to use certain features. 62 | 63 | 64 | ## Development 65 | 66 | In a virtual environment with pip ≥ 21.1, run 67 | ```shell 68 | pip install -e .[dev] 69 | ``` 70 | 71 | To run the unit tests, you can either use: 72 | ```shell 73 | tox -e py 74 | ``` 75 | or 76 | ```shell 77 | trial tests 78 | ``` 79 | 80 | To run the linters and `mypy` type checker, use `./scripts-dev/lint.sh`. 81 | 82 | 83 | ## Releasing 84 | 85 | The exact steps for releasing will vary; but this is an approach taken by the 86 | Synapse developers (assuming a Unix-like shell): 87 | 88 | 1. Set a shell variable to the version you are releasing (this just makes 89 | subsequent steps easier): 90 | ```shell 91 | version=X.Y.Z 92 | ``` 93 | 94 | 2. Update `setup.cfg` so that the `version` is correct. 95 | 96 | 3. Stage the changed files and commit. 97 | ```shell 98 | git add -u 99 | git commit -m v$version -n 100 | ``` 101 | 102 | 4. Push your changes. 103 | ```shell 104 | git push 105 | ``` 106 | 107 | 5. When ready, create a signed tag for the release: 108 | ```shell 109 | git tag -s v$version 110 | ``` 111 | Base the tag message on the changelog. 112 | 113 | 6. Push the tag. 114 | ```shell 115 | git push origin tag v$version 116 | ``` 117 | 118 | 7. If applicable: 119 | Create a *release*, based on the tag you just pushed, on GitHub or GitLab. 120 | 121 | 8. If applicable: 122 | Create a source distribution and upload it to PyPI. 123 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict = true 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.isort] 6 | profile = "black" 7 | known_first_party = [ 8 | "synapse_user_restrictions", 9 | "tests" 10 | ] 11 | -------------------------------------------------------------------------------- /scripts-dev/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Runs linting scripts and type checking 3 | # isort - sorts import statements 4 | # black - opinionated code formatter 5 | # flake8 - lints and finds mistakes 6 | # mypy - checks type annotations 7 | 8 | set -e 9 | 10 | files=( 11 | "synapse_user_restrictions" 12 | "tests" 13 | ) 14 | 15 | # Print out the commands being run 16 | set -x 17 | 18 | isort "${files[@]}" 19 | python3 -m black "${files[@]}" 20 | flake8 "${files[@]}" 21 | mypy "${files[@]}" 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = synapse_user_restrictions 3 | description = "This module allows restricting users from performing actions such as creating rooms or sending invites." 4 | version = 0.0.0 5 | 6 | install_requires = 7 | attrs 8 | 9 | classifiers = 10 | License :: OSI Approved :: Apache Software License 11 | 12 | 13 | [options] 14 | packages = 15 | synapse_user_restrictions 16 | python_requires = >= 3.7 17 | 18 | 19 | [options.extras_require] 20 | dev = 21 | # for tests 22 | matrix-synapse 23 | tox 24 | twisted 25 | aiounittest 26 | # for type checking 27 | mypy == 0.910 28 | # for linting 29 | black == 21.9b0 30 | flake8 == 4.0.1 31 | isort == 5.9.3 32 | 33 | 34 | [flake8] 35 | # line length defaulted to by black 36 | max-line-length = 88 37 | 38 | # see https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes 39 | # for error codes. The ones we ignore are: 40 | # W503: line break before binary operator 41 | # W504: line break after binary operator 42 | # E203: whitespace before ':' (which is contrary to pep8?) 43 | # (this is a subset of those ignored in Synapse) 44 | ignore=W503,W504,E203 45 | -------------------------------------------------------------------------------- /synapse_user_restrictions/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | from synapse_user_restrictions.module import UserRestrictionsModule 15 | 16 | __all__ = ["UserRestrictionsModule"] 17 | -------------------------------------------------------------------------------- /synapse_user_restrictions/config.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import enum 15 | import re 16 | from enum import Enum 17 | from typing import Any, Dict, Iterable, List, Pattern, Set, TypeVar, cast 18 | 19 | import attr 20 | 21 | ConfigDict = Dict[str, Any] 22 | 23 | 24 | def check_and_compile_regex(value: Any) -> Pattern[str]: 25 | """ 26 | Given a value from the configuration, which is validated to be a string, 27 | compiles and returns a regular expression. 28 | """ 29 | if not isinstance(value, str): 30 | raise ValueError("Regex patterns should be specified as strings.") 31 | 32 | try: 33 | return re.compile(value) 34 | except re.error as e: 35 | raise ValueError(f"Invalid regex '{value}': {e.msg}") 36 | 37 | 38 | def check_all_permissions_understood(permissions: Iterable[str]) -> None: 39 | """ 40 | Checks that all the permissions contained in the list of permissions are 41 | ones that we understand and recognise. 42 | """ 43 | for permission in permissions: 44 | if permission not in ALL_UNDERSTOOD_PERMISSIONS: 45 | nice_list_of_understood_permissions = ", ".join( 46 | sorted(ALL_UNDERSTOOD_PERMISSIONS) 47 | ) 48 | raise ValueError( 49 | f"{permission!r} is not a permission recognised " 50 | f"by the User Restrictions module; " 51 | f"try one of: {nice_list_of_understood_permissions}" 52 | ) 53 | 54 | 55 | T = TypeVar("T") 56 | 57 | 58 | def check_list_elements_are_strings( 59 | input: List[Any], failure_message: str 60 | ) -> List[str]: 61 | """ 62 | Checks that all elements in a list are of the specified type, casting it upon 63 | success. 64 | """ 65 | for ele in input: 66 | if not isinstance(ele, str): 67 | raise ValueError(failure_message) 68 | 69 | return cast(List[str], input) 70 | 71 | 72 | class RuleResult(Enum): 73 | NoDecision = enum.auto() 74 | Allow = enum.auto() 75 | Deny = enum.auto() 76 | 77 | 78 | @attr.s(auto_attribs=True, frozen=True, slots=True) 79 | class RegexMatchRule: 80 | """ 81 | A single rule that performs a regex match. 82 | """ 83 | 84 | # regex pattern to match users against 85 | match: Pattern[str] 86 | 87 | # permissions to allow 88 | allow: Set[str] 89 | 90 | # permissions to deny 91 | deny: Set[str] 92 | 93 | def apply(self, user_id: str, permission: str) -> RuleResult: 94 | """ 95 | Applies a regular expression match rule, returning a rule result. 96 | 97 | Arguments: 98 | user_id: the Matrix ID (@bob:example.org) of the user being checked 99 | permission: permission string identifying what kind of permission 100 | is being sought 101 | """ 102 | if not self.match.fullmatch(user_id): 103 | return RuleResult.NoDecision 104 | 105 | if permission in self.allow: 106 | return RuleResult.Allow 107 | 108 | if permission in self.deny: 109 | return RuleResult.Deny 110 | 111 | return RuleResult.NoDecision 112 | 113 | @staticmethod 114 | def from_config(rule: ConfigDict) -> "RegexMatchRule": 115 | if "match" not in rule: 116 | raise ValueError("Rules must have a 'match' field") 117 | match_pattern = check_and_compile_regex(rule["match"]) 118 | 119 | if "allow" in rule: 120 | if not isinstance(rule["allow"], list): 121 | raise ValueError("Rule's 'allow' field must be a list.") 122 | 123 | allow_list = check_list_elements_are_strings( 124 | rule["allow"], "Rule's 'allow' field must be a list of strings." 125 | ) 126 | check_all_permissions_understood(allow_list) 127 | else: 128 | allow_list = [] 129 | 130 | if "deny" in rule: 131 | if not isinstance(rule["deny"], list): 132 | raise ValueError("Rule's 'deny' field must be a list.") 133 | 134 | deny_list = check_list_elements_are_strings( 135 | rule["deny"], "Rule's 'deny' field must be a list of strings." 136 | ) 137 | check_all_permissions_understood(deny_list) 138 | else: 139 | deny_list = [] 140 | 141 | return RegexMatchRule( 142 | match=match_pattern, allow=set(allow_list), deny=set(deny_list) 143 | ) 144 | 145 | 146 | @attr.s(auto_attribs=True, frozen=True, slots=True) 147 | class UserRestrictionsModuleConfig: 148 | """ 149 | The root-level configuration. 150 | """ 151 | 152 | # A list of rules. 153 | rules: List[RegexMatchRule] 154 | 155 | # If the rules don't make a judgement about a user for a permission, 156 | # this is a list of denied-by-default permissions. 157 | default_deny: Set[str] 158 | 159 | @staticmethod 160 | def from_config(config_dict: ConfigDict) -> "UserRestrictionsModuleConfig": 161 | if "rules" not in config_dict: 162 | raise ValueError("'rules' list not specified in module configuration.") 163 | 164 | if not isinstance(config_dict["rules"], list): 165 | raise ValueError("'rules' should be a list.") 166 | 167 | rules = [] 168 | for index, rule in enumerate(config_dict["rules"]): 169 | if not isinstance(rule, dict): 170 | raise ValueError( 171 | f"Rules should be dicts. " 172 | f"Rule number {index + 1} is not (found: {type(rule).__name__})." 173 | ) 174 | 175 | rules.append(RegexMatchRule.from_config(rule)) 176 | 177 | default_deny = config_dict.get("default_deny") 178 | if default_deny is not None: 179 | if not isinstance(default_deny, list): 180 | raise ValueError("'default_deny' should be a list (or unspecified).") 181 | check_list_elements_are_strings( 182 | default_deny, "'default_deny' should be a list of strings." 183 | ) 184 | check_all_permissions_understood(default_deny) 185 | 186 | return UserRestrictionsModuleConfig( 187 | rules=rules, 188 | default_deny=set(default_deny) if default_deny is not None else set(), 189 | ) 190 | 191 | 192 | INVITE = "invite" 193 | CREATE_ROOM = "create_room" 194 | ALL_UNDERSTOOD_PERMISSIONS = frozenset({INVITE, CREATE_ROOM}) 195 | -------------------------------------------------------------------------------- /synapse_user_restrictions/module.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | from synapse.module_api import ModuleApi 15 | from synapse.module_api.errors import ConfigError 16 | 17 | from synapse_user_restrictions.config import ( 18 | ALL_UNDERSTOOD_PERMISSIONS, 19 | CREATE_ROOM, 20 | INVITE, 21 | ConfigDict, 22 | RuleResult, 23 | UserRestrictionsModuleConfig, 24 | ) 25 | 26 | 27 | class UserRestrictionsModule: 28 | def __init__(self, config: UserRestrictionsModuleConfig, api: ModuleApi): 29 | # Keep a reference to the config and Module API 30 | self._api = api 31 | self._config = config 32 | 33 | # Register callbacks here 34 | api.register_spam_checker_callbacks( 35 | user_may_create_room=self.callback_user_may_create_room, 36 | user_may_invite=self.callback_user_may_invite, 37 | ) 38 | 39 | @staticmethod 40 | def parse_config(config: ConfigDict) -> UserRestrictionsModuleConfig: 41 | try: 42 | return UserRestrictionsModuleConfig.from_config(config) 43 | except (TypeError, ValueError) as e: 44 | raise ConfigError(f"Failed to parse user restrictions module config: {e}") 45 | 46 | def _apply_rules(self, user_id: str, permission: str) -> bool: 47 | """ 48 | Apply the rules in-order, returning a boolean result. 49 | If no rules make a decision, the permission will be allowed by default. 50 | 51 | Arguments: 52 | user_id: the Matrix ID (@bob:example.org) of the user seeking 53 | permission 54 | permission: the string ID representing the permission being sought 55 | 56 | Returns: 57 | True if the rules allow the user to use that permission 58 | or do not make a decision, 59 | False if the user is denied from using that permission. 60 | """ 61 | if permission not in ALL_UNDERSTOOD_PERMISSIONS: 62 | raise ValueError(f"Permission not recognised: {permission!r}") 63 | 64 | for rule in self._config.rules: 65 | rule_result = rule.apply(user_id, permission) 66 | if rule_result == RuleResult.Allow: 67 | return True 68 | elif rule_result == RuleResult.Deny: 69 | return False 70 | 71 | if permission in self._config.default_deny: 72 | return False 73 | 74 | return True 75 | 76 | async def callback_user_may_create_room(self, user: str) -> bool: 77 | return self._apply_rules(user, CREATE_ROOM) 78 | 79 | async def callback_user_may_invite( 80 | self, inviter: str, invitee: str, room_id: str 81 | ) -> bool: 82 | return self._apply_rules(inviter, INVITE) 83 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright 2021 The Matrix.org Foundation C.I.C. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | from typing import Any, Dict 16 | from unittest import mock 17 | 18 | from synapse.module_api import ModuleApi 19 | 20 | from synapse_user_restrictions.module import UserRestrictionsModule 21 | 22 | 23 | def create_module( 24 | config_dict: Dict[Any, Any], server_name: str = "example.com" 25 | ) -> UserRestrictionsModule: 26 | # Create a mock based on the ModuleApi spec 27 | module_api = mock.Mock(spec=ModuleApi) 28 | 29 | config = UserRestrictionsModule.parse_config(config_dict) 30 | 31 | return UserRestrictionsModule(config, module_api) 32 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import re 15 | import unittest 16 | 17 | from synapse.module_api.errors import ConfigError 18 | 19 | from synapse_user_restrictions import UserRestrictionsModule 20 | from synapse_user_restrictions.config import ( 21 | RegexMatchRule, 22 | UserRestrictionsModuleConfig, 23 | ) 24 | from tests import create_module 25 | 26 | 27 | class ConfigTest(unittest.TestCase): 28 | def test_config_error_exceptions(self) -> None: 29 | """ 30 | Check that configuration errors raise exceptions. 31 | """ 32 | with self.assertRaisesRegex(ConfigError, ".*unterminated.*"): 33 | create_module({"rules": [{"match": "@srtsrt[a-z", "allow": "invite"}]}) 34 | 35 | with self.assertRaisesRegex( 36 | ConfigError, "'nonsense' is not a permission recognised" 37 | ): 38 | # The `nonsense` permission doesn't exist. 39 | create_module({"rules": [{"match": "@bob:test", "allow": ["nonsense"]}]}) 40 | 41 | with self.assertRaises(ConfigError): 42 | create_module({}) 43 | 44 | with self.assertRaises(ConfigError): 45 | create_module({"roooolz": []}) 46 | 47 | with self.assertRaises(ConfigError): 48 | create_module( 49 | { 50 | "rules": True, 51 | } 52 | ) 53 | 54 | with self.assertRaises(ConfigError): 55 | create_module( 56 | { 57 | "rules": [False], 58 | } 59 | ) 60 | 61 | with self.assertRaises(ConfigError): 62 | create_module( 63 | { 64 | "rules": [{"maaartch": "bleh"}], 65 | } 66 | ) 67 | 68 | def test_config_correct(self) -> None: 69 | """ 70 | A correct configuration is parsed into the correct shape. 71 | """ 72 | self.assertEqual( 73 | UserRestrictionsModule.parse_config( 74 | { 75 | "rules": [ 76 | { 77 | "match": "@unprivileged[0-9]+:.*", 78 | "allow": ["invite"], 79 | "deny": ["create_room"], 80 | } 81 | ], 82 | "default_deny": ["invite", "create_room"], 83 | } 84 | ), 85 | UserRestrictionsModuleConfig( 86 | rules=[ 87 | RegexMatchRule( 88 | re.compile("@unprivileged[0-9]+:.*"), 89 | allow={"invite"}, 90 | deny={"create_room"}, 91 | ) 92 | ], 93 | default_deny={"invite", "create_room"}, 94 | ), 95 | ) 96 | -------------------------------------------------------------------------------- /tests/test_rules.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 The Matrix.org Foundation C.I.C. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import aiounittest 15 | 16 | from tests import create_module 17 | 18 | 19 | class RuleTest(aiounittest.AsyncTestCase): 20 | async def test_rules_allow_with_default_deny(self) -> None: 21 | """ 22 | Tests allow rules when the defaults are to deny those permissions. 23 | """ 24 | module = create_module( 25 | { 26 | "rules": [ 27 | {"match": "@a.*:.*", "allow": ["invite"]}, 28 | {"match": "@b.*:.*", "allow": ["create_room"]}, 29 | ], 30 | "default_deny": ["invite", "create_room"], 31 | } 32 | ) 33 | 34 | self.assertTrue( 35 | await module.callback_user_may_invite( 36 | "@alice:hs1", "@other:hs2", "!room1:hs1" 37 | ) 38 | ) 39 | self.assertFalse( 40 | await module.callback_user_may_invite( 41 | "@bob:hs1", "@other:hs2", "!room2:hs1" 42 | ) 43 | ) 44 | self.assertFalse( 45 | await module.callback_user_may_invite( 46 | "@kristina:hs1", "@other:hs2", "!room2:hs1" 47 | ) 48 | ) 49 | 50 | self.assertFalse(await module.callback_user_may_create_room("@alice:hs1")) 51 | self.assertTrue(await module.callback_user_may_create_room("@bob:hs1")) 52 | self.assertFalse(await module.callback_user_may_create_room("@kristina:hs1")) 53 | 54 | async def test_rules_deny_with_no_default(self) -> None: 55 | """ 56 | Tests deny rules with no explicit defaults (which means all defaults 57 | are to allow). 58 | """ 59 | module = create_module( 60 | { 61 | "rules": [ 62 | {"match": "@a.*:.*", "deny": ["invite"]}, 63 | {"match": "@b.*:.*", "deny": ["create_room"]}, 64 | ], 65 | } 66 | ) 67 | 68 | self.assertFalse( 69 | await module.callback_user_may_invite( 70 | "@alice:hs1", "@other:hs2", "!room1:hs1" 71 | ) 72 | ) 73 | self.assertTrue( 74 | await module.callback_user_may_invite( 75 | "@bob:hs1", "@other:hs2", "!room2:hs1" 76 | ) 77 | ) 78 | self.assertTrue( 79 | await module.callback_user_may_invite( 80 | "@kristina:hs1", "@other:hs2", "!room2:hs1" 81 | ) 82 | ) 83 | 84 | self.assertTrue(await module.callback_user_may_create_room("@alice:hs1")) 85 | self.assertFalse(await module.callback_user_may_create_room("@bob:hs1")) 86 | self.assertTrue(await module.callback_user_may_create_room("@kristina:hs1")) 87 | 88 | async def test_rules_ordered_top_to_bottom(self) -> None: 89 | """ 90 | Tests that the rules are checked in top-to-bottom order. 91 | """ 92 | 93 | module = create_module( 94 | { 95 | "rules": [ 96 | {"match": "@bruce.*:.*", "allow": ["create_room"]}, 97 | {"match": "@b.*:.*", "deny": ["create_room"]}, 98 | ], 99 | } 100 | ) 101 | 102 | self.assertTrue(await module.callback_user_may_create_room("@bruce:hs1")) 103 | self.assertFalse(await module.callback_user_may_create_room("@bob:hs1")) 104 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py, check_codestyle, check_types 3 | 4 | # required for PEP 517 (pyproject.toml-style) builds 5 | isolated_build = true 6 | 7 | [testenv:py] 8 | 9 | # As of twisted 16.4, trial tries to import the tests as a package (previously 10 | # it loaded the files explicitly), which means they need to be on the 11 | # pythonpath. Our sdist doesn't include the 'tests' package, so normally it 12 | # doesn't work within the tox virtualenv. 13 | # 14 | # As a workaround, we tell tox to do install with 'pip -e', which just 15 | # creates a symlink to the project directory instead of unpacking the sdist. 16 | usedevelop=true 17 | 18 | extras = dev 19 | 20 | commands = 21 | python -m twisted.trial tests 22 | 23 | [testenv:check_codestyle] 24 | 25 | extras = dev 26 | 27 | commands = 28 | flake8 synapse_user_restrictions tests 29 | black --check --diff synapse_user_restrictions tests 30 | isort --check-only --diff synapse_user_restrictions tests 31 | 32 | [testenv:check_types] 33 | 34 | extras = dev 35 | 36 | commands = 37 | mypy synapse_user_restrictions tests 38 | --------------------------------------------------------------------------------