Address second-round review on PR #555: - Move CloseAuditSink into the unconditional Execute defer so async remote forwards are drained on BOTH success and failure paths. Cobra skips PersistentPostRunE when RunE returns an error, which previously dropped in-flight forwards for failed commands. Make CloseAuditSink idempotent via sync.Once so the success-path hook and the defer can both call it. - CSV export now returns a "文件:行号" error on malformed JSONL instead of silently skipping the line and exiting 0. - Add regressions: TestCloseAuditSinkDrainsOnErrorPath (error-path drain), TestExportCSVFailsOnMalformedJSON (corrupt JSONL visible), and TestAuditIdentityReresolvesOnProfileSwitch (per-profile Actor via an injectable token loader seam).
165 lines
4.7 KiB
Go
165 lines
4.7 KiB
Go
package app
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"runtime"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
|
)
|
|
|
|
var (
|
|
auditSinkOnce sync.Once
|
|
auditCloseOnce sync.Once
|
|
sharedAuditSink audit.Sink
|
|
|
|
auditIDMu sync.Mutex
|
|
cachedActor audit.Actor
|
|
cachedAgentID string
|
|
cachedProfile string
|
|
identityLoaded bool
|
|
|
|
// loadTokenForProfile is the profile-scoped token loader. It is a package
|
|
// variable so profile-switch Actor attribution can be tested deterministically
|
|
// without touching the OS keychain.
|
|
loadTokenForProfile = auth.LoadTokenDataForProfile
|
|
)
|
|
|
|
// setupAuditSink builds the process-wide audit sink once and caches it so the
|
|
// runner and the shutdown hook share a single writer/forwarder instance.
|
|
func setupAuditSink() audit.Sink {
|
|
auditSinkOnce.Do(func() {
|
|
sink, err := audit.BuildSink(defaultConfigDir(), auditReport)
|
|
if err != nil {
|
|
auditReport("initialization failed, audit disabled for this session: %v", err)
|
|
sharedAuditSink = audit.NopSink{}
|
|
return
|
|
}
|
|
sharedAuditSink = sink
|
|
})
|
|
return sharedAuditSink
|
|
}
|
|
|
|
// CloseAuditSink flushes in-flight remote forwards and closes the audit writer.
|
|
// It is invoked from an unconditional defer in Execute so the drain happens for
|
|
// both successful and failed commands (Cobra skips PersistentPostRunE when RunE
|
|
// returns an error). The sync.Once makes repeated calls safe.
|
|
func CloseAuditSink() {
|
|
auditCloseOnce.Do(func() {
|
|
if sharedAuditSink == nil {
|
|
return
|
|
}
|
|
if err := sharedAuditSink.Close(); err != nil {
|
|
auditReport("close failed: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// auditReport routes non-fatal audit-subsystem diagnostics to the structured
|
|
// file log (always, when available) and to stderr when DWS_AUDIT_DEBUG is set,
|
|
// so init/write/forward failures are observable instead of silently swallowed.
|
|
func auditReport(format string, args ...any) {
|
|
msg := "audit: " + fmt.Sprintf(format, args...)
|
|
if l := FileLoggerInstance(); l != nil {
|
|
l.Warn(msg)
|
|
}
|
|
if audit.DebugEnabled() {
|
|
fmt.Fprintln(os.Stderr, "[dws] "+msg)
|
|
}
|
|
}
|
|
|
|
// auditIdentity resolves the Actor for the active runtime profile. The result
|
|
// is cached per-profile so a profile switch within a long-running process (e.g.
|
|
// serve mode) re-resolves rather than reusing a stale identity.
|
|
func auditIdentity() (audit.Actor, string) {
|
|
profile := auth.RuntimeProfile()
|
|
|
|
auditIDMu.Lock()
|
|
defer auditIDMu.Unlock()
|
|
if identityLoaded && profile == cachedProfile {
|
|
return cachedActor, cachedAgentID
|
|
}
|
|
|
|
configDir := defaultConfigDir()
|
|
var actor audit.Actor
|
|
if td, err := loadTokenForProfile(configDir, profile); err == nil && td != nil {
|
|
actor = audit.Actor{
|
|
UserID: td.UserID,
|
|
Name: td.UserName,
|
|
CorpID: td.CorpID,
|
|
CorpName: td.CorpName,
|
|
}
|
|
} else if err != nil {
|
|
auditReport("resolve actor for profile %q failed: %v", profile, err)
|
|
}
|
|
|
|
agentID := ""
|
|
if id := auth.Load(configDir); id != nil {
|
|
agentID = id.AgentID
|
|
}
|
|
|
|
cachedActor, cachedAgentID, cachedProfile, identityLoaded = actor, agentID, profile, true
|
|
return actor, agentID
|
|
}
|
|
|
|
func emitAudit(sink audit.Sink, execID string, invokeStart time.Time, invocation executor.Invocation, endpoint string, retErr error, cliVersion string) {
|
|
if sink == nil {
|
|
return
|
|
}
|
|
if _, ok := sink.(audit.NopSink); ok {
|
|
return
|
|
}
|
|
|
|
actor, agentID := auditIdentity()
|
|
|
|
result := "success"
|
|
var errCat, errReason string
|
|
if retErr != nil {
|
|
result = "error"
|
|
errCat, errReason = classifyAuditError(retErr)
|
|
}
|
|
|
|
paramsSummary := logging.SanitizeArguments(invocation.Params, 1024)
|
|
|
|
evt := &audit.Event{
|
|
Timestamp: invokeStart,
|
|
ExecutionID: execID,
|
|
AgentID: agentID,
|
|
Actor: actor,
|
|
Product: invocation.CanonicalProduct,
|
|
Command: invocation.Tool,
|
|
Endpoint: transport.RedactURL(endpoint),
|
|
ParamsSummary: paramsSummary,
|
|
Result: result,
|
|
ErrCategory: errCat,
|
|
ErrReason: errReason,
|
|
DurationMs: time.Since(invokeStart).Milliseconds(),
|
|
CLIVersion: cliVersion,
|
|
OS: runtime.GOOS,
|
|
Arch: runtime.GOARCH,
|
|
}
|
|
|
|
if err := sink.Emit(evt); err != nil {
|
|
auditReport("emit event failed (exec %s): %v", execID, err)
|
|
}
|
|
}
|
|
|
|
func classifyAuditError(err error) (category, reason string) {
|
|
if err == nil {
|
|
return "", ""
|
|
}
|
|
var typed *apperrors.Error
|
|
if errors.As(err, &typed) {
|
|
return string(typed.Category), typed.Reason
|
|
}
|
|
return "unknown", err.Error()
|
|
}
|