Skip to content

feat(eventing): GitHub sign-in and an approved-user list - #878

Merged
mrsabath merged 4 commits into
mainfrom
feat/eventing-github-identity
Sep 29, 2026
Merged

mrsabath merged 4 commits into
mainfrom
feat/eventing-github-identity

Conversation

@mrsabath

Copy link
Copy Markdown
Contributor

Replaces the made-up identity from EB_AUTH_TOKENS with a real, externally verified one, and separates "I do not know you" from "I know you and you are not approved."

Implements rossoctl/rossoctl#2606, under the Event Identity demo epic rossoctl/rossoctl#2506. Builds on the eventing tree from #877.

The two rejections

Case Response
No credential 401 + WWW-Authenticate
Unknown or revoked token 401
Real GitHub user, not on the approved list 403 — "mrsabath is not on the approved-user list"
Approved user 202, identity on the event

The 403 is the one worth demoing: a genuine, authenticated person, refused, and told which identity was refused so it is actionable. No WWW-Authenticate on a 403 — retrying with another credential is not the remedy.

Verified end to end against a real GitHub account

Not just unit tests — the full browser flow:

$ eventbridge-cli.py login
  Open https://github.com/login/device
  Enter code: CEF6-3786
✔ signed in as mrsabath
  token stored at ~/.config/rossoctl-eventing/token (mode 600)

Then, with that token:

  • 202 on POST /v0/agents, agent ran, final=True, real reply
  • 403 against an instance whose list is aslom,Alan-Cha
  • ce_submitter:mrsabath + ce_submitter_iss:github on the Kafka requests topic
  • A group of 5 — every member carried both attributes on the wire

The constraint that shapes the design

GitHub does not issue a verifiable token for user login. The device flow returns an opaque string — no signature, no claims, nothing to check offline. (The JWKS at token.actions.githubusercontent.com is for Actions workloads, not users.)

So EventBridge cannot verify locally; it must ask GitHub who holds the token via GET /user. Three consequences, all deliberate:

  • Sign-in depends on GitHub being reachable. A failed lookup is a 401, never an allow — failing closed is the only safe direction for "who is this".
  • The cache is load-bearing, not an optimisation. Without it every request spends one of 5000 hourly API calls and adds GitHub's latency to the request path. Keyed by sha256(token), so it never holds a usable credential. Failures are not cached, so a revoked token stops working promptly and an outage does not pin a legitimate user to a failure for the whole TTL.
  • No scopes are requested. GET /user returns the login for an unscoped token, verified against the live API (x-accepted-oauth-scopes is empty). The app asks for the least access that answers the question — nothing here can read a repository.

Decisions worth reviewing

  • An empty approved list denies everyone. The other reading — empty means everybody — would turn a missing environment variable into an open door.
  • Logins compare case-insensitively, because GitHub logins are. Comparing exactly would refuse a genuinely approved user over capitalisation.
  • EB_AUTH_TOKENS still works, as a fallback: it keeps tests off the network, keeps an offline demo possible, and gives an operator a break-glass credential when GitHub is unreachable.
  • ce_submitter_iss records who vouched — github, or absent for a static token. Without it a reader cannot tell a verified identity from a name typed into an env var.
  • The client id is a committed default. The device flow has no client secret, so unlike an ntfy topic it is not a capability. Override with EB_GITHUB_CLIENT_ID.
  • login prints the code and blocks rather than opening a browser, which fails silently over SSH and in a container — where this is most often run.

Honest scope

ce_submitter is still unsigned, and Kafka is plaintext. The claim is "a real GitHub user, on an approved list, authorised this request" — not "the event proves it." Anything with write access to the topic can forge the attribute.

Making it provable needs submitter inside signing.SIGNED_ATTRS and a producer that signs. rossoctl/rossoctl#2607 covers that, and the kid plus approved-key-set groundwork for it is in aslom#3.

Also unchanged: /continue stays unauthenticated so the ntfy phone action keeps working — authenticating it would put a long-lived token in every notification traversing a public server. Per-correlation HMAC keys are the planned fix.

Tests

490 passed, 5 skipped — upstream main is 450/5, so this adds 40 and regresses nothing. No test touches the network; the device flow and GET /user are exercised through injected fakes.

ruff clean under the pinned 0.11.4.

Docs

The OAuth App setup is documented in rossoctl/rossoctl#2609 (docs/eventing/github-oauth-app.md), which also covers the scope reasoning and revocation.

Assisted-By: Claude Code

Replaces the made-up identity from EB_AUTH_TOKENS with a real, externally
verified one, and separates "I do not know you" from "I know you and you are
not approved".

- eventbridge/ghauth.py: OAuth device flow plus GET /user. GitHub does NOT
  issue a verifiable token for user login — it returns an opaque string with no
  signature and no claims — so EventBridge cannot verify locally and must ask
  GitHub who holds it. That makes sign-in depend on GitHub being reachable, and
  a failed lookup is a 401 rather than an allow.
- The cache is load-bearing, not an optimisation: without it every request
  spends one of 5000 hourly API calls and adds GitHub's latency to the request
  path. Keyed by sha256(token), so it never holds a usable credential.
  Failures are not cached, so a revoked token stops working promptly.
- No scopes are requested. GET /user returns the login for an unscoped token,
  so the app asks for the least access that answers "who is this".
- auth.resolve() returns (identity, issuer, status, reason). 401 means
  unauthenticated; 403 means authenticated and not approved, and names the
  login that was refused because that is what makes it actionable. No
  WWW-Authenticate on a 403: retrying with another credential is not the fix.
- ce_submitter_iss records who vouched — "github", or absent for a static
  token. Without it a reader cannot tell a verified identity from a name typed
  into an env var.
- EB_AUTH_TOKENS still works, as a fallback that keeps tests off the network,
  keeps an offline demo possible, and gives an operator a break-glass
  credential when GitHub is unreachable.
- An empty approved list denies everyone. The other reading — empty means
  everybody — would turn a missing variable into an open door.
- Logins compare case-insensitively, because GitHub logins are.
- CLI: login / logout / whoami. login prints the code and blocks rather than
  opening a browser, which fails silently over SSH and in a container. The
  token is stored 0600. 401/403 now print what to do instead of a traceback.
- The client id is a committed default: the device flow has no client secret,
  so unlike an ntfy topic it is not a capability.

Verified against a real GitHub account end to end: 401 unauthenticated, 401 on
a garbage token, 403 for a real user off the list, and an approved user's login
reaching the wire as ce_submitter:mrsabath / ce_submitter_iss:github.

Tests: 450 passed, 5 skipped (was 410/5). No test touches the network.

Refs: rossoctl/rossoctl#2606

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
@mrsabath
mrsabath requested a review from a team as a code owner September 29, 2026 17:44
The agentdocs record design decisions so their claims can be checked against
the code. This change had none, so the reasoning behind it lived only in a PR
description.

Written as a delta over DESIGN_PHASE1, matching the existing phase documents,
with an explicit "what does NOT change" section.

What it records that is not obvious from the diff:

- Why the design looks the way it does. GitHub does not issue a verifiable
  token for user login - the device flow returns an opaque string - so local
  validation is impossible and a GET /user lookup is forced. That single fact
  rules out the JWT-shaped design most readers will reach for, and makes the
  cache load-bearing rather than an optimisation.
- Why 401 and 403 are kept distinct, and why the 403 names the login it
  refused: a user told only "forbidden" hunts for a broken token.
- Why an empty approved list denies everyone rather than everybody.
- That trust in EventBridge is load-bearing, since EventRunner never contacts
  GitHub. Documented as a property rather than left to be discovered in
  questions.
- What ce_submitter is NOT worth while it stays unsigned, and exactly what
  would make it provable.
- Why /continue and PUT /transcript are deliberately open, with the planned
  per-correlation HMAC fix for the first.
- Why Keycloak and HMAC were both rejected for agent identity, including the
  ~170,000x speed advantage HMAC has and why it still fails the goal.
- The blocker on the agent half: sign_event() has no production caller, so
  ER_REQUIRE_SIGNATURE=true is a kill switch. Verified again while writing
  this.
- One environment trap found during testing: two EventBridge instances share a
  fixed responses consumer group, so they split partitions and the symptom
  looks like lost responses rather than a split group.

Every file:line and section reference in the document was checked against the
tree it describes.

Refs: rossoctl/rossoctl#2606, rossoctl/rossoctl#2607

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
Review fixes.

1. submitter_iss -> submitteriss (must-fix). CloudEvents v1.0 requires
   attribute names to be lower-case [a-z0-9] only: no underscore. It was the
   only one of eleven EXT_* constants to break the rule, and the codec here
   could not catch it - to_kafka_binary/from_kafka_binary only add and strip
   the ce_ prefix and validate nothing, so a bad name round-trips locally and
   is rejected or silently dropped by a spec-compliant SDK, an HTTP-binding
   gateway or a Knative broker a hop later. Renamed while nothing is persisted;
   later it would be a migration.

   Added test_roundtrip_binary.py assertions over every EXT_* constant so the
   next extension cannot repeat it, plus the 20-character SHOULD limit.
   Verified the test fails when the old name is restored.

2. The token write no longer has a permissive window. write_text creates at the
   process umask, so there was a moment where a credential was group- and
   world-readable. Now os.open(..., 0o600) at creation, and mode=0o700 on the
   directory. Kept the trailing chmod: os.open applies its mode only when it
   creates the file, so a re-login over a file left loose by an earlier version
   would otherwise keep 0644 - confirmed by experiment.

3. Removed a dead fallback in auth.resolve. That branch ran only when _bearer
   had already failed, and resolve_identity re-reads the same header through
   _bearer, so name was unconditionally None and why2 == why. The break-glass
   path is the branch below, where a token WAS presented and GitHub could not
   vouch for it; verified still working after the deletion.

DESIGN_PHASE2 updated for the rename, with the naming rule and how it escaped
local testing recorded in 2.6 - the point of agentdocs being that the next
person does not rediscover it.

Not changed, deliberately: LoginCache still evicts only on lookup of the same
key, so it grows with distinct valid tokens seen. Bounded by the approved-user
count, failures are not cached, and adding a sweep would be unexercised code at
demo scale. Noted here rather than silently accepted.

Tests: 492 passed, 5 skipped. ruff clean under the pinned 0.11.4.

Refs: rossoctl/rossoctl#2606

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 2c94c9c, verified against a local checkout: 490 passed / 7 skipped on 3.14, ruff check and ruff format --check both clean, and all 11 CI checks green.

This is the Phase 2 work that #877 explicitly deferred, and it lands the hard part well. Three things I went looking for and found handled:

  • The device flow follows GitHub's pacing contract — authorization_pending continues, slow_down adds five seconds and re-reads the interval, expired_token and access_denied are distinguished. sleep/now injected so the tests exercise pacing without waiting.
  • No scopes are requested. GET /user answers the only question being asked, so a leaked token from this app cannot read a repository. That is the right least-privilege call and it's worth how prominently the docstring says so.
  • The CLI's token write is genuinely careful. os.open(..., O_CREAT, 0o600) rather than write_text plus a later chmod, with the follow-up chmod kept precisely because os.open's mode applies only on creation. I tried to find a window here and could not reach one: the directory is created 0o700, so the pre-existing-loose-file case needs write access to a directory nobody else has.

401 vs 403 as distinct answers (§2.4) is the right decision and the reason given for it is the correct one.

No blocking issues. Three non-blocking findings, all documentation-or-efficiency rather than correctness:

1. The revocation claim does not hold (three places)

The claim that "failures are not cached [so] a revoked token must stop working promptly" appears in ghauth.py, test_ghauth.py and DESIGN_PHASE2.md §2.3. Not caching failures does not affect revocation at all — revocation is bounded by the positive TTL, because a cache hit short-circuits resolve() before fetch_login is ever called. Measured on this revision with an injected clock:

login  -> ('alice', None)
revoked on GitHub; token still presented:
  t+   0s  -> login='alice'   STILL AUTHENTICATES
  t+ 299s  -> login='alice'   STILL AUTHENTICATES
  t+ 300s  -> login=None      refused

So the window is the full 300 s default. The second half of the stated reasoning — that an outage must not pin a legitimate user to a failure for the whole TTL — is correct and is what not-caching-failures actually buys. The two reasons don't "pull the same way"; only one of them is load-bearing for this design.

Nothing here is insecure: the window is bounded, configurable via EB_GITHUB_CACHE_TTL_S, and 300 s is defensible. It's the stated property that needs correcting, and this codebase has been careful about exactly that — auth.py saying outright that submitter is unsigned is the standard I'm holding it to.

2. Break-glass pays a doomed GitHub round-trip on every request

Static tokens are checked only after GitHub resolution fails, and failures aren't cached, so a static-token request makes one GitHub call that can never succeed — every time. During the outage break-glass exists for, that's the full fetch_login timeout (5 s default) added to every request, against the same 5000/hour budget §2.3 says the cache exists to protect.

Checking the static map first would remove both costs: it's local, constant-time, and can't be shadowed in practice. The comment at auth.py:160 explains that static is tried, but not why GitHub goes first — if there's a reason, it's worth a line, because the current order makes the fallback slowest exactly when it's needed most.

3. auth.resolve's docstring says "and" where the code means "or"

It documents GitHub sign-in as active "when a client id and an approved-user list are configured", but github_on is an or. Your own test_github_on_with_only_an_allowed_list_still_enforces shows the or is deliberate, so this is the docstring being imprecise, not the code being wrong. Worth fixing because the two half-configured states behave quite differently — client-id-only refuses everyone with 403, list-only accepts any GitHub PAT from an approved login with no OAuth app involved.


Areas reviewed: device flow, token→login resolution, the cache, the approved-user list, 401/403 semantics, handler wiring on both create routes, CE attribute naming, CLI token storage, config precedence, tests, design doc
Author: mrsabath (MEMBER — maintainer)
Agent/IDE config (.claude/.vscode): none — gate clean, no matches in the diff
Commits: 3, all signed off (DCO green)
Verified locally at 2c94c9c: 490 passed / 7 skipped on 3.14; ruff check . and ruff format --check . clean
CI: 11/11 green

Approving — none of the above blocks.

One last note, because it's the part of 2c94c9c most worth keeping: the response to the submitter_iss slip was a generic guard rather than a rename. test_every_extension_attribute_name_is_cloudevents_compliant enumerates every EXT_* constant and regex-checks it, so the next non-compliant name is caught too — which matters precisely because, as the docstring says, the codec validates nothing and a bad name round-trips locally before being dropped by a spec-compliant consumer a hop later. I checked it holds: it covers all 12 extension attributes today, and re-introducing submitter_iss makes it fail. The 20-char SHOULD-limit test alongside it is the same instinct.

Comment thread eventing/eventbridge/ghauth.py Outdated
Comment on lines +174 to +176
Negative results are not cached. A revoked token must stop working promptly,
and a GitHub outage must not pin a legitimate user to a failure for the whole
TTL.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — not caching failures does nothing for revocation; the positive TTL is what governs it.

resolve() returns on a cache hit before fetch_login is ever called, so once a token has resolved successfully, revoking it on GitHub has no effect until the entry expires. Measured on this revision with an injected clock:

login  -> ('alice', None)
revoked on GitHub; token still presented:
  t+   0s  -> login='alice'   STILL AUTHENTICATES
  t+  60s  -> login='alice'   STILL AUTHENTICATES
  t+ 299s  -> login='alice'   STILL AUTHENTICATES
  t+ 300s  -> login=None      refused

The second reason is correct and is the real one — not caching failures is what stops a GitHub outage pinning a legitimate user to a failure for the whole TTL. The two just don't pull the same way, and the revocation half overstates what the design delivers.

Nothing here is insecure: the window is bounded, EB_GITHUB_CACHE_TTL_S tunes it, and 300 s is a fine default. It's worth stating accurately because the honesty of the docstrings is doing real work elsewhere in this package — auth.py saying plainly that submitter is unsigned is the standard I'm holding this to. The same sentence is in DESIGN_PHASE2.md §2.3 and in test_resolve_does_not_cache_a_failure's docstring.

Suggested change
Negative results are not cached. A revoked token must stop working promptly,
and a GitHub outage must not pin a legitimate user to a failure for the whole
TTL.
Negative results are not cached, so a GitHub outage does not pin a legitimate
user to a failure for the whole TTL. Note this does **not** make revocation
prompt: a successful lookup is cached, and `resolve()` returns from the cache
without calling GitHub, so a token revoked upstream keeps authenticating until
its entry expires — up to `ttl_s` (300 s by default). Lower the TTL if that
window matters more than the API budget.

Comment thread eventing/eventbridge/auth.py Outdated
Comment on lines +160 to +165
# A static token is checked before giving up, so an operator can keep
# a break-glass credential alongside GitHub sign-in.
if cfg.auth_tokens:
name, _ = resolve_identity(environ, cfg.auth_tokens)
if name:
return name, None, None, None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — the break-glass path makes a GitHub call that can never succeed, on every request.

A static token is not a GitHub token, so ghauth.resolve above always fails for one — and since failures aren't cached, that doomed call is repeated per request rather than once. Two costs, both landing on the path that exists for when things are already going wrong:

  • During a GitHub outage, every break-glass request waits the full fetch_login timeout (5 s default) before the static check runs. The fallback is slowest exactly when it is needed most.
  • Each one spends a call from the 5000/hour budget that §2.3 gives as the reason the cache is load-bearing.

Checking the static map first avoids both. It's a local hmac.compare_digest against a handful of entries, so it costs nothing, and it can't be shadowed in practice — a GitHub token would have to be byte-identical to a configured static token.

The comment explains that static is tried before giving up, which is the useful half. If there's a deliberate reason GitHub goes first — precedence, or not wanting a stale static entry to mask a real identity — that's worth the extra line, because the ordering is not self-evidently the cheap way round.

Comment thread eventing/eventbridge/auth.py Outdated
Comment on lines +134 to +135
1. **GitHub sign-in**, when a client id and an approved-user list are
configured. The real path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — says "and", means "or".

github_on at line 144 is bool(github_client_id) or bool(allowed_users), and test_github_on_with_only_an_allowed_list_still_enforces shows the or is intended — so the code is right and this sentence is the imprecise part.

Worth correcting rather than leaving, because the two half-configured states are quite different and an operator reading this would predict neither: client-id-only sends every caller down the GitHub path and then refuses all of them with 403 (an empty allowed_users denies, per is_allowed), while list-only accepts any valid GitHub PAT belonging to an approved login, with no OAuth App involved at all. Both fail closed, which is the right instinct — they're just not what "and" describes.

Suggested change
1. **GitHub sign-in**, when a client id and an approved-user list are
configured. The real path.
1. **GitHub sign-in**, when *either* a client id or an approved-user list is
configured. The real path. Note the consequences of configuring only one:
a client id alone refuses everyone (an empty approved list denies), and an
approved list alone accepts any GitHub token belonging to an approved
login without an OAuth App being involved.

Addresses @aslom's three non-blocking review findings on #878.

1. The revocation claim was wrong, in three places. Not caching failures does
   nothing for revocation: a cache hit short-circuits resolve() before
   fetch_login runs, so a revoked token keeps authenticating until its POSITIVE
   entry expires. Reproduced the measurement - accepted at t+299s, refused at
   t+300s. The window is the full TTL.

   Corrected ghauth.py, test_ghauth.py and DESIGN_PHASE2 2.3, and added
   test_a_revoked_token_keeps_working_until_its_positive_entry_expires plus a
   shorter-TTL case so the claim cannot drift again. The second half of the old
   reasoning - that an outage must not pin a legitimate user to a refusal - is
   correct and is what not-caching-failures actually buys; it now says only
   that.

2. Static tokens are now checked BEFORE GitHub. The old order made break-glass
   slowest exactly when it was needed: during an outage every static-token
   request paid a full fetch_login timeout on a call that could never succeed,
   against the same budget 2.3 says the cache protects. Static is local and
   constant-time, so it goes first. Nothing is shadowed - a static secret would
   have to deliberately collide with a live gho_-shaped token. Two tests: one
   asserting no GitHub call happens for a static token, one asserting real
   sign-in still resolves.

3. resolve()'s docstring said "and" where github_on is an or. Fixed, and
   documented what each half-configured state actually does, since they differ:
   client-id-only 403s everyone, list-only accepts any PAT from an approved
   login with no OAuth App involved.

Tests: 496 passed, 5 skipped (was 492/5). ruff check and format both clean.

Refs: rossoctl/rossoctl#2606

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
@mrsabath

Copy link
Copy Markdown
Contributor Author

Thanks — all three addressed in 304925d. The first one was a genuine error in a claim I had made prominently, which is the worst kind to leave standing.

1. The revocation claim. You're right, and I reproduced your measurement before changing anything — accepted at t+299 s, refused at t+300 s. Not caching failures does nothing for revocation, because a cache hit short-circuits resolve() before fetch_login ever runs. I had conflated two unrelated properties and asserted they "pull the same way"; only the outage one is load-bearing.

Corrected in all three places, and the doc now says outright that an earlier revision claimed otherwise rather than quietly editing it. Added test_a_revoked_token_keeps_working_until_its_positive_entry_expires and a shorter-TTL case, so the window is pinned by a test instead of by prose. You held it to the standard auth.py set about submitter being unsigned — that's the right comparison, and the fix is a test, not better wording.

2. Break-glass ordering. Reordered: static tokens are now checked first. Your framing was the convincing part — the fallback was slowest exactly when it was needed, and it spent the budget §2.3 says the cache exists to protect. There was no reason for GitHub-first; I simply wrote the branches in the order I thought about them. Two tests now cover it: one asserting no GitHub call happens at all for a static token, one asserting real sign-in still resolves so the reorder shadows nothing.

3. The docstring and/or. Fixed, and I took your point further — the docstring now spells out what each half-configured state does, since they diverge sharply: client-id-only 403s everyone, list-only accepts any PAT from an approved login with no OAuth App involved. Worth knowing which mistake you've made.

496 passed, 5 skipped (was 492), ruff check and ruff format --check both clean.

On LoginCache growth, which you didn't flag but is adjacent to #1: still deliberately unbounded, now with the reasoning in the commit rather than assumed. Bounded by the approved-user count, failures uncached. If this outlives the demo it wants an LRU bound — worth an issue at that point rather than speculative code now.

Next is #2607, where the interesting half of that review will be the kid allowlist and the two kafka_in hazards: the decode/store path is outside a try, so verification has to degrade rather than raise, and group events are self-published with no correlationid.

@mrsabath
mrsabath merged commit 015276e into main Sep 29, 2026
11 checks passed
@mrsabath
mrsabath deleted the feat/eventing-github-identity branch September 29, 2026 21:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants