From 34e48f89ea64b180169f9ac3aff801238e08e7de Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 28 Jun 2026 11:39:17 +0000 Subject: [PATCH 01/10] security: translate OIDC claims to Biscuit facts dynamically, reorganize token logic, and add specifications references --- api/constants.go | 22 ++ cmd/sam-hub/biscuit.go | 260 ++++++++++++++++ cmd/sam-hub/biscuit_test.go | 394 ++++++++++++++++++++++++ cmd/sam-hub/main.go | 213 ------------- cmd/sam-hub/main_test.go | 249 --------------- site/content/docs/development/policy.md | 55 +++- 6 files changed, 727 insertions(+), 466 deletions(-) create mode 100644 cmd/sam-hub/biscuit.go create mode 100644 cmd/sam-hub/biscuit_test.go diff --git a/api/constants.go b/api/constants.go index c260368..9c1f544 100644 --- a/api/constants.go +++ b/api/constants.go @@ -32,6 +32,28 @@ 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 api/translation.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 api/translation_test.go covering the new mapping. +var OIDCClaimToFact = map[string]string{ + "sub": FactUser, + "email": FactEmail, + "groups": FactGroup, +} diff --git a/cmd/sam-hub/biscuit.go b/cmd/sam-hub/biscuit.go new file mode 100644 index 0000000..49463bf --- /dev/null +++ b/cmd/sam-hub/biscuit.go @@ -0,0 +1,260 @@ +// 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 ( + "fmt" + "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" +) + +func (h *Hub) mintBiscuitToken(claims jwt.MapClaims, token *oidc.IDToken, remotePeer peer.ID) ([]byte, error) { + var oidcRoles []string + if rolesAny, ok := claims["roles"].([]any); ok { + for _, r := range rolesAny { + if str, ok := r.(string); ok && str != "" { + oidcRoles = append(oidcRoles, str) + } + } + } + + var oidcGroups []string + if groupsAny, ok := claims["groups"].([]any); ok { + for _, g := range groupsAny { + if str, ok := g.(string); ok && str != "" { + oidcGroups = append(oidcGroups, str) + } + } + } + + oidcSub, _ := claims["sub"].(string) + + // 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 + for role := range resolvedRoles { + if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactRole, + IDs: []biscuit.Term{biscuit.String(role)}, + }}); err != nil { + return nil, fmt.Errorf("failed to add role fact: %w", err) + } + + 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 { + 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 _, customFact := range rolePolicy.CustomDatalog { + trimmed := strings.TrimRight(strings.TrimSpace(customFact), ";") + if trimmed == "" { + continue + } + func() { + defer func() { + if r := recover(); r != nil { + logger.Errorw("Panic parsing custom fact", "peer_id", remotePeer, "fact", trimmed, "recover", r) + } + }() + fact, err := parser.FromStringFact(trimmed) + if err != nil { + logger.Errorw("Failed to parse custom fact", "peer_id", remotePeer, "fact", trimmed, "error", err) + return + } + if err := builder.AddAuthorityFact(fact); err != nil { + logger.Errorw("Failed to add custom fact to biscuit", "peer_id", remotePeer, "fact", trimmed, "error", err) + } + }() + } + } + } + } + + 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 +} + +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 + 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())}, + }, + }) + + timeCheck, err := parser.FromStringCheck(`check if time($time), expiration($exp), $time <= $exp`) + if err != nil { + lastErr = err + continue + } + authorizer.AddCheck(timeCheck) + + rule, err := parser.FromStringPolicy("allow if true") + if err != nil { + lastErr = err + continue + } + authorizer.AddPolicy(rule) + + if err := authorizer.Authorize(); err == nil { + return b, nil + } else { + lastErr = err + } + } + + return nil, fmt.Errorf("no valid key found for verification: %v", lastErr) +} + +func translateClaimsToFacts(builder biscuit.Builder, claims map[string]any) error { + for claimKey, factName := range api.OIDCClaimToFact { + val, ok := claims[claimKey] + 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: + if sliceVal, ok := val.([]any); ok { + seen := make(map[string]bool) + for _, item := range sliceVal { + if strItem, ok := item.(string); ok && strItem != "" { + if seen[strItem] { + continue + } + seen[strItem] = true + if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: factName, + IDs: []biscuit.Term{biscuit.String(strItem)}, + }}); err != nil { + return fmt.Errorf("failed to add %s fact: %w", factName, err) + } + } + } + } + } + } + return nil +} diff --git a/cmd/sam-hub/biscuit_test.go b/cmd/sam-hub/biscuit_test.go new file mode 100644 index 0000000..10a4eb9 --- /dev/null +++ b/cmd/sam-hub/biscuit_test.go @@ -0,0 +1,394 @@ +// 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 ( + "path/filepath" + "testing" + "time" + + "github.com/biscuit-auth/biscuit-go/v2" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/golang-jwt/jwt/v5" + "github.com/google/sam/api" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" +) + +func TestMintBiscuitToken(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + kr, err := NewKeyRing(dbPath, 24*time.Hour, nil) + if err != nil { + t.Fatal(err) + } + defer func() { _ = kr.Close() }() + + hub := &Hub{ + KeyRing: kr, + Policy: &api.PolicyConfig{ + Bindings: []api.Binding{ + { + Group: "system:serviceaccounts:sam-canary", + Role: "canary-role", + }, + { + User: "system:serviceaccount:sam-canary:sam-node-sa", + Role: "canary-role", + }, + }, + Roles: map[string]api.RolePolicy{ + "admin": { + MCP: api.MCPPolicy{ + AllowedServers: []string{"read", "write"}, + }, + Network: api.NetworkPolicy{ + AllowedTargets: []string{"target1"}, + }, + }, + "canary-role": { + MCP: api.MCPPolicy{ + AllowedServers: []string{"/sam/mcp/1.0.0"}, + }, + }, + }, + }, + } + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + dummyPeer, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + + token := &oidc.IDToken{ + Expiry: time.Now().Add(1 * time.Hour), + } + + // Case 1: Direct OIDC role (valid) + claims1 := jwt.MapClaims{ + "roles": []any{"admin"}, + } + biscuitData1, err := hub.mintBiscuitToken(claims1, token, dummyPeer) + if err != nil { + t.Fatal(err) + } + if len(biscuitData1) == 0 { + t.Error("Expected non-empty biscuit data for direct role") + } + + b1, err := biscuit.Unmarshal(biscuitData1) + if err != nil { + t.Fatal(err) + } + authorizer1, err := b1.Authorizer(kr.GetCurrentPublicKey()) + if err != nil { + t.Fatal(err) + } + rule1 := biscuit.Policy{Queries: []biscuit.Rule{ + { + Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, + Body: []biscuit.Predicate{ + {Name: "role", IDs: []biscuit.Term{biscuit.String("admin")}}, + }, + }, + }, Kind: biscuit.PolicyKindAllow} + authorizer1.AddPolicy(rule1) + if err := authorizer1.Authorize(); err != nil { + t.Errorf("Expected direct role 'admin' to be authorized: %v", err) + } + + // Case 2: OIDC group claim mapped to role via bindings + claims2 := jwt.MapClaims{ + "groups": []any{"system:serviceaccounts:sam-canary"}, + } + biscuitData2, err := hub.mintBiscuitToken(claims2, token, dummyPeer) + if err != nil { + t.Fatal(err) + } + if len(biscuitData2) == 0 { + t.Error("Expected non-empty biscuit data for mapped group") + } + + b2, err := biscuit.Unmarshal(biscuitData2) + if err != nil { + t.Fatal(err) + } + authorizer2, err := b2.Authorizer(kr.GetCurrentPublicKey()) + if err != nil { + t.Fatal(err) + } + rule2 := biscuit.Policy{Queries: []biscuit.Rule{ + { + Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, + Body: []biscuit.Predicate{ + {Name: "role", IDs: []biscuit.Term{biscuit.String("canary-role")}}, + }, + }, + }, Kind: biscuit.PolicyKindAllow} + authorizer2.AddPolicy(rule2) + if err := authorizer2.Authorize(); err != nil { + t.Errorf("Expected mapped role 'canary-role' to be authorized: %v", err) + } + + // Case 3: Unmapped OIDC group and undefined direct role + claims3 := jwt.MapClaims{ + "groups": []any{"unknown-group"}, + "roles": []any{"undefined-role"}, + } + biscuitData3, err := hub.mintBiscuitToken(claims3, token, dummyPeer) + if err != nil { + t.Fatal(err) + } + b3, err := biscuit.Unmarshal(biscuitData3) + if err != nil { + t.Fatal(err) + } + authorizer3, err := b3.Authorizer(kr.GetCurrentPublicKey()) + if err != nil { + t.Fatal(err) + } + // Verify no role matches + rule3 := biscuit.Policy{Queries: []biscuit.Rule{ + { + Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, + Body: []biscuit.Predicate{ + {Name: "role", IDs: []biscuit.Term{biscuit.Variable("any_role")}}, + }, + }, + }, Kind: biscuit.PolicyKindAllow} + authorizer3.AddPolicy(rule3) + if err := authorizer3.Authorize(); err == nil { + t.Error("Expected authorizer to fail when checking for any roles in undefined configuration") + } + + // Case 4: GKE Workload Identity projected token (no groups claim, sub-based mapping) + claims4 := jwt.MapClaims{ + "sub": "system:serviceaccount:sam-canary:sam-node-sa", + } + biscuitData4, err := hub.mintBiscuitToken(claims4, token, dummyPeer) + if err != nil { + t.Fatal(err) + } + b4, err := biscuit.Unmarshal(biscuitData4) + if err != nil { + t.Fatal(err) + } + authorizer4, err := b4.Authorizer(kr.GetCurrentPublicKey()) + if err != nil { + t.Fatal(err) + } + // Verify that it mapped the derived group "system:serviceaccounts:sam-canary" to "canary-role" + rule4 := biscuit.Policy{Queries: []biscuit.Rule{ + { + Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, + Body: []biscuit.Predicate{ + {Name: "role", IDs: []biscuit.Term{biscuit.String("canary-role")}}, + }, + }, + }, Kind: biscuit.PolicyKindAllow} + authorizer4.AddPolicy(rule4) + if err := authorizer4.Authorize(); err != nil { + t.Errorf("Expected sub-derived role 'canary-role' to be authorized: %v", err) + } +} + +func TestVerifyBiscuit_Expiration(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + kr, err := NewKeyRing(dbPath, 24*time.Hour, nil) + if err != nil { + t.Fatal(err) + } + defer func() { _ = kr.Close() }() + + hub := &Hub{ + KeyRing: kr, + Policy: &api.PolicyConfig{}, + BiscuitTimeout: 500 * time.Millisecond, + } + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + dummyPeer, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + expiry time.Time + expectError bool + }{ + { + name: "Valid unexpired token", + expiry: time.Now().Add(1 * time.Hour), + expectError: false, + }, + { + name: "Expired token", + expiry: time.Now().Add(-1 * time.Hour), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := &oidc.IDToken{ + Expiry: tt.expiry, + } + claims := jwt.MapClaims{ + "roles": []any{"admin"}, + } + + biscuitData, err := hub.mintBiscuitToken(claims, token, dummyPeer) + if err != nil { + t.Fatalf("mintBiscuitToken failed: %v", err) + } + + _, err = hub.verifyBiscuit(biscuitData, dummyPeer) + if tt.expectError && err == nil { + t.Errorf("Expected error due to expiration, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + } +} + +func TestMintBiscuitToken_ClaimsTranslation(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + kr, err := NewKeyRing(dbPath, 24*time.Hour, nil) + if err != nil { + t.Fatal(err) + } + defer func() { _ = kr.Close() }() + + hub := &Hub{ + KeyRing: kr, + Policy: &api.PolicyConfig{ + Bindings: []api.Binding{ + { + Group: "engineering", + Role: "developer-role", + }, + }, + Roles: map[string]api.RolePolicy{ + "developer-role": { + MCP: api.MCPPolicy{ + AllowedServers: []string{"git-helper"}, + }, + }, + }, + }, + } + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + dummyPeer, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + + token := &oidc.IDToken{ + Expiry: time.Now().Add(1 * time.Hour), + } + + claims := jwt.MapClaims{ + "sub": "user-12345", + "email": "agent@google.com", + "groups": []any{"beta-testers", "engineering"}, + } + + biscuitData, err := hub.mintBiscuitToken(claims, token, dummyPeer) + if err != nil { + t.Fatalf("Failed to mint biscuit: %v", err) + } + + b, err := biscuit.Unmarshal(biscuitData) + if err != nil { + t.Fatalf("Failed to unmarshal biscuit: %v", err) + } + + authorizer, err := b.Authorizer(kr.GetCurrentPublicKey()) + if err != nil { + t.Fatalf("Failed to get authorizer: %v", err) + } + + // Verify user("user-12345") fact is present + checkUser := biscuit.Check{Queries: []biscuit.Rule{ + { + Body: []biscuit.Predicate{ + {Name: "user", IDs: []biscuit.Term{biscuit.String("user-12345")}}, + }, + }, + }} + authorizer.AddCheck(checkUser) + + // Verify email("agent@google.com") fact is present + checkEmail := biscuit.Check{Queries: []biscuit.Rule{ + { + Body: []biscuit.Predicate{ + {Name: "email", IDs: []biscuit.Term{biscuit.String("agent@google.com")}}, + }, + }, + }} + authorizer.AddCheck(checkEmail) + + // Verify group("beta-testers") fact is present + checkGroupBeta := biscuit.Check{Queries: []biscuit.Rule{ + { + Body: []biscuit.Predicate{ + {Name: "group", IDs: []biscuit.Term{biscuit.String("beta-testers")}}, + }, + }, + }} + authorizer.AddCheck(checkGroupBeta) + + // Verify group("engineering") fact is present + checkGroupEng := biscuit.Check{Queries: []biscuit.Rule{ + { + Body: []biscuit.Predicate{ + {Name: "group", IDs: []biscuit.Term{biscuit.String("engineering")}}, + }, + }, + }} + authorizer.AddCheck(checkGroupEng) + + // To authorize, we also need to allow since checks run in authorizer. + // We will add an allow policy that matches anything + authorizer.AddPolicy(biscuit.Policy{Queries: []biscuit.Rule{ + { + Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, + Body: []biscuit.Predicate{}, + }, + }, Kind: biscuit.PolicyKindAllow}) + + if err := authorizer.Authorize(); err != nil { + t.Errorf("Authorization/Checks failed: %v\nWorld:\n%s", err, authorizer.PrintWorld()) + } +} diff --git a/cmd/sam-hub/main.go b/cmd/sam-hub/main.go index e3e8426..15c9350 100644 --- a/cmd/sam-hub/main.go +++ b/cmd/sam-hub/main.go @@ -39,8 +39,6 @@ import ( "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" @@ -376,166 +374,6 @@ func (h *Hub) parseAndVerifyJWT(ctx context.Context, jwtStr string, allowedAudie return claims, token, nil } -func (h *Hub) mintBiscuitToken(claims jwt.MapClaims, token *oidc.IDToken, remotePeer peer.ID) ([]byte, error) { - var oidcRoles []string - if rolesAny, ok := claims["roles"].([]any); ok { - for _, r := range rolesAny { - if str, ok := r.(string); ok && str != "" { - oidcRoles = append(oidcRoles, str) - } - } - } - - var oidcGroups []string - if groupsAny, ok := claims["groups"].([]any); ok { - for _, g := range groupsAny { - if str, ok := g.(string); ok && str != "" { - oidcGroups = append(oidcGroups, str) - } - } - } - - oidcSub, _ := claims["sub"].(string) - - // 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) - } - - if oidcSub != "" { - if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactUser, - IDs: []biscuit.Term{biscuit.String(oidcSub)}, - }}); err != nil { - return nil, fmt.Errorf("failed to add user fact: %w", err) - } - } - - // Assert original OIDC groups in the token (semantic audit trail) - seenGroups := make(map[string]bool) - for _, cg := range oidcGroups { - if seenGroups[cg] { - continue - } - seenGroups[cg] = true - if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactGroup, - IDs: []biscuit.Term{biscuit.String(cg)}, - }}); err != nil { - return nil, fmt.Errorf("failed to add group fact: %w", err) - } - } - - // Assert resolved authorized roles in the token - for role := range resolvedRoles { - if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactRole, - IDs: []biscuit.Term{biscuit.String(role)}, - }}); err != nil { - return nil, fmt.Errorf("failed to add role fact: %w", err) - } - - 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 { - 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 _, customFact := range rolePolicy.CustomDatalog { - trimmed := strings.TrimRight(strings.TrimSpace(customFact), ";") - if trimmed == "" { - continue - } - func() { - defer func() { - if r := recover(); r != nil { - logger.Errorw("Panic parsing custom fact", "peer_id", remotePeer, "fact", trimmed, "recover", r) - } - }() - fact, err := parser.FromStringFact(trimmed) - if err != nil { - logger.Errorw("Failed to parse custom fact", "peer_id", remotePeer, "fact", trimmed, "error", err) - return - } - if err := builder.AddAuthorityFact(fact); err != nil { - logger.Errorw("Failed to add custom fact to biscuit", "peer_id", remotePeer, "fact", trimmed, "error", err) - } - }() - } - } - } - } - - 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 -} - // startWatchdog periodically checks for peers that have connected but not completed OIDC // authentication within the grace period, and evicts them from the network. @@ -852,57 +690,6 @@ func isLoopbackOrLinkLocal(addr multiaddr.Multiaddr) bool { return false } -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 - 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())}, - }, - }) - - timeCheck, err := parser.FromStringCheck(`check if time($time), expiration($exp), $time <= $exp`) - if err != nil { - lastErr = err - continue - } - authorizer.AddCheck(timeCheck) - - rule, err := parser.FromStringPolicy("allow if true") - if err != nil { - lastErr = err - continue - } - authorizer.AddPolicy(rule) - - if err := authorizer.Authorize(); err == nil { - return b, nil - } else { - lastErr = err - } - } - - return nil, fmt.Errorf("no valid key found for verification: %v", lastErr) -} - func (h *Hub) HandleAuthHandshake(s network.Stream) { defer func() { if err := s.Close(); err != nil { diff --git a/cmd/sam-hub/main_test.go b/cmd/sam-hub/main_test.go index 85652f0..dcf48a7 100644 --- a/cmd/sam-hub/main_test.go +++ b/cmd/sam-hub/main_test.go @@ -22,194 +22,12 @@ import ( "testing" "time" - "github.com/biscuit-auth/biscuit-go/v2" - "github.com/coreos/go-oidc/v3/oidc" - "github.com/golang-jwt/jwt/v5" "github.com/google/sam/api" "github.com/libp2p/go-libp2p" "github.com/libp2p/go-libp2p/core/crypto" - "github.com/libp2p/go-libp2p/core/peer" "google.golang.org/protobuf/proto" ) -func TestMintBiscuitToken(t *testing.T) { - dir := t.TempDir() - dbPath := filepath.Join(dir, "test.db") - - kr, err := NewKeyRing(dbPath, 24*time.Hour, nil) - if err != nil { - t.Fatal(err) - } - defer func() { _ = kr.Close() }() - - hub := &Hub{ - KeyRing: kr, - Policy: &api.PolicyConfig{ - Bindings: []api.Binding{ - { - Group: "system:serviceaccounts:sam-canary", - Role: "canary-role", - }, - { - User: "system:serviceaccount:sam-canary:sam-node-sa", - Role: "canary-role", - }, - }, - Roles: map[string]api.RolePolicy{ - "admin": { - MCP: api.MCPPolicy{ - AllowedServers: []string{"read", "write"}, - }, - Network: api.NetworkPolicy{ - AllowedTargets: []string{"target1"}, - }, - }, - "canary-role": { - MCP: api.MCPPolicy{ - AllowedServers: []string{"/sam/mcp/1.0.0"}, - }, - }, - }, - }, - } - - priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) - if err != nil { - t.Fatal(err) - } - dummyPeer, err := peer.IDFromPrivateKey(priv) - if err != nil { - t.Fatal(err) - } - - token := &oidc.IDToken{ - Expiry: time.Now().Add(1 * time.Hour), - } - - // Case 1: Direct OIDC role (valid) - claims1 := jwt.MapClaims{ - "roles": []any{"admin"}, - } - biscuitData1, err := hub.mintBiscuitToken(claims1, token, dummyPeer) - if err != nil { - t.Fatal(err) - } - if len(biscuitData1) == 0 { - t.Error("Expected non-empty biscuit data for direct role") - } - - b1, err := biscuit.Unmarshal(biscuitData1) - if err != nil { - t.Fatal(err) - } - authorizer1, err := b1.Authorizer(kr.GetCurrentPublicKey()) - if err != nil { - t.Fatal(err) - } - rule1 := biscuit.Policy{Queries: []biscuit.Rule{ - { - Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, - Body: []biscuit.Predicate{ - {Name: "role", IDs: []biscuit.Term{biscuit.String("admin")}}, - }, - }, - }, Kind: biscuit.PolicyKindAllow} - authorizer1.AddPolicy(rule1) - if err := authorizer1.Authorize(); err != nil { - t.Errorf("Expected direct role 'admin' to be authorized: %v", err) - } - - // Case 2: OIDC group claim mapped to role via bindings - claims2 := jwt.MapClaims{ - "groups": []any{"system:serviceaccounts:sam-canary"}, - } - biscuitData2, err := hub.mintBiscuitToken(claims2, token, dummyPeer) - if err != nil { - t.Fatal(err) - } - if len(biscuitData2) == 0 { - t.Error("Expected non-empty biscuit data for mapped group") - } - - b2, err := biscuit.Unmarshal(biscuitData2) - if err != nil { - t.Fatal(err) - } - authorizer2, err := b2.Authorizer(kr.GetCurrentPublicKey()) - if err != nil { - t.Fatal(err) - } - rule2 := biscuit.Policy{Queries: []biscuit.Rule{ - { - Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, - Body: []biscuit.Predicate{ - {Name: "role", IDs: []biscuit.Term{biscuit.String("canary-role")}}, - }, - }, - }, Kind: biscuit.PolicyKindAllow} - authorizer2.AddPolicy(rule2) - if err := authorizer2.Authorize(); err != nil { - t.Errorf("Expected mapped role 'canary-role' to be authorized: %v", err) - } - - // Case 3: Unmapped OIDC group and undefined direct role - claims3 := jwt.MapClaims{ - "groups": []any{"unknown-group"}, - "roles": []any{"undefined-role"}, - } - biscuitData3, err := hub.mintBiscuitToken(claims3, token, dummyPeer) - if err != nil { - t.Fatal(err) - } - b3, err := biscuit.Unmarshal(biscuitData3) - if err != nil { - t.Fatal(err) - } - authorizer3, err := b3.Authorizer(kr.GetCurrentPublicKey()) - if err != nil { - t.Fatal(err) - } - // Verify no role matches - rule3 := biscuit.Policy{Queries: []biscuit.Rule{ - { - Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, - Body: []biscuit.Predicate{ - {Name: "role", IDs: []biscuit.Term{biscuit.Variable("any_role")}}, - }, - }, - }, Kind: biscuit.PolicyKindAllow} - authorizer3.AddPolicy(rule3) - // Case 4: GKE Workload Identity projected token (no groups claim, sub-based mapping) - claims4 := jwt.MapClaims{ - "sub": "system:serviceaccount:sam-canary:sam-node-sa", - } - biscuitData4, err := hub.mintBiscuitToken(claims4, token, dummyPeer) - if err != nil { - t.Fatal(err) - } - b4, err := biscuit.Unmarshal(biscuitData4) - if err != nil { - t.Fatal(err) - } - authorizer4, err := b4.Authorizer(kr.GetCurrentPublicKey()) - if err != nil { - t.Fatal(err) - } - // Verify that it mapped the derived group "system:serviceaccounts:sam-canary" to "canary-role" - rule4 := biscuit.Policy{Queries: []biscuit.Rule{ - { - Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, - Body: []biscuit.Predicate{ - {Name: "role", IDs: []biscuit.Term{biscuit.String("canary-role")}}, - }, - }, - }, Kind: biscuit.PolicyKindAllow} - authorizer4.AddPolicy(rule4) - if err := authorizer4.Authorize(); err != nil { - t.Errorf("Expected sub-derived role 'canary-role' to be authorized: %v", err) - } -} - func TestHandleInfoHTTP(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") @@ -270,70 +88,3 @@ func TestHandleInfoHTTP(t *testing.T) { t.Errorf("Expected audience 'test-audience-1', got %q", info.Audience) } } - -func TestVerifyBiscuit_Expiration(t *testing.T) { - dir := t.TempDir() - dbPath := filepath.Join(dir, "test.db") - - kr, err := NewKeyRing(dbPath, 24*time.Hour, nil) - if err != nil { - t.Fatal(err) - } - defer func() { _ = kr.Close() }() - - hub := &Hub{ - KeyRing: kr, - Policy: &api.PolicyConfig{}, - BiscuitTimeout: 500 * time.Millisecond, - } - - priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) - if err != nil { - t.Fatal(err) - } - dummyPeer, err := peer.IDFromPrivateKey(priv) - if err != nil { - t.Fatal(err) - } - - tests := []struct { - name string - expiry time.Time - expectError bool - }{ - { - name: "Valid unexpired token", - expiry: time.Now().Add(1 * time.Hour), - expectError: false, - }, - { - name: "Expired token", - expiry: time.Now().Add(-1 * time.Hour), - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - token := &oidc.IDToken{ - Expiry: tt.expiry, - } - claims := jwt.MapClaims{ - "roles": []any{"admin"}, - } - - biscuitData, err := hub.mintBiscuitToken(claims, token, dummyPeer) - if err != nil { - t.Fatalf("mintBiscuitToken failed: %v", err) - } - - _, err = hub.verifyBiscuit(biscuitData, dummyPeer) - if tt.expectError && err == nil { - t.Errorf("Expected error due to expiration, got nil") - } - if !tt.expectError && err != nil { - t.Errorf("Expected no error, got: %v", err) - } - }) - } -} diff --git a/site/content/docs/development/policy.md b/site/content/docs/development/policy.md index d83c3e8..bbb5ce2 100644 --- a/site/content/docs/development/policy.md +++ b/site/content/docs/development/policy.md @@ -12,10 +12,57 @@ The Hub automatically translates OIDC claims into undeniable cryptographic facts | :--- | :--- | :--- | | `sub` | `user("")` | The unique subject ID from the identity provider. | | `email` | `email("")` | The user's email address (if present). | -| `groups` / `roles` | `role("")` | One fact is injected for *each* role/group the user possesses. | -| Generated FQDN | `name("")` | The collision-proof mesh name granted by the Hub. | -| Peer ID | `node("")` | Binds the token to the specific agent's libp2p cryptographic identity. | -| Expiration | `time()` | The token expiration date based on the OIDC session. | +| `groups` | `group("")` | One fact is injected for *each* group the user possesses. | +| `roles` / Resolved Roles | `role("")` | One fact is injected for *each* role mapped or direct role. | +| Peer ID | `node("")`, `client_peer_id("")` | Binds the token to the specific agent's libp2p cryptographic identity. | +| Expiration | `expiration()` | The token expiration date based on the OIDC session. | + +### 1.1 Translating Identity to Capability: OIDC to Biscuit +The core innovation of the SAM Network's security model is translating standard web identity (OIDC JSON Web Tokens defined in [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#Claims)) into decentralized capability tokens (Biscuits). This translation happens securely at the `sam-hub` during the authentication phase. + +The Datalog authority facts generated by the Hub are constructed using the Biscuit [Symbol Table specification](https://doc.biscuitsec.org/reference/specifications.html#symbol-table), ensuring compact serialization and cryptographically undeniable credentials. + +Here is exactly how an OIDC JWT is transformed into a policy-ready Biscuit: + +#### 1. Claim Extraction +When an agent or user submits a valid OIDC JWT, the sam-hub verifies the token's signature against the Identity Provider. Once validated, the Hub extracts the payload claims—typically attributes like sub (subject), email, and custom arrays like groups or roles. + +#### 2. Datalog Fact Generation +Biscuit policies are written in Datalog, a declarative logic language. The sam-hub acts as a translator, mapping the JSON claims from the OIDC token into immutable Datalog facts. + +For example, an incoming OIDC payload like this: + +```json +{ + "sub": "user-12345", + "email": "agent@google.com", + "groups": ["beta-testers", "engineering"] +} +``` + +Is translated by the Hub into the following Datalog facts: + +```datalog +user("user-12345"); +email("agent@google.com"); +group("beta-testers"); +group("engineering"); +``` + +#### 3. Minting the Authority Block +The sam-hub takes these generated Datalog facts and embeds them into the Authority Block of a brand new Biscuit token. The Hub signs this block with its private cryptographic key. + +Because the facts are sealed in the Authority Block by the Hub, any sam-node in the mesh can implicitly trust that the user holding the Biscuit possesses those specific emails and groups. + +#### 4. Policy Evaluation at the Node +When the agent presents the Biscuit to a sam-node to execute a tool, the node evaluates its local policies against the facts embedded in the token. + +Because the OIDC claims were translated into Datalog, a node administrator can write elegant, logic-based rules in policy.go or their YAML configs: + +```datalog +// The node will only execute the tool if the Hub certified the agent is in the engineering group +allow if group("engineering"); +``` ## 2. Hub Policy Schema (`policies.yaml`) Admins define central permissions by mapping OIDC roles to specific capabilities. From facf91f42f3ad6f8d1e49332a22ba3093ed70a2e Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 28 Jun 2026 11:47:14 +0000 Subject: [PATCH 02/10] docs: fix file paths in constants comments after reorganizing biscuit token logic --- api/constants.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/constants.go b/api/constants.go index 9c1f544..f0703e4 100644 --- a/api/constants.go +++ b/api/constants.go @@ -49,9 +49,9 @@ const ( // 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 api/translation.go to handle parsing/type conversion +// 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 api/translation_test.go covering the new mapping. +// 4. Implement unit tests in cmd/sam-hub/biscuit_test.go covering the new mapping. var OIDCClaimToFact = map[string]string{ "sub": FactUser, "email": FactEmail, From 861551d24b65473bade300ccc8bf272ca47f5f1b Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 28 Jun 2026 11:47:50 +0000 Subject: [PATCH 03/10] update content --- README.md | 1 - site/content/docs/_index.md | 1 - .../docs/development/kubernetes-deployment.md | 49 +++++++++++-------- site/content/docs/development/policy.md | 5 +- site/content/docs/manifests/sam-hub.yaml | 6 +-- site/content/docs/user/agent-usage.md | 12 ++--- site/content/docs/user/hub-configuration.md | 18 ++++--- 7 files changed, 53 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 6591086..6fb5243 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/site/content/docs/_index.md b/site/content/docs/_index.md index aa6dc08..b4b115f 100644 --- a/site/content/docs/_index.md +++ b/site/content/docs/_index.md @@ -24,7 +24,6 @@ The documentation here is intentionally small and aligned with what is implement - [Quick Start](quickstart.md) - User Quick Start using Docker. - [Hub Configuration](user/hub-configuration.md) - OIDC authentication, key rings, and custom policy rules in `policies.yaml`. - [Agent Usage](user/agent-usage.md) - Node authorization flows, MCP endpoints, and how agents connect. -- [CLI Reference](cli/reference.md) - CLI command usage reference. - [Developer Guide](development/_index.md) - Building from source, local testing, and Kind setups. - [Testing Guide](development/testing.md) - Detailed test layer and troubleshooting information. - [Testnet Validation Tutorial](development/testnet-validation.md) - Real-time integration and MCP verification with public testnets. diff --git a/site/content/docs/development/kubernetes-deployment.md b/site/content/docs/development/kubernetes-deployment.md index 09a2605..2c5460b 100644 --- a/site/content/docs/development/kubernetes-deployment.md +++ b/site/content/docs/development/kubernetes-deployment.md @@ -90,31 +90,32 @@ To connect a `sam-node` to the hub, you just need the hub's external IP and port To connect a `sam-node` to the hub for the first time, you need to enroll it. The node needs to authenticate with the hub using a JWT token. -If you are using the **Mock OIDC Provider**, the node can fetch the token automatically from the mock provider's token endpoint. +If you are using the **Mock OIDC Provider**, the node can fetch the token using OIDC Client Credentials flow: 1. **Get the Mock OIDC Service IP:** ```bash MOCK_IP=$(kubectl get svc mock-oidc -o jsonpath='{.status.loadBalancer.ingress[0].ip}') ``` -2. **Run the Node with Token URL:** +2. **Run the Node to enroll:** ```bash sam-node run \ - --hub "$HUB_IP:4002" \ - --token-url "http://$MOCK_IP:18080/token" + --hub "http://$HUB_IP:9090" \ + --oidc-issuer "http://$MOCK_IP:18080" \ + --client-id "sam-mesh-audience" \ + --client-secret "sam-e2e-secret" ``` If you are using **Google OIDC**, you must obtain a valid Google ID token for your user and pass it via the `--jwt` flag: ```bash sam-node run \ - --hub "$HUB_IP:4002" \ + --hub "http://$HUB_IP:9090" \ --jwt "" ``` -Once enrolled, identity is stored in the local database, and you can run without authentication flags: +Once enrolled, the identity is stored in the local database (`agent.db`), and you can run subsequent times without OIDC credentials: ```bash -sam-node run \ - --hub "$HUB_IP:4002" +sam-node run ``` --- @@ -148,9 +149,13 @@ spec: command: ["sam-node", "run"] args: - "--hub" - - "sam-hub:4002" - - "--token-url" - - "http://mock-oidc:18080/token" + - "http://sam-hub:9090" + - "--oidc-issuer" + - "http://mock-oidc:18080" + - "--client-id" + - "sam-mesh-audience" + - "--client-secret" + - "sam-e2e-secret" env: - name: HOME value: /data @@ -169,25 +174,27 @@ The SAM project supports three primary flows for acquiring a JWT token to enroll #### 1. Client Credentials Flow (Machine-to-Machine) * **Description:** Defined in OAuth 2.0 RFC 6749, section 4.4. An application exchanges its application credentials (such as Client ID and Client Secret) for an access token. * **Use Case:** For unattended services or deployments connecting to a production OIDC provider. -* **How to use:** Pass the `--token-url`, `--client-id`, and `--client-secret` flags to `sam-node run`. +* **How to use:** Pass the `--oidc-issuer`, `--client-id`, and `--client-secret` flags to `sam-node run`. * **Example:** ```bash sam-node run \ - --hub "hub.example.com:4002" \ - --token-url "https://oauth2.googleapis.com/token" \ + --hub "http://hub.example.com:9090" \ + --oidc-issuer "https://accounts.google.com" \ --client-id "$SAM_OIDC_ID" \ --client-secret "$SAM_OIDC_SECRET" ``` #### 2. Native App Authorization Code Flow (Human Intervention) -* **Description:** For devices operated by humans, this uses the standard Authorization Code Flow with PKCE for native apps (RFC 8252). `sam-node` spins up a temporary local HTTP server, opens your browser, and receives the authentication token locally via an ephemeral loopback address. +* **Description:** For devices operated by humans, this uses the standard Authorization Code Flow with PKCE for native apps (RFC 8252). The human operator runs `sam-node join` to open a web browser (or get a verification code via `--headless`), completes the login, and obtains a Biscuit token which is stored in the local database (`agent.db`). * **Use Case:** When a human operator is enrolling a node manually via their local terminal. -* **How to use:** When you omit the `--jwt` or `--token-url` flags (or pass the OIDC issuer flag interactively), the node opens the browser and completes the flow without needing to enter complex passwords in the CLI. Alternatively, you can obtain a token yourself and pass it via the `--jwt` flag. +* **How to use:** Run `sam-node join ` before running the node daemon. Alternatively, you can obtain a token yourself and pass it via the `--jwt` flag to `sam-node run`. * **Example:** ```bash -sam-node run \ - --hub "hub.example.com:4002" \ - --jwt "eyJhbGciOiJSUzI1NiIs..." +# First, join interactively: +sam-node join https://hub.example.com + +# Then start the node daemon: +sam-node run ``` #### 3. Workload Identity Federation (Secretless Kubernetes) @@ -198,7 +205,7 @@ sam-node run \ * **Example:** ```bash sam-node run \ - --hub "hub.example.com:4002" \ + --hub "http://hub.example.com:9090" \ --jwt-path "/var/run/secrets/kubernetes.io/serviceaccount/token" ``` > [!NOTE] @@ -269,7 +276,7 @@ spec: command: ["sam-node", "run"] args: - "--hub" - - "sam-hub:4002" + - "http://sam-hub:9090" - "--jwt-path" - "/var/run/secrets/tokens/sam-token" volumeMounts: diff --git a/site/content/docs/development/policy.md b/site/content/docs/development/policy.md index bbb5ce2..d929b4c 100644 --- a/site/content/docs/development/policy.md +++ b/site/content/docs/development/policy.md @@ -67,6 +67,9 @@ allow if group("engineering"); ## 2. Hub Policy Schema (`policies.yaml`) Admins define central permissions by mapping OIDC roles to specific capabilities. +> [!IMPORTANT] +> Wildcards (e.g. `*`) are explicitly disallowed in policy definitions. All allowed network targets and MCP servers must be explicitly listed. + ```yaml version: "v1alpha1" roles: @@ -74,7 +77,7 @@ roles: network: allowed_targets: ["db-agent.data-mesh"] # Who they can connect to mcp: - allowed_tools: ["query_database"] # What tools they can run + allowed_servers: ["db-agent"] # Allowed MCP server names custom_datalog: - 'department("analytics");' # Raw injected facts ``` diff --git a/site/content/docs/manifests/sam-hub.yaml b/site/content/docs/manifests/sam-hub.yaml index 8d35a29..7afdf99 100644 --- a/site/content/docs/manifests/sam-hub.yaml +++ b/site/content/docs/manifests/sam-hub.yaml @@ -47,7 +47,7 @@ spec: - "--mesh=public-mesh" - "--issuer=$(SAM_OIDC_ISSUER)" ports: - - containerPort: 8080 + - containerPort: 9090 name: public-port env: - name: SAM_HUB_KEY @@ -81,7 +81,7 @@ spec: app: sam-hub ports: - protocol: TCP - port: 8080 - targetPort: 8080 + port: 9090 + targetPort: 9090 name: public-port diff --git a/site/content/docs/user/agent-usage.md b/site/content/docs/user/agent-usage.md index 9e1abfc..6bbc34d 100644 --- a/site/content/docs/user/agent-usage.md +++ b/site/content/docs/user/agent-usage.md @@ -19,13 +19,13 @@ sequenceDiagram participant Agent as AI Agent (Gemini/Claude) Note over User,Hub: Phase 1: Mesh Join (OIDC Authorization) - User->>Node: sam-node join --hub + User->>Node: sam-node join Node->>Hub: Get Hub OIDC Info Hub-->>Node: OIDC Issuer, Client ID Node->>User: Display Login URL & Code User->>User: Login in Browser Node->>Hub: Exchange Code for Biscuit Identity - Node->>Node: Persist Biscuit in Local Store + Node->>Node: Persist Biscuit in Local Store (agent.db) Note over User,Agent: Phase 2: Agent Tool Invocation User->>Node: sam-node run --api-token "secret-key" @@ -45,23 +45,23 @@ Before starting the node daemon, you must authorize your node and obtain a crypt ### Standard Login Run the `join` command, pointing to the mesh control hub: ```bash -sam-node join --hub https://bananas.sam-mesh.dev +sam-node join https://bananas.sam-mesh.dev ``` * **Browser Flow**: The CLI will discover the OIDC credentials from the hub, print an OIDC authorization URL, and attempt to open your system's default web browser automatically. -* **Approval**: Log in with your corporate or identity credentials (e.g. Google Accounts), approve the authorization request, and return to the terminal. The node will automatically exchange the credentials for a Biscuit token and save it to `~/.config/sam-mesh/identity.json`. +* **Approval**: Log in with your corporate or identity credentials (e.g. Google Accounts), approve the authorization request, and return to the terminal. The node will automatically exchange the credentials for a Biscuit token and save it to `~/.config/sam-mesh/agent.db`. ### Headless (Server) Login If you are running the node on a remote server via SSH (without a web browser), force headless out-of-band mode: ```bash -sam-node join --hub https://bananas.sam-mesh.dev --headless +sam-node join https://bananas.sam-mesh.dev --headless ``` The CLI will print a verification URL and code (e.g. `https://google.com/device` and `ABCD-EFGH`). Open this URL on your local laptop, enter the code, complete the login, and the remote terminal session will activate automatically. ### Automatic Token Renewal To allow long-lived nodes to automatically renew their tokens in the background, request offline access (refreshes the OIDC session): ```bash -sam-node join --hub https://bananas.sam-mesh.dev --offline-access +sam-node join https://bananas.sam-mesh.dev --offline-access ``` --- diff --git a/site/content/docs/user/hub-configuration.md b/site/content/docs/user/hub-configuration.md index 1d90c39..6b27229 100644 --- a/site/content/docs/user/hub-configuration.md +++ b/site/content/docs/user/hub-configuration.md @@ -28,7 +28,7 @@ The hub is highly configurable. Each setting can be passed as a command-line fla | `--listen` | *None* | `[]` | Comma-separated libp2p multiaddrs to listen on (e.g. `/ip4/0.0.0.0/tcp/9090`). | | `--bind-address` | *None* | `:9090` | Host and port to listen on for the HTTP/HTTPS admin service. | | `--policy-file` | *None* | `policies.yaml` | Path to the YAML file defining authorization roles and bindings. | -| `--allowed-audiences` | *None* | `sam-audience` | Comma-separated list of allowed JWT audiences. | +| `--allowed-audiences` | *None* | `sam-mesh-audience` | Comma-separated list of allowed JWT audiences. | | `--insecure-skip-tls-verify` | *None* | `false` | Set to `true` to skip certificate validation for development/testing OIDC providers. | | `--keys-db` | *None* | `keys.db` | Path to the BoltDB file storing public/private keys for token validation. | | `--admin-token` | *None* | *None* | Secret token string required in the HTTP Header `Authorization: Bearer ` for admin operations. | @@ -41,6 +41,9 @@ The hub is highly configurable. Each setting can be passed as a command-line fla The hub dynamically issues permissions inside the Biscuit token based on identity claims (users or groups) mapped to specific roles in the policy file. +> [!IMPORTANT] +> For security reasons, wildcards (e.g. `*`) are explicitly disallowed in policy definitions. All allowed network targets and MCP servers must be explicitly listed. + ### Example Policy Mapping Create a `policies.yaml` file in the directory where you run `sam-hub`: @@ -51,22 +54,25 @@ version: v1alpha1 roles: developer-role: network: - allowedTargets: + allowed_targets: - "10.0.0.0/8" - "192.168.1.0/24" mcp: - allowedServers: + allowed_servers: - "local-shell-tools" - "git-helper" admin-role: network: - allowedTargets: + allowed_targets: - "10.0.0.0/8" - "172.16.0.0/12" + - "192.168.1.0/24" mcp: - allowedServers: - - "*" # Allow access to all MCP servers + allowed_servers: + - "local-shell-tools" + - "git-helper" + - "db-agent" # Bind OIDC user emails or group claims to roles bindings: From b033fb7c3aeb99c04bdc8101d21d19ccf0a055f5 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 28 Jun 2026 11:53:32 +0000 Subject: [PATCH 04/10] security: resolve PR comments on nil token dereference, claims type conversions, and policy parsing loop optimization --- cmd/sam-hub/biscuit.go | 91 +++++++++++--------- cmd/sam-hub/biscuit_test.go | 164 ++++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 42 deletions(-) diff --git a/cmd/sam-hub/biscuit.go b/cmd/sam-hub/biscuit.go index 49463bf..34334be 100644 --- a/cmd/sam-hub/biscuit.go +++ b/cmd/sam-hub/biscuit.go @@ -29,24 +29,12 @@ import ( ) func (h *Hub) mintBiscuitToken(claims jwt.MapClaims, token *oidc.IDToken, remotePeer peer.ID) ([]byte, error) { - var oidcRoles []string - if rolesAny, ok := claims["roles"].([]any); ok { - for _, r := range rolesAny { - if str, ok := r.(string); ok && str != "" { - oidcRoles = append(oidcRoles, str) - } - } - } - - var oidcGroups []string - if groupsAny, ok := claims["groups"].([]any); ok { - for _, g := range groupsAny { - if str, ok := g.(string); ok && str != "" { - oidcGroups = append(oidcGroups, str) - } - } + if token == nil { + return nil, fmt.Errorf("token cannot be nil") } + oidcRoles := toStringSlice(claims["roles"]) + oidcGroups := toStringSlice(claims["groups"]) oidcSub, _ := claims["sub"].(string) // Resolve roles based on configured bindings and explicit OIDC roles @@ -180,6 +168,16 @@ func (h *Hub) verifyBiscuit(biscuitData []byte, remotePeer peer.ID) (*biscuit.Bi authOpts = append(authOpts, biscuit.WithWorldOptions(datalog.WithMaxDuration(h.BiscuitTimeout))) } + timeCheck, err := parser.FromStringCheck(`check if time($time), expiration($exp), $time <= $exp`) + if err != nil { + return nil, fmt.Errorf("failed to parse time check: %w", err) + } + + rule, err := parser.FromStringPolicy("allow if true") + if err != nil { + return nil, fmt.Errorf("failed to parse allow policy: %w", err) + } + keys := h.KeyRing.GetAllValidPublicKeys() var lastErr error for _, pubKey := range keys { @@ -196,18 +194,7 @@ func (h *Hub) verifyBiscuit(biscuitData []byte, remotePeer peer.ID) (*biscuit.Bi }, }) - timeCheck, err := parser.FromStringCheck(`check if time($time), expiration($exp), $time <= $exp`) - if err != nil { - lastErr = err - continue - } authorizer.AddCheck(timeCheck) - - rule, err := parser.FromStringPolicy("allow if true") - if err != nil { - lastErr = err - continue - } authorizer.AddPolicy(rule) if err := authorizer.Authorize(); err == nil { @@ -237,24 +224,44 @@ func translateClaimsToFacts(builder biscuit.Builder, claims map[string]any) erro } } case api.FactGroup: - if sliceVal, ok := val.([]any); ok { - seen := make(map[string]bool) - for _, item := range sliceVal { - if strItem, ok := item.(string); ok && strItem != "" { - if seen[strItem] { - continue - } - seen[strItem] = true - if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: factName, - IDs: []biscuit.Term{biscuit.String(strItem)}, - }}); err != nil { - return fmt.Errorf("failed to add %s fact: %w", factName, err) - } - } + 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 +} diff --git a/cmd/sam-hub/biscuit_test.go b/cmd/sam-hub/biscuit_test.go index 10a4eb9..9c69433 100644 --- a/cmd/sam-hub/biscuit_test.go +++ b/cmd/sam-hub/biscuit_test.go @@ -392,3 +392,167 @@ func TestMintBiscuitToken_ClaimsTranslation(t *testing.T) { t.Errorf("Authorization/Checks failed: %v\nWorld:\n%s", err, authorizer.PrintWorld()) } } + +func TestMintBiscuitToken_NilToken(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + kr, err := NewKeyRing(dbPath, 24*time.Hour, nil) + if err != nil { + t.Fatal(err) + } + defer func() { _ = kr.Close() }() + + hub := &Hub{KeyRing: kr} + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + dummyPeer, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + + claims := jwt.MapClaims{"sub": "user-123"} + _, err = hub.mintBiscuitToken(claims, nil, dummyPeer) + if err == nil { + t.Error("Expected mintBiscuitToken to fail with nil token, got nil error") + } +} + +func TestMintBiscuitToken_VariousClaimsTypes(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + kr, err := NewKeyRing(dbPath, 24*time.Hour, nil) + if err != nil { + t.Fatal(err) + } + defer func() { _ = kr.Close() }() + + hub := &Hub{ + KeyRing: kr, + Policy: &api.PolicyConfig{ + Bindings: []api.Binding{ + { + Group: "eng-group", + Role: "eng-role", + }, + }, + Roles: map[string]api.RolePolicy{ + "admin": {}, + "eng-role": {}, + }, + }, + } + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + dummyPeer, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + + token := &oidc.IDToken{ + Expiry: time.Now().Add(1 * time.Hour), + } + + tests := []struct { + name string + rolesClaim any + groupsClaim any + expectedRoles []string + expectedGroups []string + }{ + { + name: "String slice (standard go code paths)", + rolesClaim: []string{"admin"}, + groupsClaim: []string{"eng-group", "beta"}, + expectedRoles: []string{"admin", "eng-role"}, // eng-role comes from eng-group mapping + expectedGroups: []string{"eng-group", "beta"}, + }, + { + name: "Interface slice (standard JSON unmarshalled jwt paths)", + rolesClaim: []any{"admin"}, + groupsClaim: []any{"eng-group", "beta"}, + expectedRoles: []string{"admin", "eng-role"}, + expectedGroups: []string{"eng-group", "beta"}, + }, + { + name: "Single string claims", + rolesClaim: "admin", + groupsClaim: "eng-group", + expectedRoles: []string{"admin", "eng-role"}, + expectedGroups: []string{"eng-group"}, + }, + { + name: "Missing or nil claims", + rolesClaim: nil, + groupsClaim: nil, + expectedRoles: nil, + expectedGroups: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims := jwt.MapClaims{} + if tt.rolesClaim != nil { + claims["roles"] = tt.rolesClaim + } + if tt.groupsClaim != nil { + claims["groups"] = tt.groupsClaim + } + + biscuitData, err := hub.mintBiscuitToken(claims, token, dummyPeer) + if err != nil { + t.Fatalf("mintBiscuitToken failed: %v", err) + } + + b, err := biscuit.Unmarshal(biscuitData) + if err != nil { + t.Fatalf("Unmarshal biscuit failed: %v", err) + } + + authorizer, err := b.Authorizer(kr.GetCurrentPublicKey()) + if err != nil { + t.Fatalf("Authorizer failed: %v", err) + } + + // Add checks to verify the output facts + for _, r := range tt.expectedRoles { + authorizer.AddCheck(biscuit.Check{Queries: []biscuit.Rule{ + { + Body: []biscuit.Predicate{ + {Name: "role", IDs: []biscuit.Term{biscuit.String(r)}}, + }, + }, + }}) + } + + for _, g := range tt.expectedGroups { + authorizer.AddCheck(biscuit.Check{Queries: []biscuit.Rule{ + { + Body: []biscuit.Predicate{ + {Name: "group", IDs: []biscuit.Term{biscuit.String(g)}}, + }, + }, + }}) + } + + authorizer.AddPolicy(biscuit.Policy{Queries: []biscuit.Rule{ + { + Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, + Body: []biscuit.Predicate{}, + }, + }, Kind: biscuit.PolicyKindAllow}) + + if err := authorizer.Authorize(); err != nil { + t.Errorf("Verification failed for case: %v\nWorld:\n%s", err, authorizer.PrintWorld()) + } + }) + } +} From 9779a01c7361cc0d848fb9cbd5df8234c71cdcf4 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 28 Jun 2026 12:08:27 +0000 Subject: [PATCH 05/10] security: optimize biscuit verify, ensure deterministic serialization, protect against nil claims, and add micro-benchmarks --- cmd/sam-hub/biscuit.go | 50 +++++++--- cmd/sam-hub/biscuit_benchmark_test.go | 138 ++++++++++++++++++++++++++ cmd/sam-hub/biscuit_test.go | 31 ++++++ 3 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 cmd/sam-hub/biscuit_benchmark_test.go diff --git a/cmd/sam-hub/biscuit.go b/cmd/sam-hub/biscuit.go index 34334be..0026e62 100644 --- a/cmd/sam-hub/biscuit.go +++ b/cmd/sam-hub/biscuit.go @@ -16,6 +16,7 @@ package main import ( "fmt" + "sort" "strings" "time" @@ -32,6 +33,9 @@ func (h *Hub) mintBiscuitToken(claims jwt.MapClaims, token *oidc.IDToken, remote if token == nil { return nil, fmt.Errorf("token cannot be nil") } + if claims == nil { + return nil, fmt.Errorf("claims cannot be nil") + } oidcRoles := toStringSlice(claims["roles"]) oidcGroups := toStringSlice(claims["groups"]) @@ -93,7 +97,13 @@ func (h *Hub) mintBiscuitToken(claims jwt.MapClaims, token *oidc.IDToken, remote } // Assert resolved authorized roles in the token + roles := make([]string, 0, len(resolvedRoles)) for role := range resolvedRoles { + roles = append(roles, role) + } + sort.Strings(roles) + + for _, role := range roles { if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ Name: api.FactRole, IDs: []biscuit.Term{biscuit.String(role)}, @@ -157,6 +167,23 @@ func (h *Hub) mintBiscuitToken(claims jwt.MapClaims, token *oidc.IDToken, remote 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 { @@ -168,16 +195,6 @@ func (h *Hub) verifyBiscuit(biscuitData []byte, remotePeer peer.ID) (*biscuit.Bi authOpts = append(authOpts, biscuit.WithWorldOptions(datalog.WithMaxDuration(h.BiscuitTimeout))) } - timeCheck, err := parser.FromStringCheck(`check if time($time), expiration($exp), $time <= $exp`) - if err != nil { - return nil, fmt.Errorf("failed to parse time check: %w", err) - } - - rule, err := parser.FromStringPolicy("allow if true") - if err != nil { - return nil, fmt.Errorf("failed to parse allow policy: %w", err) - } - keys := h.KeyRing.GetAllValidPublicKeys() var lastErr error for _, pubKey := range keys { @@ -194,8 +211,8 @@ func (h *Hub) verifyBiscuit(biscuitData []byte, remotePeer peer.ID) (*biscuit.Bi }, }) - authorizer.AddCheck(timeCheck) - authorizer.AddPolicy(rule) + authorizer.AddCheck(staticTimeCheck) + authorizer.AddPolicy(staticAllowPolicy) if err := authorizer.Authorize(); err == nil { return b, nil @@ -208,7 +225,14 @@ func (h *Hub) verifyBiscuit(biscuitData []byte, remotePeer peer.ID) (*biscuit.Bi } func translateClaimsToFacts(builder biscuit.Builder, claims map[string]any) error { - for claimKey, factName := range api.OIDCClaimToFact { + keys := make([]string, 0, len(api.OIDCClaimToFact)) + for k := range api.OIDCClaimToFact { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, claimKey := range keys { + factName := api.OIDCClaimToFact[claimKey] val, ok := claims[claimKey] if !ok || val == nil { continue diff --git a/cmd/sam-hub/biscuit_benchmark_test.go b/cmd/sam-hub/biscuit_benchmark_test.go new file mode 100644 index 0000000..58f5250 --- /dev/null +++ b/cmd/sam-hub/biscuit_benchmark_test.go @@ -0,0 +1,138 @@ +// 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 ( + "path/filepath" + "testing" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/golang-jwt/jwt/v5" + "github.com/google/sam/api" + "github.com/libp2p/go-libp2p/core/crypto" + "github.com/libp2p/go-libp2p/core/peer" +) + +func BenchmarkMintBiscuitToken(b *testing.B) { + dir := b.TempDir() + dbPath := filepath.Join(dir, "test.db") + + kr, err := NewKeyRing(dbPath, 24*time.Hour, nil) + if err != nil { + b.Fatal(err) + } + defer func() { _ = kr.Close() }() + + hub := &Hub{ + KeyRing: kr, + Policy: &api.PolicyConfig{ + Bindings: []api.Binding{ + { + Group: "engineering", + Role: "developer-role", + }, + }, + Roles: map[string]api.RolePolicy{ + "developer-role": { + MCP: api.MCPPolicy{ + AllowedServers: []string{"git-helper", "mcp-server-2"}, + }, + Network: api.NetworkPolicy{ + AllowedTargets: []string{"target-1", "target-2"}, + }, + CustomDatalog: []string{ + "department(\"analytics\");", + }, + }, + }, + }, + } + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + b.Fatal(err) + } + dummyPeer, err := peer.IDFromPrivateKey(priv) + if err != nil { + b.Fatal(err) + } + + token := &oidc.IDToken{ + Expiry: time.Now().Add(1 * time.Hour), + } + + claims := jwt.MapClaims{ + "sub": "user-12345", + "email": "agent@google.com", + "groups": []any{"beta-testers", "engineering"}, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := hub.mintBiscuitToken(claims, token, dummyPeer) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkVerifyBiscuit(b *testing.B) { + dir := b.TempDir() + dbPath := filepath.Join(dir, "test.db") + + kr, err := NewKeyRing(dbPath, 24*time.Hour, nil) + if err != nil { + b.Fatal(err) + } + defer func() { _ = kr.Close() }() + + hub := &Hub{ + KeyRing: kr, + Policy: &api.PolicyConfig{}, + BiscuitTimeout: 500 * time.Millisecond, + } + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + b.Fatal(err) + } + dummyPeer, err := peer.IDFromPrivateKey(priv) + if err != nil { + b.Fatal(err) + } + + token := &oidc.IDToken{ + Expiry: time.Now().Add(1 * time.Hour), + } + + claims := jwt.MapClaims{ + "sub": "user-123", + "roles": []any{"admin"}, + } + + biscuitData, err := hub.mintBiscuitToken(claims, token, dummyPeer) + if err != nil { + b.Fatal(err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := hub.verifyBiscuit(biscuitData, dummyPeer) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/cmd/sam-hub/biscuit_test.go b/cmd/sam-hub/biscuit_test.go index 9c69433..0b9c5e9 100644 --- a/cmd/sam-hub/biscuit_test.go +++ b/cmd/sam-hub/biscuit_test.go @@ -421,6 +421,37 @@ func TestMintBiscuitToken_NilToken(t *testing.T) { } } +func TestMintBiscuitToken_NilClaims(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + kr, err := NewKeyRing(dbPath, 24*time.Hour, nil) + if err != nil { + t.Fatal(err) + } + defer func() { _ = kr.Close() }() + + hub := &Hub{KeyRing: kr} + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + dummyPeer, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + + token := &oidc.IDToken{ + Expiry: time.Now().Add(1 * time.Hour), + } + + _, err = hub.mintBiscuitToken(nil, token, dummyPeer) + if err == nil { + t.Error("Expected mintBiscuitToken to fail with nil claims, got nil error") + } +} + func TestMintBiscuitToken_VariousClaimsTypes(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") From 620a931e58908e396123f52cfc23b54f0b5112da Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 28 Jun 2026 13:15:00 +0000 Subject: [PATCH 06/10] security: make OIDCClaimToFact map read-only using maps.Clone, resolving concurrency and bypass vulnerabilities --- api/constants.go | 18 ++++++++--- cmd/sam-hub/biscuit.go | 7 +++-- cmd/sam-hub/biscuit_test.go | 59 +++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/api/constants.go b/api/constants.go index f0703e4..8a11e56 100644 --- a/api/constants.go +++ b/api/constants.go @@ -14,7 +14,11 @@ 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" @@ -37,7 +41,7 @@ const ( FactNetworkTarget = "allow_network_target" ) -// OIDCClaimToFact maps standard OIDC claims to their corresponding Biscuit facts. +// 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: @@ -48,12 +52,18 @@ const ( // 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). +// 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{ +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) +} diff --git a/cmd/sam-hub/biscuit.go b/cmd/sam-hub/biscuit.go index 0026e62..3f63829 100644 --- a/cmd/sam-hub/biscuit.go +++ b/cmd/sam-hub/biscuit.go @@ -225,14 +225,15 @@ func (h *Hub) verifyBiscuit(biscuitData []byte, remotePeer peer.ID) (*biscuit.Bi } func translateClaimsToFacts(builder biscuit.Builder, claims map[string]any) error { - keys := make([]string, 0, len(api.OIDCClaimToFact)) - for k := range api.OIDCClaimToFact { + 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 := api.OIDCClaimToFact[claimKey] + factName := claimMap[claimKey] val, ok := claims[claimKey] if !ok || val == nil { continue diff --git a/cmd/sam-hub/biscuit_test.go b/cmd/sam-hub/biscuit_test.go index 0b9c5e9..df9e9b3 100644 --- a/cmd/sam-hub/biscuit_test.go +++ b/cmd/sam-hub/biscuit_test.go @@ -16,6 +16,7 @@ package main import ( "path/filepath" + "sync" "testing" "time" @@ -587,3 +588,61 @@ func TestMintBiscuitToken_VariousClaimsTypes(t *testing.T) { }) } } + +func TestVerifyBiscuit_Concurrent(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + kr, err := NewKeyRing(dbPath, 24*time.Hour, nil) + if err != nil { + t.Fatal(err) + } + defer func() { _ = kr.Close() }() + + hub := &Hub{ + KeyRing: kr, + Policy: &api.PolicyConfig{}, + BiscuitTimeout: 500 * time.Millisecond, + } + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + dummyPeer, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + + token := &oidc.IDToken{ + Expiry: time.Now().Add(1 * time.Hour), + } + claims := jwt.MapClaims{ + "sub": "user-123", + "roles": []any{"admin"}, + } + + biscuitData, err := hub.mintBiscuitToken(claims, token, dummyPeer) + if err != nil { + t.Fatalf("mintBiscuitToken failed: %v", err) + } + + // Spin up 50 goroutines performing verification concurrently to detect any data races on static check/policy globals + const workers = 50 + var wg sync.WaitGroup + wg.Add(workers) + + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + _, err := hub.verifyBiscuit(biscuitData, dummyPeer) + if err != nil { + t.Errorf("Concurrent verification failed: %v", err) + return + } + } + }() + } + wg.Wait() +} From 5d87969d9782dd5a5afc7b1515ab1f26dba014bc Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 28 Jun 2026 13:18:32 +0000 Subject: [PATCH 07/10] docs: simplify site docs index to match README tone and structure --- site/content/docs/_index.md | 39 ++++++++++++++----------------------- 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/site/content/docs/_index.md b/site/content/docs/_index.md index b4b115f..808e464 100644 --- a/site/content/docs/_index.md +++ b/site/content/docs/_index.md @@ -2,33 +2,24 @@ title: "SAM Documentation" linkTitle: "Documentation" --- -This repository currently provides a minimal SAM runtime with two binaries: +SAM (Sovereign Agent Mesh) is a smart, zero-config, zero-trust P2P network built for autonomous AI agents. -- `sam-hub`: OIDC bridge and identity biscuit issuer -- `sam-node`: mesh node CLI with login and run commands +## Architecture -The documentation here is intentionally small and aligned with what is implemented today. +* **`sam-hub`**: The control plane for identity mapping, token issuing, and policy distribution. +* **`sam-node`**: The P2P nodes providing the mesh transport layer, self-healing connectivity, and local Model Context Protocol (MCP) HTTP interfaces. -## What Works Today - -1. Build and run `sam-node` and `sam-hub` -2. Perform node login via hub OIDC flow -3. Persist identity in local node store -4. Run long-lived hub and node processes -5. Run local and containerized BATS tests -6. Expose local tools and mesh info via MCP server over HTTP SSE -7. Automated peer discovery via DHT and GossipSub events - -## Start Here +--- -- [Quick Start](quickstart.md) - User Quick Start using Docker. -- [Hub Configuration](user/hub-configuration.md) - OIDC authentication, key rings, and custom policy rules in `policies.yaml`. -- [Agent Usage](user/agent-usage.md) - Node authorization flows, MCP endpoints, and how agents connect. -- [Developer Guide](development/_index.md) - Building from source, local testing, and Kind setups. -- [Testing Guide](development/testing.md) - Detailed test layer and troubleshooting information. -- [Testnet Validation Tutorial](development/testnet-validation.md) - Real-time integration and MCP verification with public testnets. +## Where to Start -## Notes +### For Users & Operators +Get a node running on the public testnet (`bananas.sam-mesh.dev`) in minutes: +- 🚀 **[User Quick Start Guide](quickstart.md)**: Connect and run a SAM node using binaries or Docker, and query the local MCP server. +- 🤖 **[Agent Integration Guide](user/agent-usage.md)**: Connect Google Gemini, Claude, and other AI agents to your SAM node to call tools across the mesh. +- 📡 **[Testnet Validation Tutorial](development/testnet-validation.md)**: Real-time verification, remote tool invocation, and HTTP stream proxies. -- Older architecture and feature-heavy docs were removed to avoid drift. -- If a feature is not documented here, assume it is not part of the current minimal scope. +### For Developers & Contributors +Compile from source, run local clusters, or execute tests: +- 🛠️ **[Developer Guide](development/_index.md)**: Prereqs, compilation, local hub setup, and Kubernetes Kind deployment. +- 🧪 **[Testing Guide](development/testing.md)**: Go tests, E2E BATS, and containerized mesh execution. From 217267a041db1472d16a82bb8a4711542ec22ee4 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 28 Jun 2026 13:22:41 +0000 Subject: [PATCH 08/10] api: extract /sam/catalog to api.CatalogTarget constant to prevent drift --- api/constants.go | 3 +++ cmd/sam-node/gate.go | 4 +++- cmd/sam-node/mcp.go | 4 ++-- cmd/sam-node/middleware.go | 3 ++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/api/constants.go b/api/constants.go index 8a11e56..30beeb1 100644 --- a/api/constants.go +++ b/api/constants.go @@ -26,6 +26,9 @@ 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 diff --git a/cmd/sam-node/gate.go b/cmd/sam-node/gate.go index 4bf7e82..7eaf66d 100644 --- a/cmd/sam-node/gate.go +++ b/cmd/sam-node/gate.go @@ -27,6 +27,8 @@ import ( "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/google/sam/api" ) var _ connmgr.ConnectionGater = (*nodeConnGate)(nil) @@ -75,7 +77,7 @@ func (g *nodeConnGate) InterceptSecured(dir network.Direction, p peer.ID, n netw func (n *SamNode) HandleMCPStream(s network.Stream, reqCtx RequestContext) { // If the TargetService is for a registered local backend, dumb-pipe proxy to it. target := reqCtx.Target - if target != "" && target != "/sam/catalog" { + if target != "" && target != api.CatalogTarget { svc, ok := n.services.Get(target) if ok { mcpSvc, isMcp := svc.(*MCPService) diff --git a/cmd/sam-node/mcp.go b/cmd/sam-node/mcp.go index 1479635..461cfc4 100644 --- a/cmd/sam-node/mcp.go +++ b/cmd/sam-node/mcp.go @@ -302,7 +302,7 @@ func (n *SamNode) ConnectMCPSession(ctx context.Context, targetPeer peer.ID, tar } func (n *SamNode) callMCPToolOnce(ctx context.Context, targetPeer peer.ID, toolName string, params any) (*mcp.CallToolResult, error) { - targetService := "/sam/catalog" + targetService := api.CatalogTarget originalToolName := toolName if parts := strings.SplitN(toolName, ".", 2); len(parts) == 2 { targetService = parts[0] @@ -335,7 +335,7 @@ func (n *SamNode) callMCPToolOnce(ctx context.Context, targetPeer peer.ID, toolN func (n *SamNode) fetchRemoteServiceCatalog(ctx context.Context, peerID peer.ID, typeStr string) ([]*api.ServiceInfo, error) { n.preparePeerAddrs(ctx, peerID) - session, cleanup, err := n.ConnectMCPSession(ctx, peerID, "/sam/catalog") + session, cleanup, err := n.ConnectMCPSession(ctx, peerID, api.CatalogTarget) if err != nil { return nil, err } diff --git a/cmd/sam-node/middleware.go b/cmd/sam-node/middleware.go index 818a002..174d7dc 100644 --- a/cmd/sam-node/middleware.go +++ b/cmd/sam-node/middleware.go @@ -54,7 +54,8 @@ func init() { panic(fmt.Sprintf("failed to parse baseline rule 2: %v", err)) } - baselineRule3, err = parser.FromStringPolicy(`allow if operation("/sam/catalog")`) + rule3Str := fmt.Sprintf(`allow if operation("%s")`, api.CatalogTarget) + baselineRule3, err = parser.FromStringPolicy(rule3Str) if err != nil { panic(fmt.Sprintf("failed to parse baseline rule 3: %v", err)) } From 6ba919b9ca4d3d1439afefcb7feae29a8094e3ee Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 28 Jun 2026 13:24:21 +0000 Subject: [PATCH 09/10] docs: add Node Baseline Security Rules and CatalogTarget description to policy docs --- site/content/docs/development/policy.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/site/content/docs/development/policy.md b/site/content/docs/development/policy.md index d929b4c..31e4952 100644 --- a/site/content/docs/development/policy.md +++ b/site/content/docs/development/policy.md @@ -93,3 +93,24 @@ attenuation: checks: - 'check if time($time), $time < 2026-12-31T00:00:00Z;' ``` + +## 4. Node Baseline Security Rules + +Every `sam-node` enforces a set of baseline security rules (defined in Go code) to secure the transport layer. These rules run automatically before evaluating custom OIDC or local policies: + +### 4.1 Replay & Impersonation Prevention +Every request must prove that the libp2p cryptographic peer ID of the connection matches the client peer ID embedded in the authorization token: +```datalog +check if client_peer_id($id), connection_peer_id($id); +``` + +### 4.2 The Catalog Service (`/sam/catalog`) +To allow remote peers to discover tools and query connectivity, each node hosts a built-in catalog service at the special target `/sam/catalog`. This service exposes local metadata tools (e.g. `list_local_services`, `get_mesh_info`). + +To ensure tool discovery works out-of-the-box, the node automatically injects a baseline rule allowing all verified peers to access it: +```datalog +allow if operation("/sam/catalog"); +``` +> [!IMPORTANT] +> Without this baseline rule (or if a custom local attenuation policy explicitly blocks it), remote nodes will not be able to retrieve this node's tool catalog. As a result, agents across the mesh will fail to discover or call any of this node's tools. + From a1cc52ee1d5a41bb9563fe0a70f3792454127912 Mon Sep 17 00:00:00 2001 From: Antonio Ojea Date: Sun, 28 Jun 2026 13:33:35 +0000 Subject: [PATCH 10/10] security: fail closed and aggregate all policy fact errors during biscuit token generation --- cmd/sam-hub/biscuit.go | 23 +++++++++++---- cmd/sam-hub/biscuit_test.go | 58 +++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/cmd/sam-hub/biscuit.go b/cmd/sam-hub/biscuit.go index 3f63829..9223f50 100644 --- a/cmd/sam-hub/biscuit.go +++ b/cmd/sam-hub/biscuit.go @@ -15,6 +15,7 @@ package main import ( + "errors" "fmt" "sort" "strings" @@ -103,12 +104,14 @@ func (h *Hub) mintBiscuitToken(claims jwt.MapClaims, token *oidc.IDToken, remote } sort.Strings(roles) + var errs []error for _, role := range roles { if err := builder.AddAuthorityFact(biscuit.Fact{Predicate: biscuit.Predicate{ Name: api.FactRole, IDs: []biscuit.Term{biscuit.String(role)}, }}); err != nil { - return nil, fmt.Errorf("failed to add role fact: %w", err) + errs = append(errs, fmt.Errorf("failed to add role fact for %s: %w", role, err)) + continue } if h.Policy != nil { @@ -118,7 +121,7 @@ func (h *Hub) mintBiscuitToken(claims jwt.MapClaims, token *oidc.IDToken, remote 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) + errs = append(errs, fmt.Errorf("failed to add MCP tool fact for %s: %w", tool, err)) } } for _, target := range rolePolicy.Network.AllowedTargets { @@ -126,7 +129,7 @@ func (h *Hub) mintBiscuitToken(claims jwt.MapClaims, token *oidc.IDToken, remote 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) + errs = append(errs, fmt.Errorf("failed to add network target fact for %s: %w", target, err)) } } for _, customFact := range rolePolicy.CustomDatalog { @@ -134,26 +137,34 @@ func (h *Hub) mintBiscuitToken(claims jwt.MapClaims, token *oidc.IDToken, remote if trimmed == "" { continue } + var factErr error func() { defer func() { if r := recover(); r != nil { - logger.Errorw("Panic parsing custom fact", "peer_id", remotePeer, "fact", trimmed, "recover", r) + factErr = fmt.Errorf("panic parsing custom fact %q: %v", trimmed, r) } }() fact, err := parser.FromStringFact(trimmed) if err != nil { - logger.Errorw("Failed to parse custom fact", "peer_id", remotePeer, "fact", trimmed, "error", err) + factErr = fmt.Errorf("failed to parse custom fact %q: %w", trimmed, err) return } if err := builder.AddAuthorityFact(fact); err != nil { - logger.Errorw("Failed to add custom fact to biscuit", "peer_id", remotePeer, "fact", trimmed, "error", err) + 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) diff --git a/cmd/sam-hub/biscuit_test.go b/cmd/sam-hub/biscuit_test.go index df9e9b3..4ce1576 100644 --- a/cmd/sam-hub/biscuit_test.go +++ b/cmd/sam-hub/biscuit_test.go @@ -16,6 +16,7 @@ package main import ( "path/filepath" + "strings" "sync" "testing" "time" @@ -646,3 +647,60 @@ func TestVerifyBiscuit_Concurrent(t *testing.T) { } wg.Wait() } + +func TestMintBiscuitToken_ErrorAggregation(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + + kr, err := NewKeyRing(dbPath, 24*time.Hour, nil) + if err != nil { + t.Fatal(err) + } + defer func() { _ = kr.Close() }() + + hub := &Hub{ + KeyRing: kr, + Policy: &api.PolicyConfig{ + Roles: map[string]api.RolePolicy{ + "admin": { + CustomDatalog: []string{ + "invalid-datalog-fact(123", // syntax error + "another-bad-fact);", // syntax error + }, + }, + }, + }, + } + + priv, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatal(err) + } + dummyPeer, err := peer.IDFromPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + + token := &oidc.IDToken{ + Expiry: time.Now().Add(1 * time.Hour), + } + claims := jwt.MapClaims{ + "sub": "user-123", + "roles": []any{"admin"}, + } + + _, err = hub.mintBiscuitToken(claims, token, dummyPeer) + if err == nil { + t.Fatal("Expected mintBiscuitToken to fail on custom datalog syntax errors, got nil error") + } + + errStr := err.Error() + if !strings.Contains(errStr, "failed to parse custom fact") && !strings.Contains(errStr, "panic parsing custom fact") { + t.Errorf("Expected error message to contain parse failure info, got: %s", errStr) + } + + // Verify that BOTH errors are aggregated in the error message + if !strings.Contains(errStr, "invalid-datalog-fact") || !strings.Contains(errStr, "another-bad-fact") { + t.Errorf("Expected error to aggregate both failures, got: %s", errStr) + } +}