fix(19.7): RA-IN-05 a token whose scopes no longer resolve gets its own error
The built-in backend guard's principal answers errScopeUnavailable, not a nil principal, when an admin personal token's stored scopes all fail to resolve (a scope's plugin was removed). MCPDispatch passes it through and dispatch maps it to the fixed tool error "pier: the token's scopes no longer grant this route" instead of claiming the administrator's account is unknown or deactivated; the listing check still treats it as a refusal, not as a failed check.
This commit is contained in:
@@ -30,7 +30,7 @@ Use cases:
|
||||
- Route binding by exact method and pattern (D-10); a tool targets a route that carries its guard's token-accepting middleware or names its guard explicitly (D-15); the requirements a route's middleware implies come from the guard provider's `MCPRequirements` and are checked together with the tool's `Permissions` (D-16).
|
||||
- Input schema reflected from the struct with `jsonschema.ForType` (`jsonschema:"..."` tags describe fields, fields without `omitempty` are required, no additional properties); every call's arguments are validated against it, then split by the `path:"x"` and `query:"q"` tags into path values, query string and the raw JSON body minus those keys (D-11); a `GET` or `DELETE` tool may carry only path and query fields, and an embedded struct (whose promoted fields would be body fields) fails boot there. A `path:` field must be required (no `omitempty` or `omitzero`), and at call time pier delivers only path values the HTTP mux could: a missing value, an empty, `.` or `..` segment, or a `/` inside a single-segment value is a tool error before dispatch. Numbers in path and query values keep their literal digits, so integer ids above 2^53 stay exact. An optional `Output` struct produces the output schema.
|
||||
- Annotations from the method (D-14): `GET` read-only and idempotent, `PUT` idempotent, `DELETE` destructive; `ReadOnly`, `Destructive`, `Idempotent` and `OpenWorld` override; the destructive and open-world hints are always explicit.
|
||||
- In-process dispatch through `pier.Host.Handler`, the route's compiled chain, with `bouncer.WithDispatch` carrying the guard provider's `MCPDispatch` principal and credential, path values set as the mux would, `RemoteAddr`, the `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Real-IP` and `Accept-Language` copied from the MCP request (never `Authorization` or `Cookie`), `mcp.call_timeout` on the context and the answer captured up to `mcp.max_result_bytes` (D-17). A failed guard dispatch answers the fixed tool error `pier: the tool could not be run for this caller` and logs the cause server-side, so no provider or database error reaches the agent (an invalid token or a deactivated account keep their own fixed texts); a dispatch whose guard list names `backend` for a frontend tool, or whose principal kind contradicts the tool's guard, is refused the same way.
|
||||
- In-process dispatch through `pier.Host.Handler`, the route's compiled chain, with `bouncer.WithDispatch` carrying the guard provider's `MCPDispatch` principal and credential, path values set as the mux would, `RemoteAddr`, the `Host`, `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Real-IP` and `Accept-Language` copied from the MCP request (never `Authorization` or `Cookie`), `mcp.call_timeout` on the context and the answer captured up to `mcp.max_result_bytes` (D-17). A failed guard dispatch answers the fixed tool error `pier: the tool could not be run for this caller` and logs the cause server-side, so no provider or database error reaches the agent (an invalid token, a deactivated account and an admin personal token whose stored scopes no longer resolve keep their own fixed texts); a dispatch whose guard list names `backend` for a frontend tool, or whose principal kind contradicts the tool's guard, is refused the same way.
|
||||
- Results (D-13): 2xx is text plus structured content (an object as is, any other JSON wrapped as `{"data": …}`); a 4xx or 5xx is a tool error whose text is the route's body; a too-large answer is a tool error with a fixed text.
|
||||
- Built-in `backend` guard provider (`pier.BackendGuard`): routes behind cabana's `backend` middleware, listing through `cabana.Allows` on the administrator's principal from `cabana.BackendPrincipal`, an admin personal token's scope limit computed per dispatched route method with `cabana.ScopeLimitFor`; a plugin that registers its own `backend` provider wins.
|
||||
- Identity per request (`pier.Identity`): `oas_at_` tokens through the published verifier, personal tokens only with `mcp.allow_personal_tokens`, the backend subject through the published link resolver with an in-process cache of at most `pier.LinkCacheTTL`; the binding key `oauth:<client_id>:<user_id>`, `pat:user:<token_id>` or `pat:backend:<token_id>` survives access-token rotation. One `pact.MCPPersonalTokenResolver` serves the application; a subject it answers without a `TokenID` is refused, so personal-token users never share a binding.
|
||||
|
||||
@@ -61,6 +61,9 @@ func (g backendGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, re
|
||||
// personal token's scope limit computed for method.
|
||||
func (g backendGuard) MCPAllowsMethod(ctx context.Context, subject pact.MCPSubject, _ pact.MCPRequirements, permissions []string, method string) (bool, error) {
|
||||
principal, err := g.principal(ctx, subject, method)
|
||||
if errors.Is(err, errScopeUnavailable) {
|
||||
return false, nil // a refusal, not a failed check
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -75,6 +78,13 @@ func (g backendGuard) MCPAllowsMethod(ctx context.Context, subject pact.MCPSubje
|
||||
// MCP client.
|
||||
var errSubjectUnavailable = errors.New("pier: the caller's account is unknown or deactivated")
|
||||
|
||||
// errScopeUnavailable is what a dispatch answers when an admin personal
|
||||
// token's stored scopes no longer resolve to any registered scope (a
|
||||
// scope's plugin was removed, for example); its text is safe to show the
|
||||
// MCP client and, unlike errSubjectUnavailable, does not claim the account
|
||||
// is gone (RA-IN-05).
|
||||
var errScopeUnavailable = errors.New("pier: the token's scopes no longer grant this route")
|
||||
|
||||
// MCPDispatch builds the admin principal for one in-process call.
|
||||
func (g backendGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject, method string) (pact.MCPDispatch, error) {
|
||||
principal, err := g.principal(ctx, subject, method)
|
||||
@@ -90,7 +100,8 @@ func (g backendGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject,
|
||||
// principal loads the admin through cabana.BackendPrincipal (nil when the
|
||||
// admin does not exist or is deactivated) and, for a personal-token subject
|
||||
// (no OAuth client), sets the scope limit for method; a token whose stored
|
||||
// scopes are all unknown answers nil, as the HTTP guard refuses it.
|
||||
// scopes are all unknown answers errScopeUnavailable, as the HTTP guard
|
||||
// refuses it.
|
||||
func (g backendGuard) principal(ctx context.Context, subject pact.MCPSubject, method string) (*bouncer.Principal, error) {
|
||||
principal, err := cabana.BackendPrincipal(ctx, g.app, subject.UserID)
|
||||
if err != nil || principal == nil {
|
||||
@@ -99,7 +110,7 @@ func (g backendGuard) principal(ctx context.Context, subject pact.MCPSubject, me
|
||||
if subject.ClientID == "" && subject.TokenID != 0 {
|
||||
limit, known, _ := cabana.ScopeLimitFor(g.app, subject.Scopes, method)
|
||||
if known == 0 {
|
||||
return nil, nil
|
||||
return nil, errScopeUnavailable
|
||||
}
|
||||
if limit == nil {
|
||||
limit = &bouncer.ScopeLimit{}
|
||||
|
||||
@@ -183,6 +183,8 @@ func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *too
|
||||
return toolError(tokenInvalid), 0
|
||||
case errors.Is(err, errSubjectUnavailable):
|
||||
return toolError(errSubjectUnavailable.Error()), 0
|
||||
case errors.Is(err, errScopeUnavailable):
|
||||
return toolError(errScopeUnavailable.Error()), 0
|
||||
}
|
||||
logger().Warn("pier: guard dispatch failed", "tool", t.name, "guard", t.guardName, "error", err)
|
||||
return toolError(dispatchFailed), 0
|
||||
|
||||
Reference in New Issue
Block a user