feat(handler): audit log records active_admin alongside elevated
The access log now reports whether the elevated user actually held
admin authority on the request's target path — i.e., whether the
single bypass branch in policy.InternalDecider.Allow would have
fired here. Three states fall out:
elevated=false, active_admin=false: normal user
elevated=true, active_admin=false: opted into admin but no admin
grant on this path (subtree-
admin out of scope)
elevated=true, active_admin=true: admin authority active for
this path — WORM/ACL bypass
Implementation: AccessLogMiddleware gains a cfg parameter and calls
activeAdminForRequest at log emission, walking the closest existing
ancestor (same logic the file API uses to build its ACL chain).
The cascade is mtime-cached upstream so the per-request cost is one
map lookup in the common case.
Audit value: a reviewer can spot at a glance whether a destructive
write was authorized by ACL or by admin bypass. Plus "elevated=true
active_admin=false" rows surface users who tried to elevate outside
their actual scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6c818648ca
commit
a85b25ce08
3 changed files with 74 additions and 13 deletions
|
|
@ -243,7 +243,7 @@ func main() {
|
||||||
if useTLS {
|
if useTLS {
|
||||||
inner = handler.HSTSMiddleware(inner)
|
inner = handler.HSTSMiddleware(inner)
|
||||||
}
|
}
|
||||||
inner = handler.AccessLogMiddleware(auditLogger, inner)
|
inner = handler.AccessLogMiddleware(cfg, auditLogger, inner)
|
||||||
inner = handler.ACLMiddleware(cfg, decider, tokens, inner)
|
inner = handler.ACLMiddleware(cfg, decider, tokens, inner)
|
||||||
mux.Handle("/", inner)
|
mux.Handle("/", inner)
|
||||||
|
|
||||||
|
|
@ -369,7 +369,7 @@ func runClient(cfg config.Config) {
|
||||||
if useTLS {
|
if useTLS {
|
||||||
inner = handler.HSTSMiddleware(inner)
|
inner = handler.HSTSMiddleware(inner)
|
||||||
}
|
}
|
||||||
inner = handler.AccessLogMiddleware(auditLogger, inner)
|
inner = handler.AccessLogMiddleware(cfg, auditLogger, inner)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("/", inner)
|
mux.Handle("/", inner)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -165,6 +167,56 @@ func WithElevation(ctx context.Context, elevated bool) context.Context {
|
||||||
return context.WithValue(ctx, ElevatedKey, elevated)
|
return context.WithValue(ctx, ElevatedKey, elevated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// activeAdminForRequest reports whether the elevated principal would
|
||||||
|
// trigger the decider's admin-bypass branch on the chain at the
|
||||||
|
// request's target path. Best-effort: walks the closest existing
|
||||||
|
// ancestor (mirroring the file API's authorize logic) so a write
|
||||||
|
// targeting a not-yet-existing file still answers correctly. Returns
|
||||||
|
// false on anonymous or un-elevated requests without touching the
|
||||||
|
// filesystem. The cascade is mtime-cached upstream, so the per-
|
||||||
|
// request cost is one map lookup in the common case.
|
||||||
|
func activeAdminForRequest(cfg config.Config, r *http.Request, elevated bool, email string) bool {
|
||||||
|
if !elevated || email == "" || email == "anonymous" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
cleanURL := strings.TrimSuffix(r.URL.Path, "/")
|
||||||
|
if cleanURL == "" {
|
||||||
|
cleanURL = "/"
|
||||||
|
}
|
||||||
|
rel := strings.TrimPrefix(cleanURL, "/")
|
||||||
|
if rel == "" {
|
||||||
|
// Root request: chain is just the root .zddc.
|
||||||
|
chain, err := zddc.EffectivePolicy(cfg.Root, cfg.Root)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return zddc.IsAdminForChain(chain, email, false)
|
||||||
|
}
|
||||||
|
abs := filepath.Join(cfg.Root, filepath.FromSlash(rel))
|
||||||
|
if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
probe := abs
|
||||||
|
for {
|
||||||
|
if info, err := os.Stat(probe); err == nil && info.IsDir() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if probe == cfg.Root {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(probe)
|
||||||
|
if parent == probe {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
probe = parent
|
||||||
|
}
|
||||||
|
chain, err := zddc.EffectivePolicy(cfg.Root, probe)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return zddc.IsAdminForChain(chain, email, false)
|
||||||
|
}
|
||||||
|
|
||||||
// PrincipalFromContext bundles the request's authenticated email plus
|
// PrincipalFromContext bundles the request's authenticated email plus
|
||||||
// its elevation flag into a zddc.Principal — the value type the admin
|
// its elevation flag into a zddc.Principal — the value type the admin
|
||||||
// functions (IsAdmin, IsSubtreeAdmin, CanEditZddc) consume. One call
|
// functions (IsAdmin, IsSubtreeAdmin, CanEditZddc) consume. One call
|
||||||
|
|
@ -244,7 +296,7 @@ func HSTSMiddleware(next http.Handler) http.Handler {
|
||||||
// so an operator gets a persisted audit trail on disk in addition to the
|
// so an operator gets a persisted audit trail on disk in addition to the
|
||||||
// stderr stream — useful when stderr is not journald-captured (e.g.
|
// stderr stream — useful when stderr is not journald-captured (e.g.
|
||||||
// container logging where the orchestrator drops stderr after restarts).
|
// container logging where the orchestrator drops stderr after restarts).
|
||||||
func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handler {
|
func AccessLogMiddleware(cfg config.Config, auditLogger *slog.Logger, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Capture request start time
|
// Capture request start time
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
@ -264,22 +316,31 @@ func AccessLogMiddleware(auditLogger *slog.Logger, next http.Handler) http.Handl
|
||||||
// Calculate duration
|
// Calculate duration
|
||||||
durationMs := int(time.Since(start).Milliseconds())
|
durationMs := int(time.Since(start).Milliseconds())
|
||||||
|
|
||||||
// Get email + elevation from context. The `elevated` field
|
// Get email + elevation from context. `elevated` records the
|
||||||
// records whether the caller had opted into admin powers for
|
// per-request opt-in (sudo-style); `active_admin` says whether
|
||||||
// this request — sudo-style. Surfacing it in the audit stream
|
// the elevated user actually held admin authority on the path
|
||||||
// lets forensics distinguish "admin acting as a normal user"
|
// the request targeted — i.e., whether the single bypass
|
||||||
// from "admin exercising authority" when reviewing a
|
// branch in policy.InternalDecider.Allow would have fired
|
||||||
// destructive action.
|
// here. Surfacing both lets forensics distinguish:
|
||||||
|
// elevated=false, active_admin=false: normal user
|
||||||
|
// elevated=true, active_admin=false: tried to elevate but no
|
||||||
|
// admin authority on this
|
||||||
|
// path (subtree-admin
|
||||||
|
// cooled by scope)
|
||||||
|
// elevated=true, active_admin=true: admin authority active,
|
||||||
|
// WORM/ACL bypassed
|
||||||
email := EmailFromContext(r)
|
email := EmailFromContext(r)
|
||||||
if email == "" {
|
if email == "" {
|
||||||
email = "anonymous"
|
email = "anonymous"
|
||||||
}
|
}
|
||||||
elevated := ElevatedFromContext(r)
|
elevated := ElevatedFromContext(r)
|
||||||
|
activeAdmin := activeAdminForRequest(cfg, r, elevated, email)
|
||||||
|
|
||||||
args := []any{
|
args := []any{
|
||||||
"ts", start.Format(time.RFC3339),
|
"ts", start.Format(time.RFC3339),
|
||||||
"email", email,
|
"email", email,
|
||||||
"elevated", elevated,
|
"elevated", elevated,
|
||||||
|
"active_admin", activeAdmin,
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
"path", requestedPath,
|
"path", requestedPath,
|
||||||
"status", wrapped.status,
|
"status", wrapped.status,
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ func TestAccessLogReadsEmailFromACLContext(t *testing.T) {
|
||||||
|
|
||||||
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads
|
// Correct order: ACL is outer, AccessLog is inner. AccessLog reads
|
||||||
// email from the context ACL populated.
|
// email from the context ACL populated.
|
||||||
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(nil, noop))
|
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, nil, noop))
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||||
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
|
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
|
||||||
|
|
@ -60,7 +60,7 @@ func TestAccessLogAnonymousWhenNoEmail(t *testing.T) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
|
|
||||||
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(nil, noop))
|
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, nil, noop))
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||||
// Note: no X-Auth-Request-Email header set.
|
// Note: no X-Auth-Request-Email header set.
|
||||||
|
|
@ -90,7 +90,7 @@ func TestAccessLogOuterDoesNotSeeInnerContext(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Inverted order — the ORIGINAL buggy chain.
|
// Inverted order — the ORIGINAL buggy chain.
|
||||||
chain := AccessLogMiddleware(nil, ACLMiddleware(cfg, nil, nil, noop))
|
chain := AccessLogMiddleware(cfg, nil, ACLMiddleware(cfg, nil, nil, noop))
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
|
||||||
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
|
req.Header.Set("X-Auth-Request-Email", "alice@example.com")
|
||||||
|
|
@ -119,7 +119,7 @@ func TestAccessLogMiddleware_AuditLoggerReceivesSameFields(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
|
cfg := config.Config{EmailHeader: "X-Auth-Request-Email"}
|
||||||
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(auditLogger, noop))
|
chain := ACLMiddleware(cfg, nil, nil, AccessLogMiddleware(cfg, auditLogger, noop))
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/some/path", nil)
|
req := httptest.NewRequest(http.MethodGet, "/some/path", nil)
|
||||||
req.Header.Set("X-Auth-Request-Email", "bob@example.com")
|
req.Header.Set("X-Auth-Request-Email", "bob@example.com")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue