Files
1Panel/core/utils/mcp/authorization.go
T
蘭 d7355f3ccc feat(mcp): add 1Panel MCP and enhance MCP credential management (#14189)
* feat(mcp): add 1Panel MCP and enhance MCP credential management

* feat(i18n): implement MCP document localization and remove legacy code
2026-10-26 13:59:49 +08:00

250 lines
7.6 KiB
Go

package mcp
import (
"encoding/json"
"errors"
"reflect"
"slices"
)
const toolAuthorizationSnapshotVersion = 1
const toolAuthorizationSnapshotLimit = 2 << 20
type toolAuthorizationSnapshot struct {
Version int `json:"version"`
PolicyVersion int `json:"policyVersion"`
Tool *Tool `json:"tool"`
}
// ToolAuthorizationSnapshot records the tool the owner explicitly approved.
// It is produced from the trusted catalog, never from credential request data.
func ToolAuthorizationSnapshot(tool Tool) (string, error) {
catalog, err := ValidateCatalog([]Tool{tool})
if err != nil {
return "", err
}
data, err := json.Marshal(toolAuthorizationSnapshot{
Version: toolAuthorizationSnapshotVersion, PolicyVersion: ToolPolicyVersion, Tool: &catalog[0],
})
if err != nil {
return "", err
}
if len(data) > toolAuthorizationSnapshotLimit {
return "", errors.New("MCP authorization snapshot is too large")
}
return string(data), nil
}
// ToolAuthorizationAllows accepts only equivalent or provably narrower tools.
// The original snapshot remains the baseline; callers must separately require
// the current schema version and complete policy hash on every invocation.
func ToolAuthorizationAllows(snapshot, grantedPolicyHash string, current Tool) bool {
if grantedPolicyHash == "" {
return false
}
var saved toolAuthorizationSnapshot
if DecodeObjectLimit([]byte(snapshot), &saved, toolAuthorizationSnapshotLimit) != nil ||
saved.Version != toolAuthorizationSnapshotVersion || saved.PolicyVersion != ToolPolicyVersion || saved.Tool == nil ||
saved.Tool.PolicyHash != grantedPolicyHash || ToolPolicyHash(*saved.Tool) != grantedPolicyHash {
return false
}
// A snapshot must carry its read/write classification explicitly. Unlike
// the API manifest, that classification is not part of ToolPolicyHash.
var fields struct {
Tool map[string]json.RawMessage `json:"tool"`
}
var classification *bool
if json.Unmarshal([]byte(snapshot), &fields) != nil || json.Unmarshal(fields.Tool["readOnly"], &classification) != nil || classification == nil {
return false
}
previous, err := ValidateCatalog([]Tool{*saved.Tool})
if err != nil {
return false
}
latest, err := ValidateCatalog([]Tool{current})
if err != nil {
return false
}
old, next := previous[0], latest[0]
if old.ToolID != next.ToolID || old.ReadOnly && !next.ReadOnly || !authorizationSchemaSubset(old.InputSchema, next.InputSchema, 0) {
return false
}
ops := make(map[string]Operation, len(old.Operations))
for _, op := range old.Operations {
ops[op.ID] = op
}
for _, op := range next.Operations {
approved, exists := ops[op.ID]
if !exists || approved.Method != op.Method || approved.Path != op.Path ||
!authorizationSchemaSubset(approved.BodySchema, op.BodySchema, 0) ||
!authorizationResponseSubset(approved.ResponseFields, op.ResponseFields) {
return false
}
}
return true
}
func authorizationResponseSubset(previous, current []string) bool {
for _, field := range current {
// Projections expose scalar leaves. An approved parent path never
// authorizes new descendants, which could contain sensitive data.
if !slices.Contains(previous, field) {
return false
}
}
return true
}
func authorizationSchemaSubset(previous, current map[string]any, depth int) bool {
if authorizationSchemaEqual(previous, current, depth) {
return true
}
if depth > 16 || validateSchema(previous, depth) != nil || validateSchema(current, depth) != nil {
return false
}
oldType, nextType := previous["type"], current["type"]
if oldType != nextType && !(oldType == "number" && nextType == "integer") {
return false
}
oldDefault, oldHasDefault := previous["default"]
nextDefault, nextHasDefault := current["default"]
if oldHasDefault != nextHasDefault || !reflect.DeepEqual(oldDefault, nextDefault) {
return false
}
if values, ok := previous["enum"].([]any); ok {
selected, ok := current["enum"].([]any)
if !ok {
return false
}
for _, value := range selected {
if !slices.ContainsFunc(values, func(approved any) bool { return reflect.DeepEqual(approved, value) }) {
return false
}
}
}
for _, key := range []string{"minimum", "minLength", "minItems"} {
if bound, ok := previous[key].(float64); ok {
value, exists := current[key].(float64)
if !exists || value < bound {
return false
}
}
}
for _, key := range []string{"maximum", "maxLength", "maxItems"} {
if bound, ok := previous[key].(float64); ok {
value, exists := current[key].(float64)
if !exists || value > bound {
return false
}
}
}
if pattern, ok := previous["pattern"]; ok && !reflect.DeepEqual(pattern, current["pattern"]) {
return false
}
switch nextType {
case "object":
oldProperties := previous["properties"].(map[string]any)
nextProperties := current["properties"].(map[string]any)
for name, raw := range oldProperties {
if _, remains := nextProperties[name]; !remains && authorizationSchemaHasDefault(raw.(map[string]any)) {
// Removing a default can change the meaning of an omitted input.
return false
}
}
for name, raw := range nextProperties {
approved, exists := oldProperties[name].(map[string]any)
if !exists || !authorizationSchemaSubset(approved, raw.(map[string]any), depth+1) {
return false
}
}
nextRequired, _ := current["required"].([]any)
if required, ok := previous["required"].([]any); ok {
for _, field := range required {
if !slices.Contains(nextRequired, field) {
return false
}
}
}
case "array":
return authorizationSchemaSubset(previous["items"].(map[string]any), current["items"].(map[string]any), depth+1)
}
return true
}
func authorizationSchemaHasDefault(schema map[string]any) bool {
if _, exists := schema["default"]; exists {
return true
}
if properties, ok := schema["properties"].(map[string]any); ok {
for _, raw := range properties {
if child, ok := raw.(map[string]any); ok && authorizationSchemaHasDefault(child) {
return true
}
}
}
if child, ok := schema["items"].(map[string]any); ok {
return authorizationSchemaHasDefault(child)
}
return false
}
// Input schemas may contain extensions not interpreted by Core. Exact equality
// is safe, while a changed unknown constraint remains subject to confirmation.
// Only schema annotations are ignored, never defaults or enum object contents.
func authorizationSchemaEqual(previous, current map[string]any, depth int) bool {
if reflect.DeepEqual(previous, current) {
return true
}
if depth > 16 {
return false
}
count := func(schema map[string]any) int {
n := len(schema)
if _, exists := schema["description"]; exists {
n--
}
if _, exists := schema["title"]; exists {
n--
}
return n
}
if count(previous) != count(current) {
return false
}
for key, approved := range previous {
if key == "description" || key == "title" {
continue
}
value, exists := current[key]
if !exists {
return false
}
switch key {
case "properties":
oldProperties, oldOK := approved.(map[string]any)
nextProperties, nextOK := value.(map[string]any)
if !oldOK || !nextOK || len(oldProperties) != len(nextProperties) {
return false
}
for name, raw := range oldProperties {
oldSchema, oldOK := raw.(map[string]any)
nextSchema, nextOK := nextProperties[name].(map[string]any)
if !oldOK || !nextOK || !authorizationSchemaEqual(oldSchema, nextSchema, depth+1) {
return false
}
}
case "items":
oldSchema, oldOK := approved.(map[string]any)
nextSchema, nextOK := value.(map[string]any)
if !oldOK || !nextOK || !authorizationSchemaEqual(oldSchema, nextSchema, depth+1) {
return false
}
default:
if !reflect.DeepEqual(approved, value) {
return false
}
}
}
return true
}