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:
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user