package rbac import ( "net/http" "net/url" "nadir/internal/auditlog" "nadir/internal/auth" "github.com/danielgtaylor/huma/v2" ) func RbacMiddleware(api huma.API, sessions *auth.SessionStore, tokens *auth.TokenAuth, roles *RBAC, auditor *auditlog.Store) func(huma.Context, func(huma.Context)) { return func(ctx huma.Context, next func(huma.Context)) { // CSRF defense-in-depth (beyond the SameSite=Strict cookie): reject any // state-changing request whose Origin doesn't match our Host. Runs before // the permission check so it also covers the public login/logout writes. // // Bearer tokens are exempt by construction: browsers don't auto-attach an // Authorization header, so a token request carries no ambient authority for // CSRF to exploit. Such requests also send no Origin, so they pass here too. if !sameOriginOK(ctx) { huma.WriteErr(api, ctx, http.StatusForbidden, "cross-origin request blocked") return } op := ctx.Operation() perm, _ := op.Metadata["permission"].(string) if perm == "" { next(ctx) return } moduleID, _ := op.Metadata["module"].(string) // Machine-to-machine: a Bearer token authenticates as its token name (a // dashboard managing N nodes needs no PAM session). Checked before the // cookie. The token name is the RBAC subject - assign it a role in // config.yaml's `assignments`, same as a username. if raw, isBearer := auth.BearerToken(ctx.Header("Authorization")); isBearer { if tokens == nil { huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") return } name, ok, throttled := tokens.Verify(auth.ClientIP(ctx.Context()), raw) if throttled { huma.WriteErr(api, ctx, http.StatusTooManyRequests, "too many failed token attempts; wait a minute") return } if !ok { huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") return } // Audit actor is prefixed "token:" so the trail distinguishes a token // from a human with the same name; RBAC still checks the bare name. if !roles.Can(name, moduleID, Permission(perm)) { huma.WriteErr(api, ctx, http.StatusForbidden, "forbidden") record(auditor, "token:"+name, op, ctx.URL().Path, http.StatusForbidden) return } next(ctx) record(auditor, "token:"+name, op, ctx.URL().Path, ctx.Status()) return } cookie, err := huma.ReadCookie(ctx, "nadir_session_id") if err != nil || cookie == nil { huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") return } sess, ok := sessions.GetByToken(cookie.Value) if !ok { huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") return } if !roles.Can(sess.Username, moduleID, Permission(perm)) { huma.WriteErr(api, ctx, http.StatusForbidden, "forbidden") record(auditor, sess.Username, op, ctx.URL().Path, http.StatusForbidden) return } next(ctx) record(auditor, sess.Username, op, ctx.URL().Path, ctx.Status()) } } // sameOriginOK allows safe methods and any request whose Origin header matches // the request Host. A missing Origin (non-browser client, or a same-origin // navigation) is allowed - CSRF is a browser-only concern. Only browser-issued // cross-origin writes, which always carry an Origin, are rejected. func sameOriginOK(ctx huma.Context) bool { switch ctx.Method() { case http.MethodGet, http.MethodHead, http.MethodOptions: return true } origin := ctx.Header("Origin") if origin == "" { return true } u, err := url.Parse(origin) if err != nil { return false } return u.Host == ctx.Host() } // record logs a mutation (anything but a read) to the audit trail. Reads are // skipped to keep the trail to "who changed what". Best-effort: a logging // failure is reported to the server log, never to the caller. func record(auditor *auditlog.Store, username string, op *huma.Operation, path string, status int) { if op.Method == http.MethodGet || op.Method == http.MethodHead || op.Method == http.MethodOptions { return } // SSE handlers stream via BodyWriter and never call SetStatus, so huma's // context reports status 0 even though net/http has implicitly sent a 200. // A streamed response that reached here passed RBAC and started, so record // it as 200 (stream-level failures surface as `error` events, not codes). if status == 0 { status = http.StatusOK } moduleID, _ := op.Metadata["module"].(string) auditor.Record(username, op.Method, path, moduleID, status) }