Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ Start exploring the Sovereign Agent Mesh:
Get a node running on the public testnet (`bananas.sam-mesh.dev`) in minutes:
- 🚀 **[User Quick Start Guide](site/content/docs/quickstart.md)**: Connect and run a SAM node using binaries or Docker, and query the local MCP server.
- 🤖 **[Agent Integration Guides](site/content/docs/integrations/_index.md)**: Connect Google Gemini, Claude, and other AI agents to your SAM node to dynamically discover and call tools across the mesh.
- 📖 **[CLI Reference](site/content/docs/cli/reference.md)**: Comprehensive CLI reference and configurations.
- 📡 **[Testnet Validation Tutorial](site/content/docs/development/testnet-validation.md)**: Real-time verification, remote tool invocation, and HTTP stream proxies.

### For Developers & Contributors
Expand Down
37 changes: 36 additions & 1 deletion api/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,21 @@

package api

import "github.com/libp2p/go-libp2p/core/protocol"
import (
"maps"

"github.com/libp2p/go-libp2p/core/protocol"
)

const EnrollProtocolID protocol.ID = "/sam/enroll/1.0.0"
const MCPProtocolID protocol.ID = "/sam/mcp/1.0.0"
const GossipEvents = "/sam/mesh/events/v1"
const GossipHubSync = "/sam/hub/sync/v1"
const AuthProtocolID protocol.ID = "/sam/auth/1.0.0"

// CatalogTarget is the special target service name used to retrieve tool catalogs from remote nodes.
const CatalogTarget = "/sam/catalog"

const DefaultAudience = "sam-mesh-audience"

// Biscuit fact names
Expand All @@ -32,6 +39,34 @@ const (
FactGroup = "group"
FactRole = "role"
FactUser = "user"
FactEmail = "email"
FactMCPServer = "allow_mcp_server"
FactNetworkTarget = "allow_network_target"
)

// oidcClaimToFact maps standard OIDC claims to their corresponding Biscuit facts.
//
// Specification References:
// - OIDC Claims: Standard JWT payload claims are defined in OpenID Connect Core 1.0 section 5.1:
// https://openid.net/specs/openid-connect-core-1_0.html#Claims
// - Biscuit Symbols / Facts: The Biscuit symbol table and fact specification is defined at:
// https://doc.biscuitsec.org/reference/specifications.html#symbol-table
//
// How to add a new translation:
// 1. Define a constant for the Biscuit fact name in the "Biscuit fact names" block above
// (e.g., FactMyNewClaim = "my_new_fact").
// 2. Add an entry to the oidcClaimToFact map below (e.g., "my_oidc_claim": FactMyNewClaim).
// 3. Update translateClaimsToFacts in cmd/sam-hub/biscuit.go to handle parsing/type conversion
// for the new fact if it uses a custom format (e.g. integer, date, list).
// 4. Implement unit tests in cmd/sam-hub/biscuit_test.go covering the new mapping.
var oidcClaimToFact = map[string]string{
"sub": FactUser,
"email": FactEmail,
"groups": FactGroup,
}

// OIDCClaimToFact returns a copy of the OIDC claims to Biscuit facts map.
// This ensures that the global map is immutable and thread-safe for concurrent readers.
func OIDCClaimToFact() map[string]string {
return maps.Clone(oidcClaimToFact)
}
303 changes: 303 additions & 0 deletions cmd/sam-hub/biscuit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"errors"
"fmt"
"sort"
"strings"
"time"

"github.com/biscuit-auth/biscuit-go/v2"
"github.com/biscuit-auth/biscuit-go/v2/datalog"
"github.com/biscuit-auth/biscuit-go/v2/parser"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
"github.com/google/sam/api"
"github.com/libp2p/go-libp2p/core/peer"
)
Comment thread
aojea marked this conversation as resolved.

func (h *Hub) mintBiscuitToken(claims jwt.MapClaims, token *oidc.IDToken, remotePeer peer.ID) ([]byte, error) {
if token == nil {
return nil, fmt.Errorf("token cannot be nil")
}
if claims == nil {
return nil, fmt.Errorf("claims cannot be nil")
}

Comment thread
aojea marked this conversation as resolved.
oidcRoles := toStringSlice(claims["roles"])
oidcGroups := toStringSlice(claims["groups"])
oidcSub, _ := claims["sub"].(string)
Comment thread
aojea marked this conversation as resolved.

// Resolve roles based on configured bindings and explicit OIDC roles
resolvedRoles := make(map[string]bool)
if h.Policy != nil {
// 1. Map OIDC groups and users to roles via configured bindings (RBAC mapping)
for _, b := range h.Policy.Bindings {
if b.Group != "" {
for _, cg := range oidcGroups {
if b.Group == cg {
resolvedRoles[b.Role] = true
}
}
}
if b.User != "" && oidcSub != "" {
if b.User == oidcSub {
resolvedRoles[b.Role] = true
}
}
}

// 2. Validate and accept pre-resolved OIDC roles directly if defined in policy (Zero-Trust check)
for _, r := range oidcRoles {
if _, exists := h.Policy.Roles[r]; exists {
resolvedRoles[r] = true
}
}
}

builder := biscuit.NewBuilder(h.KeyRing.GetCurrentKey())

if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
Name: api.FactExpiration,
IDs: []biscuit.Term{biscuit.Date(token.Expiry)},
}}); err != nil {
return nil, fmt.Errorf("failed to add expiration fact: %w", err)
}

if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
Name: api.FactNode,
IDs: []biscuit.Term{biscuit.String(remotePeer.String())},
}}); err != nil {
return nil, fmt.Errorf("failed to add node fact: %w", err)
}

if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
Name: api.FactClientPeerID,
IDs: []biscuit.Term{biscuit.String(remotePeer.String())},
}}); err != nil {
return nil, fmt.Errorf("failed to add client_peer_id fact: %w", err)
}

// Dynamic claims to facts mapping using api.OIDCClaimToFact
if err := translateClaimsToFacts(builder, claims); err != nil {
return nil, err
}

// Assert resolved authorized roles in the token
roles := make([]string, 0, len(resolvedRoles))
for role := range resolvedRoles {
roles = append(roles, role)
}
Comment on lines +101 to +104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If b.Role is empty in the policy bindings, an empty role string "" will be added to resolvedRoles, resulting in an empty role fact role("") being added to the Biscuit token. We should filter out empty roles to prevent minting tokens with invalid empty roles.

Suggested change
roles := make([]string, 0, len(resolvedRoles))
for role := range resolvedRoles {
roles = append(roles, role)
}
roles := make([]string, 0, len(resolvedRoles))
for role := range resolvedRoles {
if role != "" {
roles = append(roles, role)
}
}

sort.Strings(roles)

var errs []error
for _, role := range roles {
if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
Comment thread
aojea marked this conversation as resolved.
Name: api.FactRole,
IDs: []biscuit.Term{biscuit.String(role)},
}}); err != nil {
errs = append(errs, fmt.Errorf("failed to add role fact for %s: %w", role, err))
continue
}

if h.Policy != nil {
if rolePolicy, ok := h.Policy.Roles[role]; ok {
for _, tool := range rolePolicy.MCP.AllowedServers {
if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
Name: api.FactMCPServer,
IDs: []biscuit.Term{biscuit.String(tool)},
}}); err != nil {
errs = append(errs, fmt.Errorf("failed to add MCP tool fact for %s: %w", tool, err))
}
}
for _, target := range rolePolicy.Network.AllowedTargets {
if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
Name: api.FactNetworkTarget,
IDs: []biscuit.Term{biscuit.String(target)},
}}); err != nil {
errs = append(errs, fmt.Errorf("failed to add network target fact for %s: %w", target, err))
}
}
Comment on lines +119 to +134

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If builder.AddAuthorityFact fails when adding allowed MCP servers or network targets, the error is logged but the token minting process continues. This results in a partially constructed Biscuit token that is missing critical authorization facts, which will cause downstream authorization failures for the client. We should return the error and fail the token minting process.

Suggested change
for _, tool := range rolePolicy.MCP.AllowedServers {
if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
Name: api.FactMCPServer,
IDs: []biscuit.Term{biscuit.String(tool)},
}}); err != nil {
logger.Errorw("Failed to add MCP tool fact to biscuit", "peer_id", remotePeer, "tool", tool, "error", err)
}
}
for _, target := range rolePolicy.Network.AllowedTargets {
if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
Name: api.FactNetworkTarget,
IDs: []biscuit.Term{biscuit.String(target)},
}}); err != nil {
logger.Errorw("Failed to add network target fact to biscuit", "peer_id", remotePeer, "target", target, "error", err)
}
}
for _, tool := range rolePolicy.MCP.AllowedServers {
if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
Name: api.FactMCPServer,
IDs: []biscuit.Term{biscuit.String(tool)},
}}); err != nil {
return nil, fmt.Errorf("failed to add MCP tool fact: %w", err)
}
}
for _, target := range rolePolicy.Network.AllowedTargets {
if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
Name: api.FactNetworkTarget,
IDs: []biscuit.Term{biscuit.String(target)},
}}); err != nil {
return nil, fmt.Errorf("failed to add network target fact: %w", err)
}
}

for _, customFact := range rolePolicy.CustomDatalog {
trimmed := strings.TrimRight(strings.TrimSpace(customFact), ";")
if trimmed == "" {
continue
}
var factErr error
func() {
defer func() {
if r := recover(); r != nil {
factErr = fmt.Errorf("panic parsing custom fact %q: %v", trimmed, r)
}
}()
fact, err := parser.FromStringFact(trimmed)
if err != nil {
factErr = fmt.Errorf("failed to parse custom fact %q: %w", trimmed, err)
return
}
if err := builder.AddAuthorityFact(fact); err != nil {
factErr = fmt.Errorf("failed to add custom fact %q: %w", trimmed, err)
}
}()
if factErr != nil {
errs = append(errs, factErr)
}
}
}
}
}

if len(errs) > 0 {
return nil, fmt.Errorf("biscuit policy validation failed: %w", errors.Join(errs...))
}

t, err := builder.Build()
if err != nil {
return nil, fmt.Errorf("failed to build biscuit: %w", err)
}

biscuitData, err := t.Serialize()
if err != nil {
return nil, fmt.Errorf("failed to serialize biscuit: %w", err)
}

return biscuitData, nil
}

var (
staticTimeCheck biscuit.Check
staticAllowPolicy biscuit.Policy
)

func init() {
var err error
staticTimeCheck, err = parser.FromStringCheck(`check if time($time), expiration($exp), $time <= $exp`)
if err != nil {
panic(fmt.Sprintf("failed to parse static time check: %v", err))
}
staticAllowPolicy, err = parser.FromStringPolicy("allow if true")
if err != nil {
panic(fmt.Sprintf("failed to parse static allow policy: %v", err))
}
}

func (h *Hub) verifyBiscuit(biscuitData []byte, remotePeer peer.ID) (*biscuit.Biscuit, error) {
b, err := biscuit.Unmarshal(biscuitData)
if err != nil {
return nil, fmt.Errorf("malformed biscuit: %w", err)
}

var authOpts []biscuit.AuthorizerOption
if h.BiscuitTimeout > 0 {
authOpts = append(authOpts, biscuit.WithWorldOptions(datalog.WithMaxDuration(h.BiscuitTimeout)))
}

keys := h.KeyRing.GetAllValidPublicKeys()
var lastErr error
Comment on lines +209 to +210

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If keys is empty (i.e., no valid public keys are found in the keyring), lastErr will remain nil, and the function will return a confusing error message: "no valid key found for verification: <nil>". We should explicitly check if keys is empty first to provide a clearer diagnostic error.

	keys := h.KeyRing.GetAllValidPublicKeys()
	if len(keys) == 0 {
		return nil, fmt.Errorf("no valid public keys found in keyring")
	}
	var lastErr error

for _, pubKey := range keys {
authorizer, err := b.Authorizer(pubKey, authOpts...)
if err != nil {
lastErr = err
continue
}

authorizer.AddFact(biscuit.Fact{
Predicate: biscuit.Predicate{
Name: "time",
IDs: []biscuit.Term{biscuit.Date(time.Now())},
},
})

authorizer.AddCheck(staticTimeCheck)
authorizer.AddPolicy(staticAllowPolicy)
Comment on lines +218 to +226

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The errors returned by authorizer.AddFact, authorizer.AddCheck, and authorizer.AddPolicy are ignored. If any of these operations fail, the authorizer will be configured incorrectly, which could lead to silent authorization bypasses or failures. We should check and handle these errors.

		if err := authorizer.AddFact(biscuit.Fact{
			Predicate: biscuit.Predicate{
				Name: "time",
				IDs:  []biscuit.Term{biscuit.Date(time.Now())},
			},
		}); err != nil {
			lastErr = err
			continue
		}

		if err := authorizer.AddCheck(staticTimeCheck); err != nil {
			lastErr = err
			continue
		}
		if err := authorizer.AddPolicy(staticAllowPolicy); err != nil {
			lastErr = err
			continue
		}


if err := authorizer.Authorize(); err == nil {
return b, nil
} else {
lastErr = err
}
}

return nil, fmt.Errorf("no valid key found for verification: %v", lastErr)
}
Comment thread
aojea marked this conversation as resolved.
Comment thread
aojea marked this conversation as resolved.

func translateClaimsToFacts(builder biscuit.Builder, claims map[string]any) error {
claimMap := api.OIDCClaimToFact()
keys := make([]string, 0, len(claimMap))
for k := range claimMap {
keys = append(keys, k)
}
sort.Strings(keys)

for _, claimKey := range keys {
factName := claimMap[claimKey]
val, ok := claims[claimKey]
Comment thread
aojea marked this conversation as resolved.
if !ok || val == nil {
continue
}
switch factName {
case api.FactUser, api.FactEmail:
if strVal, ok := val.(string); ok && strVal != "" {
if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
Name: factName,
IDs: []biscuit.Term{biscuit.String(strVal)},
}}); err != nil {
return fmt.Errorf("failed to add %s fact: %w", factName, err)
}
}
case api.FactGroup:
groups := toStringSlice(val)
seen := make(map[string]bool)
for _, g := range groups {
if seen[g] {
continue
}
seen[g] = true
if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{
Name: factName,
IDs: []biscuit.Term{biscuit.String(g)},
}}); err != nil {
return fmt.Errorf("failed to add %s fact: %w", factName, err)
}
}
}
}
return nil
}

func toStringSlice(val any) []string {
if val == nil {
return nil
}
switch v := val.(type) {
case string:
if v != "" {
return []string{v}
}
case []string:
return v
case []any:
var res []string
for _, item := range v {
if str, ok := item.(string); ok && str != "" {
res = append(res, str)
}
}
return res
}
return nil
}
Comment thread
aojea marked this conversation as resolved.
Loading
Loading