feat(audit): log successful JWT auth with subject, acting_party, roles (#21)
CI / CI (push) Successful in 3m39s
CI / CI (push) Successful in 3m39s
Add audit trail for authenticated API requests.
## Changes
- Add `auth ok` info log after JWT validation passes
- Export `ClaimString`/`ClaimStringSlice` from identity package
- Log fields: subject, acting_party, roles/permissions, path, method
## Log format
```json
{"level":"info","message":"auth ok","extra":{"subject":"2213aa...","acting_party":"portfolio-agent","roles":"llm:inference,memory:read","path":"/v1/chat/completions","method":"POST"}}
```
## Why
Gateway only logged auth failures — no audit trail for who accessed LLM endpoints. Required for cluster access auditing.
Complements existing X-Forwarded-User header injection (already complete).
---------
Co-authored-by: poimen <[email protected]>
Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
@@ -52,18 +52,18 @@ func StripIncoming(r *http.Request) {
|
||||
func Inject(r *http.Request, claims jwt.MapClaims) {
|
||||
r.Header.Set(HeaderAuthVerified, "true")
|
||||
|
||||
if sub := claimString(claims, "sub"); sub != "" {
|
||||
if sub := ClaimString(claims, "sub"); sub != "" {
|
||||
r.Header.Set(HeaderUser, sub)
|
||||
}
|
||||
|
||||
if roles := claimStringSlice(claims, "roles"); len(roles) > 0 {
|
||||
if roles := ClaimStringSlice(claims, "roles"); len(roles) > 0 {
|
||||
r.Header.Set(HeaderRoles, strings.Join(roles, ","))
|
||||
} else if perms := claimStringSlice(claims, "permissions"); len(perms) > 0 {
|
||||
} else if perms := ClaimStringSlice(claims, "permissions"); len(perms) > 0 {
|
||||
r.Header.Set(HeaderRoles, strings.Join(perms, ","))
|
||||
}
|
||||
|
||||
if azp := claimString(claims, "azp"); azp != "" {
|
||||
sub := claimString(claims, "sub")
|
||||
if azp := ClaimString(claims, "azp"); azp != "" {
|
||||
sub := ClaimString(claims, "sub")
|
||||
// Only set acting-service when azp differs from sub
|
||||
// (i.e., a service account acting, not the user themselves)
|
||||
if azp != sub {
|
||||
@@ -72,9 +72,9 @@ func Inject(r *http.Request, claims jwt.MapClaims) {
|
||||
}
|
||||
}
|
||||
|
||||
// claimString extracts a string value from claims, returning "" if
|
||||
// ClaimString extracts a string value from claims, returning "" if
|
||||
// the key is missing or not a string.
|
||||
func claimString(claims jwt.MapClaims, key string) string {
|
||||
func ClaimString(claims jwt.MapClaims, key string) string {
|
||||
val, ok := claims[key]
|
||||
if !ok || val == nil {
|
||||
return ""
|
||||
@@ -86,10 +86,10 @@ func claimString(claims jwt.MapClaims, key string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// claimStringSlice extracts a []string from claims. JWT libraries
|
||||
// ClaimStringSlice extracts a []string from claims. JWT libraries
|
||||
// deserialize JSON arrays as []interface{}, so each element is
|
||||
// type-asserted individually. Non-string elements are skipped.
|
||||
func claimStringSlice(claims jwt.MapClaims, key string) []string {
|
||||
func ClaimStringSlice(claims jwt.MapClaims, key string) []string {
|
||||
val, ok := claims[key]
|
||||
if !ok || val == nil {
|
||||
return nil
|
||||
|
||||
@@ -359,6 +359,24 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Inject identity headers for downstream services
|
||||
identity.Inject(r, claims)
|
||||
|
||||
// Audit trail: log successful JWT authentication
|
||||
auditFields := map[string]string{
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
}
|
||||
if sub := identity.ClaimString(claims, "sub"); sub != "" {
|
||||
auditFields["subject"] = sub
|
||||
}
|
||||
if azp := identity.ClaimString(claims, "azp"); azp != "" {
|
||||
auditFields["acting_party"] = azp
|
||||
}
|
||||
if roles := identity.ClaimStringSlice(claims, "roles"); len(roles) > 0 {
|
||||
auditFields["roles"] = strings.Join(roles, ",")
|
||||
} else if perms := identity.ClaimStringSlice(claims, "permissions"); len(perms) > 0 {
|
||||
auditFields["permissions"] = strings.Join(perms, ",")
|
||||
}
|
||||
logging.Infof("auth ok", auditFields)
|
||||
|
||||
// Check required capability if configured
|
||||
if h.config.Auth.RequiredCapability != "" {
|
||||
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {
|
||||
|
||||
Reference in New Issue
Block a user