feat(trust): Phase 3 — approval workflow, audit log, dynamic policies, scope wildcards
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
fc21d01a71
commit
9b65defdd8
9 changed files with 1513 additions and 6 deletions
8
TODO.md
8
TODO.md
|
|
@ -42,10 +42,10 @@ All Phase 2: commit `301eac1`. 55 tests total, all pass with `-race`.
|
|||
|
||||
## Phase 3: Trust Policy Extensions
|
||||
|
||||
- [ ] **Approval workflow** — Implement the approval flow that `NeedsApproval` decisions point to. Queue-based: agent requests approval → admin reviews → approve/deny.
|
||||
- [ ] **Audit log** — Record all policy evaluations (agent, capability, decision, timestamp). Append-only log for compliance.
|
||||
- [ ] **Dynamic policies** — Load policies from YAML/JSON config. Currently hardcoded in `DefaultPolicies()`.
|
||||
- [ ] **Scope wildcards** — Support `core/*` scope patterns in ScopedRepos, not just exact strings.
|
||||
- [x] **Approval workflow** — `ApprovalQueue` with `Submit`, `Approve`, `Deny`, `Get`, `Pending` methods. Thread-safe queue with unique IDs, status tracking, reviewer attribution. 22 tests including concurrent and end-to-end integration with PolicyEngine.
|
||||
- [x] **Audit log** — `AuditLog` with append-only `Record`, `Entries`, `EntriesFor` methods. Optional `io.Writer` for JSON-line persistence. Custom `Decision` JSON marshalling. 18 tests including writer errors and concurrent logging.
|
||||
- [x] **Dynamic policies** — `LoadPolicies`/`LoadPoliciesFromFile` parse JSON config. `ApplyPolicies`/`ApplyPoliciesFromFile` replace engine policies. `ExportPolicies` for round-trip serialisation. `DisallowUnknownFields` for strict parsing. 18 tests including round-trip.
|
||||
- [x] **Scope wildcards** — `matchScope` supports exact match, single-level wildcard (`core/*`), and recursive wildcard (`core/**`). Updated `repoAllowed` to use pattern matching. 18 tests covering all edge cases including integration with PolicyEngine.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
174
trust/approval.go
Normal file
174
trust/approval.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package trust
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ApprovalStatus represents the state of an approval request.
|
||||
type ApprovalStatus int
|
||||
|
||||
const (
|
||||
// ApprovalPending means the request is awaiting review.
|
||||
ApprovalPending ApprovalStatus = iota
|
||||
// ApprovalApproved means the request was approved.
|
||||
ApprovalApproved
|
||||
// ApprovalDenied means the request was denied.
|
||||
ApprovalDenied
|
||||
)
|
||||
|
||||
// String returns the human-readable name of the approval status.
|
||||
func (s ApprovalStatus) String() string {
|
||||
switch s {
|
||||
case ApprovalPending:
|
||||
return "pending"
|
||||
case ApprovalApproved:
|
||||
return "approved"
|
||||
case ApprovalDenied:
|
||||
return "denied"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", int(s))
|
||||
}
|
||||
}
|
||||
|
||||
// ApprovalRequest represents a queued capability approval request.
|
||||
type ApprovalRequest struct {
|
||||
// ID is the unique identifier for this request.
|
||||
ID string
|
||||
// Agent is the name of the requesting agent.
|
||||
Agent string
|
||||
// Cap is the capability being requested.
|
||||
Cap Capability
|
||||
// Repo is the optional repo context for repo-scoped capabilities.
|
||||
Repo string
|
||||
// Status is the current approval status.
|
||||
Status ApprovalStatus
|
||||
// Reason is a human-readable explanation from the reviewer.
|
||||
Reason string
|
||||
// RequestedAt is when the request was created.
|
||||
RequestedAt time.Time
|
||||
// ReviewedAt is when the request was reviewed (zero if pending).
|
||||
ReviewedAt time.Time
|
||||
// ReviewedBy is the name of the admin who reviewed the request.
|
||||
ReviewedBy string
|
||||
}
|
||||
|
||||
// ApprovalQueue manages pending approval requests for NeedsApproval decisions.
|
||||
type ApprovalQueue struct {
|
||||
mu sync.RWMutex
|
||||
requests map[string]*ApprovalRequest
|
||||
nextID int
|
||||
}
|
||||
|
||||
// NewApprovalQueue creates an empty approval queue.
|
||||
func NewApprovalQueue() *ApprovalQueue {
|
||||
return &ApprovalQueue{
|
||||
requests: make(map[string]*ApprovalRequest),
|
||||
}
|
||||
}
|
||||
|
||||
// Submit creates a new approval request and returns its ID.
|
||||
// Returns an error if the agent name or capability is empty.
|
||||
func (q *ApprovalQueue) Submit(agent string, cap Capability, repo string) (string, error) {
|
||||
if agent == "" {
|
||||
return "", fmt.Errorf("trust.ApprovalQueue.Submit: agent name is required")
|
||||
}
|
||||
if cap == "" {
|
||||
return "", fmt.Errorf("trust.ApprovalQueue.Submit: capability is required")
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
q.nextID++
|
||||
id := fmt.Sprintf("approval-%d", q.nextID)
|
||||
|
||||
q.requests[id] = &ApprovalRequest{
|
||||
ID: id,
|
||||
Agent: agent,
|
||||
Cap: cap,
|
||||
Repo: repo,
|
||||
Status: ApprovalPending,
|
||||
RequestedAt: time.Now(),
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Approve marks a pending request as approved. Returns an error if the
|
||||
// request is not found or is not in pending status.
|
||||
func (q *ApprovalQueue) Approve(id string, reviewedBy string, reason string) error {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
req, ok := q.requests[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("trust.ApprovalQueue.Approve: request %q not found", id)
|
||||
}
|
||||
if req.Status != ApprovalPending {
|
||||
return fmt.Errorf("trust.ApprovalQueue.Approve: request %q is already %s", id, req.Status)
|
||||
}
|
||||
|
||||
req.Status = ApprovalApproved
|
||||
req.ReviewedBy = reviewedBy
|
||||
req.Reason = reason
|
||||
req.ReviewedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deny marks a pending request as denied. Returns an error if the
|
||||
// request is not found or is not in pending status.
|
||||
func (q *ApprovalQueue) Deny(id string, reviewedBy string, reason string) error {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
req, ok := q.requests[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("trust.ApprovalQueue.Deny: request %q not found", id)
|
||||
}
|
||||
if req.Status != ApprovalPending {
|
||||
return fmt.Errorf("trust.ApprovalQueue.Deny: request %q is already %s", id, req.Status)
|
||||
}
|
||||
|
||||
req.Status = ApprovalDenied
|
||||
req.ReviewedBy = reviewedBy
|
||||
req.Reason = reason
|
||||
req.ReviewedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the approval request with the given ID, or nil if not found.
|
||||
func (q *ApprovalQueue) Get(id string) *ApprovalRequest {
|
||||
q.mu.RLock()
|
||||
defer q.mu.RUnlock()
|
||||
|
||||
req, ok := q.requests[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// Return a copy to prevent mutation.
|
||||
copy := *req
|
||||
return ©
|
||||
}
|
||||
|
||||
// Pending returns all requests with ApprovalPending status.
|
||||
func (q *ApprovalQueue) Pending() []ApprovalRequest {
|
||||
q.mu.RLock()
|
||||
defer q.mu.RUnlock()
|
||||
|
||||
var out []ApprovalRequest
|
||||
for _, req := range q.requests {
|
||||
if req.Status == ApprovalPending {
|
||||
out = append(out, *req)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Len returns the total number of requests in the queue.
|
||||
func (q *ApprovalQueue) Len() int {
|
||||
q.mu.RLock()
|
||||
defer q.mu.RUnlock()
|
||||
return len(q.requests)
|
||||
}
|
||||
296
trust/approval_test.go
Normal file
296
trust/approval_test.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
package trust
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- ApprovalStatus ---
|
||||
|
||||
func TestApprovalStatusString_Good(t *testing.T) {
|
||||
assert.Equal(t, "pending", ApprovalPending.String())
|
||||
assert.Equal(t, "approved", ApprovalApproved.String())
|
||||
assert.Equal(t, "denied", ApprovalDenied.String())
|
||||
}
|
||||
|
||||
func TestApprovalStatusString_Bad_Unknown(t *testing.T) {
|
||||
assert.Contains(t, ApprovalStatus(99).String(), "unknown")
|
||||
}
|
||||
|
||||
// --- Submit ---
|
||||
|
||||
func TestApprovalSubmit_Good(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
id, err := q.Submit("Clotho", CapMergePR, "host-uk/core")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, id)
|
||||
assert.Equal(t, 1, q.Len())
|
||||
}
|
||||
|
||||
func TestApprovalSubmit_Good_MultipleRequests(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
id1, err := q.Submit("Clotho", CapMergePR, "host-uk/core")
|
||||
require.NoError(t, err)
|
||||
id2, err := q.Submit("Hypnos", CapMergePR, "host-uk/docs")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, id1, id2, "each request should get a unique ID")
|
||||
assert.Equal(t, 2, q.Len())
|
||||
}
|
||||
|
||||
func TestApprovalSubmit_Good_EmptyRepo(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
id, err := q.Submit("Clotho", CapMergePR, "")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, id)
|
||||
|
||||
req := q.Get(id)
|
||||
require.NotNil(t, req)
|
||||
assert.Empty(t, req.Repo)
|
||||
}
|
||||
|
||||
func TestApprovalSubmit_Bad_EmptyAgent(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
_, err := q.Submit("", CapMergePR, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "agent name is required")
|
||||
}
|
||||
|
||||
func TestApprovalSubmit_Bad_EmptyCapability(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
_, err := q.Submit("Clotho", "", "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "capability is required")
|
||||
}
|
||||
|
||||
// --- Get ---
|
||||
|
||||
func TestApprovalGet_Good(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
id, err := q.Submit("Clotho", CapMergePR, "host-uk/core")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := q.Get(id)
|
||||
require.NotNil(t, req)
|
||||
assert.Equal(t, id, req.ID)
|
||||
assert.Equal(t, "Clotho", req.Agent)
|
||||
assert.Equal(t, CapMergePR, req.Cap)
|
||||
assert.Equal(t, "host-uk/core", req.Repo)
|
||||
assert.Equal(t, ApprovalPending, req.Status)
|
||||
assert.False(t, req.RequestedAt.IsZero())
|
||||
assert.True(t, req.ReviewedAt.IsZero())
|
||||
}
|
||||
|
||||
func TestApprovalGet_Good_ReturnsSnapshot(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
id, err := q.Submit("Clotho", CapMergePR, "host-uk/core")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := q.Get(id)
|
||||
require.NotNil(t, req)
|
||||
req.Status = ApprovalApproved // Mutate the copy
|
||||
|
||||
// Original should be unchanged.
|
||||
original := q.Get(id)
|
||||
assert.Equal(t, ApprovalPending, original.Status)
|
||||
}
|
||||
|
||||
func TestApprovalGet_Bad_NotFound(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
assert.Nil(t, q.Get("nonexistent"))
|
||||
}
|
||||
|
||||
// --- Approve ---
|
||||
|
||||
func TestApprovalApprove_Good(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core")
|
||||
|
||||
err := q.Approve(id, "admin", "looks good")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := q.Get(id)
|
||||
require.NotNil(t, req)
|
||||
assert.Equal(t, ApprovalApproved, req.Status)
|
||||
assert.Equal(t, "admin", req.ReviewedBy)
|
||||
assert.Equal(t, "looks good", req.Reason)
|
||||
assert.False(t, req.ReviewedAt.IsZero())
|
||||
}
|
||||
|
||||
func TestApprovalApprove_Bad_NotFound(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
err := q.Approve("nonexistent", "admin", "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestApprovalApprove_Bad_AlreadyApproved(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core")
|
||||
require.NoError(t, q.Approve(id, "admin", ""))
|
||||
|
||||
err := q.Approve(id, "admin2", "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already approved")
|
||||
}
|
||||
|
||||
func TestApprovalApprove_Bad_AlreadyDenied(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core")
|
||||
require.NoError(t, q.Deny(id, "admin", "nope"))
|
||||
|
||||
err := q.Approve(id, "admin2", "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already denied")
|
||||
}
|
||||
|
||||
// --- Deny ---
|
||||
|
||||
func TestApprovalDeny_Good(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core")
|
||||
|
||||
err := q.Deny(id, "admin", "not appropriate")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := q.Get(id)
|
||||
require.NotNil(t, req)
|
||||
assert.Equal(t, ApprovalDenied, req.Status)
|
||||
assert.Equal(t, "admin", req.ReviewedBy)
|
||||
assert.Equal(t, "not appropriate", req.Reason)
|
||||
assert.False(t, req.ReviewedAt.IsZero())
|
||||
}
|
||||
|
||||
func TestApprovalDeny_Bad_NotFound(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
err := q.Deny("nonexistent", "admin", "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
}
|
||||
|
||||
func TestApprovalDeny_Bad_AlreadyDenied(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core")
|
||||
require.NoError(t, q.Deny(id, "admin", ""))
|
||||
|
||||
err := q.Deny(id, "admin2", "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already denied")
|
||||
}
|
||||
|
||||
// --- Pending ---
|
||||
|
||||
func TestApprovalPending_Good(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
q.Submit("Clotho", CapMergePR, "host-uk/core")
|
||||
q.Submit("Hypnos", CapMergePR, "host-uk/docs")
|
||||
|
||||
id3, _ := q.Submit("Darbs", CapMergePR, "host-uk/tools")
|
||||
q.Approve(id3, "admin", "")
|
||||
|
||||
pending := q.Pending()
|
||||
assert.Len(t, pending, 2)
|
||||
}
|
||||
|
||||
func TestApprovalPending_Good_Empty(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
assert.Empty(t, q.Pending())
|
||||
}
|
||||
|
||||
// --- Concurrent operations ---
|
||||
|
||||
func TestApprovalConcurrent_Good(t *testing.T) {
|
||||
q := NewApprovalQueue()
|
||||
|
||||
const n = 10
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
|
||||
ids := make([]string, n)
|
||||
var mu sync.Mutex
|
||||
|
||||
// Submit concurrently
|
||||
for i := 0; i < n; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
id, err := q.Submit(
|
||||
fmt.Sprintf("agent-%d", idx),
|
||||
CapMergePR,
|
||||
"host-uk/core",
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
mu.Lock()
|
||||
ids[idx] = id
|
||||
mu.Unlock()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
assert.Equal(t, n, q.Len())
|
||||
|
||||
// Approve/deny concurrently
|
||||
wg.Add(n)
|
||||
for i := 0; i < n; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
mu.Lock()
|
||||
id := ids[idx]
|
||||
mu.Unlock()
|
||||
if idx%2 == 0 {
|
||||
_ = q.Approve(id, "admin", "ok")
|
||||
} else {
|
||||
_ = q.Deny(id, "admin", "no")
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
assert.Empty(t, q.Pending())
|
||||
}
|
||||
|
||||
// --- Integration: PolicyEngine + ApprovalQueue ---
|
||||
|
||||
func TestApprovalWorkflow_Good_EndToEnd(t *testing.T) {
|
||||
pe := newTestEngine(t)
|
||||
q := NewApprovalQueue()
|
||||
|
||||
// Clotho (Tier 2) tries to merge a PR — should get NeedsApproval
|
||||
result := pe.Evaluate("Clotho", CapMergePR, "host-uk/core")
|
||||
assert.Equal(t, NeedsApproval, result.Decision)
|
||||
|
||||
// Submit an approval request
|
||||
id, err := q.Submit(result.Agent, result.Cap, "host-uk/core")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Admin approves
|
||||
err = q.Approve(id, "Virgil", "PR reviewed, merge approved")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify approval
|
||||
req := q.Get(id)
|
||||
require.NotNil(t, req)
|
||||
assert.Equal(t, ApprovalApproved, req.Status)
|
||||
assert.Equal(t, "Virgil", req.ReviewedBy)
|
||||
}
|
||||
|
||||
func TestApprovalWorkflow_Good_DenyEndToEnd(t *testing.T) {
|
||||
pe := newTestEngine(t)
|
||||
q := NewApprovalQueue()
|
||||
|
||||
result := pe.Evaluate("Clotho", CapMergePR, "host-uk/core")
|
||||
assert.Equal(t, NeedsApproval, result.Decision)
|
||||
|
||||
id, err := q.Submit(result.Agent, result.Cap, "host-uk/core")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = q.Deny(id, "Virgil", "needs more review")
|
||||
require.NoError(t, err)
|
||||
|
||||
req := q.Get(id)
|
||||
require.NotNil(t, req)
|
||||
assert.Equal(t, ApprovalDenied, req.Status)
|
||||
}
|
||||
125
trust/audit.go
Normal file
125
trust/audit.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package trust
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditEntry records a single policy evaluation for compliance.
|
||||
type AuditEntry struct {
|
||||
// Timestamp is when the evaluation occurred.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
// Agent is the name of the agent being evaluated.
|
||||
Agent string `json:"agent"`
|
||||
// Cap is the capability that was evaluated.
|
||||
Cap Capability `json:"capability"`
|
||||
// Repo is the repo context (empty if not repo-scoped).
|
||||
Repo string `json:"repo,omitempty"`
|
||||
// Decision is the evaluation outcome.
|
||||
Decision Decision `json:"decision"`
|
||||
// Reason is the human-readable reason for the decision.
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom JSON encoding for Decision.
|
||||
func (d Decision) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(d.String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON decoding for Decision.
|
||||
func (d *Decision) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
switch s {
|
||||
case "deny":
|
||||
*d = Deny
|
||||
case "allow":
|
||||
*d = Allow
|
||||
case "needs_approval":
|
||||
*d = NeedsApproval
|
||||
default:
|
||||
return fmt.Errorf("trust: unknown decision %q", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuditLog is an append-only log of policy evaluations.
|
||||
type AuditLog struct {
|
||||
mu sync.Mutex
|
||||
entries []AuditEntry
|
||||
writer io.Writer
|
||||
}
|
||||
|
||||
// NewAuditLog creates an in-memory audit log. If a writer is provided,
|
||||
// each entry is also written as a JSON line to that writer (append-only).
|
||||
func NewAuditLog(w io.Writer) *AuditLog {
|
||||
return &AuditLog{
|
||||
writer: w,
|
||||
}
|
||||
}
|
||||
|
||||
// Record appends an evaluation result to the audit log.
|
||||
func (l *AuditLog) Record(result EvalResult, repo string) error {
|
||||
entry := AuditEntry{
|
||||
Timestamp: time.Now(),
|
||||
Agent: result.Agent,
|
||||
Cap: result.Cap,
|
||||
Repo: repo,
|
||||
Decision: result.Decision,
|
||||
Reason: result.Reason,
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.entries = append(l.entries, entry)
|
||||
|
||||
if l.writer != nil {
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trust.AuditLog.Record: marshal failed: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if _, err := l.writer.Write(data); err != nil {
|
||||
return fmt.Errorf("trust.AuditLog.Record: write failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Entries returns a snapshot of all audit entries.
|
||||
func (l *AuditLog) Entries() []AuditEntry {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
out := make([]AuditEntry, len(l.entries))
|
||||
copy(out, l.entries)
|
||||
return out
|
||||
}
|
||||
|
||||
// Len returns the number of entries in the log.
|
||||
func (l *AuditLog) Len() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.entries)
|
||||
}
|
||||
|
||||
// EntriesFor returns all audit entries for a specific agent.
|
||||
func (l *AuditLog) EntriesFor(agent string) []AuditEntry {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var out []AuditEntry
|
||||
for _, e := range l.entries {
|
||||
if e.Agent == agent {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
281
trust/audit_test.go
Normal file
281
trust/audit_test.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
package trust
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- AuditLog basic ---
|
||||
|
||||
func TestAuditRecord_Good(t *testing.T) {
|
||||
log := NewAuditLog(nil)
|
||||
|
||||
result := EvalResult{
|
||||
Decision: Allow,
|
||||
Agent: "Athena",
|
||||
Cap: CapPushRepo,
|
||||
Reason: "capability repo.push allowed for tier full",
|
||||
}
|
||||
err := log.Record(result, "host-uk/core")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, log.Len())
|
||||
}
|
||||
|
||||
func TestAuditRecord_Good_EntryFields(t *testing.T) {
|
||||
log := NewAuditLog(nil)
|
||||
|
||||
result := EvalResult{
|
||||
Decision: Deny,
|
||||
Agent: "BugSETI-001",
|
||||
Cap: CapPushRepo,
|
||||
Reason: "denied",
|
||||
}
|
||||
err := log.Record(result, "host-uk/core")
|
||||
require.NoError(t, err)
|
||||
|
||||
entries := log.Entries()
|
||||
require.Len(t, entries, 1)
|
||||
|
||||
e := entries[0]
|
||||
assert.Equal(t, "BugSETI-001", e.Agent)
|
||||
assert.Equal(t, CapPushRepo, e.Cap)
|
||||
assert.Equal(t, "host-uk/core", e.Repo)
|
||||
assert.Equal(t, Deny, e.Decision)
|
||||
assert.Equal(t, "denied", e.Reason)
|
||||
assert.False(t, e.Timestamp.IsZero())
|
||||
}
|
||||
|
||||
func TestAuditRecord_Good_NoRepo(t *testing.T) {
|
||||
log := NewAuditLog(nil)
|
||||
result := EvalResult{
|
||||
Decision: Allow,
|
||||
Agent: "Athena",
|
||||
Cap: CapCommentIssue,
|
||||
Reason: "ok",
|
||||
}
|
||||
err := log.Record(result, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
entries := log.Entries()
|
||||
require.Len(t, entries, 1)
|
||||
assert.Empty(t, entries[0].Repo)
|
||||
}
|
||||
|
||||
func TestAuditEntries_Good_Snapshot(t *testing.T) {
|
||||
log := NewAuditLog(nil)
|
||||
log.Record(EvalResult{Agent: "A", Cap: CapPushRepo, Decision: Allow, Reason: "ok"}, "")
|
||||
|
||||
entries := log.Entries()
|
||||
require.Len(t, entries, 1)
|
||||
|
||||
// Mutating the snapshot should not affect the log.
|
||||
entries[0].Agent = "MUTATED"
|
||||
assert.Equal(t, "A", log.Entries()[0].Agent)
|
||||
}
|
||||
|
||||
func TestAuditEntries_Good_Empty(t *testing.T) {
|
||||
log := NewAuditLog(nil)
|
||||
assert.Empty(t, log.Entries())
|
||||
}
|
||||
|
||||
func TestAuditEntries_Good_AppendOnly(t *testing.T) {
|
||||
log := NewAuditLog(nil)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
log.Record(EvalResult{
|
||||
Agent: fmt.Sprintf("agent-%d", i),
|
||||
Cap: CapPushRepo,
|
||||
Decision: Allow,
|
||||
Reason: "ok",
|
||||
}, "")
|
||||
}
|
||||
assert.Equal(t, 5, log.Len())
|
||||
}
|
||||
|
||||
// --- EntriesFor ---
|
||||
|
||||
func TestAuditEntriesFor_Good(t *testing.T) {
|
||||
log := NewAuditLog(nil)
|
||||
|
||||
log.Record(EvalResult{Agent: "Athena", Cap: CapPushRepo, Decision: Allow, Reason: "ok"}, "")
|
||||
log.Record(EvalResult{Agent: "Clotho", Cap: CapCreatePR, Decision: Allow, Reason: "ok"}, "")
|
||||
log.Record(EvalResult{Agent: "Athena", Cap: CapMergePR, Decision: Allow, Reason: "ok"}, "")
|
||||
|
||||
athenaEntries := log.EntriesFor("Athena")
|
||||
assert.Len(t, athenaEntries, 2)
|
||||
for _, e := range athenaEntries {
|
||||
assert.Equal(t, "Athena", e.Agent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditEntriesFor_Bad_NotFound(t *testing.T) {
|
||||
log := NewAuditLog(nil)
|
||||
log.Record(EvalResult{Agent: "Athena", Cap: CapPushRepo, Decision: Allow, Reason: "ok"}, "")
|
||||
|
||||
assert.Empty(t, log.EntriesFor("NonExistent"))
|
||||
}
|
||||
|
||||
// --- Writer output ---
|
||||
|
||||
func TestAuditRecord_Good_WritesToWriter(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := NewAuditLog(&buf)
|
||||
|
||||
result := EvalResult{
|
||||
Decision: Allow,
|
||||
Agent: "Athena",
|
||||
Cap: CapPushRepo,
|
||||
Reason: "allowed",
|
||||
}
|
||||
err := log.Record(result, "host-uk/core")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have written a JSON line.
|
||||
output := buf.String()
|
||||
assert.True(t, strings.HasSuffix(output, "\n"))
|
||||
|
||||
var entry AuditEntry
|
||||
err = json.Unmarshal([]byte(output), &entry)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Athena", entry.Agent)
|
||||
assert.Equal(t, CapPushRepo, entry.Cap)
|
||||
assert.Equal(t, Allow, entry.Decision)
|
||||
assert.Equal(t, "host-uk/core", entry.Repo)
|
||||
}
|
||||
|
||||
func TestAuditRecord_Good_MultipleLines(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := NewAuditLog(&buf)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
log.Record(EvalResult{
|
||||
Agent: fmt.Sprintf("agent-%d", i),
|
||||
Cap: CapPushRepo,
|
||||
Decision: Allow,
|
||||
Reason: "ok",
|
||||
}, "")
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||||
assert.Len(t, lines, 3)
|
||||
|
||||
// Each line should be valid JSON.
|
||||
for _, line := range lines {
|
||||
var entry AuditEntry
|
||||
err := json.Unmarshal([]byte(line), &entry)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditRecord_Bad_WriterError(t *testing.T) {
|
||||
log := NewAuditLog(&failWriter{})
|
||||
|
||||
result := EvalResult{
|
||||
Decision: Allow,
|
||||
Agent: "Athena",
|
||||
Cap: CapPushRepo,
|
||||
Reason: "ok",
|
||||
}
|
||||
err := log.Record(result, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "write failed")
|
||||
|
||||
// Entry should still be recorded in memory.
|
||||
assert.Equal(t, 1, log.Len())
|
||||
}
|
||||
|
||||
// failWriter always returns an error.
|
||||
type failWriter struct{}
|
||||
|
||||
func (f *failWriter) Write(_ []byte) (int, error) {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
|
||||
// --- Decision JSON marshalling ---
|
||||
|
||||
func TestDecisionJSON_Good_RoundTrip(t *testing.T) {
|
||||
decisions := []Decision{Deny, Allow, NeedsApproval}
|
||||
expected := []string{`"deny"`, `"allow"`, `"needs_approval"`}
|
||||
|
||||
for i, d := range decisions {
|
||||
data, err := json.Marshal(d)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected[i], string(data))
|
||||
|
||||
var decoded Decision
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, d, decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecisionJSON_Bad_UnknownString(t *testing.T) {
|
||||
var d Decision
|
||||
err := json.Unmarshal([]byte(`"invalid"`), &d)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown decision")
|
||||
}
|
||||
|
||||
func TestDecisionJSON_Bad_NonString(t *testing.T) {
|
||||
var d Decision
|
||||
err := json.Unmarshal([]byte(`42`), &d)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- Concurrent audit logging ---
|
||||
|
||||
func TestAuditConcurrent_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := NewAuditLog(&buf)
|
||||
|
||||
const n = 10
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
log.Record(EvalResult{
|
||||
Agent: fmt.Sprintf("agent-%d", idx),
|
||||
Cap: CapPushRepo,
|
||||
Decision: Allow,
|
||||
Reason: "ok",
|
||||
}, "")
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
assert.Equal(t, n, log.Len())
|
||||
}
|
||||
|
||||
// --- Integration: PolicyEngine + AuditLog ---
|
||||
|
||||
func TestAuditPolicyIntegration_Good(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
log := NewAuditLog(&buf)
|
||||
pe := newTestEngine(t)
|
||||
|
||||
// Evaluate and record
|
||||
result := pe.Evaluate("Athena", CapPushRepo, "host-uk/core")
|
||||
err := log.Record(result, "host-uk/core")
|
||||
require.NoError(t, err)
|
||||
|
||||
result = pe.Evaluate("BugSETI-001", CapPushRepo, "host-uk/core")
|
||||
err = log.Record(result, "host-uk/core")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2, log.Len())
|
||||
|
||||
// Verify entries match evaluation results.
|
||||
entries := log.Entries()
|
||||
assert.Equal(t, Allow, entries[0].Decision)
|
||||
assert.Equal(t, Deny, entries[1].Decision)
|
||||
}
|
||||
137
trust/config.go
Normal file
137
trust/config.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package trust
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// PolicyConfig is the JSON-serialisable representation of a trust policy.
|
||||
type PolicyConfig struct {
|
||||
Tier int `json:"tier"`
|
||||
Allowed []string `json:"allowed"`
|
||||
RequiresApproval []string `json:"requires_approval,omitempty"`
|
||||
Denied []string `json:"denied,omitempty"`
|
||||
}
|
||||
|
||||
// PoliciesConfig is the top-level configuration containing all tier policies.
|
||||
type PoliciesConfig struct {
|
||||
Policies []PolicyConfig `json:"policies"`
|
||||
}
|
||||
|
||||
// LoadPoliciesFromFile reads a JSON file and returns parsed policies.
|
||||
func LoadPoliciesFromFile(path string) ([]Policy, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trust.LoadPoliciesFromFile: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
return LoadPolicies(f)
|
||||
}
|
||||
|
||||
// LoadPolicies reads JSON from a reader and returns parsed policies.
|
||||
func LoadPolicies(r io.Reader) ([]Policy, error) {
|
||||
var cfg PoliciesConfig
|
||||
dec := json.NewDecoder(r)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("trust.LoadPolicies: %w", err)
|
||||
}
|
||||
return convertPolicies(cfg)
|
||||
}
|
||||
|
||||
// convertPolicies transforms config DTOs into domain Policy structs.
|
||||
func convertPolicies(cfg PoliciesConfig) ([]Policy, error) {
|
||||
var policies []Policy
|
||||
|
||||
for i, pc := range cfg.Policies {
|
||||
tier := Tier(pc.Tier)
|
||||
if !tier.Valid() {
|
||||
return nil, fmt.Errorf("trust.LoadPolicies: invalid tier %d at index %d", pc.Tier, i)
|
||||
}
|
||||
|
||||
p := Policy{
|
||||
Tier: tier,
|
||||
Allowed: toCapabilities(pc.Allowed),
|
||||
RequiresApproval: toCapabilities(pc.RequiresApproval),
|
||||
Denied: toCapabilities(pc.Denied),
|
||||
}
|
||||
policies = append(policies, p)
|
||||
}
|
||||
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// ApplyPolicies loads policies from a reader and sets them on the engine,
|
||||
// replacing any existing policies for the same tiers.
|
||||
func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error {
|
||||
policies, err := LoadPolicies(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range policies {
|
||||
if err := pe.SetPolicy(p); err != nil {
|
||||
return fmt.Errorf("trust.ApplyPolicies: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyPoliciesFromFile loads policies from a JSON file and sets them on the engine.
|
||||
func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trust.ApplyPoliciesFromFile: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
return pe.ApplyPolicies(f)
|
||||
}
|
||||
|
||||
// ExportPolicies serialises the current policies as JSON to the given writer.
|
||||
func (pe *PolicyEngine) ExportPolicies(w io.Writer) error {
|
||||
var cfg PoliciesConfig
|
||||
for _, tier := range []Tier{TierUntrusted, TierVerified, TierFull} {
|
||||
p := pe.GetPolicy(tier)
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
cfg.Policies = append(cfg.Policies, PolicyConfig{
|
||||
Tier: int(p.Tier),
|
||||
Allowed: fromCapabilities(p.Allowed),
|
||||
RequiresApproval: fromCapabilities(p.RequiresApproval),
|
||||
Denied: fromCapabilities(p.Denied),
|
||||
})
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(cfg); err != nil {
|
||||
return fmt.Errorf("trust.ExportPolicies: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toCapabilities converts string slices to Capability slices.
|
||||
func toCapabilities(ss []string) []Capability {
|
||||
if len(ss) == 0 {
|
||||
return nil
|
||||
}
|
||||
caps := make([]Capability, len(ss))
|
||||
for i, s := range ss {
|
||||
caps[i] = Capability(s)
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
// fromCapabilities converts Capability slices to string slices.
|
||||
func fromCapabilities(caps []Capability) []string {
|
||||
if len(caps) == 0 {
|
||||
return nil
|
||||
}
|
||||
ss := make([]string, len(caps))
|
||||
for i, c := range caps {
|
||||
ss[i] = string(c)
|
||||
}
|
||||
return ss
|
||||
}
|
||||
256
trust/config_test.go
Normal file
256
trust/config_test.go
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
package trust
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const validPolicyJSON = `{
|
||||
"policies": [
|
||||
{
|
||||
"tier": 3,
|
||||
"allowed": ["repo.push", "pr.merge", "pr.create"]
|
||||
},
|
||||
{
|
||||
"tier": 2,
|
||||
"allowed": ["pr.create", "issue.create"],
|
||||
"requires_approval": ["pr.merge"],
|
||||
"denied": ["cmd.privileged"]
|
||||
},
|
||||
{
|
||||
"tier": 1,
|
||||
"allowed": ["issue.comment"],
|
||||
"denied": ["repo.push", "pr.merge"]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// --- LoadPolicies ---
|
||||
|
||||
func TestLoadPolicies_Good(t *testing.T) {
|
||||
policies, err := LoadPolicies(strings.NewReader(validPolicyJSON))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, policies, 3)
|
||||
}
|
||||
|
||||
func TestLoadPolicies_Good_FieldMapping(t *testing.T) {
|
||||
policies, err := LoadPolicies(strings.NewReader(validPolicyJSON))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Tier 3
|
||||
assert.Equal(t, TierFull, policies[0].Tier)
|
||||
assert.Len(t, policies[0].Allowed, 3)
|
||||
assert.Contains(t, policies[0].Allowed, CapPushRepo)
|
||||
assert.Nil(t, policies[0].RequiresApproval)
|
||||
assert.Nil(t, policies[0].Denied)
|
||||
|
||||
// Tier 2
|
||||
assert.Equal(t, TierVerified, policies[1].Tier)
|
||||
assert.Len(t, policies[1].Allowed, 2)
|
||||
assert.Len(t, policies[1].RequiresApproval, 1)
|
||||
assert.Equal(t, CapMergePR, policies[1].RequiresApproval[0])
|
||||
assert.Len(t, policies[1].Denied, 1)
|
||||
|
||||
// Tier 1
|
||||
assert.Equal(t, TierUntrusted, policies[2].Tier)
|
||||
assert.Len(t, policies[2].Allowed, 1)
|
||||
assert.Len(t, policies[2].Denied, 2)
|
||||
}
|
||||
|
||||
func TestLoadPolicies_Good_EmptyPolicies(t *testing.T) {
|
||||
input := `{"policies": []}`
|
||||
policies, err := LoadPolicies(strings.NewReader(input))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, policies)
|
||||
}
|
||||
|
||||
func TestLoadPolicies_Bad_InvalidJSON(t *testing.T) {
|
||||
_, err := LoadPolicies(strings.NewReader(`{invalid`))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestLoadPolicies_Bad_InvalidTier(t *testing.T) {
|
||||
input := `{"policies": [{"tier": 0, "allowed": ["repo.push"]}]}`
|
||||
_, err := LoadPolicies(strings.NewReader(input))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid tier")
|
||||
}
|
||||
|
||||
func TestLoadPolicies_Bad_TierTooHigh(t *testing.T) {
|
||||
input := `{"policies": [{"tier": 99, "allowed": ["repo.push"]}]}`
|
||||
_, err := LoadPolicies(strings.NewReader(input))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid tier")
|
||||
}
|
||||
|
||||
func TestLoadPolicies_Bad_UnknownField(t *testing.T) {
|
||||
input := `{"policies": [{"tier": 1, "allowed": ["repo.push"], "bogus": true}]}`
|
||||
_, err := LoadPolicies(strings.NewReader(input))
|
||||
assert.Error(t, err, "DisallowUnknownFields should reject unknown fields")
|
||||
}
|
||||
|
||||
// --- LoadPoliciesFromFile ---
|
||||
|
||||
func TestLoadPoliciesFromFile_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "policies.json")
|
||||
err := os.WriteFile(path, []byte(validPolicyJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies, err := LoadPoliciesFromFile(path)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, policies, 3)
|
||||
}
|
||||
|
||||
func TestLoadPoliciesFromFile_Bad_NotFound(t *testing.T) {
|
||||
_, err := LoadPoliciesFromFile("/nonexistent/path/policies.json")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- ApplyPolicies ---
|
||||
|
||||
func TestApplyPolicies_Good(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
require.NoError(t, r.Register(Agent{Name: "TestAgent", Tier: TierVerified}))
|
||||
pe := NewPolicyEngine(r)
|
||||
|
||||
// Apply custom policies from JSON
|
||||
err := pe.ApplyPolicies(strings.NewReader(validPolicyJSON))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the Tier 2 policy was replaced
|
||||
p := pe.GetPolicy(TierVerified)
|
||||
require.NotNil(t, p)
|
||||
assert.Len(t, p.Allowed, 2)
|
||||
assert.Contains(t, p.Allowed, CapCreatePR)
|
||||
assert.Contains(t, p.Allowed, CapCreateIssue)
|
||||
|
||||
// Verify evaluation uses the new policy
|
||||
result := pe.Evaluate("TestAgent", CapPushRepo, "")
|
||||
assert.Equal(t, Deny, result.Decision, "repo.push should not be allowed under new Tier 2 policy")
|
||||
|
||||
result = pe.Evaluate("TestAgent", CapCreatePR, "")
|
||||
assert.Equal(t, Allow, result.Decision)
|
||||
}
|
||||
|
||||
func TestApplyPolicies_Bad_InvalidJSON(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
pe := NewPolicyEngine(r)
|
||||
|
||||
err := pe.ApplyPolicies(strings.NewReader(`{invalid`))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestApplyPolicies_Bad_InvalidTier(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
pe := NewPolicyEngine(r)
|
||||
|
||||
input := `{"policies": [{"tier": 0, "allowed": ["repo.push"]}]}`
|
||||
err := pe.ApplyPolicies(strings.NewReader(input))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- ApplyPoliciesFromFile ---
|
||||
|
||||
func TestApplyPoliciesFromFile_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "policies.json")
|
||||
err := os.WriteFile(path, []byte(validPolicyJSON), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
r := NewRegistry()
|
||||
require.NoError(t, r.Register(Agent{Name: "A", Tier: TierFull}))
|
||||
pe := NewPolicyEngine(r)
|
||||
|
||||
err = pe.ApplyPoliciesFromFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify Tier 3 was replaced — only 3 allowed caps now
|
||||
p := pe.GetPolicy(TierFull)
|
||||
require.NotNil(t, p)
|
||||
assert.Len(t, p.Allowed, 3)
|
||||
}
|
||||
|
||||
func TestApplyPoliciesFromFile_Bad_NotFound(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
pe := NewPolicyEngine(r)
|
||||
err := pe.ApplyPoliciesFromFile("/nonexistent/policies.json")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- ExportPolicies ---
|
||||
|
||||
func TestExportPolicies_Good(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
pe := NewPolicyEngine(r) // loads defaults
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := pe.ExportPolicies(&buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Output should be valid JSON
|
||||
var cfg PoliciesConfig
|
||||
err = json.Unmarshal(buf.Bytes(), &cfg)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cfg.Policies, 3)
|
||||
}
|
||||
|
||||
func TestExportPolicies_Good_RoundTrip(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
require.NoError(t, r.Register(Agent{Name: "A", Tier: TierFull}))
|
||||
pe := NewPolicyEngine(r)
|
||||
|
||||
// Export
|
||||
var buf bytes.Buffer
|
||||
err := pe.ExportPolicies(&buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a new engine and apply the exported policies
|
||||
r2 := NewRegistry()
|
||||
require.NoError(t, r2.Register(Agent{Name: "A", Tier: TierFull}))
|
||||
pe2 := NewPolicyEngine(r2)
|
||||
err = pe2.ApplyPolicies(strings.NewReader(buf.String()))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Evaluations should produce the same results
|
||||
caps := []Capability{CapPushRepo, CapMergePR, CapCreatePR, CapRunPrivileged}
|
||||
for _, cap := range caps {
|
||||
r1 := pe.Evaluate("A", cap, "")
|
||||
r2 := pe2.Evaluate("A", cap, "")
|
||||
assert.Equal(t, r1.Decision, r2.Decision,
|
||||
"decision mismatch for %s: original=%s, round-tripped=%s", cap, r1.Decision, r2.Decision)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper conversion ---
|
||||
|
||||
func TestToCapabilities_Good(t *testing.T) {
|
||||
caps := toCapabilities([]string{"repo.push", "pr.merge"})
|
||||
assert.Len(t, caps, 2)
|
||||
assert.Equal(t, CapPushRepo, caps[0])
|
||||
assert.Equal(t, CapMergePR, caps[1])
|
||||
}
|
||||
|
||||
func TestToCapabilities_Good_Empty(t *testing.T) {
|
||||
assert.Nil(t, toCapabilities(nil))
|
||||
assert.Nil(t, toCapabilities([]string{}))
|
||||
}
|
||||
|
||||
func TestFromCapabilities_Good(t *testing.T) {
|
||||
ss := fromCapabilities([]Capability{CapPushRepo, CapMergePR})
|
||||
assert.Len(t, ss, 2)
|
||||
assert.Equal(t, "repo.push", ss[0])
|
||||
assert.Equal(t, "pr.merge", ss[1])
|
||||
}
|
||||
|
||||
func TestFromCapabilities_Good_Empty(t *testing.T) {
|
||||
assert.Nil(t, fromCapabilities(nil))
|
||||
assert.Nil(t, fromCapabilities([]Capability{}))
|
||||
}
|
||||
|
|
@ -225,14 +225,55 @@ func isRepoScoped(cap Capability) bool {
|
|||
}
|
||||
|
||||
// repoAllowed checks if repo is in the agent's scoped list.
|
||||
// Supports wildcard patterns: "core/*" matches "core/foo" but not "core/foo/bar".
|
||||
// "core/**" matches "core/foo", "core/foo/bar", etc.
|
||||
// Exact matches are always checked first.
|
||||
func repoAllowed(scoped []string, repo string) bool {
|
||||
if repo == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range scoped {
|
||||
if r == repo {
|
||||
for _, pattern := range scoped {
|
||||
if matchScope(pattern, repo) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchScope checks if a repo matches a scope pattern.
|
||||
// Supports exact match, single-level wildcard (*), and recursive wildcard (**).
|
||||
func matchScope(pattern, repo string) bool {
|
||||
// Exact match — fast path.
|
||||
if pattern == repo {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for wildcard patterns.
|
||||
if !strings.Contains(pattern, "*") {
|
||||
return false
|
||||
}
|
||||
|
||||
// "prefix/**" — recursive: matches anything under prefix/.
|
||||
if strings.HasSuffix(pattern, "/**") {
|
||||
prefix := pattern[:len(pattern)-3] // strip "/**"
|
||||
if !strings.HasPrefix(repo, prefix+"/") {
|
||||
return false
|
||||
}
|
||||
// Must have something after the prefix/.
|
||||
return len(repo) > len(prefix)+1
|
||||
}
|
||||
|
||||
// "prefix/*" — single level: matches prefix/X but not prefix/X/Y.
|
||||
if strings.HasSuffix(pattern, "/*") {
|
||||
prefix := pattern[:len(pattern)-2] // strip "/*"
|
||||
if !strings.HasPrefix(repo, prefix+"/") {
|
||||
return false
|
||||
}
|
||||
remainder := repo[len(prefix)+1:]
|
||||
// Must have a non-empty name, and no further slashes.
|
||||
return remainder != "" && !strings.Contains(remainder, "/")
|
||||
}
|
||||
|
||||
// Unsupported wildcard position — fall back to no match.
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
197
trust/scope_test.go
Normal file
197
trust/scope_test.go
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
package trust
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- matchScope ---
|
||||
|
||||
func TestMatchScope_Good_ExactMatch(t *testing.T) {
|
||||
assert.True(t, matchScope("host-uk/core", "host-uk/core"))
|
||||
}
|
||||
|
||||
func TestMatchScope_Good_SingleWildcard(t *testing.T) {
|
||||
assert.True(t, matchScope("core/*", "core/php"))
|
||||
assert.True(t, matchScope("core/*", "core/go-crypt"))
|
||||
assert.True(t, matchScope("host-uk/*", "host-uk/core"))
|
||||
}
|
||||
|
||||
func TestMatchScope_Good_RecursiveWildcard(t *testing.T) {
|
||||
assert.True(t, matchScope("core/**", "core/php"))
|
||||
assert.True(t, matchScope("core/**", "core/php/sub"))
|
||||
assert.True(t, matchScope("core/**", "core/a/b/c"))
|
||||
}
|
||||
|
||||
func TestMatchScope_Bad_ExactMismatch(t *testing.T) {
|
||||
assert.False(t, matchScope("host-uk/core", "host-uk/docs"))
|
||||
}
|
||||
|
||||
func TestMatchScope_Bad_SingleWildcardNoNested(t *testing.T) {
|
||||
// "core/*" should NOT match "core/php/sub" — only single level.
|
||||
assert.False(t, matchScope("core/*", "core/php/sub"))
|
||||
assert.False(t, matchScope("core/*", "core/a/b"))
|
||||
}
|
||||
|
||||
func TestMatchScope_Bad_SingleWildcardNoPrefix(t *testing.T) {
|
||||
// "core/*" should NOT match "other/php".
|
||||
assert.False(t, matchScope("core/*", "other/php"))
|
||||
}
|
||||
|
||||
func TestMatchScope_Bad_RecursiveWildcardNoPrefix(t *testing.T) {
|
||||
assert.False(t, matchScope("core/**", "other/php"))
|
||||
}
|
||||
|
||||
func TestMatchScope_Bad_EmptyRepo(t *testing.T) {
|
||||
assert.False(t, matchScope("core/*", ""))
|
||||
}
|
||||
|
||||
func TestMatchScope_Bad_WildcardInMiddle(t *testing.T) {
|
||||
// Wildcard not at the end — should not match.
|
||||
assert.False(t, matchScope("core/*/sub", "core/php/sub"))
|
||||
}
|
||||
|
||||
func TestMatchScope_Bad_WildcardOnlyPrefix(t *testing.T) {
|
||||
// "core/*" should not match the prefix itself.
|
||||
assert.False(t, matchScope("core/*", "core"))
|
||||
assert.False(t, matchScope("core/*", "core/"))
|
||||
}
|
||||
|
||||
func TestMatchScope_Good_RecursiveWildcardSingleLevel(t *testing.T) {
|
||||
// "core/**" should also match single-level children.
|
||||
assert.True(t, matchScope("core/**", "core/php"))
|
||||
}
|
||||
|
||||
func TestMatchScope_Bad_RecursiveWildcardPrefixOnly(t *testing.T) {
|
||||
assert.False(t, matchScope("core/**", "core"))
|
||||
assert.False(t, matchScope("core/**", "corefoo"))
|
||||
}
|
||||
|
||||
// --- repoAllowed with wildcards ---
|
||||
|
||||
func TestRepoAllowedWildcard_Good(t *testing.T) {
|
||||
scoped := []string{"core/*", "host-uk/docs"}
|
||||
assert.True(t, repoAllowed(scoped, "core/php"))
|
||||
assert.True(t, repoAllowed(scoped, "core/go-crypt"))
|
||||
assert.True(t, repoAllowed(scoped, "host-uk/docs"))
|
||||
}
|
||||
|
||||
func TestRepoAllowedWildcard_Good_Recursive(t *testing.T) {
|
||||
scoped := []string{"core/**"}
|
||||
assert.True(t, repoAllowed(scoped, "core/php"))
|
||||
assert.True(t, repoAllowed(scoped, "core/php/sub"))
|
||||
}
|
||||
|
||||
func TestRepoAllowedWildcard_Bad_NoMatch(t *testing.T) {
|
||||
scoped := []string{"core/*"}
|
||||
assert.False(t, repoAllowed(scoped, "other/repo"))
|
||||
assert.False(t, repoAllowed(scoped, "core/php/sub"))
|
||||
}
|
||||
|
||||
func TestRepoAllowedWildcard_Bad_EmptyRepo(t *testing.T) {
|
||||
scoped := []string{"core/*"}
|
||||
assert.False(t, repoAllowed(scoped, ""))
|
||||
}
|
||||
|
||||
func TestRepoAllowedWildcard_Bad_EmptyScope(t *testing.T) {
|
||||
assert.False(t, repoAllowed(nil, "core/php"))
|
||||
assert.False(t, repoAllowed([]string{}, "core/php"))
|
||||
}
|
||||
|
||||
// --- Integration: PolicyEngine with wildcard scopes ---
|
||||
|
||||
func TestEvaluateWildcardScope_Good_SingleLevel(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
require.NoError(t, r.Register(Agent{
|
||||
Name: "WildAgent",
|
||||
Tier: TierVerified,
|
||||
ScopedRepos: []string{"core/*"},
|
||||
}))
|
||||
pe := NewPolicyEngine(r)
|
||||
|
||||
result := pe.Evaluate("WildAgent", CapPushRepo, "core/php")
|
||||
assert.Equal(t, Allow, result.Decision)
|
||||
|
||||
result = pe.Evaluate("WildAgent", CapPushRepo, "core/go-crypt")
|
||||
assert.Equal(t, Allow, result.Decision)
|
||||
}
|
||||
|
||||
func TestEvaluateWildcardScope_Bad_OutOfScope(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
require.NoError(t, r.Register(Agent{
|
||||
Name: "WildAgent",
|
||||
Tier: TierVerified,
|
||||
ScopedRepos: []string{"core/*"},
|
||||
}))
|
||||
pe := NewPolicyEngine(r)
|
||||
|
||||
result := pe.Evaluate("WildAgent", CapPushRepo, "host-uk/docs")
|
||||
assert.Equal(t, Deny, result.Decision)
|
||||
assert.Contains(t, result.Reason, "does not have access")
|
||||
}
|
||||
|
||||
func TestEvaluateWildcardScope_Bad_NestedNotAllowedBySingleStar(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
require.NoError(t, r.Register(Agent{
|
||||
Name: "WildAgent",
|
||||
Tier: TierVerified,
|
||||
ScopedRepos: []string{"core/*"},
|
||||
}))
|
||||
pe := NewPolicyEngine(r)
|
||||
|
||||
result := pe.Evaluate("WildAgent", CapPushRepo, "core/php/sub")
|
||||
assert.Equal(t, Deny, result.Decision)
|
||||
}
|
||||
|
||||
func TestEvaluateWildcardScope_Good_RecursiveAllowsNested(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
require.NoError(t, r.Register(Agent{
|
||||
Name: "DeepAgent",
|
||||
Tier: TierVerified,
|
||||
ScopedRepos: []string{"core/**"},
|
||||
}))
|
||||
pe := NewPolicyEngine(r)
|
||||
|
||||
result := pe.Evaluate("DeepAgent", CapPushRepo, "core/php/sub")
|
||||
assert.Equal(t, Allow, result.Decision)
|
||||
}
|
||||
|
||||
func TestEvaluateWildcardScope_Good_MixedExactAndWildcard(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
require.NoError(t, r.Register(Agent{
|
||||
Name: "MixedAgent",
|
||||
Tier: TierVerified,
|
||||
ScopedRepos: []string{"core/*", "host-uk/docs"},
|
||||
}))
|
||||
pe := NewPolicyEngine(r)
|
||||
|
||||
// Wildcard match
|
||||
result := pe.Evaluate("MixedAgent", CapPushRepo, "core/php")
|
||||
assert.Equal(t, Allow, result.Decision)
|
||||
|
||||
// Exact match
|
||||
result = pe.Evaluate("MixedAgent", CapPushRepo, "host-uk/docs")
|
||||
assert.Equal(t, Allow, result.Decision)
|
||||
|
||||
// Neither
|
||||
result = pe.Evaluate("MixedAgent", CapPushRepo, "host-uk/core")
|
||||
assert.Equal(t, Deny, result.Decision)
|
||||
}
|
||||
|
||||
func TestEvaluateWildcardScope_Good_ReadSecretsScoped(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
require.NoError(t, r.Register(Agent{
|
||||
Name: "ScopedSecrets",
|
||||
Tier: TierVerified,
|
||||
ScopedRepos: []string{"core/*"},
|
||||
}))
|
||||
pe := NewPolicyEngine(r)
|
||||
|
||||
result := pe.Evaluate("ScopedSecrets", CapReadSecrets, "core/php")
|
||||
assert.Equal(t, Allow, result.Decision)
|
||||
|
||||
result = pe.Evaluate("ScopedSecrets", CapReadSecrets, "other/repo")
|
||||
assert.Equal(t, Deny, result.Decision)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue