diff --git a/modules/pier/README.md b/modules/pier/README.md index 57f53e67..48dcd485 100644 --- a/modules/pier/README.md +++ b/modules/pier/README.md @@ -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::`, `pat:user:` or `pat:backend:` 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. diff --git a/modules/pier/backend_guard.go b/modules/pier/backend_guard.go index 74ee36f2..1d9444f4 100644 --- a/modules/pier/backend_guard.go +++ b/modules/pier/backend_guard.go @@ -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{} diff --git a/modules/pier/dispatch.go b/modules/pier/dispatch.go index 45e642ac..ceab8756 100644 --- a/modules/pier/dispatch.go +++ b/modules/pier/dispatch.go @@ -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