Files
llmgw/internal/store/toolgrants.go
T
Ilker ManapandClaude Opus 5.5 1948a787ae
CI / test (push) Successful in 9m15s
Fixes from the second review: grants, flags, updates, setup
- Revoking a server's tools takes them from every level beneath too, and a
  level left naming no tool stays closed ([]) while any level above or
  beneath names tools: an organization's revoke left its teams the copies
  their first grants took, and going back to no list opened the level to
  what was above it. A closed level is shown on the page and in the
  console with an action to open it (store.OpenTools). A key's owner's
  list is among the levels above a key.
- The flag check matches short flags as written again (-m, -hf, -mu were
  let through), takes a long flag's beginning for it only for the engines
  that parse with argparse (vLLM, SGLang, ComfyUI: --li is --listen), so
  llama.cpp's own --chat-template is not refused, and refuses llama.cpp's
  short spellings (-hfr, -hff, -mm, -jf, …).
- A stored model with an adapter by Hugging Face id can be changed again:
  what it already had is not asked about anew.
- The catalog reads a Hugging Face id as the agent does (engines.IsHubID).
- The page's form cannot move llmgw:admin to another address.
- server init skips the memory check only where the gateway already runs,
  not wherever a half-finished first run left server.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:31:07 +01:00

264 lines
7.3 KiB
Go

package store
import (
"context"
"errors"
"slices"
"strings"
)
// Granting one MCP server's tools at a level of the tree, and taking them
// back, as the console and the admin page do. A tool is usable only where
// every level above that names tools names it too (Effective.AllowsTool),
// which makes adding a list where there was none a narrowing: a team that
// saw its organization's jira.* through having no list of its own would see
// only hr.* once given hr.*. So a level given its first list starts from
// the nearest list above it, and keeps what it had.
// ToolGrant is what granting came to: the levels above whose lists do not
// name the tools granted, so that they stay out of reach until those grant
// them too.
type ToolGrant struct {
BlockedBy []LimitOwner
}
// GrantTools makes a level's grants of server's tools these, keeping its
// grants of other servers' and the rest of its limits.
func (s *DB) GrantTools(ctx context.Context, scope Scope, id, server string, tools []string) (ToolGrant, error) {
above, err := s.limitsAbove(ctx, scope, id)
if err != nil {
return ToolGrant{}, err
}
lim, err := s.LimitsFor(ctx, scope, id)
switch {
case errors.Is(err, ErrNotFound):
lim = Limits{Scope: scope, ScopeID: id}
case err != nil:
return ToolGrant{}, err
}
if lim.AllowedTools == nil {
// Its first list: what it saw through the levels above, kept.
for _, a := range above {
if a.AllowedTools != nil {
lim.AllowedTools = slices.Clone(a.AllowedTools)
break
}
}
}
keep := withoutServer(lim.AllowedTools, server)
for _, t := range tools {
if !slices.Contains(keep, t) {
keep = append(keep, t)
}
}
lim.AllowedTools = keep
if err := s.PutLimits(ctx, lim); err != nil {
return ToolGrant{}, err
}
var g ToolGrant
for _, a := range above {
if a.AllowedTools == nil {
continue
}
for _, t := range tools {
if !matchesTool(a.AllowedTools, sampleTool(t)) {
g.BlockedBy = append(g.BlockedBy, LimitOwner{Scope: a.Scope, ID: a.ScopeID})
break
}
}
}
return g, nil
}
// RevokeTools takes server's tools away from a level and from every level
// beneath it: taken from an organization, no team, project or key in it
// keeps them by having been given them below. A level left naming nothing
// is closed — it names no tool, so sees none — while any level above or
// beneath it names tools: going back to naming none at all would open it
// to what the levels above allow. With none anywhere, it names none.
func (s *DB) RevokeTools(ctx context.Context, scope Scope, id, server string) error {
below, err := s.limitsBelow(ctx, scope, id)
if err != nil {
return err
}
for _, l := range below {
if l.AllowedTools == nil {
continue
}
if kept := withoutServer(l.AllowedTools, server); len(kept) != len(l.AllowedTools) {
l.AllowedTools = kept
if err := s.PutLimits(ctx, l); err != nil {
return err
}
}
}
lim, err := s.LimitsFor(ctx, scope, id)
if errors.Is(err, ErrNotFound) {
return nil
}
if err != nil {
return err
}
lim.AllowedTools = withoutServer(lim.AllowedTools, server)
if len(lim.AllowedTools) == 0 {
above, err := s.limitsAbove(ctx, scope, id)
if err != nil {
return err
}
lim.AllowedTools = nil
for _, l := range append(above, below...) {
if l.AllowedTools != nil {
lim.AllowedTools = []string{}
break
}
}
}
return s.PutLimits(ctx, lim)
}
// OpenTools has a level name no tools of its own: it then sees what the
// levels above it allow. For a level left closed by RevokeTools, on
// purpose.
func (s *DB) OpenTools(ctx context.Context, scope Scope, id string) error {
lim, err := s.LimitsFor(ctx, scope, id)
if errors.Is(err, ErrNotFound) {
return nil
}
if err != nil {
return err
}
lim.AllowedTools = nil
return s.PutLimits(ctx, lim)
}
// limitsBelow are the limits set beneath a level: an organization's teams,
// projects and their keys, a team's projects and keys, a project's keys,
// and the member scopes of each.
func (s *DB) limitsBelow(ctx context.Context, scope Scope, id string) ([]Limits, error) {
var owners []LimitOwner
addProject := func(pid string) error {
owners = append(owners, LimitOwner{Scope: ScopeProject, ID: pid}, LimitOwner{Scope: MemberScope(ScopeProject), ID: pid})
keys, err := s.Keys(ctx, pid)
if err != nil {
return err
}
for _, k := range keys {
owners = append(owners, LimitOwner{Scope: ScopeKey, ID: k.ID})
}
return nil
}
switch scope {
case ScopeOrg, ScopeTeam:
tree, err := s.Tree(ctx)
if err != nil {
return nil, err
}
for _, o := range tree {
if scope == ScopeOrg && o.ID != id {
continue
}
if scope == ScopeOrg {
owners = append(owners, LimitOwner{Scope: MemberScope(ScopeOrg), ID: o.ID})
}
for _, t := range o.Teams {
if scope == ScopeTeam && t.ID != id {
continue
}
if scope == ScopeOrg {
owners = append(owners, LimitOwner{Scope: ScopeTeam, ID: t.ID})
}
owners = append(owners, LimitOwner{Scope: MemberScope(ScopeTeam), ID: t.ID})
for _, p := range t.Projects {
if err := addProject(p.ID); err != nil {
return nil, err
}
}
}
}
case ScopeProject:
if err := addProject(id); err != nil {
return nil, err
}
owners = owners[1:] // the project itself is not beneath it
}
var out []Limits
for _, o := range owners {
if o.Scope == "" {
continue
}
l, err := s.LimitsFor(ctx, o.Scope, o.ID)
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return nil, err
}
out = append(out, l)
}
return out, nil
}
func withoutServer(tools []string, server string) []string {
return slices.DeleteFunc(slices.Clone(tools), func(t string) bool { return strings.HasPrefix(t, server+".") })
}
// sampleTool is a tool a pattern grants, for asking whether a list above
// grants it too: jira.* stands for any of jira's.
func sampleTool(pattern string) string {
if before, ok := strings.CutSuffix(pattern, "*"); ok {
return before + "x"
}
return pattern
}
// limitsAbove are the limits of the levels above one, nearest first: a
// project's team and organization, a team's organization, a key's project
// and those above it. None for an organization or a person.
func (s *DB) limitsAbove(ctx context.Context, scope Scope, id string) ([]Limits, error) {
var owners []LimitOwner
if scope == ScopeKey {
k, err := s.KeyByID(ctx, id)
if errors.Is(err, ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
if k.UserID != "" {
owners = append(owners, LimitOwner{Scope: ScopeUser, ID: k.UserID})
}
scope, id = ScopeProject, k.ProjectID
owners = append(owners, LimitOwner{Scope: ScopeProject, ID: id})
}
if scope == ScopeTeam || scope == ScopeProject {
tree, err := s.Tree(ctx)
if err != nil {
return nil, err
}
for _, o := range tree {
for _, t := range o.Teams {
if scope == ScopeTeam && t.ID == id {
owners = append(owners, LimitOwner{Scope: ScopeOrg, ID: o.ID})
}
for _, p := range t.Projects {
if scope == ScopeProject && p.ID == id {
owners = append(owners, LimitOwner{Scope: ScopeTeam, ID: t.ID}, LimitOwner{Scope: ScopeOrg, ID: o.ID})
}
}
}
}
}
var out []Limits
for _, o := range owners {
l, err := s.LimitsFor(ctx, o.Scope, o.ID)
if errors.Is(err, ErrNotFound) {
continue
}
if err != nil {
return nil, err
}
out = append(out, l)
}
return out, nil
}