Skip to content

Commit 1e06e6d

Browse files
committed
DRU-761 -- Use the manager's GitHub App for API calls, Git credentials, and account links
1 parent 581c9f4 commit 1e06e6d

18 files changed

Lines changed: 966 additions & 134 deletions

File tree

‎AGENTS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ For app-surface changes, inspect the proof app at
6868
- Route a workflow cadence or pause through its schedule overrides. Do not use a
6969
settings column for this purpose.
7070
- Use one canonical name and shape for each contract. Fail loudly on every other shape.
71+
- A managed service differs from a self-hosted one only in configuration: a URL,
72+
a key, or a verifier. Any manager that serves the same routes and publishes a
73+
JWKS works.
7174
- Do not type-switch over a typed stream in app code. When a projection needs
7275
ordering or anchoring, grow the SDK primitive instead of an `isinstance` chain.
7376
- Put only identity and facts in a read-side field. Keep UI wording in the app pages.

‎backend/druks/accounts/jwt.py‎

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import re
2-
from functools import lru_cache
2+
from functools import cache
33

44
from fastmcp.server.auth.providers.jwt import JWTVerifier
55
from jsonpointer import JsonPointer
@@ -13,32 +13,39 @@
1313
_ARRAY_INDEX = re.compile(r"0|[1-9][0-9]*")
1414

1515

16-
@lru_cache(maxsize=1)
16+
@cache
1717
def _verifier(jwks_uri: str, issuer: str, audience: str) -> JWTVerifier:
1818
return JWTVerifier(jwks_uri=jwks_uri, issuer=issuer, audience=audience, algorithm=_ALGORITHM)
1919

2020

21+
async def verify_claims(token: str, *, jwks_url: str, issuer: str, audience: str) -> dict:
22+
"""The claims of a token that the keys at ``jwks_url`` signed for ``issuer`` and
23+
``audience``, else InvalidAssertionError."""
24+
access = await _verifier(jwks_url, issuer, audience).verify_token(token)
25+
# The verifier checks signature, issuer, audience, and expiry-if-present;
26+
# our contract additionally requires exp.
27+
if access and "exp" in access.claims:
28+
return access.claims
29+
raise InvalidAssertionError("Assertion rejected.")
30+
31+
2132
async def verify_assertion(token: str, settings: Settings) -> str:
2233
"""The verified identity claim of an edge-minted assertion, else
2334
InvalidAssertionError."""
24-
verifier = _verifier(
25-
settings.identity.jwks_url,
26-
settings.identity.jwt_issuer,
27-
settings.identity.jwt_audience,
35+
claim = await verify_claims(
36+
token,
37+
jwks_url=settings.identity.jwks_url,
38+
issuer=settings.identity.jwt_issuer,
39+
audience=settings.identity.jwt_audience,
2840
)
29-
access = await verifier.verify_token(token)
30-
# The verifier checks signature, issuer, audience, and expiry-if-present;
31-
# our contract additionally requires exp and a nonblank string identity.
32-
if not access or "exp" not in access.claims:
33-
raise InvalidAssertionError("Assertion rejected.")
34-
claim: object = access.claims
41+
# Objects and arrays only: jsonpointer would index a string's characters.
3542
for part in JsonPointer(settings.identity.jwt_identity_claim).parts:
36-
if isinstance(claim, dict) and part in claim:
37-
claim = claim[part]
43+
if isinstance(claim, dict):
44+
claim = claim.get(part)
3845
elif isinstance(claim, list) and _ARRAY_INDEX.fullmatch(part) and int(part) < len(claim):
3946
claim = claim[int(part)]
4047
else:
41-
raise InvalidAssertionError("Assertion carries no value at the identity pointer.")
48+
claim = None
4249
if isinstance(claim, str) and claim.strip():
4350
return claim.strip()
4451
raise InvalidAssertionError(

‎backend/druks/core/apis/github.py‎

Lines changed: 53 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44
import time
55
from collections.abc import Awaitable, Callable
66
from dataclasses import dataclass
7-
from datetime import datetime
7+
from datetime import UTC, datetime, timedelta
88
from typing import Any, Literal, TypeVar
99

10-
from githubkit import AppAuthStrategy, AppInstallationAuthStrategy, GitHub
10+
from githubkit import GitHub, TokenAuthStrategy
11+
from githubkit.auth import BaseAuthStrategy
1112
from githubkit.exception import GraphQLFailed, RequestFailed
1213

1314
from druks.core.apis.exceptions import GitHubAppNotInstalledError
1415
from druks.core.utils.time import ensure_utc
16+
from druks.redis import get_client
17+
from druks.services.constants import OAUTH_TOKEN_TTL_SKEW_SECONDS
1518

1619
logger = logging.getLogger(__name__)
1720

@@ -63,22 +66,20 @@ async def wrapper(self: "GitHubClient", repo: str, *args: Any, **kwargs: Any) ->
6366

6467

6568
class GitHubClient:
69+
"""``app_auth`` signs the App routes at ``app_url``. Repository calls go to GitHub
70+
with the installation tokens those routes mint."""
71+
6672
def __init__(
6773
self,
6874
*,
6975
app_id: str,
70-
private_key: str,
71-
base_url: str = GITHUB_API_URL,
76+
app_auth: BaseAuthStrategy,
77+
app_url: str = GITHUB_API_URL,
7278
slug: str = "",
7379
) -> None:
7480
self._app_id = app_id
75-
self._private_key = private_key
76-
self._base_url = base_url
7781
self._slug = slug
78-
self._app = GitHub(
79-
AppAuthStrategy(app_id, private_key),
80-
base_url=base_url,
81-
)
82+
self._app = GitHub(app_auth, base_url=app_url)
8283
self._installation_cache: dict[str, int] = {}
8384
self._repo_gh_cache: dict[int, GitHub] = {}
8485

@@ -116,10 +117,8 @@ async def list_repos_for_owner(self, owner: str) -> list[dict[str, Any]]:
116117
except GitHubAppNotInstalledError:
117118
return []
118119

119-
async with GitHub(
120-
AppInstallationAuthStrategy(self._app_id, self._private_key, installation_id),
121-
base_url=self._base_url,
122-
) as github:
120+
github = await self._get_for_installation(installation_id)
121+
async with github:
123122
repos: list[dict[str, Any]] = []
124123
page = 1
125124
while True:
@@ -149,10 +148,8 @@ async def create_repo_from_template(self, owner: str, template_repo: str, name:
149148
installation_id = await self._owner_installation_id(owner)
150149
template_owner, template_name = template_repo.split("/", 1)
151150
full_name = f"{owner}/{name}"
152-
async with GitHub(
153-
AppInstallationAuthStrategy(self._app_id, self._private_key, installation_id),
154-
base_url=self._base_url,
155-
) as github:
151+
github = await self._get_for_installation(installation_id)
152+
async with github:
156153
try:
157154
await github.rest.repos.async_create_using_template(
158155
template_owner,
@@ -168,15 +165,16 @@ async def create_repo_from_template(self, owner: str, template_repo: str, name:
168165
raise
169166
return full_name
170167

171-
async def list_installation_accounts(self) -> tuple[str, ...]:
168+
async def list_installation_accounts(self, cached: bool = True) -> tuple[str, ...]:
172169
"""Account logins (orgs/users) this App is installed on — the
173170
authoritative "where druks may act". Install the App somewhere and
174171
druks works there; uninstall and it stops. Cached ~10 min per app
175-
id, serving the last-known set when GitHub hiccups."""
176-
cached = _INSTALLATION_ACCOUNTS_CACHE.get(self._app_id)
172+
id, serving the last-known set when GitHub hiccups. ``cached=False``
173+
asks now, after an install."""
174+
known = _INSTALLATION_ACCOUNTS_CACHE.get(self._app_id)
177175
now = time.monotonic()
178-
if cached and now - cached[0] < _INSTALLATION_ACCOUNTS_TTL_SECONDS:
179-
return cached[1]
176+
if cached and known and now - known[0] < _INSTALLATION_ACCOUNTS_TTL_SECONDS:
177+
return known[1]
180178
try:
181179
accounts: list[str] = []
182180
page = 1
@@ -194,12 +192,12 @@ async def list_installation_accounts(self) -> tuple[str, ...]:
194192
break
195193
page += 1
196194
except Exception:
197-
if cached:
195+
if known:
198196
logger.warning(
199197
"Could not refresh App installations; serving the last-known set.",
200198
exc_info=True,
201199
)
202-
return cached[1]
200+
return known[1]
203201
raise
204202
result = tuple(dict.fromkeys(accounts))
205203
_INSTALLATION_ACCOUNTS_CACHE[self._app_id] = (now, result)
@@ -228,7 +226,7 @@ async def get_bot_git_author(self) -> tuple[str, str]:
228226
bot_name = f"{await self.get_mention_handle()}[bot]"
229227
user_id = _BOT_USER_ID_CACHE.get(self._app_id)
230228
if not user_id:
231-
async with GitHub(base_url=self._base_url) as github:
229+
async with GitHub(base_url=GITHUB_API_URL) as github:
232230
response = await github.rest.users.async_get_by_username(bot_name)
233231
user_id = response.parsed_data.id
234232
_BOT_USER_ID_CACHE[self._app_id] = user_id
@@ -238,12 +236,8 @@ async def get_authenticated_app_slug(self) -> str:
238236
"""The slug GitHub reports for these credentials — a live App-JWT call,
239237
so it proves the App ID matches the PEM. The connect flow's validation;
240238
mention resolution reads the stored slug instead."""
241-
async with GitHub(
242-
AppAuthStrategy(self._app_id, self._private_key),
243-
base_url=self._base_url,
244-
) as github:
245-
response = await github.rest.apps.async_get_authenticated()
246-
return str(getattr(response.parsed_data, "slug", None) or "")
239+
response = await self._app.rest.apps.async_get_authenticated()
240+
return str(getattr(response.parsed_data, "slug", None) or "")
247241

248242
async def _installation_id(self, repo: str) -> int:
249243
if repo in self._installation_cache:
@@ -259,22 +253,38 @@ async def _installation_id(self, repo: str) -> int:
259253
self._installation_cache[repo] = installation_id
260254
return installation_id
261255

256+
async def _get_installation_token(self, installation_id: int) -> tuple[str, datetime]:
257+
"""The installation's token and its expiry, kept in Redis until the skew before it
258+
expires."""
259+
redis = get_client()
260+
key = f"github:installation_token:{installation_id}"
261+
if token := await redis.get(key):
262+
return token.decode(), datetime.now(UTC) + timedelta(seconds=await redis.ttl(key))
263+
response = await self._app.rest.apps.async_create_installation_access_token(
264+
installation_id,
265+
)
266+
minted = response.parsed_data
267+
expires_at = ensure_utc(datetime.fromisoformat(str(minted.expires_at)))
268+
ttl = int((expires_at - datetime.now(UTC)).total_seconds()) - OAUTH_TOKEN_TTL_SKEW_SECONDS
269+
if ttl > 0:
270+
await redis.set(key, str(minted.token), ex=ttl)
271+
return str(minted.token), expires_at
272+
273+
async def _get_for_installation(self, installation_id: int) -> GitHub:
274+
token, _ = await self._get_installation_token(installation_id)
275+
return GitHub(TokenAuthStrategy(token), base_url=GITHUB_API_URL)
276+
262277
async def _for_repo(self, repo: str) -> GitHub:
263278
installation_id = await self._installation_id(repo)
264279
if installation_id not in self._repo_gh_cache:
265-
self._repo_gh_cache[installation_id] = GitHub(
266-
AppInstallationAuthStrategy(
267-
self._app_id,
268-
self._private_key,
269-
installation_id,
270-
),
271-
base_url=self._base_url,
272-
)
280+
self._repo_gh_cache[installation_id] = await self._get_for_installation(installation_id)
273281
return self._repo_gh_cache[installation_id]
274282

275283
async def _invalidate_for_repo(self, repo: str) -> None:
276284
installation_id = self._installation_cache.pop(repo, None)
277285
github = self._repo_gh_cache.pop(installation_id, None) if installation_id else None
286+
if installation_id:
287+
await get_client().delete(f"github:installation_token:{installation_id}")
278288
if github:
279289
try:
280290
await github.__aexit__(None, None, None)
@@ -288,22 +298,9 @@ async def _invalidate_for_repo(self, repo: str) -> None:
288298
@_retry_on_401
289299
async def token_for_repo(self, repo: str) -> tuple[str, datetime]:
290300
"""The installation token for ``repo`` and the expiry GitHub gave it."""
291-
# The decorator drops the cached installation client + id on a
292-
# 401 and retries once. Important here because git is the
293-
# consumer of the minted token — once it's handed to git,
294-
# there's no httpx-layer retry hook to recover from a stale
295-
# ``installation_id`` (e.g. after the App was reinstalled and
296-
# got a new id, leaving every worker's cache pointing at the
297-
# dead one). The 401 from this method's own SDK call gives us
298-
# the only chance to invalidate before git presents a bad
299-
# token to GitHub and produces ``expected flush after ref
300-
# listing``.
301-
installation_id = await self._installation_id(repo)
302-
token_response = await self._app.rest.apps.async_create_installation_access_token(
303-
installation_id,
304-
)
305-
token = token_response.parsed_data
306-
return str(token.token), ensure_utc(datetime.fromisoformat(str(token.expires_at)))
301+
# git holds the token after this call, so a 401 here is the one chance to drop
302+
# a stale installation id (the App was reinstalled) and mint again.
303+
return await self._get_installation_token(await self._installation_id(repo))
307304

308305
@_retry_on_401
309306
async def get_repository(self, repo: str) -> dict[str, Any]:

‎backend/druks/core/routes.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,12 @@ async def github_manifest_callback(slug: str, code: str = "") -> HTMLResponse:
8383
"webhook_secret": app["webhook_secret"],
8484
}
8585
)
86-
app_slug = row.identity["slug"]
87-
install_url = f"https://github.com/apps/{quote(app_slug, safe='')}/installations/new"
8886
# druks opened this tab via window.open; the broadcast tells the connect
8987
# card to refetch, then the tab moves on to the one step GitHub still
9088
# needs — installing the App on the repositories druks should work in.
9189
return render_page(
92-
"github_manifest_callback.html", slug=app_slug, install_url=install_url, service=slug
90+
"github_manifest_callback.html",
91+
slug=row.identity["slug"],
92+
install_url=await service.get_install_endpoint(),
93+
service=slug,
9394
)

‎backend/druks/core/services.py‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
from contextlib import suppress
55
from datetime import datetime
66
from typing import Any
7+
from urllib.parse import quote
78

89
import httpx
9-
from githubkit import GitHub
10+
from githubkit import AppAuthStrategy, GitHub, TokenAuthStrategy
1011
from pydantic import BaseModel, Field, SecretStr
1112
from slack_sdk.errors import SlackApiError
1213
from sqlalchemy import select
@@ -96,7 +97,7 @@ def get_manifest(cls, *, endpoint: str, webhook_base: str) -> dict[str, Any]:
9697
async def verify(cls, settings: Settings) -> dict[str, Any]:
9798
client = GitHubClient(
9899
app_id=settings.app_id,
99-
private_key=settings.private_key.get_secret_value(),
100+
app_auth=AppAuthStrategy(settings.app_id, settings.private_key.get_secret_value()),
100101
)
101102
try:
102103
slug = await client.get_authenticated_app_slug()
@@ -109,15 +110,33 @@ async def verify(cls, settings: Settings) -> dict[str, Any]:
109110

110111
@classmethod
111112
async def get_client(cls) -> GitHubClient:
112-
"""The client of the connected App's vault row. PEM plaintext exists only
113-
here, feeding the auth strategy."""
113+
"""The client of the connected App's vault row."""
114114
row = await cls.get()
115+
if cls.is_manager_held():
116+
return GitHubClient(
117+
app_id=row.identity["app_id"],
118+
app_auth=TokenAuthStrategy(load_settings().manager.token.get_secret_value()),
119+
app_url=row.identity["url"],
120+
slug=row.identity["slug"],
121+
)
115122
return GitHubClient(
116123
app_id=row.identity["app_id"],
117-
private_key=row.secrets["private_key"],
124+
app_auth=AppAuthStrategy(row.identity["app_id"], row.secrets["private_key"]),
118125
slug=row.identity["slug"],
119126
)
120127

128+
@classmethod
129+
async def get_install_endpoint(cls) -> str:
130+
slug = (await cls.get()).identity["slug"]
131+
return f"https://github.com/apps/{quote(slug, safe='')}/installations/new"
132+
133+
@classmethod
134+
async def sync_installations(cls) -> None:
135+
"""The accounts the App is installed on, as the fact ``installations``."""
136+
card = await cls.get()
137+
accounts = await (await cls.get_client()).list_installation_accounts(cached=False)
138+
card.identity = {**card.identity, "installations": list(accounts)}
139+
121140
@classmethod
122141
async def issue_token(cls, resource: str) -> tuple[str, datetime]:
123142
"""The installation token for the repo, and the expiry GitHub gave it."""

0 commit comments

Comments
 (0)