Skip to content

Commit 5c03090

Browse files
authored
Merge pull request #349 from esnible/expose-stats-cfg
Feat: Collect and serve Kagenti Authbridge statistics and configuration
2 parents 4cb992b + d5e736e commit 5c03090

9 files changed

Lines changed: 1079 additions & 40 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ A **single binary** providing transparent traffic interception for both inbound
6666
- `authproxy/init-iptables.sh` — Traffic interception setup (Istio ambient mesh compatible)
6767
- `authproxy/Dockerfile.init` — Init container image
6868

69-
**Ports:** 15123 (outbound), 15124 (inbound), 9090 (ext-proc/ext-authz), 9901 (admin)
69+
**Ports:** 15123 (outbound), 15124 (inbound), 9090 (ext-proc/ext-authz), 9901 (admin), 9093 (stats and config)
7070

7171
### 2. Client Registration (Python)
7272

authbridge/authlib/auth/auth.go

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package auth
22

33
import (
44
"context"
5+
"encoding/json"
56
"log/slog"
67
"net/http"
78
"strings"
9+
"sync"
810
"sync/atomic"
911
"time"
1012

@@ -56,6 +58,171 @@ type Auth struct {
5658
actorTokenSource ActorTokenSource
5759
audienceDeriver AudienceDeriver
5860
log *slog.Logger
61+
Stats *Stats
62+
}
63+
64+
// Stats holds statistics for validation and exchange.
65+
// The current implementation keeps approvals/denials/exchange as counters.
66+
type Stats struct {
67+
mu sync.Mutex // protects the following fields
68+
inboundApprovals map[InboundApprovalReason]int
69+
inboundDenials map[InboundDenialReason]int
70+
outboundApprovals map[OutboundApprovalReason]int
71+
outboundDenials map[OutboundDenialReason]int
72+
outboundReplaceTokens map[OutboundReplaceTokenReason]int
73+
}
74+
75+
// InboundDenialReason enumerates the reasons for inbound validation failure.
76+
type InboundDenialReason int
77+
78+
const (
79+
DENY_NO_HEADER InboundDenialReason = iota
80+
DENY_MALFORMED_HEADER
81+
DENY_VALIDATOR_MISSING
82+
DENY_JWT_FAILED
83+
)
84+
85+
// InboundApprovalReason enumerates the rationale for inbound validation success.
86+
type InboundApprovalReason int
87+
88+
const (
89+
APPROVE_PASSTHROUGH InboundApprovalReason = iota
90+
APPROVE_AUTHORIZED
91+
)
92+
93+
// OutboundApprovalReason enumerates the rationale for outbound validation success.
94+
type OutboundApprovalReason int
95+
96+
const (
97+
OUTBOUND_NO_MATCHING_ROUTE OutboundApprovalReason = iota
98+
OUTBOUND_NO_EXCHANGER
99+
OUTBOUND_PASSTHROUGH
100+
OUTBOUND_NO_TOKEN_POLICY
101+
)
102+
103+
// OutboundDenialReason enumerates the reasons for outbound denial.
104+
type OutboundDenialReason int
105+
106+
const (
107+
OUTBOUND_CREDS_REQUESTED_NO_EXCHANGER OutboundDenialReason = iota
108+
OUTBOUND_CREDENTIALS_GRANT_FAILURE
109+
OUTBOUND_NO_TOKEN
110+
OUTBOUND_TOKEN_EXCHANGE_FAILED
111+
)
112+
113+
// OutboundReplaceTokenReason enumerates the reasons for outbound token exchange.
114+
type OutboundReplaceTokenReason int
115+
116+
const (
117+
OUTBOUND_ACTION_REPLACE_TOKEN OutboundReplaceTokenReason = iota
118+
OUTBOUND_ACTION_CACHE_HIT
119+
)
120+
121+
func (r InboundDenialReason) String() string {
122+
switch r {
123+
case DENY_NO_HEADER:
124+
return "no_header"
125+
case DENY_MALFORMED_HEADER:
126+
return "malformed_header"
127+
case DENY_VALIDATOR_MISSING:
128+
return "validator_missing"
129+
case DENY_JWT_FAILED:
130+
return "jwt_failed"
131+
default:
132+
return "unknown"
133+
}
134+
}
135+
136+
func (r InboundApprovalReason) String() string {
137+
switch r {
138+
case APPROVE_PASSTHROUGH:
139+
return "passthrough"
140+
case APPROVE_AUTHORIZED:
141+
return "authorized"
142+
default:
143+
return "unknown"
144+
}
145+
}
146+
147+
func (r OutboundApprovalReason) String() string {
148+
switch r {
149+
case OUTBOUND_NO_MATCHING_ROUTE:
150+
return "no_matching_route"
151+
case OUTBOUND_NO_EXCHANGER:
152+
return "no_exchanger"
153+
case OUTBOUND_PASSTHROUGH:
154+
return "passthrough"
155+
case OUTBOUND_NO_TOKEN_POLICY:
156+
return "no_token_policy"
157+
default:
158+
return "unknown"
159+
}
160+
}
161+
162+
func (r OutboundDenialReason) String() string {
163+
switch r {
164+
case OUTBOUND_CREDS_REQUESTED_NO_EXCHANGER:
165+
return "creds_requested_no_exchanger"
166+
case OUTBOUND_CREDENTIALS_GRANT_FAILURE:
167+
return "credentials_grant_failure"
168+
case OUTBOUND_NO_TOKEN:
169+
return "no_token"
170+
case OUTBOUND_TOKEN_EXCHANGE_FAILED:
171+
return "token_exchange_failed"
172+
default:
173+
return "unknown"
174+
}
175+
}
176+
177+
func (r OutboundReplaceTokenReason) String() string {
178+
switch r {
179+
case OUTBOUND_ACTION_REPLACE_TOKEN:
180+
return "replace_token"
181+
case OUTBOUND_ACTION_CACHE_HIT:
182+
return "cache_hit"
183+
default:
184+
return "unknown"
185+
}
186+
}
187+
188+
func (s *Stats) MarshalJSON() ([]byte, error) {
189+
s.mu.Lock()
190+
defer s.mu.Unlock()
191+
192+
inApprovals := make(map[string]int, len(s.inboundApprovals))
193+
for k, v := range s.inboundApprovals {
194+
inApprovals[k.String()] = v
195+
}
196+
inDenials := make(map[string]int, len(s.inboundDenials))
197+
for k, v := range s.inboundDenials {
198+
inDenials[k.String()] = v
199+
}
200+
outApprovals := make(map[string]int, len(s.outboundApprovals))
201+
for k, v := range s.outboundApprovals {
202+
outApprovals[k.String()] = v
203+
}
204+
outDenials := make(map[string]int, len(s.outboundDenials))
205+
for k, v := range s.outboundDenials {
206+
outDenials[k.String()] = v
207+
}
208+
outReplaceTokens := make(map[string]int, len(s.outboundReplaceTokens))
209+
for k, v := range s.outboundReplaceTokens {
210+
outReplaceTokens[k.String()] = v
211+
}
212+
213+
return json.Marshal(struct {
214+
InboundApprovals map[string]int `json:"inbound_approvals"`
215+
InboundDenials map[string]int `json:"inbound_denials"`
216+
OutboundApprovals map[string]int `json:"outbound_approvals"`
217+
OutboundDenials map[string]int `json:"outbound_denials"`
218+
OutboundReplaceTokens map[string]int `json:"outbound_replace_tokens"`
219+
}{
220+
InboundApprovals: inApprovals,
221+
InboundDenials: inDenials,
222+
OutboundApprovals: outApprovals,
223+
OutboundDenials: outDenials,
224+
OutboundReplaceTokens: outReplaceTokens,
225+
})
59226
}
60227

61228
// New creates an Auth instance from resolved configuration.
@@ -74,6 +241,7 @@ func New(cfg Config) *Auth {
74241
actorTokenSource: cfg.ActorTokenSource,
75242
audienceDeriver: cfg.AudienceDeriver,
76243
log: logger,
244+
Stats: NewStats(),
77245
}
78246
id := cfg.Identity
79247
a.identity.Store(&id)
@@ -98,12 +266,14 @@ func (a *Auth) UpdateIdentity(id IdentityConfig, clientAuth exchange.ClientAuth)
98266
func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience string) *InboundResult {
99267
// 1. Bypass check
100268
if a.bypass != nil && a.bypass.Match(path) {
269+
a.IncInboundApprove(APPROVE_PASSTHROUGH)
101270
a.log.Debug("bypass path matched", "path", path)
102271
return &InboundResult{Action: ActionAllow}
103272
}
104273

105274
// 2. Extract bearer token
106275
if authHeader == "" {
276+
a.IncInboundDeny(DENY_NO_HEADER)
107277
a.log.Debug("inbound denied: no Authorization header", "path", path)
108278
return &InboundResult{
109279
Action: ActionDeny,
@@ -113,6 +283,7 @@ func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience str
113283
}
114284
token := extractBearer(authHeader)
115285
if token == "" {
286+
a.IncInboundDeny(DENY_MALFORMED_HEADER)
116287
a.log.Debug("inbound denied: malformed Authorization header", "path", path)
117288
return &InboundResult{
118289
Action: ActionDeny,
@@ -123,6 +294,7 @@ func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience str
123294

124295
// 3. Validate JWT
125296
if a.verifier == nil {
297+
a.IncInboundDeny(DENY_VALIDATOR_MISSING)
126298
return &InboundResult{
127299
Action: ActionDeny,
128300
DenyStatus: http.StatusUnauthorized,
@@ -137,6 +309,7 @@ func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience str
137309
if err != nil {
138310
// Log full error at Info; log detailed context at Debug.
139311
// Generic message returned to client to avoid leaking details.
312+
a.IncInboundDeny(DENY_JWT_FAILED)
140313
a.log.Info("JWT validation failed", "error", err)
141314
a.log.Debug("JWT validation details",
142315
"path", path,
@@ -151,6 +324,7 @@ func (a *Auth) HandleInbound(ctx context.Context, authHeader, path, audience str
151324
}
152325

153326
// 4. Allow with claims
327+
a.IncInboundApprove(APPROVE_AUTHORIZED)
154328
a.log.Info("inbound authorized",
155329
"subject", claims.Subject, "clientID", claims.ClientID)
156330
a.log.Debug("inbound authorized details",
@@ -170,10 +344,12 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
170344

171345
// 2. Passthrough
172346
if resolved == nil {
347+
a.IncOutboundApprove(OUTBOUND_NO_MATCHING_ROUTE)
173348
a.log.Info("outbound passthrough", "host", host, "reason", "no matching route")
174349
return &OutboundResult{Action: ActionAllow}
175350
}
176351
if resolved.Passthrough {
352+
a.IncOutboundApprove(OUTBOUND_PASSTHROUGH)
177353
a.log.Info("outbound passthrough", "host", host, "reason", "route action")
178354
return &OutboundResult{Action: ActionAllow}
179355
}
@@ -205,13 +381,15 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
205381
// 5. Cache check
206382
if a.cache != nil {
207383
if cached, ok := a.cache.Get(subjectToken, audience); ok {
384+
a.IncOutboundReplaceToken(OUTBOUND_ACTION_CACHE_HIT)
208385
a.log.Debug("outbound cache hit", "host", host, "audience", audience)
209386
return &OutboundResult{Action: ActionReplaceToken, Token: cached}
210387
}
211388
}
212389

213390
// 6. Token exchange
214391
if a.exchanger == nil {
392+
a.IncOutboundApprove(OUTBOUND_NO_EXCHANGER)
215393
a.log.Warn("exchanger not configured, passing through",
216394
"host", host, "audience", audience)
217395
return &OutboundResult{Action: ActionAllow}
@@ -236,6 +414,7 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
236414
TokenEndpoint: resolved.TokenEndpoint, // per-route override
237415
})
238416
if err != nil {
417+
a.IncOutboundDeny(OUTBOUND_TOKEN_EXCHANGE_FAILED)
239418
a.log.Info("token exchange failed", "host", host, "error", err)
240419
a.log.Debug("token exchange failure details",
241420
"host", host,
@@ -257,6 +436,7 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
257436
time.Duration(resp.ExpiresIn)*time.Second)
258437
}
259438

439+
a.IncOutboundReplaceToken(OUTBOUND_ACTION_REPLACE_TOKEN)
260440
a.log.Info("outbound token exchanged", "host", host, "audience", audience)
261441
a.log.Debug("outbound exchange details",
262442
"host", host, "audience", audience, "expiresIn", resp.ExpiresIn)
@@ -266,11 +446,13 @@ func (a *Auth) HandleOutbound(ctx context.Context, authHeader, host string) *Out
266446
func (a *Auth) handleNoToken(ctx context.Context, audience, scopes string) *OutboundResult {
267447
switch a.noTokenPolicy {
268448
case NoTokenPolicyAllow:
449+
a.IncOutboundApprove(OUTBOUND_NO_TOKEN_POLICY)
269450
a.log.Debug("no token, policy=allow")
270451
return &OutboundResult{Action: ActionAllow}
271452

272453
case NoTokenPolicyClientCredentials:
273454
if a.exchanger == nil {
455+
a.IncOutboundDeny(OUTBOUND_CREDS_REQUESTED_NO_EXCHANGER)
274456
a.log.Debug("no token, client_credentials requested but exchanger not configured",
275457
"audience", audience)
276458
return &OutboundResult{
@@ -283,6 +465,7 @@ func (a *Auth) handleNoToken(ctx context.Context, audience, scopes string) *Outb
283465
"audience", audience, "scopes", scopes)
284466
resp, err := a.exchanger.ClientCredentials(ctx, audience, scopes)
285467
if err != nil {
468+
a.IncOutboundDeny(OUTBOUND_CREDENTIALS_GRANT_FAILURE)
286469
a.log.Info("client credentials grant failed", "error", err)
287470
a.log.Debug("client credentials failure details",
288471
"audience", audience, "scopes", scopes, "error", err)
@@ -292,9 +475,11 @@ func (a *Auth) handleNoToken(ctx context.Context, audience, scopes string) *Outb
292475
DenyReason: "client credentials token acquisition failed",
293476
}
294477
}
478+
a.IncOutboundReplaceToken(OUTBOUND_ACTION_REPLACE_TOKEN)
295479
return &OutboundResult{Action: ActionReplaceToken, Token: resp.AccessToken}
296480

297481
default: // NoTokenDeny or unknown
482+
a.IncOutboundDeny(OUTBOUND_NO_TOKEN)
298483
a.log.Debug("no token, policy denies request",
299484
"policy", a.noTokenPolicy, "audience", audience)
300485
return &OutboundResult{
@@ -312,3 +497,47 @@ func extractBearer(authHeader string) string {
312497
}
313498
return ""
314499
}
500+
501+
func NewStats() *Stats {
502+
return &Stats{
503+
inboundApprovals: make(map[InboundApprovalReason]int),
504+
inboundDenials: make(map[InboundDenialReason]int),
505+
outboundApprovals: make(map[OutboundApprovalReason]int),
506+
outboundDenials: make(map[OutboundDenialReason]int),
507+
outboundReplaceTokens: make(map[OutboundReplaceTokenReason]int),
508+
}
509+
}
510+
511+
// IncInboundApprove records a new approval (for statistics)
512+
func (a *Auth) IncInboundApprove(reason InboundApprovalReason) {
513+
a.Stats.mu.Lock()
514+
a.Stats.inboundApprovals[reason]++
515+
a.Stats.mu.Unlock()
516+
}
517+
518+
// IncInboundDeny records a new denial (for statistics)
519+
func (a *Auth) IncInboundDeny(reason InboundDenialReason) {
520+
a.Stats.mu.Lock()
521+
a.Stats.inboundDenials[reason]++
522+
a.Stats.mu.Unlock()
523+
}
524+
525+
// IncOutboundApprove records a new approval (for statistics)
526+
func (a *Auth) IncOutboundApprove(reason OutboundApprovalReason) {
527+
a.Stats.mu.Lock()
528+
a.Stats.outboundApprovals[reason]++
529+
a.Stats.mu.Unlock()
530+
}
531+
532+
// IncOutboundDeny records a new denial (for statistics)
533+
func (a *Auth) IncOutboundDeny(reason OutboundDenialReason) {
534+
a.Stats.mu.Lock()
535+
a.Stats.outboundDenials[reason]++
536+
a.Stats.mu.Unlock()
537+
}
538+
539+
func (a *Auth) IncOutboundReplaceToken(reason OutboundReplaceTokenReason) {
540+
a.Stats.mu.Lock()
541+
a.Stats.outboundReplaceTokens[reason]++
542+
a.Stats.mu.Unlock()
543+
}

0 commit comments

Comments
 (0)