feat(#3297): read the caller's identity out of introspection

The auth-callout responder learned whether a token was valid, never whose
it was, so every admitted client got the same unscoped grant. Scoping a
grant to one hive's subjects needs an identity to scope it to.

`is_active` becomes `identify_caller` and returns the identity rather
than a boolean: `Ok(Some(client_id))` admits as that client, `Ok(None)`
denies. `active: true` with no `client_id` is a denial, and returning an
identity is what makes that unrepresentable instead of remembered --
there is no admitted-but-unscoped value to construct, so there is no
branch a later edit can forget to handle.

The module deliberately ignores `sub`/`scope`/`exp` on the grounds that
modelling a field implies checking it. That still holds: `active` remains
the whole admission decision, made in one place. `client_id` answers a
different question -- as whom -- and is used downstream of an admission
that has already happened, not as a second gate. The module docs say so,
next to the paragraph that would otherwise argue for deleting the field.

An inactive token has no identity even when the body names one, and an
empty `client_id` counts as absent: it would become a blank component in
a subject the grant is scoped to, which is not a narrower permission but
a different one. The two credential parameters are renamed `own_*` --
this responder's introspection credential is not the caller's identity,
and the function now takes one and returns the other.

The grant itself is still unscoped; narrowing it is the next slice.
This commit is contained in:
atlas 2026-08-16 23:24:32 +02:00 committed by mara
commit f287ff1ee8
2 changed files with 120 additions and 32 deletions

View file

@ -135,8 +135,12 @@ async fn main() -> anyhow::Result<()> {
// No token is a denial, not an error: an anonymous connect is a
// normal thing for a client to attempt and an abnormal thing to
// grant. Introspection is only reached once something was presented.
let granted = match &req.connect_opts.auth_token {
Some(token) => introspect::is_active(
//
// The caller is an identity or nothing — see `introspect`'s module
// docs. There is no "admitted, identity unknown" branch to write here
// because there is no such value to receive.
let caller = match &req.connect_opts.auth_token {
Some(token) => introspect::identify_caller(
&http,
&args.introspection_url,
&args.client_id,
@ -149,14 +153,17 @@ async fn main() -> anyhow::Result<()> {
// which an attacker would most like this to fall open.
.unwrap_or_else(|e| {
tracing::warn!(error = ?e, "introspection failed; denying");
false
None
}),
None => false,
None => None,
};
// The client id is an identifier, not a credential, and it is the
// only thing tying a connection in this log to a hive.
tracing::info!(
user_nkey = %req.user_nkey,
server_id = %req.server_id.id,
granted,
granted = caller.is_some(),
caller = caller.as_deref().unwrap_or("-"),
"auth request"
);
@ -168,7 +175,11 @@ async fn main() -> anyhow::Result<()> {
tracing::warn!("auth request had no reply subject; dropping");
continue;
};
let token = if granted {
// The grant is still unscoped: knowing *who* connected is what makes
// scoping possible, not what performs it. Narrowing the permissions
// to the caller's own subjects is the next slice, and lands in
// `respond::grant` where the JWT is minted.
let token = if caller.is_some() {
respond::grant(&issuer, &args.account, &req.server_id.id, &req.user_nkey)
} else {
respond::deny(&issuer, &req.server_id.id, &req.user_nkey)