From aea53325adc10e03ead81225e7fe1934c85c452b Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Tue, 8 Feb 2022 23:38:26 +0100 Subject: [PATCH 1/6] Initial support for aliases maubot's mandatory positional arguments make things a little difficult. --- oscbot/__init__.py | 121 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/oscbot/__init__.py b/oscbot/__init__.py index 979fe56..02ff417 100644 --- a/oscbot/__init__.py +++ b/oscbot/__init__.py @@ -36,6 +36,21 @@ STATUS_TEMPLATE = """ {% endfor %} """ +ALIAS_TEMPLATE = """ +### Configured aliases + +{% for alias, contents in repo_aliases.items() %} +#### {{ alias }} + +- project: {{ contents.project }} +- repository: {{ contents.repository }} +- package(s): {{ contents.package }} +- architecture(s): {{ contents.arch }} +- state: {{ contents.state }} + +{% endfor %} +""" + @dataclass class BuildResult: @@ -200,6 +215,103 @@ class OSCBot(Plugin): async def osc(self) -> None: pass + @osc.subcommand("alias", help="Manage aliases") + async def alias(self) -> None: + pass + + @alias.subcommand("list", help="List configured aliases") + async def list_aliases(self, evt: MessageEvent) -> None: + + body = self.template.from_string(ALIAS_TEMPLATE) + + if self.config.get("repo_aliases", None) is None: + await evt.reply("No aliases defined.") + return + + message = body.render(repo_aliases=self.config["repo_aliases"]) + await evt.respond(message, markdown=True) + + @alias.subcommand("edit", help="Edit configured aliases") + @command.argument("alias", "alias name") + @command.argument("project", "project name") + @command.argument("package", "package name", required=False) + @command.argument("repository", "repository", required=False) + @command.argument("arch", "architecture", required=False) + @command.argument("state", "state", required=False) + async def edit_alias(self, evt: MessageEvent, + alias: str, + project: Optional[str] = None, + package: Optional[str] = None, + repository: Optional[str] = None, + arch: Optional[str] = None, + state: Optional[str] = None) -> None: + + if alias not in self.config["repo_aliases"]: + await evt.respond(f"Unknown alias {alias}") + return + + alias_data = self.config["repo_aliases"][alias] + + if project: + alias_data["project"] = project + + if repository: + alias_data["repository"] = repository + + if package: + alias_data["package"] = package + + if arch: + alias_data["arch"] = alias + + if state: + alias_data["state"] = state + + self.config["repo_aliases"][alias] = alias_data + self.config.save() + await evt.reply(f"Alias {alias} successfully changed.") + + @alias.subcommand("create", help="Create a new alias") + @command.argument("alias", "alias name") + @command.argument("project", "project name") + @command.argument("package", "package name", required=False) + @command.argument("repository", "repository", required=False) + @command.argument("arch", "architecture", required=False) + @command.argument("state", "state", required=False) + async def create_alias(self, evt: MessageEvent, + alias: str, + project: Optional[str] = None, + package: Optional[str] = None, + repository: Optional[str] = None, + arch: Optional[str] = None, + state: Optional[str] = None) -> None: + + repository = "all" if not repository else repository + package = "all" if not package else package + arch = "all" if not arch else arch + state = "all" if not state else state + alias_data = { + "project": project, + "package": package, + "repository": repository, + "arch": arch, + "state": state + } + self.config["repo_aliases"][alias] = alias_data + self.config.save() + await evt.reply(f"Alias {alias} successfully created.") + + @alias.subcommand("delete", help="Delete an alias", aliases=("rm", )) + @command.argument("alias", "alias name") + async def delete_alias(self, evt: MessageEvent, alias: str): + if alias not in self.config["repo_aliases"]: + await evt.respond(f"Unknown alias {alias}") + return + + del self.config["repo_aliases"][alias] + self.config.save() + await evt.reply(f"Alias {alias} successfully deleted.") + @osc.subcommand( "rebuildpac", aliases=("rb",), help="Rebuild a package or all packages in the repositories") @@ -213,9 +325,14 @@ class OSCBot(Plugin): repository: Optional[str] = None, arch: Optional[str] = None) -> None: + if project in self.config["repo_aliases"]: + # We're not interested in state and we query package explicitly + project, _, repository, _, arch = self.get_alias(project) + else: + repository = None if repository == "all" else repository + arch = None if arch == "all" else arch + package = None if package == "all" else package - repository = None if repository == "all" else repository - arch = None if arch == "all" else arch result, status = await self.parse_rebuilpac(project, package, repository, From c9d4e0312d4149a5d4de3cbadae3b9bb8826dc3e Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sat, 12 Feb 2022 10:19:06 +0100 Subject: [PATCH 2/6] Support for ACLs Users in the "admin" level can set aliases, rebuild packages, and trigger services. On the other hand, users in the "user" level can only perform read-only operations (reading status at this point). --- base-config.yaml | 5 +++++ oscbot/__init__.py | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/base-config.yaml b/base-config.yaml index 96c03f6..cf823be 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -34,3 +34,8 @@ repo_aliases: repository: "KDE_Unstable_Frameworks_openSUSE_Factory" state: "failed" arch: "all" +acl: + admin: + - "@CHANGE_ME" + user: [] + diff --git a/oscbot/__init__.py b/oscbot/__init__.py index 02ff417..73735b5 100644 --- a/oscbot/__init__.py +++ b/oscbot/__init__.py @@ -83,6 +83,7 @@ class Config(BaseProxyConfig): helper.copy("username") helper.copy("repo_aliases") + helper.copy("acl") class OSCBot(Plugin): @@ -114,6 +115,18 @@ class OSCBot(Plugin): return (project, package, repository, state, arch) + def check_acl(self, user_id: str, need_admin: bool = False) -> bool: + + acls = self.config["acl"] + if not need_admin: + if user_id in acls["admin"] or acls["user"]: + return True + else: + if user_id in acls["admin"]: + return True + self.log.debug(f"Denied operation as {user_id}") + return False + async def parse_rebuilpac( self, project: str, @@ -222,6 +235,10 @@ class OSCBot(Plugin): @alias.subcommand("list", help="List configured aliases") async def list_aliases(self, evt: MessageEvent) -> None: + if not self.check_acl(evt.sender, need_admin=False): + await evt.reply("You are not authorized to perform this action.") + return + body = self.template.from_string(ALIAS_TEMPLATE) if self.config.get("repo_aliases", None) is None: @@ -246,6 +263,10 @@ class OSCBot(Plugin): arch: Optional[str] = None, state: Optional[str] = None) -> None: + if not self.check_acl(evt.sender, need_admin=True): + await evt.reply("You are not authorized to perform this action.") + return + if alias not in self.config["repo_aliases"]: await evt.respond(f"Unknown alias {alias}") return @@ -286,6 +307,10 @@ class OSCBot(Plugin): arch: Optional[str] = None, state: Optional[str] = None) -> None: + if not self.check_acl(evt.sender, need_admin=True): + await evt.reply("You are not authorized to perform this action.") + return + repository = "all" if not repository else repository package = "all" if not package else package arch = "all" if not arch else arch @@ -304,6 +329,11 @@ class OSCBot(Plugin): @alias.subcommand("delete", help="Delete an alias", aliases=("rm", )) @command.argument("alias", "alias name") async def delete_alias(self, evt: MessageEvent, alias: str): + + if not self.check_acl(evt.sender, need_admin=True): + await evt.reply("You are not authorized to perform this action.") + return + if alias not in self.config["repo_aliases"]: await evt.respond(f"Unknown alias {alias}") return @@ -325,6 +355,10 @@ class OSCBot(Plugin): repository: Optional[str] = None, arch: Optional[str] = None) -> None: + if not self.check_acl(evt.sender, need_admin=True): + await evt.reply("You are not authorized to perform this action.") + return + if project in self.config["repo_aliases"]: # We're not interested in state and we query package explicitly project, _, repository, _, arch = self.get_alias(project) @@ -353,6 +387,10 @@ class OSCBot(Plugin): project: str, package: str) -> None: + if not self.check_acl(evt.sender, need_admin=True): + await evt.reply("You are not authorized to perform this action.") + return + token = self.config["trigger_token"] trigger_url = f"{self.config['api_url']}/trigger/runservice" params = {"project": project, "package": package} @@ -382,6 +420,10 @@ class OSCBot(Plugin): repository: Optional[str] = None, arch: Optional[str] = None) -> None: + if not self.check_acl(evt.sender, need_admin=False): + await evt.reply("You are not authorized to perform this action.") + return + if project in self.config["repo_aliases"]: project, package, repository, state, arch = self.get_alias(project) else: @@ -428,5 +470,5 @@ class OSCBot(Plugin): message = body.render(packages=packagelist, base_url=base_url, project=project, repo=repository) await evt.respond(message, markdown=True) - # Wait 100 milliseconds to avoid triggering rate limiting - sleep(0.1) + # Wait 200 milliseconds to avoid triggering rate limiting + sleep(0.2) From 26d34967a06252b1da008eb6cdd1ebc81e41f9f0 Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sat, 12 Feb 2022 10:20:14 +0100 Subject: [PATCH 3/6] Bump version --- maubot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maubot.yaml b/maubot.yaml index c1456f6..403a0ee 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: CC0-1.0 maubot: 0.1.0 id: org.dennogumi.osc -version: 0.3.1 +version: 0.4.0 license: AGPL-3.0-or-later modules: - oscbot From 34a24e599af2a226b97d518baf3f288a5bfaa932 Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sat, 12 Feb 2022 10:30:58 +0100 Subject: [PATCH 4/6] [skip ci] Update README --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 495cf87..98a580f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Then you need to modify a few configuration parameters for your instance: - `trigger_token`: An OBS access token with the `runservice` capability. See "Creating tokens" below. - `rebuild_token`: An OBS access token with the `rebuild` capability. See "Creating tokens" below. - `secret`: A random string used to encrypt the password in the configuration file. +- `acl`: User IDs (`@user:homeserver.com`) which are allowed to interact with the bot. See "Access control lists" below. ### Creating tokens @@ -59,9 +60,26 @@ Save both tokens in the configuration. Should you need to, you can view them lat Make sure your bot instance is in a room (refer to the maubot docs for how) and then type `!osc help` for help. +### Access control lists (ACLs) + +By default, no user is allowed to perform any operation. You need to change the `acl` section of the configuration to add user IDs (`@user:homeserver.com`) allowed to interact with the bot. Users in the `admin` list have full powers over the bot, while users in the `user` list can only perform read-only (e.g. status querying) actions. + +This example shows how it works in practice: + +```yaml +acl: + admin: + # Fred and Sue will be able to perform all commands + - @fred:myhome.com + - @sue:otherserver.com + user: + # Phil will only be able to run read-only (status, etc.) commands + - @phil:elsewhere.com +``` + ### Notice -I made this in about three or four hours for my own use. Don't expect high quality code or a lot of polish. +I made this for my own use. Don't expect high quality code or a lot of polish. ### License From 8089dd36e5a0c455a1ee41b1b17d13f499af7492 Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sat, 12 Feb 2022 10:34:07 +0100 Subject: [PATCH 5/6] [skip ci] Update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 98a580f..cc659e3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ These are in addition to what maubot requires by itself. - lxml - jinja2 +- [cryptocode](https://github.com/gdavid7/cryptocode) ### Installation From f4bc26ac1328ded5ee6f0e580ac0f18051a073f2 Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sat, 12 Feb 2022 10:35:38 +0100 Subject: [PATCH 6/6] [skip ci] Update README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cc659e3..559df52 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ SPDX-License-Identifier: CC0-1.0 ## maubot-osc +![status-badge](https://ci.dennogumi.org/api/badges/einar/maubot-osc/status.svg) + `maubot-osc` is a simple [maubot](https://maubot.xyz/) plugin to query an [Open Build Service](https://openbuildservice.org/) instance. Basically it replicates a few of the features of the `osc` command line tool used to interact with the OBS. ### Features