Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
383 changes: 383 additions & 0 deletions eventing/agentdocs/DESIGN_PHASE2.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions eventing/agentdocs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and what is *not* verified.
| [`DESIGN_PHASE1.md`](DESIGN_PHASE1.md) | The Phase 1 design — a delta over Phase 0, not a replacement: KEDA scaling on consumer lag, scale-to-zero, the three cluster findings that shaped it, the §16 gaps (A: rebalance floor, B: ephemeral session state, C: idle replay), §3.2 on supporting a local Kind cluster, and §21 designing agent **groups** — batch fan-out with a tracked fan-in, its own page and exactly two notifications. |
| [`IMPLEMENTATION_REPORT1.md`](IMPLEMENTATION_REPORT1.md) | What Phase 1 built, the measured results on both clusters, 28 findings including two real bugs only a scale-to-zero deployment could expose, and an honest account of what is still blocked and why. |
| [`README_PHASE1.md`](README_PHASE1.md) | The Phase 1 runbook in full detail, covering all four ways to run it: locally without containers, under Docker, on a Kind cluster, and on OpenShift. |
| [`DESIGN_PHASE2.md`](DESIGN_PHASE2.md) | The Phase 2 design — identity on the event path. Why GitHub's opaque user token rules out local verification and forces a `GET /user` lookup plus a load-bearing cache; why `401` and `403` are kept distinct; what `ce_submitter` is and is not worth while it remains unsigned; the `kid` and approved-key-set groundwork for proving *which agent* answered, and the blocker that nothing signs yet. |

## Reading order

Expand All @@ -27,8 +28,12 @@ and what is *not* verified.
- **Changing it?** [`DESIGN_PHASE0.md`](DESIGN_PHASE0.md) for the wire contract that
must not change, then [`DESIGN_PHASE1.md`](DESIGN_PHASE1.md) for the deployment
model.
- **Working on auth or identity?** [`DESIGN_PHASE2.md`](DESIGN_PHASE2.md) §2.1 first:
the opaque-token constraint is what rules out the design most people reach for.
- **Debugging something odd?** [`IMPLEMENTATION_REPORT1.md`](IMPLEMENTATION_REPORT1.md)
§4 — most surprises are already recorded there with their cause.
- **Presenting it?** [`DESIGN_PHASE2.md`](DESIGN_PHASE2.md) §2.6 and §3, for what the
identity controls do *not* prove.

The `PHASE0` documents describe a laptop demo and remain accurate for that; where
Phase 1 supersedes them it says so explicitly rather than editing them in place.
131 changes: 116 additions & 15 deletions eventing/eventbridge/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
Scope is deliberately narrow: this answers "who is asking me to run an agent?"
on the two routes that CREATE work. It is not a general authorization layer.

Two mechanisms live here. `ghauth` verifies a GitHub sign-in and is the real
path; the static token map below is the fallback that keeps tests off the network
and an offline demo possible. `resolve()` is the entry point that picks.

Two properties worth stating, because both are easy to get wrong:

* **Constant-time comparison.** Tokens are compared with
Expand Down Expand Up @@ -51,35 +55,43 @@ def parse_tokens(raw: str) -> dict[str, str]:
return out


def resolve_identity(environ: dict[str, Any], tokens: dict[str, str]) -> tuple[str | None, str | None]:
"""Resolve the caller from a WSGI environ.

Returns `(identity, error)`:

* `(None, None)` — auth is disabled (no tokens configured). Callers treat
this as "allowed, anonymous", which keeps the default
demo path working unchanged.
* `(name, None)` — a valid credential for `name`.
* `(None, reason)` — reject with 401; `reason` is safe to return to the
client (it never echoes the presented token).
def _bearer(environ: dict[str, Any]) -> tuple[str | None, str | None]:
"""Pull the bearer token out of the environ. `(token, error)`.

Reads only headers. It must not touch `wsgi.input`: `handlers._read_json`
reads the body from a non-seekable stream, so consuming it here would leave
every downstream handler with an empty body.
"""
if not tokens:
return None, None

header = environ.get("HTTP_AUTHORIZATION") or ""
if not header:
return None, "authentication required"

scheme, _, presented = header.partition(" ")
if scheme.lower() != "bearer":
return None, "unsupported authentication scheme; expected Bearer"
presented = presented.strip()
if not presented:
return None, "empty bearer token"
return presented, None


def resolve_identity(environ: dict[str, Any], tokens: dict[str, str]) -> tuple[str | None, str | None]:
"""Resolve the caller from a static token map.

Returns `(identity, error)`:

* `(None, None)` — auth is disabled (no tokens configured). Callers treat
this as "allowed, anonymous", which keeps the default
demo path working unchanged.
* `(name, None)` — a valid credential for `name`.
* `(None, reason)` — reject with 401; `reason` is safe to return to the
client (it never echoes the presented token).
"""
if not tokens:
return None, None

presented, why = _bearer(environ)
if why:
return None, why

# Compare against every configured token so the work done is independent of
# which entry matches (and of whether any does). `compare_digest` on str
Expand All @@ -95,3 +107,92 @@ def resolve_identity(environ: dict[str, Any], tokens: dict[str, str]) -> tuple[s
if matched is None:
return None, "invalid bearer token"
return matched, None


# ---- the combined entry point ----------------------------------------------

def resolve(environ: dict[str, Any], cfg, *, cache=None,
fetch=None) -> tuple[str | None, str | None, int | None, str | None]:
"""Resolve the caller. `(identity, issuer, status, reason)` — handlers call this.

`issuer` records *who vouched* for the identity: `"github"` for a verified
sign-in, `None` for a static token. A reader of the event can then tell a
real identity from a name an operator typed into an environment variable.

`status` is the HTTP status to refuse with, and it carries real information:

* `401` — "I do not know you": no credential, a malformed one, or one GitHub
does not recognise.
* `403` — "I know exactly who you are, and you are not approved." A real,
authenticated person who is not on the list.

Collapsing those into one answer would tell an operator less, and would tell
a user debugging their own access much less.

GitHub sign-in is active when a client id **or** an approved-user list is set
— deliberately an `or`, so a half-configured deployment fails closed rather
than silently falling back to no authentication. The two halves behave
differently, and it is worth knowing which mistake you have made:

* **client id only** — every request gets `403`, because the approved list is
empty and an empty list approves nobody.
* **approved list only** — any GitHub token from an approved login is
accepted, with no OAuth App involved. Useful for a quick test with a
personal access token; not what you want in a deployment.

Within that, checked in order:

1. **Static tokens** (`EB_AUTH_TOKENS`) — a local constant-time compare, and
the break-glass path when GitHub is unreachable, so it goes first.
2. **GitHub sign-in** — the real path.

Static tokens also keep tests off the network and an offline demo possible,
which is why they are not removed now that sign-in exists.

With neither configured the result is all-`None` — allowed and
anonymous, which is what keeps the default demo working out of the box.
"""
from eventbridge import ghauth

github_on = bool(getattr(cfg, "github_client_id", "")) or bool(
getattr(cfg, "allowed_users", frozenset()))

if github_on:
presented, why = _bearer(environ)
if why:
# No usable credential at all. Falling back to a static token here
# would be dead code: `resolve_identity` re-reads the same header via
# `_bearer` and fails for the same reason. The break-glass path is the
# branch below, which is the case that matters — a token WAS presented
# and GitHub could not vouch for it.
return None, None, 401, why

# Static tokens first, because they are the break-glass path and this is a
# local constant-time compare. Checking GitHub first made the fallback
# slowest exactly when it is needed: during an outage every break-glass
# request paid a full `fetch_login` timeout on a call that could never
# succeed, against the same API budget the cache exists to protect.
#
# Nothing is shadowed by the order. A GitHub token is `gho_`/`ghp_`-shaped
# and an operator choosing a static secret that collides with a live
# GitHub token would have to do so deliberately.
if cfg.auth_tokens:
name, _ = resolve_identity(environ, cfg.auth_tokens)
if name:
return name, None, None, None

login, err = ghauth.resolve(
presented, cache, **({"fetch": fetch} if fetch else {}))
if login is None:
return None, None, 401, err or "could not identify this token"

if not ghauth.is_allowed(login, cfg.allowed_users):
# Name the login in the refusal: the user knows who they are, and
# being told which identity was refused is what makes it actionable.
return None, None, 403, f"{login} is not on the approved-user list"
return login, "github", None, None

name, why = resolve_identity(environ, cfg.auth_tokens)
if why:
return None, None, 401, why
return name, None, None, None
24 changes: 23 additions & 1 deletion eventing/eventbridge/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import tomllib
from dataclasses import dataclass, field

from eventbridge import auth
from eventbridge import auth, ghauth


@dataclass
Expand Down Expand Up @@ -47,6 +47,17 @@ class Cfg:
# only via EB_AUTH_TOKENS — deliberately never read from config.toml, which
# is committed (tests/test_manifests.py pins the same rule for NTFY_TOKEN).
auth_tokens: dict[str, str] = field(default_factory=dict)
# GitHub sign-in. The client id is PUBLIC — the OAuth device flow has no
# client secret, which is why it can be committed while an ntfy topic cannot.
github_client_id: str = ""
# Who may submit. Empty means nobody is approved: if GitHub sign-in is
# configured at all, an operator has to say who may use it, because the other
# reading ("empty means everybody") turns a missing variable into an open door.
allowed_users: frozenset[str] = frozenset()
# Token -> login lookups are cached for this long. Not an optimisation: a
# GitHub API call per request would spend a 5000/hour budget and add GitHub's
# latency to the request path.
github_cache_ttl_s: float = 300.0
ntfy: NtfyCfg = field(default_factory=NtfyCfg)


Expand Down Expand Up @@ -78,6 +89,11 @@ def load() -> Cfg:
phases=tuple(n.get("phases", cfg.ntfy.phases)),
)
cfg.public_base_url = n.get("public_base_url", cfg.public_base_url)
g = d.get("github", {})
cfg.github_client_id = g.get("client_id", cfg.github_client_id)
if g.get("allowed_users"):
cfg.allowed_users = frozenset(
str(u).strip().lower() for u in g["allowed_users"] if str(u).strip())

# env overrides
e = os.environ.get
Expand All @@ -98,6 +114,12 @@ def load() -> Cfg:
auth_env = e("EB_AUTH_TOKENS")
if auth_env:
cfg.auth_tokens = auth.parse_tokens(auth_env)
cfg.github_client_id = e("EB_GITHUB_CLIENT_ID", cfg.github_client_id)
allowed_env = e("EB_ALLOWED_USERS")
if allowed_env:
cfg.allowed_users = ghauth.parse_allowed_users(allowed_env)
cfg.github_cache_ttl_s = float(e("EB_GITHUB_CACHE_TTL_S",
str(cfg.github_cache_ttl_s)))

cfg.ntfy.enabled = (e("NTFY_ENABLED", "true" if cfg.ntfy.enabled else "false").lower() == "true")
cfg.ntfy.base_url = e("NTFY_BASE_URL", cfg.ntfy.base_url)
Expand Down
8 changes: 8 additions & 0 deletions eventing/eventbridge/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,11 @@ topic = ""
token = ""
phases = ["result", "error"]
public_base_url = "http://127.0.0.1:8080"

[github]
# The OAuth App client id is public: the device flow has no client secret, so
# unlike an ntfy topic this is safe to commit. Override with EB_GITHUB_CLIENT_ID.
client_id = ""
# Who may submit. Empty means nobody, so sign-in stays off until an operator
# names the approved logins. Override with EB_ALLOWED_USERS.
allowed_users = []
Loading
Loading