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

@@ -462,3 +462,52 @@ func TestEvaluationResult_IsAllowed(t *testing.T) {
})
}
}
func TestEvaluationResult_PolicyID(t *testing.T) {
t.Parallel()
result := EvaluationResult{
Decision: DecisionAllow,
MatchedStatement: &Statement{
SID: "read-thirdParties",
},
MatchedPolicy: &Policy{
ID: "probo:viewer",
},
}
if got := result.PolicyID(); got != "read-thirdParties" {
t.Errorf("PolicyID() = %q, want read-thirdParties", got)
}
}
func TestEvaluationResult_Reason(t *testing.T) {
t.Parallel()
t.Run("implicit deny includes role", func(t *testing.T) {
t.Parallel()
result := EvaluationResult{Decision: DecisionNoMatch}
want := "implicit deny: no matching allow for role VIEWER"
if got := result.Reason("VIEWER"); got != want {
t.Errorf("Reason() = %q, want %q", got, want)
}
})
t.Run("explicit deny uses statement sid", func(t *testing.T) {
t.Parallel()
result := EvaluationResult{
Decision: DecisionDeny,
MatchedStatement: &Statement{
SID: "deny-delete",
},
}
want := "explicit deny by statement deny-delete"
if got := result.Reason("ADMIN"); got != want {
t.Errorf("Reason() = %q, want %q", got, want)
}
})
}