feat: I5 audit path + classification-aware degradation (#54, capture 49e) #60
@@ -140,4 +140,9 @@ type CaptureReceipt struct {
|
|||||||
Errors []ItemError `json:"errors"`
|
Errors []ItemError `json:"errors"`
|
||||||
EffectiveClassification string `json:"effective_classification,omitempty"`
|
EffectiveClassification string `json:"effective_classification,omitempty"`
|
||||||
DryRun bool `json:"dry_run"`
|
DryRun bool `json:"dry_run"`
|
||||||
|
// AuditBuffered is true when the central audit sink was unreachable and
|
||||||
|
// this capture's audit record was written to the durable local buffer
|
||||||
|
// instead (internal/public tier). Surfaces the degraded state to the
|
||||||
|
// caller per §4.4.
|
||||||
|
AuditBuffered bool `json:"audit_buffered,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,9 +96,31 @@ type AuditEntry struct {
|
|||||||
SecurityEvents []string
|
SecurityEvents []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuditSink records the audit entry. The classification-aware
|
// AuditOutcome is how a capture's audit record was (or will be) persisted.
|
||||||
// degradation/refusal policy (confidential fails closed, internal
|
type AuditOutcome int
|
||||||
// degrades) is the caller's concern in #54; this port just records.
|
|
||||||
|
const (
|
||||||
|
// AuditCentral means the record goes to the central sink (loki).
|
||||||
|
AuditCentral AuditOutcome = iota
|
||||||
|
// AuditBuffered means the central sink was unreachable and the record
|
||||||
|
// is written to a durable local buffer for later reconciliation
|
||||||
|
// (internal/public tier only).
|
||||||
|
AuditBuffered
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditSink is the two-phase, classification-aware audit port (I5, §4.4).
|
||||||
|
//
|
||||||
|
// Reserve runs BEFORE any write and decides whether the capture can be
|
||||||
|
// audited at its effective classification: it returns the outcome to use,
|
||||||
|
// or an error to refuse the capture before anything is written
|
||||||
|
// (confidential + central sink down → refuse; the all-tiers floor when
|
||||||
|
// nothing can record → refuse). Record runs AFTER the writes and persists
|
||||||
|
// the final entry per the reserved outcome.
|
||||||
|
//
|
||||||
|
// Splitting reserve from record is what lets "confidential + sink-down →
|
||||||
|
// refuse before any write" be literally true while the record itself
|
||||||
|
// (which lists what landed) is necessarily written afterwards.
|
||||||
type AuditSink interface {
|
type AuditSink interface {
|
||||||
Record(ctx context.Context, e AuditEntry) error
|
Reserve(ctx context.Context, level classification.Level) (AuditOutcome, error)
|
||||||
|
Record(ctx context.Context, e AuditEntry, outcome AuditOutcome) error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ var validActions = map[string]bool{"create": true, "close": true, "comment": tru
|
|||||||
// REST adapter maps it to HTTP 403. Callers test with errors.Is.
|
// REST adapter maps it to HTTP 403. Callers test with errors.Is.
|
||||||
var ErrSovereigntyRefused = fmt.Errorf("capture refused by I1 sovereignty gate")
|
var ErrSovereigntyRefused = fmt.Errorf("capture refused by I1 sovereignty gate")
|
||||||
|
|
||||||
|
// ErrAuditUnavailable is returned when the I5 audit gate refuses a capture
|
||||||
|
// before any write: a confidential capture whose central audit sink is
|
||||||
|
// unreachable, or the all-tiers floor where nothing can record the audit.
|
||||||
|
// The REST adapter maps it to HTTP 503. Callers test with errors.Is.
|
||||||
|
var ErrAuditUnavailable = fmt.Errorf("capture refused: audit substrate unavailable")
|
||||||
|
|
||||||
// assertedZoneMismatch returns a security-event string when the caller's
|
// assertedZoneMismatch returns a security-event string when the caller's
|
||||||
// harness label asserts a trust zone that contradicts the server-derived
|
// harness label asserts a trust zone that contradicts the server-derived
|
||||||
// origin. A harness label that names no zone (the normal case, e.g.
|
// origin. A harness label that names no zone (the normal case, e.g.
|
||||||
@@ -105,7 +111,7 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
EffectiveClassification: effective.String(),
|
EffectiveClassification: effective.String(),
|
||||||
Items: nil, // refused before any write
|
Items: nil, // refused before any write
|
||||||
SecurityEvents: append(securityEvents, "I1 refusal: confidential capture via us-nexus origin"),
|
SecurityEvents: append(securityEvents, "I1 refusal: confidential capture via us-nexus origin"),
|
||||||
})
|
}, AuditCentral)
|
||||||
return CaptureReceipt{}, fmt.Errorf("%w: effective classification confidential through %s origin",
|
return CaptureReceipt{}, fmt.Errorf("%w: effective classification confidential through %s origin",
|
||||||
ErrSovereigntyRefused, in.Context.Origin)
|
ErrSovereigntyRefused, in.Context.Origin)
|
||||||
}
|
}
|
||||||
@@ -131,6 +137,16 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
return receipt, nil
|
return receipt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// I5 audit gate: decide BEFORE any write whether this capture can be
|
||||||
|
// audited at its effective classification. Confidential + central sink
|
||||||
|
// down → refuse here, before writing anything; the all-tiers floor
|
||||||
|
// (nothing can record) likewise refuses. Internal/public degrade to the
|
||||||
|
// durable local buffer (signalled by AuditBuffered).
|
||||||
|
outcome, err := s.audit.Reserve(ctx, effective)
|
||||||
|
if err != nil {
|
||||||
|
return CaptureReceipt{}, fmt.Errorf("%w: %v", ErrAuditUnavailable, err)
|
||||||
|
}
|
||||||
|
|
||||||
var landed []string
|
var landed []string
|
||||||
|
|
||||||
for i, ins := range in.Insights {
|
for i, ins := range in.Insights {
|
||||||
@@ -163,9 +179,9 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// I5: emit a request-level audit record of exactly what landed.
|
// I5: persist the request-level audit record of exactly what landed,
|
||||||
// Best-effort here; the classification-aware refusal/degradation
|
// using the outcome reserved before the writes. AuditBuffered surfaces
|
||||||
// policy is #54.
|
// the degraded (locally-buffered) state on the receipt.
|
||||||
if err := s.audit.Record(ctx, AuditEntry{
|
if err := s.audit.Record(ctx, AuditEntry{
|
||||||
Timestamp: s.now().UTC(),
|
Timestamp: s.now().UTC(),
|
||||||
Principal: in.Context.Principal,
|
Principal: in.Context.Principal,
|
||||||
@@ -175,9 +191,12 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt,
|
|||||||
EffectiveClassification: effective.String(),
|
EffectiveClassification: effective.String(),
|
||||||
Items: landed,
|
Items: landed,
|
||||||
SecurityEvents: securityEvents,
|
SecurityEvents: securityEvents,
|
||||||
}); err != nil {
|
}, outcome); err != nil {
|
||||||
receipt.Errors = append(receipt.Errors, ItemError{Item: "audit", Error: err.Error()})
|
receipt.Errors = append(receipt.Errors, ItemError{Item: "audit", Error: err.Error()})
|
||||||
}
|
}
|
||||||
|
if outcome == AuditBuffered {
|
||||||
|
receipt.AuditBuffered = true
|
||||||
|
}
|
||||||
|
|
||||||
return receipt, nil
|
return receipt, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,11 +112,20 @@ func (p fakePolicy) Derive(t classification.Target) classification.Level {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeAudit struct {
|
type fakeAudit struct {
|
||||||
entries []AuditEntry
|
entries []AuditEntry
|
||||||
err error
|
err error // Record error
|
||||||
|
reserveErr error // Reserve error (refuse before write)
|
||||||
|
reserveMode AuditOutcome
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeAudit) Record(_ context.Context, e AuditEntry) error {
|
func (f *fakeAudit) Reserve(_ context.Context, _ classification.Level) (AuditOutcome, error) {
|
||||||
|
if f.reserveErr != nil {
|
||||||
|
return 0, f.reserveErr
|
||||||
|
}
|
||||||
|
return f.reserveMode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAudit) Record(_ context.Context, e AuditEntry, _ AuditOutcome) error {
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return f.err
|
return f.err
|
||||||
}
|
}
|
||||||
@@ -408,3 +417,55 @@ func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, rec.Insights[0].OK)
|
assert.True(t, rec.Insights[0].OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- I5 audit gate (#54) ---
|
||||||
|
|
||||||
|
func TestCaptureRefusesWhenAuditReserveFails(t *testing.T) {
|
||||||
|
// Reserve refusing (e.g. confidential + central sink down, or the floor)
|
||||||
|
// aborts the capture before any write.
|
||||||
|
b := &fakeBrain{}
|
||||||
|
tr := &fakeTracker{}
|
||||||
|
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
|
||||||
|
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
||||||
|
|
||||||
|
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrAuditUnavailable)
|
||||||
|
assert.Empty(t, b.writes, "nothing written when audit unavailable")
|
||||||
|
assert.Empty(t, tr.created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
|
||||||
|
// Reserve returns AuditBuffered (internal/public, central down) → capture
|
||||||
|
// proceeds and the receipt flags the degraded audit state.
|
||||||
|
b := &fakeBrain{}
|
||||||
|
au := &fakeAudit{reserveMode: AuditBuffered}
|
||||||
|
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, rec.Insights[0].OK, "capture proceeds on degraded audit")
|
||||||
|
assert.True(t, rec.AuditBuffered, "receipt flags locally-buffered audit")
|
||||||
|
require.Len(t, au.entries, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
||||||
|
// dry_run must not even probe the audit sink (writes nothing anywhere).
|
||||||
|
au := &fakeAudit{reserveErr: errors.New("would refuse")}
|
||||||
|
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, au)
|
||||||
|
|
||||||
|
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||||
|
Context: baseCtx(),
|
||||||
|
DryRun: true,
|
||||||
|
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "dry-run does not hit the audit gate")
|
||||||
|
assert.True(t, rec.DryRun)
|
||||||
|
assert.Empty(t, au.entries)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user