122 lines
4.4 KiB
Go
122 lines
4.4 KiB
Go
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)
|
|
}
|