125 lines
4.2 KiB
Go
125 lines
4.2 KiB
Go
package audit
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
)
|
|
|
|
// Sentinel errors returned by Approver. Use errors.Is to test for them.
|
|
var (
|
|
// ErrActionMissing is returned when the action is blank.
|
|
ErrActionMissing = errors.New("audit: action is missing")
|
|
// ErrUnknownActor is returned when the acting role name is blank. This
|
|
// package has no role registry, so a blank name is the only "unknown" it
|
|
// can detect; whether a role is truly authorized is decided upstream.
|
|
ErrUnknownActor = errors.New("audit: actor is unknown")
|
|
// ErrExplainMissing is returned when the explanation is blank.
|
|
ErrExplainMissing = errors.New("audit: explanation is missing")
|
|
)
|
|
|
|
// reviewerMain is the name recorded for the main agent that performs the review.
|
|
const reviewerMain = "main"
|
|
|
|
// Approval is the immutable record of a single main-agent review-and-approve of
|
|
// a subagent's sensitive action.
|
|
type Approval struct {
|
|
// Actor is the acting role name (角色名).
|
|
Actor string
|
|
// Action is the sensitive action being approved.
|
|
Action string
|
|
// Description is the explanation of what the actor wants to do (what the
|
|
// action means). It is named Description rather than Explain because the
|
|
// Explain method (below) returns the frontend notice.
|
|
Description string
|
|
// Allowed reports whether the action was granted.
|
|
Allowed bool
|
|
// Reviewer is the main agent that performed the review.
|
|
Reviewer string
|
|
// At is when the review-and-approve happened.
|
|
At time.Time
|
|
}
|
|
|
|
// Explain returns the short frontend notice describing the review outcome. For
|
|
// an allowed action it is "「<actor> 想要干什么,我审查后批准了」"; for a denied
|
|
// action it uses "我审查后拒绝了". Actor is interpolated in place of <actor>.
|
|
func (a Approval) Explain() string {
|
|
if a.Allowed {
|
|
return "「" + a.Actor + " 想要干什么,我审查后批准了」"
|
|
}
|
|
return "「" + a.Actor + " 想要干什么,我审查后拒绝了」"
|
|
}
|
|
|
|
// Approver records main-agent review-and-approve events and their audit logs.
|
|
type Approver struct {
|
|
log *slog.Logger
|
|
}
|
|
|
|
// NewApprover builds an Approver that writes audit logs through log. The logger
|
|
// is tagged with source "orchestration/audit". A nil logger is allowed, making
|
|
// the approver record silently (no log output).
|
|
func NewApprover(log *slog.Logger) *Approver {
|
|
return &Approver{log: logger.WithSource(log, "orchestration/audit")}
|
|
}
|
|
|
|
// ReviewAndApprove validates the request, checks ctx, writes a structured audit
|
|
// log (who / what / when / result), and returns the recorded approval with
|
|
// Allowed set to true.
|
|
//
|
|
// Whether an action is actually sensitive and whether it should be granted is
|
|
// decided by the upper policy layer; this method only records, narrates and
|
|
// returns the result model. It returns ErrUnknownActor / ErrActionMissing /
|
|
// ErrExplainMissing for blank inputs and wraps ctx.Err() when ctx is canceled.
|
|
func (a *Approver) ReviewAndApprove(ctx context.Context, actor, action, describe string) (*Approval, error) {
|
|
if err := validateApproval(actor, action, describe); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, fmt.Errorf("audit: review-and-approve: %w", err)
|
|
}
|
|
ap := &Approval{
|
|
Actor: actor,
|
|
Action: action,
|
|
Description: describe,
|
|
Allowed: true,
|
|
Reviewer: reviewerMain,
|
|
At: time.Now(),
|
|
}
|
|
a.logAudit(ap)
|
|
return ap, nil
|
|
}
|
|
|
|
// logAudit writes the structured who/what/when/result audit record.
|
|
func (a *Approver) logAudit(ap *Approval) {
|
|
if a.log == nil {
|
|
return
|
|
}
|
|
a.log.Info("audit: review-and-approve",
|
|
slog.String("actor", ap.Actor),
|
|
slog.String("action", ap.Action),
|
|
slog.String("explain", ap.Description),
|
|
slog.Bool("allowed", ap.Allowed),
|
|
slog.String("reviewer", ap.Reviewer),
|
|
slog.Time("at", ap.At),
|
|
)
|
|
}
|
|
|
|
// validateApproval rejects blank inputs with the matching sentinel.
|
|
func validateApproval(actor, action, describe string) error {
|
|
if strings.TrimSpace(actor) == "" {
|
|
return fmt.Errorf("%w: actor is blank", ErrUnknownActor)
|
|
}
|
|
if strings.TrimSpace(action) == "" {
|
|
return fmt.Errorf("%w: action is blank", ErrActionMissing)
|
|
}
|
|
if strings.TrimSpace(describe) == "" {
|
|
return fmt.Errorf("%w: explanation is blank", ErrExplainMissing)
|
|
}
|
|
return nil
|
|
}
|