From c3821c70aeee255a7fcf0db1a869f4efaa312f01 Mon Sep 17 00:00:00 2001 From: Vit Zikmund Date: Thu, 21 Nov 2024 17:25:55 +0100 Subject: [PATCH 1/3] feat(github): add restrict_to configuration option This allows the operator to configure orgs (and repos) that are restricted to the GitHub authentication. Auth attempts for anything outside the list gets rejected. --- docs/source/auth-providers.md | 1 + giftless/auth/github.py | 25 +++++++++++++++++++++++++ tests/auth/test_github.py | 20 ++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/docs/source/auth-providers.md b/docs/source/auth-providers.md index 399dbf3..8d7a565 100644 --- a/docs/source/auth-providers.md +++ b/docs/source/auth-providers.md @@ -233,6 +233,7 @@ This token represents a special identity of an "application installation", actin * `api_url` (`str` = `"https://api.github.com"`): Base URL for the GitHub API (enterprise servers have API at `"https:///api/v3/"`). * `api_timeout` (`float | tuple[float, float]` = `(10.0, 20.0)`): Timeout for the GitHub API calls ([details](https://requests.readthedocs.io/en/stable/user/advanced/#timeouts)). * `api_version` (`str | None` = `"2022-11-28"`): Target GitHub API version; set to `None` to use GitHub's latest (rather experimental). +* `restrict_to` (`dict[str, list[str] | None] | None` = `None`): Optional (but very recommended) dictionary of GitHub organizations/users the authentication is restricted to. Each key (organization name) in the dictionary can contain a list of further restricted repository names. When the list is empty (or null), only the organizations are considered. * `cache` (`dict`): Cache configuration section * `token_max_size` (`int` = `32`): Max number of entries in the token -> user LRU cache. This cache holds the authentication data for a token. Evicted tokens will need to be re-authenticated. * `auth_max_size` (`int` = `32`): Max number of [un]authorized org/repos TTL(LRU) for each user. Evicted repos will need to get re-authorized. diff --git a/giftless/auth/github.py b/giftless/auth/github.py index c55ccfc..3505c14 100644 --- a/giftless/auth/github.py +++ b/giftless/auth/github.py @@ -252,6 +252,8 @@ class Config: api_version: str | None # GitHub API requests timeout api_timeout: float | tuple[float, float] + # Orgs and repos this instance is restricted to + restrict_to: dict[str, list[str] | None] | None # cache config above cache: CacheConfig @@ -261,6 +263,14 @@ class Schema(ma.Schema): load_default="2022-11-28", allow_none=True ) api_timeout = RequestsTimeout(load_default=(5.0, 10.0)) + restrict_to = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.List( + ma.fields.String(allow_none=True), allow_none=True + ), + load_default=None, + allow_none=True, + ) # always provide default CacheConfig when not present in the input cache = ma.fields.Nested( CacheConfig.Schema(), @@ -314,12 +324,27 @@ def __post_init__(self, request: flask.Request) -> None: org_repo_getter = itemgetter("organization", "repo") self.org, self.repo = org_repo_getter(request.view_args or {}) self.user, self.token = self._extract_auth(request) + self._check_restricted_to() self._api_url = self.cfg.api_url self._api_headers["Authorization"] = f"Bearer {self.token}" if self.cfg.api_version: self._api_headers["X-GitHub-Api-Version"] = self.cfg.api_version + def _check_restricted_to(self) -> None: + restrict_to = self.cfg.restrict_to + if restrict_to: + try: + rest_repos = restrict_to[self.org] + except KeyError: + raise Unauthorized( + f"Unauthorized GitHub organization '{self.org}'" + ) from None + if rest_repos and self.repo not in rest_repos: + raise Unauthorized( + f"Unauthorized GitHub repository '{self.repo}'" + ) + def __enter__(self) -> "CallContext": self._session = self._exit_stack.enter_context(requests.Session()) self._session.headers.update(self._api_headers) diff --git a/tests/auth/test_github.py b/tests/auth/test_github.py index 50268f4..7d4c443 100644 --- a/tests/auth/test_github.py +++ b/tests/auth/test_github.py @@ -410,6 +410,26 @@ def mock_installation_repos( return cast(responses.BaseResponse, ret) +def test_call_context_restrict_to_org_only(app: flask.Flask) -> None: + cfg = gh.Config.from_dict({"restrict_to": {ORG: None}}) + with auth_request_context(app): + ctx = gh.CallContext(cfg, flask.request) + assert ctx is not None + with auth_request_context(app, org="bogus"): + with pytest.raises(Unauthorized): + gh.CallContext(cfg, flask.request) + + +def test_call_context_restrict_to_org_and_repo(app: flask.Flask) -> None: + cfg = gh.Config.from_dict({"restrict_to": {ORG: [REPO]}}) + with auth_request_context(app): + ctx = gh.CallContext(cfg, flask.request) + assert ctx is not None + with auth_request_context(app, repo="bogus"): + with pytest.raises(Unauthorized): + gh.CallContext(cfg, flask.request) + + def test_call_context_api_get_no_session(app: flask.Flask) -> None: with auth_request_context(app): ctx = gh.CallContext(DEFAULT_CONFIG, flask.request) From d0923e254054613f211c05a67f35d17475e9fe8a Mon Sep 17 00:00:00 2001 From: Vit Zikmund Date: Tue, 10 Dec 2024 15:58:56 +0100 Subject: [PATCH 2/3] chore(github): tiny improvements --- giftless/auth/github.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/giftless/auth/github.py b/giftless/auth/github.py index 3505c14..a570d24 100644 --- a/giftless/auth/github.py +++ b/giftless/auth/github.py @@ -265,9 +265,7 @@ class Schema(ma.Schema): api_timeout = RequestsTimeout(load_default=(5.0, 10.0)) restrict_to = ma.fields.Dict( keys=ma.fields.String(), - values=ma.fields.List( - ma.fields.String(allow_none=True), allow_none=True - ), + values=ma.fields.List(ma.fields.String(), allow_none=True), load_default=None, allow_none=True, ) @@ -342,7 +340,7 @@ def _check_restricted_to(self) -> None: ) from None if rest_repos and self.repo not in rest_repos: raise Unauthorized( - f"Unauthorized GitHub repository '{self.repo}'" + f"Unauthorized GitHub repository '{self.org}/{self.repo}'" ) def __enter__(self) -> "CallContext": From 332270b0b90e7e309b143fc899cdcb4c63e73050 Mon Sep 17 00:00:00 2001 From: Vit Zikmund Date: Fri, 13 Dec 2024 10:23:31 +0100 Subject: [PATCH 3/3] chore(github): tackle some czenglish --- docs/source/auth-providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/auth-providers.md b/docs/source/auth-providers.md index 8d7a565..df4062f 100644 --- a/docs/source/auth-providers.md +++ b/docs/source/auth-providers.md @@ -233,7 +233,7 @@ This token represents a special identity of an "application installation", actin * `api_url` (`str` = `"https://api.github.com"`): Base URL for the GitHub API (enterprise servers have API at `"https:///api/v3/"`). * `api_timeout` (`float | tuple[float, float]` = `(10.0, 20.0)`): Timeout for the GitHub API calls ([details](https://requests.readthedocs.io/en/stable/user/advanced/#timeouts)). * `api_version` (`str | None` = `"2022-11-28"`): Target GitHub API version; set to `None` to use GitHub's latest (rather experimental). -* `restrict_to` (`dict[str, list[str] | None] | None` = `None`): Optional (but very recommended) dictionary of GitHub organizations/users the authentication is restricted to. Each key (organization name) in the dictionary can contain a list of further restricted repository names. When the list is empty (or null), only the organizations are considered. +* `restrict_to` (`dict[str, list[str] | None] | None` = `None`): Optional (but highly recommended) dictionary of GitHub organizations/users the authentication is restricted to. Each key (organization name) in the dictionary can contain a list of further restricted repository names. When the list is empty (or null), only the organizations are considered. * `cache` (`dict`): Cache configuration section * `token_max_size` (`int` = `32`): Max number of entries in the token -> user LRU cache. This cache holds the authentication data for a token. Evicted tokens will need to be re-authenticated. * `auth_max_size` (`int` = `32`): Max number of [un]authorized org/repos TTL(LRU) for each user. Evicted repos will need to get re-authorized.