From 0cd4f46d2141f41218c79435db660f3aba0ae769 Mon Sep 17 00:00:00 2001 From: Thomas Desveaux Date: Wed, 17 Jan 2024 10:26:21 +0100 Subject: [PATCH 1/3] Add GiteaAuthWithAuthz login class which will retrieve user permissions on login --- buildbot_gitea/auth.py | 18 ++++ buildbot_gitea/test/test_auth.py | 152 ++++++++++++++++++++++++++++++- setup.py | 3 +- 3 files changed, 171 insertions(+), 2 deletions(-) diff --git a/buildbot_gitea/auth.py b/buildbot_gitea/auth.py index 674001a..bcbf31e 100644 --- a/buildbot_gitea/auth.py +++ b/buildbot_gitea/auth.py @@ -17,3 +17,21 @@ def __init__(self, endpoint, client_id, client_secret, **kwargs): def getUserInfoFromOAuthClient(self, c): return self.get(c, '/api/v1/user') + + +class GiteaAuthWithPermissions(GiteaAuth): + def getUserInfoFromOAuthClient(self, c): + user_info = super(GiteaAuthWithPermissions, self).getUserInfoFromOAuthClient(c) + + teams_info = self.get(c, '/api/v1/user/teams') + + user_organizations = user_info.setdefault("organizations", {}) + for team in teams_info: + org = team.get("organization") + if org is None: + continue + user_organizations.setdefault( + org["name"], {} + )[team["name"]] = team["permission"] + + return user_info diff --git a/buildbot_gitea/test/test_auth.py b/buildbot_gitea/test/test_auth.py index fce41f9..dffd6b5 100644 --- a/buildbot_gitea/test/test_auth.py +++ b/buildbot_gitea/test/test_auth.py @@ -9,7 +9,7 @@ from buildbot.secrets.manager import SecretManager from buildbot.test.fake.secrets import FakeSecretStorage -from buildbot_gitea.auth import GiteaAuth +from buildbot_gitea.auth import GiteaAuth, GiteaAuthWithPermissions try: import requests @@ -86,3 +86,153 @@ def test_getGiteaLoginURL_with_secret(self): exp = ("https://gitea.test/login/oauth/authorize?client_id=secretClientId&" "redirect_uri=h%3A%2Fa%2Fb%2Fauth%2Flogin&response_type=code") self.assertEqual(res, exp) + + +class TestGiteaAuthWithPermissions(TestReactorMixin, www.WwwTestMixin, ConfigErrorsMixin, + unittest.TestCase): + + USER = { + 'avatar_url': 'http://pic', + 'email': 'bar@foo', + 'full_name': 'foo bar', + 'username': 'bar', + } + + ORG1 = { + "id": 1, + "name": "Owners", + "description": "", + "organization": { + "id": 1, + "name": "org1", + "full_name": "Organization 1", + "email": "", + "avatar_url": "https://gitea.com/avatars/sha1", + "description": "", + "website": "", + "location": "", + "visibility": "limited", + "repo_admin_change_team_access": True, + "username": "org1" + }, + "includes_all_repositories": True, + "permission": "owner", + "units": [ + "repo.code", + "repo.issues", + "repo.ext_issues", + "repo.pulls", + "repo.releases", + "repo.wiki", + "repo.packages", + "repo.ext_wiki", + "repo.projects", + "repo.actions" + ], + "units_map": { + "repo.actions": "owner", + "repo.code": "owner", + "repo.ext_issues": "owner", + "repo.ext_wiki": "owner", + "repo.issues": "owner", + "repo.packages": "owner", + "repo.projects": "owner", + "repo.pulls": "owner", + "repo.releases": "owner", + "repo.wiki": "owner" + }, + "can_create_org_repo": True + } + ORG2 = { + "id": 2, + "name": "Users", + "description": "Basic Users", + "organization": { + "id": 2, + "name": "org2", + "full_name": "Organization 2", + "email": "", + "avatar_url": "https://gitea.com/avatars/sha1", + "description": "", + "website": "https://confluence.dont-nod.com/display/DEV/", + "location": "", + "visibility": "limited", + "repo_admin_change_team_access": False, + "username": "org2" + }, + "includes_all_repositories": False, + "permission": "write", + "units": [ + "repo.ext_issues", + "repo.code", + "repo.issues", + "repo.pulls", + "repo.releases", + "repo.wiki", + "repo.ext_wiki" + ], + "units_map": { + "repo.code": "write", + "repo.ext_issues": "read", + "repo.ext_wiki": "read", + "repo.issues": "write", + "repo.pulls": "write", + "repo.releases": "write", + "repo.wiki": "write" + }, + "can_create_org_repo": False + } + + def setUp(self): + self.setup_test_reactor() + if requests is None: + raise unittest.SkipTest("Need to install requests to test oauth2") + + self.patch(requests, 'request', mock.Mock(spec=requests.request)) + self.patch(requests, 'post', mock.Mock(spec=requests.post)) + self.patch(requests, 'get', mock.Mock(spec=requests.get)) + + self.giteaAuth = GiteaAuthWithPermissions( + 'https://gitea.test', + 'client-id', + 'client-secret') + self._master = master = self.make_master( + url='h:/a/b/', auth=self.giteaAuth) + self.giteaAuth.reconfigAuth(master, master.config) + + def mock_gitea_auth_get(session, path): + return { + "/api/v1/user": TestGiteaAuthWithPermissions.USER, + "/api/v1/user/teams": [ + TestGiteaAuthWithPermissions.ORG1, + TestGiteaAuthWithPermissions.ORG2, + ], + }.get(path) + + self.patch(self.giteaAuth, 'get', mock.Mock( + spec=GiteaAuthWithPermissions.get, + side_effect=mock_gitea_auth_get, + )) + + @defer.inlineCallbacks + def test_getGiteaUserOrgTeamPermissions(self): + # won't be used + fake_session = None + res = yield self.giteaAuth.getUserInfoFromOAuthClient(fake_session) + self.assertDictEqual( + res, + { + 'avatar_url': 'http://pic', + 'email': 'bar@foo', + 'full_name': 'foo bar', + 'username': 'bar', + 'organizations': { + 'org1': { + 'Owners': 'owner', + }, + 'org2': { + 'Users': 'write', + }, + } + }, + ) diff --git a/setup.py b/setup.py index bef32dd..2b77485 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,8 @@ "GiteaStatusPush = buildbot_gitea.reporter:GiteaStatusPush" ], "buildbot.util": [ - "GiteaAuth = buildbot_gitea.auth:GiteaAuth" + "GiteaAuth = buildbot_gitea.auth:GiteaAuth", + "GiteaAuthWithPermissions = buildbot_gitea.auth:GiteaAuthWithPermissions" ] }, classifiers=[ From e07ed90f8c7351ecb173f21ce18361df8d18a0e9 Mon Sep 17 00:00:00 2001 From: Thomas Desveaux Date: Wed, 17 Jan 2024 10:27:24 +0100 Subject: [PATCH 2/3] Add RolesFromGitea authz handler class to assign roles to user from Gitea permissions --- buildbot_gitea/authz.py | 56 ++++++++++++++++++++++++ buildbot_gitea/test/test_authz.py | 71 +++++++++++++++++++++++++++++++ setup.py | 3 +- 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 buildbot_gitea/authz.py create mode 100644 buildbot_gitea/test/test_authz.py diff --git a/buildbot_gitea/authz.py b/buildbot_gitea/authz.py new file mode 100644 index 0000000..a9ecfd5 --- /dev/null +++ b/buildbot_gitea/authz.py @@ -0,0 +1,56 @@ +from buildbot.www.authz.roles import RolesFromBase + +class RolesFromGitea(RolesFromBase): + def __init__(self, roles, orgs=None, teams=None, permissions=None): + self.roles = roles + self.orgs = orgs + self.teams = teams + self.permissions = permissions + + if not any(e is not None and len(e) > 0 for e in [ + self.orgs, + self.teams, + self.permissions, + ]): + from buildbot import config + config.error('RolesFromGitea require one valid of orgs, teams or permissions') + + + def getRolesFromUserPermissions(self, user_permissions): + if self.permissions is None or user_permissions in self.permissions: + return self.roles + + return None + + def getRolesFromUserTeam(self, user_teams): + check_teams = self.teams if self.teams is not None else user_teams.keys() + for team in check_teams: + user_permissions = user_teams.get(team) + if user_permissions is None: + continue + + roles = self.getRolesFromUserPermissions(user_permissions) + if roles is not None: + return roles + + return None + + def getRolesFromUserOrg(self, user_orgs): + check_orgs = self.orgs if self.orgs is not None else user_orgs.keys() + for org in check_orgs: + user_teams = user_orgs.get(org) + if user_teams is None: + continue + + roles = self.getRolesFromUserTeam(user_teams) + if roles is not None: + return roles + + return None + + def getRolesFromUser(self, userDetails): + user_orgs = userDetails.get("organizations", {}) + roles = self.getRolesFromUserOrg(user_orgs) + if roles is not None: + return roles + return [] diff --git a/buildbot_gitea/test/test_authz.py b/buildbot_gitea/test/test_authz.py new file mode 100644 index 0000000..f26156e --- /dev/null +++ b/buildbot_gitea/test/test_authz.py @@ -0,0 +1,71 @@ +from twisted.trial import unittest +from buildbot_gitea.authz import RolesFromGitea + + +class TestGiteaAuthz_RolesFromGitea(unittest.TestCase): + + def test_RolesFromGitea_NoMatch(self): + user_detail = {} + + role_provider = RolesFromGitea( + roles=["test"], + orgs=["org1"], + ) + res = role_provider.getRolesFromUser(user_detail) + self.assertNot(res) + + role_provider = RolesFromGitea( + roles=["test"], + teams=["team1"], + ) + res = role_provider.getRolesFromUser(user_detail) + self.assertNot(res) + + role_provider = RolesFromGitea( + roles=["test"], + permissions=["owner"], + ) + res = role_provider.getRolesFromUser(user_detail) + self.assertNot(res) + + def test_RolesFromGitea_Match(self): + user_detail = { + 'organizations': { + 'org1': { + 'Owners': 'owner', + }, + 'org2': { + 'Users': 'write', + }, + } + } + + role_provider = RolesFromGitea( + roles=["test"], + orgs=["org1"], + ) + res = role_provider.getRolesFromUser(user_detail) + self.assertEqual(res, ["test"]) + + role_provider = RolesFromGitea( + roles=["test"], + teams=["Owners"], + ) + res = role_provider.getRolesFromUser(user_detail) + self.assertEqual(res, ["test"]) + + role_provider = RolesFromGitea( + roles=["test"], + permissions=["owner"], + ) + res = role_provider.getRolesFromUser(user_detail) + self.assertEqual(res, ["test"]) + + role_provider = RolesFromGitea( + roles=["test"], + orgs=["org1"], + teams=["Owners"], + permissions=["owner"], + ) + res = role_provider.getRolesFromUser(user_detail) + self.assertEqual(res, ["test"]) diff --git a/setup.py b/setup.py index 2b77485..a142df5 100644 --- a/setup.py +++ b/setup.py @@ -32,7 +32,8 @@ ], "buildbot.util": [ "GiteaAuth = buildbot_gitea.auth:GiteaAuth", - "GiteaAuthWithPermissions = buildbot_gitea.auth:GiteaAuthWithPermissions" + "GiteaAuthWithPermissions = buildbot_gitea.auth:GiteaAuthWithPermissions", + "RolesFromGitea = buildbot_gitea.authz:RolesFromGitea" ] }, classifiers=[ From 9855b0ada0886506a5c1fe3b299c9ec5a0a1157e Mon Sep 17 00:00:00 2001 From: Thomas Desveaux Date: Wed, 17 Jan 2024 11:19:00 +0100 Subject: [PATCH 3/3] Add Authz documentation --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 7154994..77f3b41 100644 --- a/README.md +++ b/README.md @@ -152,3 +152,35 @@ Resources: + [Gitea OAuth2 Provider documentation](https://docs.gitea.io/en-us/oauth2-provider/) + [Buildbot OAuth2 documentation](https://docs.buildbot.net/current/developer/cls-auth.html?highlight=oauth2#buildbot.www.oauth2.OAuth2Auth) + +## Authorization + +You can handle authorization based on user permissions in Gitea organizations. + +`master.cfg` + +```py +from buildbot.plugins import util +c['www']['auth'] = util.GiteaAuthWithPermissions( + endpoint="https://your-gitea-host", + client_id='oauth2-client-id', + client_secret='oauth2-client-secret') + +c['www']['authz'] = util.Authz( + stringsMatcher=util.fnmatchStrMatcher, + allowRules=[ + util.endpointmatchers.AnyControlEndpointMatcher(role="admins"), + util.endpointmatchers.StopBuildEndpointMatcher(role="admins"), + util.endpointmatchers.EnableSchedulerEndpointMatcher(role="admins"), + util.endpointmatchers.ForceBuildEndpointMatcher(role="admins"), + util.endpointmatchers.RebuildBuildEndpointMatcher(role="admins"), + ], + roleMatchers=[ + util.RolesFromGitea(roles=["admins"], orgs=['org1'], teams=["Owners"], permissions=["owner"]) + ] + ) +``` + +Resources: + ++ [Buildbot Authorization documentation](https://docs.buildbot.net/current/manual/configuration/www.html#authorization-rules)