Log every authorization decision from the authorizer

Denials were invisible in the audit trail and evaluator explainability
(policy_id, reason) was discarded before reaching logs. Emit a structured
authz decision line on every evaluation in evaluateMultiInTx — allow,
deny, no_match, and assumption errors — using the existing authorizer
logger with opaque IDs only.

Add decision_log.go with DecisionRecord and logDecision. Surface
PolicyID and Reason on EvaluationResult for logging. Audit log
behavior is unchanged (allow-only). Document the convention in
authorization.md.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-06-15 16:00:04 +02:00
parent c8de75cc03
commit 71e8d662bc
6 changed files with 388 additions and 1 deletions

View File

@@ -47,6 +47,52 @@ func (r EvaluationResult) IsAllowed() bool {
return r.Decision == DecisionAllow
}
func (r EvaluationResult) statementSID() string {
if r.MatchedStatement != nil {
return r.MatchedStatement.SID
}
return ""
}
// PolicyID returns the statement SID or matched policy ID for logging.
func (r EvaluationResult) PolicyID() string {
if sid := r.statementSID(); sid != "" {
return sid
}
if r.MatchedPolicy != nil {
return r.MatchedPolicy.ID
}
return ""
}
// Reason returns a human-readable explanation for logging.
func (r EvaluationResult) Reason(role string) string {
if sid := r.statementSID(); sid != "" {
switch r.Decision {
case DecisionAllow:
return "allowed by statement " + sid
case DecisionDeny:
return "explicit deny by statement " + sid
}
}
switch r.Decision {
case DecisionAllow:
return "allowed"
case DecisionDeny:
return "explicit deny"
default:
if role != "" {
return "implicit deny: no matching allow for role " + role
}
return "implicit deny: no matching allow"
}
}
// AuthorizationRequest contains all information needed to evaluate access.
type AuthorizationRequest struct {
// Principal is the actor requesting access.