Skip to content

Commit

Permalink
Merge pull request #858 from go-kivik/dbSecurity
Browse files Browse the repository at this point in the history
Add middleware security layer for database access in server
  • Loading branch information
flimzy authored Dec 29, 2023
2 parents 81a7fb9 + b55a0c5 commit 8e596d7
Show file tree
Hide file tree
Showing 4 changed files with 362 additions and 87 deletions.
108 changes: 108 additions & 0 deletions x/server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ import (
"context"
"net/http"

"github.com/go-chi/chi/v5"
"gitlab.com/flimzy/httpe"

"github.com/go-kivik/kivik/v4"
"github.com/go-kivik/kivik/v4/internal"
"github.com/go-kivik/kivik/v4/x/server/auth"
)
Expand All @@ -29,6 +31,11 @@ type contextKey struct{ name string }

var userContextKey = &contextKey{"userCtx"}

func userFromContext(ctx context.Context) *auth.UserContext {
user, _ := ctx.Value(userContextKey).(*auth.UserContext)
return user
}

type authService struct {
s *Server
}
Expand Down Expand Up @@ -59,6 +66,7 @@ func (w *doneWriter) Write(b []byte) (int, error) {
return w.ResponseWriter.Write(b)
}

// authMiddleware sets the user context based on the authenticated user, if any.
func (s *Server) authMiddleware(next httpe.HandlerWithError) httpe.HandlerWithError {
return httpe.HandlerWithErrorFunc(func(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
Expand Down Expand Up @@ -92,6 +100,8 @@ func (s *Server) authMiddleware(next httpe.HandlerWithError) httpe.HandlerWithEr
})
}

// adminRequired returns Status Forbidden if the session is not authenticated as
// an admin.
func adminRequired(next httpe.HandlerWithError) httpe.HandlerWithError {
return httpe.HandlerWithErrorFunc(func(w http.ResponseWriter, r *http.Request) error {
userCtx, _ := r.Context().Value(userContextKey).(*auth.UserContext)
Expand All @@ -104,3 +114,101 @@ func adminRequired(next httpe.HandlerWithError) httpe.HandlerWithError {
return next.ServeHTTPWithError(w, r)
})
}

func (s *Server) dbMembershipRequired(next httpe.HandlerWithError) httpe.HandlerWithError {
return httpe.HandlerWithErrorFunc(func(w http.ResponseWriter, r *http.Request) error {
db := chi.URLParam(r, "db")
security, err := s.client.DB(db).Security(r.Context())
if err != nil {
return &internal.Error{Status: http.StatusBadGateway, Err: err}
}

if err := validateDBMembership(userFromContext(r.Context()), security); err != nil {
return err
}

return next.ServeHTTPWithError(w, r)
})
}

// validateDBMembership returns an error if the user lacks sufficient membersip.
//
// See the [CouchDB documentation] for the rules for granting access.
//
// [CouchDB documentatio]: https://docs.couchdb.org/en/stable/api/database/security.html#get--db-_security
func validateDBMembership(user *auth.UserContext, security *kivik.Security) error {
// No membership names/roles means open read access.
if len(security.Members.Names) == 0 && len(security.Members.Roles) == 0 {
return nil
}

if user == nil {
return &internal.Error{Status: http.StatusUnauthorized, Message: "User not authenticated"}
}

for _, name := range security.Members.Names {
if name == user.Name {
return nil
}
}
for _, role := range security.Members.Roles {
if user.HasRole(role) {
return nil
}
}
for _, name := range security.Admins.Names {
if name == user.Name {
return nil
}
}
for _, role := range security.Admins.Roles {
if user.HasRole(role) {
return nil
}
}
if user.HasRole(auth.RoleAdmin) {
return nil
}
return &internal.Error{Status: http.StatusForbidden, Message: "User lacks sufficient privileges"}
}

func (s *Server) dbAdminRequired(next httpe.HandlerWithError) httpe.HandlerWithError {
return httpe.HandlerWithErrorFunc(func(w http.ResponseWriter, r *http.Request) error {
db := chi.URLParam(r, "db")
security, err := s.client.DB(db).Security(r.Context())
if err != nil {
return &internal.Error{Status: http.StatusBadGateway, Err: err}
}

if err := validateDBAdmin(userFromContext(r.Context()), security); err != nil {
return err
}

return next.ServeHTTPWithError(w, r)
})
}

// validateDBAdmin returns an error if the user lacks sufficient membersip.
//
// See the [CouchDB documentation] for the rules for granting access.
//
// [CouchDB documentatio]: https://docs.couchdb.org/en/stable/api/database/security.html#get--db-_security
func validateDBAdmin(user *auth.UserContext, security *kivik.Security) error {
if user == nil {
return &internal.Error{Status: http.StatusUnauthorized, Message: "User not authenticated"}
}
for _, name := range security.Admins.Names {
if name == user.Name {
return nil
}
}
if user.HasRole(auth.RoleAdmin) {
return nil
}
for _, role := range security.Admins.Roles {
if user.HasRole(role) {
return nil
}
}
return &internal.Error{Status: http.StatusForbidden, Message: "User lacks sufficient privileges"}
}
162 changes: 87 additions & 75 deletions x/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ func (s *Server) routes(mux *chi.Mux) {
GetHead,
httpe.ToMiddleware(s.handleErrors),
)
mux.Get("/", httpe.ToHandler(s.root()).ServeHTTP)

auth := mux.With(
httpe.ToMiddleware(s.authMiddleware),
Expand All @@ -86,6 +85,7 @@ func (s *Server) routes(mux *chi.Mux) {
admin := auth.With(
httpe.ToMiddleware(adminRequired),
)
auth.Get("/", httpe.ToHandler(s.root()).ServeHTTP)
admin.Get("/_active_tasks", httpe.ToHandler(s.activeTasks()).ServeHTTP)
admin.Get("/_all_dbs", httpe.ToHandler(s.allDBs()).ServeHTTP)
auth.Get("/_dbs_info", httpe.ToHandler(s.allDBsStats()).ServeHTTP)
Expand All @@ -103,7 +103,7 @@ func (s *Server) routes(mux *chi.Mux) {
auth.Get("/_node/{node-name}/_stats", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/_node/{node-name}/_prometheus", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/_node/{node-name}/_system", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/_node/{node-name}/_restart", httpe.ToHandler(s.notImplemented()).ServeHTTP)
admin.Post("/_node/{node-name}/_restart", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/_node/{node-name}/_versions", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/_search_analyze", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/_utils", httpe.ToHandler(s.notImplemented()).ServeHTTP)
Expand All @@ -130,82 +130,94 @@ func (s *Server) routes(mux *chi.Mux) {
admin.Post("/_node/{node-name}/_config/_reload", httpe.ToHandler(s.reloadConfig()).ServeHTTP)

// Databases
auth.Head("/{db}", httpe.ToHandler(s.dbExists()).ServeHTTP)
auth.Get("/{db}", httpe.ToHandler(s.db()).ServeHTTP)
auth.Put("/{db}", httpe.ToHandler(s.createDB()).ServeHTTP)
auth.Delete("/{db}", httpe.ToHandler(s.deleteDB()).ServeHTTP)
auth.Get("/{db}/_all_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_all_docs/queries", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_all_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_design_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_design_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_design_docs/queries", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_local_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_local_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_local_docs/queries", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_bulk_get", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_bulk_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_find", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_index", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_index", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Delete("/{db}/_index/{designdoc}/json/{name}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_explain", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_shards", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_shards/{docid}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_sync_shards", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_changes", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_changes", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_compact", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_compact/{ddoc}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_ensure_full_commit", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_view_cleanup", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_security", httpe.ToHandler(s.getSecurity()).ServeHTTP)
auth.Put("/{db}/_security", httpe.ToHandler(s.putSecurity()).ServeHTTP)
auth.Post("/{db}/_purge", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_purged_infos_limit", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Put("/{db}/_purged_infos_limit", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_missing_revs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_revs_diff", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_revs_limit", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Put("/{db}/_revs_limit", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Route("/{db}", func(db chi.Router) {
admin := db.With(
httpe.ToMiddleware(adminRequired),
)
member := db.With(
httpe.ToMiddleware(s.dbMembershipRequired),
)
dbAdmin := member.With(
httpe.ToMiddleware(s.dbAdminRequired),
)

// Documents
auth.Post("/{db}", httpe.ToHandler(s.postDoc()).ServeHTTP)
auth.Get("/{db}/{docid}", httpe.ToHandler(s.doc()).ServeHTTP)
auth.Put("/{db}/{docid}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Delete("/{db}/{docid}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Method("COPY", "/{db}/{docid}", httpe.ToHandler(s.notImplemented()))
auth.Delete("/{db}/{docid}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/{docid}/{attname}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/{docid}/{attname}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Delete("/{db}/{docid}/{attname}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Head("/", httpe.ToHandler(s.dbExists()).ServeHTTP)
member.Get("/", httpe.ToHandler(s.db()).ServeHTTP)
admin.Put("/", httpe.ToHandler(s.createDB()).ServeHTTP)
admin.Delete("/", httpe.ToHandler(s.deleteDB()).ServeHTTP)
member.Get("/_all_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_all_docs/queries", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_all_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_design_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_design_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_design_docs/queries", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_local_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_local_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_local_docs/queries", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_bulk_get", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_bulk_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_find", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_index", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_index", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Delete("/_index/{designdoc}/json/{name}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_explain", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_shards", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_shards/{docid}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_sync_shards", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_changes", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_changes", httpe.ToHandler(s.notImplemented()).ServeHTTP)
admin.Post("/_compact", httpe.ToHandler(s.notImplemented()).ServeHTTP)
admin.Post("/_compact/{ddoc}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_ensure_full_commit", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_view_cleanup", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_security", httpe.ToHandler(s.getSecurity()).ServeHTTP)
dbAdmin.Put("/_security", httpe.ToHandler(s.putSecurity()).ServeHTTP)
member.Post("/_purge", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_purged_infos_limit", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Put("/_purged_infos_limit", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_missing_revs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_revs_diff", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_revs_limit", httpe.ToHandler(s.notImplemented()).ServeHTTP)
dbAdmin.Put("/_revs_limit", httpe.ToHandler(s.notImplemented()).ServeHTTP)

// Design docs
auth.Get("/{db}/_design/{ddoc}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Put("/{db}/_design/{ddoc}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Delete("/{db}/_design/{ddoc}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Method("COPY", "/{db}/_design/{ddoc}", httpe.ToHandler(s.notImplemented()))
auth.Get("/{db}/_design/{ddoc}/{attname}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Put("/{db}/_design/{ddoc}/{attname}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Delete("/{db}/_design/{ddoc}/{attname}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_design/{ddoc}/_view/{view}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_design/{ddoc}/_info", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_design/{ddoc}/_view/{view}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_design/{ddoc}/_view/{view}/queries", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_design/{ddoc}/_search/{index}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_design/{ddoc}/_search_info/{index}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_design/{ddoc}/_update/{func}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_design/{ddoc}/_update/{func}/{docid}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_design/{ddoc}/_rewrite/{path}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Put("/{db}/_design/{ddoc}/_rewrite/{path}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_design/{ddoc}/_rewrite/{path}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Delete("/{db}/_design/{ddoc}/_rewrite/{path}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
// Documents
member.Post("/", httpe.ToHandler(s.postDoc()).ServeHTTP)
member.Get("/{docid}", httpe.ToHandler(s.doc()).ServeHTTP)
member.Put("/{docid}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Delete("/{docid}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Method("COPY", "/{db}/{docid}", httpe.ToHandler(s.notImplemented()))
member.Delete("/{docid}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/{docid}/{attname}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/{docid}/{attname}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Delete("/{docid}/{attname}", httpe.ToHandler(s.notImplemented()).ServeHTTP)

auth.Get("/{db}/_partition/{partition}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_partition/{partition}/_all_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_partition/{partition}/_design/{ddoc}/_view/{view}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Post("/{db}/_partition/{partition_id}/_find", httpe.ToHandler(s.notImplemented()).ServeHTTP)
auth.Get("/{db}/_partition/{partition_id}/_explain", httpe.ToHandler(s.notImplemented()).ServeHTTP)
// Design docs
member.Get("/_design/{ddoc}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
dbAdmin.Put("/_design/{ddoc}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
dbAdmin.Delete("/_design/{ddoc}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
dbAdmin.Method("COPY", "/{db}/_design/{ddoc}", httpe.ToHandler(s.notImplemented()))
member.Get("/_design/{ddoc}/{attname}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
dbAdmin.Put("/_design/{ddoc}/{attname}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
dbAdmin.Delete("/_design/{ddoc}/{attname}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_design/{ddoc}/_view/{view}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_design/{ddoc}/_info", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_design/{ddoc}/_view/{view}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_design/{ddoc}/_view/{view}/queries", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_design/{ddoc}/_search/{index}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_design/{ddoc}/_search_info/{index}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_design/{ddoc}/_update/{func}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_design/{ddoc}/_update/{func}/{docid}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_design/{ddoc}/_rewrite/{path}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Put("/_design/{ddoc}/_rewrite/{path}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_design/{ddoc}/_rewrite/{path}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Delete("/_design/{ddoc}/_rewrite/{path}", httpe.ToHandler(s.notImplemented()).ServeHTTP)

member.Get("/_partition/{partition}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_partition/{partition}/_all_docs", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_partition/{partition}/_design/{ddoc}/_view/{view}", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Post("/_partition/{partition_id}/_find", httpe.ToHandler(s.notImplemented()).ServeHTTP)
member.Get("/_partition/{partition_id}/_explain", httpe.ToHandler(s.notImplemented()).ServeHTTP)
})
}

func (s *Server) handleErrors(next httpe.HandlerWithError) httpe.HandlerWithError {
Expand Down
Loading

0 comments on commit 8e596d7

Please sign in to comment.