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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion internal/pep/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,17 @@ func (event AuditEvent) Validate() error {
if err := validateSafeText("policy audit actor", event.Actor, maxActorBytes); err != nil {
return err
}
if !event.Role.Valid() || event.Action != tenancy.ActionRead || !event.Verb.Valid() {
if !event.Role.Valid() || (event.Action != tenancy.ActionRead && event.Action != tenancy.ActionProposeIntent) {
return fmt.Errorf("policy audit has unsupported role, action, or verb")
}
if !event.Role.Allows(event.Action) && (event.Verdict != VerdictDeny || (event.ReasonCode != "role-denied" && event.ReasonCode != "invalid-request")) {
return fmt.Errorf("policy audit has impossible role and action outcome")
}
if event.Verb == invalidVerb {
if event.Verdict != VerdictDeny || event.ReasonCode != "invalid-request" {
return fmt.Errorf("policy audit has unsafe invalid-request sentinel")
}
} else if !event.Verb.validForAction(event.Action) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return fmt.Errorf("policy audit has unsupported role, action, or verb")
}
return (Decision{Verdict: event.Verdict, ReasonCode: event.ReasonCode}).Validate()
Expand Down
84 changes: 84 additions & 0 deletions internal/pep/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,90 @@ func TestSlogAuditorTextHandlerPreservesSafeFieldsAndSeverity(t *testing.T) {
}
}

func TestSlogAuditorAcceptsProposalAndConstrainedInvalidRequestEvents(t *testing.T) {
var output bytes.Buffer
auditor, err := NewSlogAuditor(slog.New(slog.NewJSONHandler(&output, nil)))
if err != nil {
t.Fatal(err)
}
proposal := policyAuditEvent(time.Now().UTC(), VerdictAllow, "proposal-allowed")
proposal.Actor = "user:operator"
proposal.Role = tenancy.RoleOperator
proposal.Action = tenancy.ActionProposeIntent
proposal.Verb = "deployment.restart"
invalid := proposal
invalid.Verb = invalidVerb
invalid.Verdict = VerdictDeny
invalid.ReasonCode = "invalid-request"
invalidRead := policyAuditEvent(time.Now().UTC(), VerdictDeny, "invalid-request")
invalidRead.Verb = invalidVerb
for _, event := range []AuditEvent{proposal, invalid, invalidRead} {
if err := auditor.Record(context.Background(), event); err != nil {
t.Fatalf("Record(%s) error = %v", event.Verb, err)
}
}
logged := output.String()
for _, field := range []string{`"action":"propose-intent"`, `"verb":"deployment.restart"`, `"verb":"invalid"`} {
if !strings.Contains(logged, field) {
t.Fatalf("proposal audit missing %s: %s", field, logged)
}
}
for _, forbidden := range []string{"arguments_digest", "resolved_digest", "payments", "token", "secret"} {
if strings.Contains(logged, forbidden) {
t.Fatalf("proposal audit leaked %q: %s", forbidden, logged)
}
}
}

func TestAuditEventRejectsUnsafeInvalidRequestSentinel(t *testing.T) {
base := policyAuditEvent(time.Now().UTC(), VerdictDeny, "invalid-request")
base.Verb = invalidVerb
if err := base.Validate(); err != nil {
t.Fatalf("baseline invalid-request event should validate: %v", err)
}
for _, mutate := range []func(*AuditEvent){
func(event *AuditEvent) { event.Verdict = VerdictAllow },
func(event *AuditEvent) { event.Verdict = VerdictRequireApproval },
func(event *AuditEvent) { event.ReasonCode = "policy-deny" },
} {
event := base
mutate(&event)
if err := event.Validate(); err == nil {
t.Fatalf("AuditEvent.Validate() accepted unsafe invalid sentinel: %#v", event)
}
}
}

func TestAuditEventRejectsImpossibleProposalRoleOutcome(t *testing.T) {
base := policyAuditEvent(time.Now().UTC(), VerdictDeny, "role-denied")
base.Action = tenancy.ActionProposeIntent
base.Verb = "deployment.restart"
if err := base.Validate(); err != nil {
t.Fatalf("role-denied proposal event should validate: %v", err)
}

invalidRequest := base
invalidRequest.ReasonCode = "invalid-request"
if err := invalidRequest.Validate(); err != nil {
t.Fatalf("pre-role invalid-request event should validate: %v", err)
}

for _, mutate := range []func(*AuditEvent){
func(event *AuditEvent) { event.Verdict = VerdictAllow; event.ReasonCode = "proposal-allowed" },
func(event *AuditEvent) {
event.Verdict = VerdictRequireApproval
event.ReasonCode = "approval-required"
},
func(event *AuditEvent) { event.ReasonCode = "policy-deny" },
} {
event := base
mutate(&event)
if err := event.Validate(); err == nil {
t.Fatalf("AuditEvent.Validate() accepted impossible role outcome: %#v", event)
}
}
}

func policyAuditEvent(at time.Time, verdict Verdict, reason string) AuditEvent {
return AuditEvent{
At: at, TraceID: tracing.ID("0123456789abcdef0123456789abcdef"), WorkspaceID: "workspace-a", Actor: "user:reader", Role: tenancy.RoleReader,
Expand Down
21 changes: 18 additions & 3 deletions internal/pep/boundary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,23 @@ func TestPEPHasNoNetworkOrDispatchImports(t *testing.T) {
}
}

func TestAuditEventOmitsArgumentDigest(t *testing.T) {
if _, exists := reflect.TypeFor[AuditEvent]().FieldByName("ArgumentsDigest"); exists {
t.Fatal("audit event must not retain the policy argument digest")
func TestPolicyBoundaryOmitsRawProposalAndDigestMaterial(t *testing.T) {
assertExactFields(t, reflect.TypeFor[AuditEvent](), []string{
"At", "TraceID", "WorkspaceID", "Actor", "Role", "Action", "Verb", "Verdict", "ReasonCode",
})
assertExactFields(t, reflect.TypeFor[ProposalInput](), []string{
"intentID", "workspaceID", "actor", "verb", "target", "argumentsDigest", "resolvedDigest",
})
}

func assertExactFields(t *testing.T, value reflect.Type, expected []string) {
t.Helper()
if value.NumField() != len(expected) {
t.Fatalf("%s fields = %d, want exactly %d", value.Name(), value.NumField(), len(expected))
}
for _, name := range expected {
if _, exists := value.FieldByName(name); !exists {
t.Fatalf("%s is missing approved field %s", value.Name(), name)
}
}
}
7 changes: 4 additions & 3 deletions internal/pep/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import (
"context"
"time"

"github.com/ArdurAI/sith/internal/intent"
"github.com/ArdurAI/sith/internal/tracing"
)

// DecisionOutcome is the bounded self-observability result of one policy read attempt.
// DecisionOutcome is the bounded self-observability result of one policy decision.
// It intentionally carries no workspace, actor, selector, credential, or reason-code material.
type DecisionOutcome string

Expand All @@ -21,7 +22,7 @@ const (
DecisionOutcomeError DecisionOutcome = "error"
)

// DecisionObserver receives passive, bounded measurements for policy reads. Implementations must
// DecisionObserver receives passive, bounded measurements for policy decisions. Implementations must
// not block or mutate the authorization path. The enforcer isolates observer panics defensively.
type DecisionObserver interface {
ObserveDecision(verb Verb, outcome DecisionOutcome, duration time.Duration)
Expand Down Expand Up @@ -66,7 +67,7 @@ func traceOutcome(outcome DecisionOutcome) tracing.Outcome {
}

func normalizedObservedVerb(verb Verb) Verb {
if verb.Valid() {
if verb.Valid() || intent.Verb(verb).Valid() {
return verb
}
return "invalid"
Expand Down
18 changes: 18 additions & 0 deletions internal/pep/metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ func TestEnforcerRecoversFromPanickingObserver(t *testing.T) {
}
}

func TestEnforcerObservesCanonicalProposalVerb(t *testing.T) {
observer := &recordingDecisionObserver{}
enforcer, err := NewEnforcer(Config{
Hook: proposalDecision(VerdictAllow, "proposal-allowed"),
Auditor: AuditFunc(func(context.Context, AuditEvent) error { return nil }),
Observer: observer,
})
if err != nil {
t.Fatal(err)
}
if err := enforcer.AuthorizeProposal(context.Background(), operatorScope(t), testProposalInput(t)); err != nil {
t.Fatalf("AuthorizeProposal() error = %v", err)
}
if len(observer.events) != 1 || observer.events[0].verb != "deployment.restart" || observer.events[0].outcome != DecisionOutcomeAllow {
t.Fatalf("observations = %#v", observer.events)
}
}

type decisionObservation struct {
verb Verb
outcome DecisionOutcome
Expand Down
Loading