Skip to content

Commit 541213e

Browse files
Merge branch 'main' into feat/python-sdk-parity
2 parents b99c6f0 + a0cc2c5 commit 541213e

File tree

9 files changed

+701
-19
lines changed

9 files changed

+701
-19
lines changed

packages/agent-mesh/sdks/go/audit.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
14
package agentmesh
25

36
import (
47
"crypto/sha256"
58
"encoding/hex"
9+
"encoding/json"
10+
"fmt"
611
"sync"
712
"time"
813
)
@@ -19,8 +24,9 @@ type AuditEntry struct {
1924

2025
// AuditLogger maintains an append-only hash-chained audit log.
2126
type AuditLogger struct {
22-
mu sync.Mutex
23-
entries []*AuditEntry
27+
mu sync.Mutex
28+
entries []*AuditEntry
29+
MaxEntries int
2430
}
2531

2632
// NewAuditLogger creates an empty AuditLogger.
@@ -29,10 +35,15 @@ func NewAuditLogger() *AuditLogger {
2935
}
3036

3137
// Log appends a new entry to the audit chain.
38+
// When MaxEntries is set and exceeded, the oldest entries are evicted.
3239
func (al *AuditLogger) Log(agentID, action string, decision PolicyDecision) *AuditEntry {
3340
al.mu.Lock()
3441
defer al.mu.Unlock()
3542

43+
if al.MaxEntries > 0 && len(al.entries) >= al.MaxEntries {
44+
al.entries = al.entries[len(al.entries)-al.MaxEntries+1:]
45+
}
46+
3647
prevHash := ""
3748
if len(al.entries) > 0 {
3849
prevHash = al.entries[len(al.entries)-1].Hash
@@ -61,7 +72,8 @@ func (al *AuditLogger) Verify() bool {
6172
return false
6273
}
6374
if i == 0 {
64-
if entry.PreviousHash != "" {
75+
// Allow non-empty PreviousHash when retention eviction is active
76+
if al.MaxEntries == 0 && entry.PreviousHash != "" {
6577
return false
6678
}
6779
} else {
@@ -109,3 +121,15 @@ func computeHash(e *AuditEntry) string {
109121
h := sha256.Sum256([]byte(data))
110122
return hex.EncodeToString(h[:])
111123
}
124+
125+
// ExportJSON serialises all audit entries to a JSON string.
126+
func (al *AuditLogger) ExportJSON() (string, error) {
127+
al.mu.Lock()
128+
defer al.mu.Unlock()
129+
130+
data, err := json.Marshal(al.entries)
131+
if err != nil {
132+
return "", fmt.Errorf("marshalling audit entries: %w", err)
133+
}
134+
return string(data), nil
135+
}

packages/agent-mesh/sdks/go/audit_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,51 @@ func TestAuditHashesAreUnique(t *testing.T) {
6565
t.Error("different entries should have different hashes")
6666
}
6767
}
68+
69+
func TestExportJSON(t *testing.T) {
70+
al := NewAuditLogger()
71+
al.Log("agent-1", "read", Allow)
72+
al.Log("agent-2", "write", Deny)
73+
74+
jsonStr, err := al.ExportJSON()
75+
if err != nil {
76+
t.Fatalf("ExportJSON: %v", err)
77+
}
78+
if jsonStr == "" {
79+
t.Error("ExportJSON returned empty string")
80+
}
81+
if len(jsonStr) < 10 {
82+
t.Error("ExportJSON result too short")
83+
}
84+
}
85+
86+
func TestMaxEntriesRetention(t *testing.T) {
87+
al := NewAuditLogger()
88+
al.MaxEntries = 3
89+
90+
al.Log("a1", "action1", Allow)
91+
al.Log("a2", "action2", Deny)
92+
al.Log("a3", "action3", Allow)
93+
al.Log("a4", "action4", Deny)
94+
95+
entries := al.GetEntries(AuditFilter{})
96+
if len(entries) != 3 {
97+
t.Errorf("entries after retention = %d, want 3", len(entries))
98+
}
99+
if entries[0].AgentID != "a2" {
100+
t.Errorf("oldest entry agent = %q, want a2", entries[0].AgentID)
101+
}
102+
}
103+
104+
func TestMaxEntriesVerify(t *testing.T) {
105+
al := NewAuditLogger()
106+
al.MaxEntries = 2
107+
108+
al.Log("a1", "x", Allow)
109+
al.Log("a2", "y", Deny)
110+
al.Log("a3", "z", Allow)
111+
112+
if !al.Verify() {
113+
t.Error("chain with retention eviction should still verify")
114+
}
115+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package agentmesh
5+
6+
import "strings"
7+
8+
// ConflictResolutionStrategy defines how conflicting policy decisions are resolved.
9+
type ConflictResolutionStrategy string
10+
11+
const (
12+
DenyOverrides ConflictResolutionStrategy = "deny_overrides"
13+
AllowOverrides ConflictResolutionStrategy = "allow_overrides"
14+
PriorityFirstMatch ConflictResolutionStrategy = "priority_first_match"
15+
MostSpecificWins ConflictResolutionStrategy = "most_specific_wins"
16+
)
17+
18+
// CandidateDecision pairs a matched rule with its resulting decision.
19+
type CandidateDecision struct {
20+
Rule PolicyRule
21+
Decision PolicyDecision
22+
}
23+
24+
// PolicyConflictResolver resolves conflicts between multiple matching rules.
25+
type PolicyConflictResolver struct {
26+
Strategy ConflictResolutionStrategy
27+
}
28+
29+
// Resolve returns a single PolicyDecision from a set of candidates using the configured strategy.
30+
func (r *PolicyConflictResolver) Resolve(candidates []CandidateDecision) PolicyDecision {
31+
if len(candidates) == 0 {
32+
return Deny
33+
}
34+
35+
switch r.Strategy {
36+
case DenyOverrides:
37+
for _, c := range candidates {
38+
if c.Decision == Deny {
39+
return Deny
40+
}
41+
}
42+
return candidates[0].Decision
43+
44+
case AllowOverrides:
45+
for _, c := range candidates {
46+
if c.Decision == Allow {
47+
return Allow
48+
}
49+
}
50+
return candidates[0].Decision
51+
52+
case PriorityFirstMatch:
53+
best := candidates[0]
54+
for _, c := range candidates[1:] {
55+
if c.Rule.Priority < best.Rule.Priority {
56+
best = c
57+
}
58+
}
59+
return best.Decision
60+
61+
case MostSpecificWins:
62+
best := candidates[0]
63+
bestSpec := ruleSpecificity(best)
64+
for _, c := range candidates[1:] {
65+
s := ruleSpecificity(c)
66+
if s > bestSpec {
67+
best = c
68+
bestSpec = s
69+
}
70+
}
71+
return best.Decision
72+
73+
default:
74+
return candidates[0].Decision
75+
}
76+
}
77+
78+
func ruleSpecificity(c CandidateDecision) int {
79+
score := len(c.Rule.Conditions)
80+
switch c.Rule.Scope {
81+
case Agent:
82+
score += 3
83+
case Tenant:
84+
score += 2
85+
case Global:
86+
score += 1
87+
}
88+
if c.Rule.Action != "*" && !strings.HasSuffix(c.Rule.Action, ".*") {
89+
score += 2
90+
}
91+
return score
92+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package agentmesh
5+
6+
import "testing"
7+
8+
func TestDenyOverrides(t *testing.T) {
9+
r := &PolicyConflictResolver{Strategy: DenyOverrides}
10+
candidates := []CandidateDecision{
11+
{Rule: PolicyRule{Action: "data.read", Effect: Allow}, Decision: Allow},
12+
{Rule: PolicyRule{Action: "data.read", Effect: Deny}, Decision: Deny},
13+
}
14+
if d := r.Resolve(candidates); d != Deny {
15+
t.Errorf("DenyOverrides = %q, want deny", d)
16+
}
17+
}
18+
19+
func TestDenyOverridesAllAllow(t *testing.T) {
20+
r := &PolicyConflictResolver{Strategy: DenyOverrides}
21+
candidates := []CandidateDecision{
22+
{Rule: PolicyRule{Action: "data.read", Effect: Allow}, Decision: Allow},
23+
{Rule: PolicyRule{Action: "data.read", Effect: Review}, Decision: Review},
24+
}
25+
if d := r.Resolve(candidates); d != Allow {
26+
t.Errorf("DenyOverrides with no deny = %q, want allow", d)
27+
}
28+
}
29+
30+
func TestAllowOverrides(t *testing.T) {
31+
r := &PolicyConflictResolver{Strategy: AllowOverrides}
32+
candidates := []CandidateDecision{
33+
{Rule: PolicyRule{Action: "data.read", Effect: Deny}, Decision: Deny},
34+
{Rule: PolicyRule{Action: "data.read", Effect: Allow}, Decision: Allow},
35+
}
36+
if d := r.Resolve(candidates); d != Allow {
37+
t.Errorf("AllowOverrides = %q, want allow", d)
38+
}
39+
}
40+
41+
func TestAllowOverridesNoneAllow(t *testing.T) {
42+
r := &PolicyConflictResolver{Strategy: AllowOverrides}
43+
candidates := []CandidateDecision{
44+
{Rule: PolicyRule{Action: "data.read", Effect: Deny}, Decision: Deny},
45+
{Rule: PolicyRule{Action: "data.read", Effect: Review}, Decision: Review},
46+
}
47+
if d := r.Resolve(candidates); d != Deny {
48+
t.Errorf("AllowOverrides with no allow = %q, want deny", d)
49+
}
50+
}
51+
52+
func TestPriorityFirstMatch(t *testing.T) {
53+
r := &PolicyConflictResolver{Strategy: PriorityFirstMatch}
54+
candidates := []CandidateDecision{
55+
{Rule: PolicyRule{Action: "data.read", Effect: Deny, Priority: 10}, Decision: Deny},
56+
{Rule: PolicyRule{Action: "data.read", Effect: Allow, Priority: 1}, Decision: Allow},
57+
}
58+
if d := r.Resolve(candidates); d != Allow {
59+
t.Errorf("PriorityFirstMatch = %q, want allow (priority 1)", d)
60+
}
61+
}
62+
63+
func TestMostSpecificWins(t *testing.T) {
64+
r := &PolicyConflictResolver{Strategy: MostSpecificWins}
65+
candidates := []CandidateDecision{
66+
{
67+
Rule: PolicyRule{Action: "*", Effect: Deny, Scope: Global},
68+
Decision: Deny,
69+
},
70+
{
71+
Rule: PolicyRule{
72+
Action: "data.read",
73+
Effect: Allow,
74+
Scope: Agent,
75+
Conditions: map[string]interface{}{"role": "admin"},
76+
},
77+
Decision: Allow,
78+
},
79+
}
80+
if d := r.Resolve(candidates); d != Allow {
81+
t.Errorf("MostSpecificWins = %q, want allow (more specific)", d)
82+
}
83+
}
84+
85+
func TestResolveEmptyCandidates(t *testing.T) {
86+
r := &PolicyConflictResolver{Strategy: DenyOverrides}
87+
if d := r.Resolve(nil); d != Deny {
88+
t.Errorf("empty candidates = %q, want deny", d)
89+
}
90+
}

0 commit comments

Comments
 (0)