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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,32 @@ connections cannot be told apart, so an agent may hold only one of them.
Declaring a second one in `gete.yaml` is fine; naming both under one agent's
`connections` is what `gete validate` refuses.

Some services announce themselves without a prefix: their access tokens are
JWTs whose `iss` claim names the service's own host. `tokens.format: jwt` says
so, and gete holds the connection to it — a token that is not such a JWT is
refused, however little else claims it. In return the connection is no longer
accepted by elimination, and an agent may hold it next to one that is:

```yaml
connections:
zendesk:
base_url: https://acme.zendesk.com
tokens:
format: jwt # tokens are JWTs issued by acme.zendesk.com, nothing else
```

An agent may then read Zendesk and write to `internal-api` above, which keeps
the one place a token is taken by elimination. What the two may not share is
an issuer: one authorization server in front of both services puts its host
in either connection's tokens, and `gete validate` refuses that pairing for
the same reason it refuses two anonymous connections.

Whether a provider issues such tokens can be a setting rather than a promise —
Zendesk does so only with token expiry turned on — so the declaration belongs
in `gete.yaml`, not in a catalog entry that would promise it for every
installation. It cuts both ways: turn that setting off and every token the
connection is handed is refused until the declaration goes with it.

A service whose root moves with the installation — the tenant in a subdomain,
or a deployment you host — writes its URLs around `{base_url}`, and the
installation fills it in:
Expand Down
32 changes: 22 additions & 10 deletions src/gete/catalog/connections/zendesk.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ docs: https://developer.zendesk.com/api-reference/
# authorization and token endpoints move with it. Nothing here is an address
# until the installation sets connections.zendesk.base_url in its gete.yaml;
# hosts stays empty because the only host comes from that root.
# Zendesk issues JWT-shaped access tokens whose claims name no issuer, and
# the service announces no prefix. A JWT like that says as little about its
# origin as an opaque token, so it is accepted by elimination, and an agent
# may hold only one connection of this kind.
# Zendesk announces no prefix, and what its access tokens look like is the
# installation's own setting rather than the service's promise: with token
# expiry turned on they are JWTs issued by the subdomain, and without it they
# are JWTs whose claims name no issuer, which says as little about their
# origin as an opaque token. The catalog cannot promise either, so tokens are
# accepted by elimination here - and an agent may hold only one connection of
# that kind. An installation that has expiry on says so with
# connections.zendesk.tokens.format: jwt in its gete.yaml, and then this
# connection no longer takes that place.
token_prefixes: []

oauth:
Expand Down Expand Up @@ -40,13 +45,20 @@ setup: |
here. When they do not, the consent screen still appears and the exchange
fails afterwards.

# The accepted example records the observed shape - a JWT whose claims name
# no issuer - not a contract: the service promises nothing about its tokens.
# verified is absent because no authorization has been taken through Gemini
# Enterprise yet.
Zendesk can be set to issue access tokens that expire. Those tokens are
JWTs issued by the subdomain, and an installation that has the setting on
may declare connections.zendesk.tokens.format: jwt in its gete.yaml, which
frees an agent to hold Zendesk beside another connection whose tokens
announce themselves no way at all. The declaration is held to: turn the
setting off again and every Zendesk token is refused until it is removed.

# No accepted example: which of the two shapes arrives is the installation's
# setting, and an entry fixing one of them would contradict an installation
# that has the other - the declared one most of all, whose accepted tokens
# name a subdomain the catalog cannot know. What is refused is refused either
# way. verified is absent because no authorization has been taken through
# Gemini Enterprise yet.
examples:
accepts:
- "eyJhbGciOiJFZERTQSJ9.eyJleHAiOjB9.sig" # claims: {"exp": 0}, no issuer
rejects:
- "ya29.a0AfH6SMB" # Google access token
# Claims: {"iss": "https://accounts.google.com"}, as in an ID token.
Expand Down
80 changes: 62 additions & 18 deletions src/gete/connection/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,62 @@ def elimination_problems(
) -> list[str]:
"""Describe connections that cannot be held together, or return an empty list.

A connection without token prefixes accepts whatever no other connection
claims. Two of them are indistinguishable: a token issued by either
authorization passes as the other's. Only the connections handed to the
same agent can be confused that way, so the pairing is what is refused,
not the second prefixless connection an installation declares. The
registry holds every connection gete ships as well, and declaring a
service of your own must not depend on which of those announce themselves.
A connection that announces itself neither by a token prefix nor by a
declared token format accepts whatever no other connection claims. Two of
them are indistinguishable: a token issued by either authorization passes
as the other's. Only the connections handed to the same agent can be
confused that way, so the pairing is what is refused, not the second such
connection an installation declares. The registry holds every connection
gete ships as well, and declaring a service of your own must not depend on
which of those announce themselves.

A declared format announces the service in every token, so it does not
take that one place - unless one issuer stands behind two of the
connections judging tokens by issuer, which is the same confusion by
another route. An anonymous connection is one of those: it takes a JWT
naming its own authorization server as readily as a declaring one does,
so a shared issuer confuses the two whichever of them declared.
"""
prefixless = sorted(
connection_id
for connection_id in set(connection_ids)
if not registry.get(connection_id, include_retired=True).token_prefixes
)
if len(prefixless) < 2:
return []
return [
f"{', '.join(prefixless)} declare no token_prefixes; only one of an "
"agent's connections may accept tokens by elimination, or a token from "
"one of them would be accepted as another's"
connections = [
registry.get(connection_id, include_retired=True)
for connection_id in sorted(set(connection_ids))
]
problems: list[str] = []
anonymous = [
connection
for connection in connections
if not connection.token_prefixes and connection.token_format is None
]
if len(anonymous) >= 2:
problems.append(
f"{', '.join(connection.id for connection in anonymous)} declare "
"neither token_prefixes nor tokens.format; only one of an agent's "
"connections may accept tokens by elimination, or a token from one "
"of them would be accepted as another's"
)
# A prefix decides on its own and before any issuer does, so a connection
# held to one judges no token by who issued it, and shares an issuer with
# nothing.
by_issuer = [
connection
for connection in connections
if connection.token_format is not None or not connection.token_prefixes
]
for index, connection in enumerate(by_issuer):
for other in by_issuer[index + 1 :]:
if connection.token_format is None and other.token_format is None:
# Already refused above, by everything the pair fails to say
# about itself rather than by the one issuer they happen to
# share; naming it here would say it twice.
continue
shared = connection.issuer_hosts & other.issuer_hosts
if shared:
problems.append(
f"{connection.id}, {other.id} both accept tokens issued by "
f"{', '.join(sorted(shared))}; a token from one of them "
"would be accepted as the other's"
)
return problems


def connection_problems(connection: Connection, registry: Registry) -> list[str]:
Expand Down Expand Up @@ -101,6 +137,14 @@ def connection_problems(connection: Connection, registry: Registry) -> list[str]
problems.append(
f"hosts: {entry} never applies; the bare {host} entry admits every path"
)
if connection.token_format is not None and connection.token_prefixes:
# The format decides on its own, so the prefixes beside it are never
# read - and a reader would have to know that to see which of the two
# rules the connection is actually held to.
problems.append(
f"tokens: format {connection.token_format} decides on its own; the "
"token_prefixes declared beside it are never read"
)
for other in registry.all(include_retired=True):
if other.id == connection.id:
continue
Expand Down
82 changes: 62 additions & 20 deletions src/gete/connection/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
# otherwise a prefixless service accepts Google tokens by elimination.
GOOGLE_ACCESS_TOKEN_PREFIX = "ya29."

# The one token format a connection can commit to. A service that issues it
# names itself in every token, which is what a prefix would otherwise do.
JWT_FORMAT = "jwt"

# Stands for the root of a service that differs per installation, wherever the
# connection's URLs are written around it. A definition meant to be shared
# cannot spell a tenant's host, and a stand-in host would be a name a stranger
Expand Down Expand Up @@ -175,6 +179,11 @@ class Connection:
# never travels to them. Empty means downloads stay on hosts.
redirect_hosts: frozenset[str] = frozenset()
token_prefixes: tuple[str, ...] = ()
# The format the service commits to issuing, JWT_FORMAT or None. A
# connection that declares one takes nothing else, so it announces itself
# as surely as a prefix does and is not accepted by elimination; one this
# gete cannot judge takes nothing at all.
token_format: str | None = None
# Prefixes declared by every other connection in the registry, filled in by
# Registry. A connection without prefixes of its own accepts a token only
# if none of these match, so a bare from_mapping() connection judges more
Expand Down Expand Up @@ -213,6 +222,7 @@ def from_mapping(cls, data: Mapping[str, Any]) -> "Connection":
hosts=frozenset(hosts),
redirect_hosts=frozenset(data.get("redirect_hosts", ())),
token_prefixes=tuple(data.get("token_prefixes", ())),
token_format=data.get("tokens", {}).get("format"),
base_url=base_url,
docs=data.get("docs"),
oauth_client=data.get("oauth_client"),
Expand Down Expand Up @@ -262,16 +272,23 @@ def client_secret_secret(self) -> str:
def accepts_token(self, token: str) -> bool:
"""Whether the token may be treated as this connection's.

A declared prefix decides on its own: Google issues ya29.c.<payload>
access tokens that carry two dots, and a shape heuristic must not
overrule a prefix the catalog vouches for. Without prefixes, a JWT
is judged by the issuer its claims name - Google ID tokens and
service account tokens always name Google's - and a JWT that names
none says as little as an opaque token, so it is judged like one.
One that cannot be read is refused rather than guessed about.
A declared token format decides first and alone: the connection has
promised what its tokens look like, and anything else is refused
however little else claims it. A declared prefix decides next: Google
issues ya29.c.<payload> access tokens that carry two dots, and a shape
heuristic must not overrule a prefix the catalog vouches for. With
neither, a JWT is judged by the issuer its claims name - Google ID
tokens and service account tokens always name Google's - and a JWT
that names none says as little as an opaque token, so it is judged
like one. One that cannot be read is refused rather than guessed about.
"""
if not token:
return False
if self.token_format is not None:
# A resolved declaration outlives the gete that wrote it, and a
# format this one cannot judge is not one it may fall back from:
# elimination would accept what the declaration meant to narrow.
return self.token_format == JWT_FORMAT and self._issued_here_jwt(token)
if self.token_prefixes:
return token.startswith(self.token_prefixes)
if token.startswith(GOOGLE_ACCESS_TOKEN_PREFIX):
Expand All @@ -287,29 +304,54 @@ def accepts_token(self, token: str) -> bool:
# it is not some other service's.
return not any(token.startswith(prefix) for prefix in self.foreign_prefixes)

def issued_here(self, issuer: str) -> bool:
"""Whether the issuer names this connection's own service.
def _issued_here_jwt(self, token: str) -> bool:
"""Whether the token is a JWT this connection's own service issued.

The hosts the token may travel to and the OAuth endpoints all count
as its service: the issuer of a service's tokens is its authorization
server, which may live beside the API rather than on it. Matched by
host, exactly, whether the issuer is written as a URL or bare.
What a connection declaring the format holds every token to. A token
of any other shape, one whose claims cannot be read, and one that
names no issuer are all refused alike: none of them is what was
promised, and the promise is the only reason the connection may sit
beside another that accepts tokens by elimination.
"""
try:
# The issuer arrives inside an unvetted token; one that urlsplit
# cannot read names no host.
named = urlsplit(issuer if "://" in issuer else f"//{issuer}").hostname
except ValueError:
if not looks_like_jwt(token):
return False
if named is None:
claims = jwt_claims(token)
if claims is None:
return False
issuer = claims.get("iss")
return isinstance(issuer, str) and self.issued_here(issuer)

@property
def issuer_hosts(self) -> frozenset[str]:
"""Hosts a token of this connection's may name as its issuer.

The hosts the token may travel to and the OAuth endpoints all count
as its service: the issuer of a service's tokens is its authorization
server, which may live beside the API rather than on it. A URL still
written around {base_url} contributes nothing, because it is not a
name until the installation sets the root.
"""
own = {entry.partition("/")[0] for entry in self.hosts}
own.update(
host
for url in (self.oauth.authorization_url, self.oauth.token_url)
if (host := urlsplit(url).hostname) is not None
)
return named in own
return frozenset(own)

def issued_here(self, issuer: str) -> bool:
"""Whether the issuer names this connection's own service.

Matched by host, exactly, whether the issuer is written as a URL or
bare.
"""
try:
# The issuer arrives inside an unvetted token; one that urlsplit
# cannot read names no host.
named = urlsplit(issuer if "://" in issuer else f"//{issuer}").hostname
except ValueError:
return False
return named is not None and named in self.issuer_hosts

def allows(self, url: str) -> bool:
"""Whether a token may be attached to a request for this URL.
Expand Down
58 changes: 49 additions & 9 deletions src/gete/connection/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@
from functools import cache
from typing import Any

from gete.connection.registry import Connection, Registry, jwt_claims, looks_like_jwt
from gete.connection.registry import (
JWT_FORMAT,
Connection,
Registry,
jwt_claims,
looks_like_jwt,
)
from gete.request_context import current_tool_call

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -88,24 +94,58 @@ def describe_state(state: Any) -> list[dict[str, Any]]:
]


NO_DECLARED_SHAPE = "matches none of the declared shapes"


def describe_token(connection: Connection, token: str) -> str:
"""Which declared shape the token has, without repeating any of it.

A connection without prefixes declares no shapes, so a refused JWT is
described by what refused it - claims that cannot be read, or an issuer
that does not name this service - or the shape message would leave the
refusal looking like a mystery.
refusal looking like a mystery. A connection that declares a token format
is refusing against a promise, and the promise is named: an operator who
turned the provider's expiring-token setting off has every token refused
at once, and nothing else in the log would say why. Read in the order
accepts_token decides in, so the message names the rule that actually
refused the token.
"""
if connection.token_format is not None:
return _unmet_format(connection.token_format, token)
for prefix in connection.token_prefixes:
if token.startswith(prefix):
return f"starts with {prefix}"
if not connection.token_prefixes and looks_like_jwt(token):
claims = jwt_claims(token)
if claims is None:
return "a JWT whose claims cannot be read"
if "iss" in claims:
return "a JWT whose issuer does not name this service"
return "matches none of the declared shapes"
if connection.token_prefixes:
return NO_DECLARED_SHAPE
return _refused_jwt(token) or NO_DECLARED_SHAPE


def _unmet_format(token_format: str, token: str) -> str:
"""Which part of the declared format the token missed.

A format this gete cannot judge refuses every token alike, so it is the
format that has to be named and not the token: an operator told the
issuer was wrong would go looking at a token that may be exactly right,
when what refused it is a declaration newer than the gete reading it.
"""
if token_format != JWT_FORMAT:
return f"declared as {token_format}, which this gete cannot judge"
if not looks_like_jwt(token):
return f"not the {token_format} this connection declares"
return _refused_jwt(token) or "a JWT that names no issuer"


def _refused_jwt(token: str) -> str | None:
"""Why a JWT-shaped token is not taken for this service's, or None when
nothing about the JWT itself says so."""
if not looks_like_jwt(token):
return None
claims = jwt_claims(token)
if claims is None:
return "a JWT whose claims cannot be read"
if "iss" in claims:
return "a JWT whose issuer does not name this service"
return None


def usable_token(connection: Connection, key: str, state: Any) -> str | None:
Expand Down
Loading