feat(32.1-02): expand OAuth MCP scopes from the published catalog

- Publish the compiled member-token catalog as pact.MemberTokenScopeCatalog
- Imply declared names only when the MCP subject has a ClientID
- Reject a bad Implies and the reserved names backend and offline_access
This commit is contained in:
2026-10-28 15:01:04 +01:00
parent a1b78b80f5
commit 25fd981d7f
5 changed files with 305 additions and 24 deletions

View File

@@ -15,23 +15,29 @@ import (
// a built-in (read, write, ai) or one a plugin declared through
// pact.HasFrontendTokenScopes. Label and Description are phrase keys
// (containing "::") or literal text; PluginID is the declaring plugin, empty
// for a built-in.
// for a built-in. Implies is empty on a built-in. On a declared scope it is
// empty, read, or write: an OAuth MCP grant of that stock scope also
// satisfies the declared name. The name is not stored on the grant.
type APITokenScope struct {
Name string
Label string
Description string
PluginID string
BuiltIn bool
Implies string
}
// APITokenScopeCatalog is the compiled list of member token scopes of one
// application: the built-ins first, then the declared scopes in activation
// order. Boot publishes it; APITokenScopesFor reads it.
// order. Boot publishes it, and the same value as pact.MemberTokenScopeCatalog.
// APITokenScopesFor reads the concrete catalog.
type APITokenScopeCatalog struct {
scopes []APITokenScope
byName map[string]int
}
var _ pact.MemberTokenScopeCatalog = (*APITokenScopeCatalog)(nil)
// Scopes returns a copy of the catalog, built-ins first.
func (c *APITokenScopeCatalog) Scopes() []APITokenScope {
if c == nil {
@@ -49,6 +55,23 @@ func (c *APITokenScopeCatalog) Allowed(name string) bool {
return ok
}
// MemberTokenScopes returns the compiled list as pact scopes, built-ins
// first, then declared scopes. Implied names are not a separate entry: they
// are the Implies field of the declared scope.
func (c *APITokenScopeCatalog) MemberTokenScopes() []pact.FrontendTokenScope {
scopes := c.Scopes()
out := make([]pact.FrontendTokenScope, 0, len(scopes))
for _, scope := range scopes {
out = append(out, pact.FrontendTokenScope{
Name: scope.Name,
Label: scope.Label,
Description: scope.Description,
Implies: scope.Implies,
})
}
return out
}
// builtInAPITokenScopeKeys are the label and description phrase keys of
// the built-in scopes, in AllowedAPITokenScopes order.
var builtInAPITokenScopeKeys = map[string][2]string{
@@ -78,12 +101,14 @@ func newAPITokenScopeCatalog(scopes []APITokenScope) *APITokenScopeCatalog {
// CompileAPITokenScopes builds the member token scope catalog from the
// built-ins and every plugin that implements pact.HasFrontendTokenScopes, in
// the order given (party.Activated). A declared name is trimmed and must
// pass pact.ValidTokenScopeName, must not be a built-in (read, write, ai)
// and must not repeat a name another plugin (or the same one) declared; its
// label and description must not be empty. Any violation is an error, which
// fails boot. Phrase keys are checked against the translator by Boot. Every
// name of a catalog that compiles is added to the process-wide set the
// legacy MintAPIToken accepts.
// pass pact.ValidTokenScopeName, must not be a built-in (read, write, ai),
// must not be the reserved name backend or offline_access, and must not
// repeat a name another plugin (or the same one) declared; its label and
// description must not be empty, and Implies must be empty, read, or write.
// Built-ins keep Implies empty. Any violation is an error, which fails boot.
// Phrase keys are checked against the translator by Boot. Every name of a
// catalog that compiles is added to the process-wide set the legacy
// MintAPIToken accepts.
func CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error) {
scopes := builtInAPITokenScopes()
owner := map[string]string{}
@@ -111,14 +136,22 @@ func CompileAPITokenScopes(plugins []party.Plugin) (*APITokenScopeCatalog, error
}
return nil, fmt.Errorf("golem15.user: duplicate member token scope %q declared by %s and %s", name, other, id)
}
if name == "backend" || name == "offline_access" {
return nil, fmt.Errorf("golem15.user: plugin %s declared the reserved member token scope %q", id, name)
}
if strings.TrimSpace(scope.Label) == "" {
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has an empty label", id, name)
}
if strings.TrimSpace(scope.Description) == "" {
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has an empty description", id, name)
}
if scope.Implies != "" && scope.Implies != "read" && scope.Implies != "write" {
return nil, fmt.Errorf("golem15.user: plugin %s member token scope %s has implies %q", id, name, scope.Implies)
}
owner[name] = id
scopes = append(scopes, APITokenScope{Name: name, Label: scope.Label, Description: scope.Description, PluginID: id})
scopes = append(scopes, APITokenScope{
Name: name, Label: scope.Label, Description: scope.Description, PluginID: id, Implies: scope.Implies,
})
declared = append(declared, name)
}
}