fix(pier): widen returning identities, warm the Redis store, end stateless listen streams, refuse personal tokens without a TokenID

- Identity.fingerprint covers scopes, both subjects, the ceiling and the
  token kind; a stateful lookup or stateless hit whose fingerprint changed
  re-filters the server with the event diff under its own mutex (A-WR-04)
- Mount warms a Redis store in the background with a 1 s to 30 s backoff
  and every request kicks a pending open without waiting, so stateless-only
  instances subscribe to fan-out (A-WR-05)
- a stateless subscriptions/listen POST re-verifies its bearer every
  recheck interval; an LRU-evicted stateless server closes its sessions
  (A-WR-06)
- resolvePersonalToken refuses a subject with TokenID 0, and the
  pact.MCPPersonalTokenResolver contract says one resolver per application,
  false means unauthorized and TokenID keys the binding (A-WR-09, A-IN-03)
This commit is contained in:
Jakub Zych
2026-10-23 15:59:54 +02:00
parent c1c0c77ddc
commit 240457291f
10 changed files with 217 additions and 39 deletions

View File

@@ -77,6 +77,44 @@ func (id Identity) writes() bool {
return !id.ceiling || slices.Contains(id.Scopes, "write")
}
// fingerprint encodes the authorization-relevant parts of the identity
// (P-26): the sorted scopes, both subjects (guard, user, client, token and
// their sorted scopes), the ceiling and the token kind. Two requests of one
// binding with different fingerprints may see different tools, so a cached
// server is re-filtered when it changes (A-WR-04).
func (id Identity) fingerprint() string {
var b strings.Builder
sorted := func(in []string) string {
out := slices.Clone(in)
slices.Sort(out)
return strings.Join(out, ",")
}
subject := func(s *pact.MCPSubject) {
if s == nil {
b.WriteString("-|")
return
}
b.WriteString(s.Guard)
b.WriteByte(':')
b.WriteString(strconv.FormatUint(uint64(s.UserID), 10))
b.WriteByte(':')
b.WriteString(s.ClientID)
b.WriteByte(':')
b.WriteString(strconv.FormatUint(uint64(s.TokenID), 10))
b.WriteByte(':')
b.WriteString(sorted(s.Scopes))
b.WriteByte('|')
}
b.WriteString(sorted(id.Scopes))
b.WriteByte('|')
subject(id.Frontend)
subject(id.Backend)
b.WriteString(strconv.FormatBool(id.ceiling))
b.WriteByte('|')
b.WriteString(id.TokenKind)
return b.String()
}
// subjects lists the identity's subjects (frontend first).
func (id Identity) subjects() []pact.MCPSubject {
var out []pact.MCPSubject
@@ -227,7 +265,9 @@ func (r *identityResolver) resolvePersonalToken(ctx context.Context, raw string)
return Identity{}, ErrUnauthorized
}
subject, ok, err := resolver.MCPResolvePersonalToken(ctx, raw)
if err != nil || !ok || subject.UserID == 0 {
// TokenID keys the binding (pat:user:<token_id>): a subject without one
// would put every user of the resolver on one binding (A-WR-09).
if err != nil || !ok || subject.UserID == 0 || subject.TokenID == 0 {
return Identity{}, ErrUnauthorized
}
if subject.Guard == "" {