Bootstrap copied BFF and local accounts with public Sendico contracts (#34)
This commit is contained in:
1 parent
67dc5b5b1e
commit
7062672a87
556 files changed
+42291
-777
No files matched your search
@@ -0,0 +1,36 @@
|
||||
package api
|
||||
|
||||
import "fmt"
|
||||
|
||||
type HTTPMethod int
|
||||
|
||||
const (
|
||||
Get HTTPMethod = iota
|
||||
Post
|
||||
Put
|
||||
Patch
|
||||
Delete
|
||||
Options
|
||||
Head
|
||||
)
|
||||
|
||||
func HTTPMethod2String(method HTTPMethod) string {
|
||||
switch method {
|
||||
case Get:
|
||||
return "GET"
|
||||
case Post:
|
||||
return "POST"
|
||||
case Put:
|
||||
return "PUT"
|
||||
case Delete:
|
||||
return "DELETE"
|
||||
case Patch:
|
||||
return "PATCH"
|
||||
case Options:
|
||||
return "OPTIONS"
|
||||
case Head:
|
||||
return "HEAD"
|
||||
default:
|
||||
return fmt.Sprintf("unknown: %d", method)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// BaseResponse is a general structure for all API responses.
|
||||
type BaseResponse struct {
|
||||
Status string `json:"status"` // "success" or "error"
|
||||
Data any `json:"data"` // The actual data payload or the error details
|
||||
}
|
||||
|
||||
// ErrorResponse provides more details about an error.
|
||||
type ErrorResponse struct {
|
||||
Code int `json:"code"` // A unique identifier for the error type, useful for client handling
|
||||
Error string `json:"error"`
|
||||
Source string `json:"source"`
|
||||
Details any `json:"details"` // Additional details or hints about the error, if necessary
|
||||
}
|
||||
|
||||
func errMessage(err error) string {
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func logRequest(logger mlogger.Logger, r *http.Request, message string) {
|
||||
logger.Debug(
|
||||
message,
|
||||
zap.String("host", r.Host),
|
||||
zap.String("address", r.RemoteAddr),
|
||||
zap.String("method", r.Method),
|
||||
zap.String("request_uri", r.RequestURI),
|
||||
zap.String("proto", r.Proto),
|
||||
zap.String("user_agent", r.UserAgent()),
|
||||
)
|
||||
}
|
||||
|
||||
func writeJSON(logger mlogger.Logger, w http.ResponseWriter, r *http.Request, code int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.WriteHeader(code)
|
||||
if err := json.NewEncoder(w).Encode(&payload); err != nil {
|
||||
logger.Warn("Failed to encode JSON response",
|
||||
zap.Error(err),
|
||||
zap.String("host", r.Host),
|
||||
zap.String("address", r.RemoteAddr),
|
||||
zap.String("method", r.Method),
|
||||
zap.String("request_uri", r.RequestURI),
|
||||
zap.String("proto", r.Proto),
|
||||
zap.String("user_agent", r.UserAgent()))
|
||||
}
|
||||
}
|
||||
|
||||
func errorf(
|
||||
logger mlogger.Logger,
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
source mservice.Type, code int, message string, details any,
|
||||
) {
|
||||
logRequest(logger, r, message)
|
||||
|
||||
errorMessage := BaseResponse{
|
||||
Status: api.MSError,
|
||||
Data: ErrorResponse{
|
||||
Code: code,
|
||||
Details: details,
|
||||
Source: source,
|
||||
Error: message,
|
||||
},
|
||||
}
|
||||
|
||||
writeJSON(logger, w, r, code, errorMessage)
|
||||
}
|
||||
|
||||
func Accepted(logger mlogger.Logger, data any) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := BaseResponse{
|
||||
Status: api.MSSuccess,
|
||||
Data: data,
|
||||
}
|
||||
writeJSON(logger, w, r, http.StatusAccepted, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func Ok(logger mlogger.Logger, data any) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := BaseResponse{
|
||||
Status: api.MSSuccess,
|
||||
Data: data,
|
||||
}
|
||||
writeJSON(logger, w, r, http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func Created(logger mlogger.Logger, data any) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := BaseResponse{
|
||||
Status: api.MSSuccess,
|
||||
Data: data,
|
||||
}
|
||||
writeJSON(logger, w, r, http.StatusCreated, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func Auto(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
|
||||
if err == nil {
|
||||
return Success(logger)
|
||||
}
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
return AccessDenied(logger, source, errMessage(err))
|
||||
}
|
||||
if errors.Is(err, merrors.ErrDocumentsReportOwned) {
|
||||
return Error(logger, source, http.StatusConflict, "documents_report_owned", errMessage(err))
|
||||
}
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
return DataConflict(logger, source, errMessage(err))
|
||||
}
|
||||
if errors.Is(err, merrors.ErrInvalidArg) {
|
||||
return BadRequest(logger, source, "invalid_argument", errMessage(err))
|
||||
}
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return NotFound(logger, source, errMessage(err))
|
||||
}
|
||||
if errors.Is(err, merrors.ErrUnauthorized) {
|
||||
return Unauthorized(logger, source, errMessage(err))
|
||||
}
|
||||
if errors.Is(err, merrors.ErrServiceUnavailable) {
|
||||
return Error(logger, source, http.StatusServiceUnavailable, "service_unavailable", errMessage(err))
|
||||
}
|
||||
return Internal(logger, source, err)
|
||||
}
|
||||
|
||||
func Internal(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
errorf(logger, w, r, source, http.StatusInternalServerError, "internal_error", errMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
func NotImplemented(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
errorf(logger, w, r, source, http.StatusNotImplemented, "not_implemented", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func BadRequest(logger mlogger.Logger, source mservice.Type, err, hint string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
errorf(logger, w, r, source, http.StatusBadRequest, err, hint)
|
||||
}
|
||||
}
|
||||
|
||||
func Gone(logger mlogger.Logger, source mservice.Type, err, hint string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
errorf(logger, w, r, source, http.StatusGone, err, hint)
|
||||
}
|
||||
}
|
||||
|
||||
func BadQueryParam(logger mlogger.Logger, source mservice.Type, param string, err error) http.HandlerFunc {
|
||||
return BadRequest(logger, source, "invalid_query_parameter", fmt.Sprintf("Failed to parse '%s': %v", param, err))
|
||||
}
|
||||
|
||||
func BadReference(logger mlogger.Logger, source mservice.Type, refName, refVal string, err error) http.HandlerFunc {
|
||||
return BadRequest(logger, source, "broken_reference",
|
||||
fmt.Sprintf("broken object reference: %s = %s, error: %v", refName, refVal, err))
|
||||
}
|
||||
|
||||
func BadPayload(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFunc {
|
||||
return BadRequest(logger, source, "broken_payload",
|
||||
fmt.Sprintf("broken '%s' object payload, error: %v", source, err))
|
||||
}
|
||||
|
||||
func DataConflict(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
errorf(logger, w, r, source, http.StatusConflict, "data_conflict", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func Error(logger mlogger.Logger, source mservice.Type, code int, errType, hint string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
errorf(logger, w, r, source, code, errType, hint)
|
||||
}
|
||||
}
|
||||
|
||||
func ErrorWithDetails(logger mlogger.Logger, source mservice.Type, code int, errType string, details any) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
errorf(logger, w, r, source, code, errType, details)
|
||||
}
|
||||
}
|
||||
|
||||
func AccessDenied(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
|
||||
return Error(logger, source, http.StatusForbidden, "access_denied", hint)
|
||||
}
|
||||
|
||||
func Forbidden(logger mlogger.Logger, source mservice.Type, errType, hint string) http.HandlerFunc {
|
||||
return Error(logger, source, http.StatusForbidden, errType, hint)
|
||||
}
|
||||
|
||||
func TooManyRequests(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
|
||||
return Error(logger, source, http.StatusTooManyRequests, "too_many_requests", hint)
|
||||
}
|
||||
|
||||
func LicenseRequired(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
errorf(logger, w, r, source, http.StatusPaymentRequired, "license_required", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func Unauthorized(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
errorf(logger, w, r, source, http.StatusUnauthorized, "unauthorized", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func NotFound(logger mlogger.Logger, source mservice.Type, hint string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
errorf(logger, w, r, source, http.StatusNotFound, "not_found", hint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestAutoMapsServiceUnavailable(t *testing.T) {
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/health", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
Auto(zap.NewNop(), mservice.Type("test"), merrors.ServiceUnavailable("dependency down")).ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
|
||||
require.True(t, strings.Contains(recorder.Body.String(), "service_unavailable"))
|
||||
}
|
||||
|
||||
func TestAutoMapsDocumentsReportOwned(t *testing.T) {
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodPut, "/reports", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
Auto(zap.NewNop(), mservice.Type("documents_report"), merrors.DocumentsReportOwned("report has an active payout")).ServeHTTP(recorder, req)
|
||||
|
||||
var payload struct {
|
||||
Data ErrorResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||
require.Equal(t, http.StatusConflict, recorder.Code)
|
||||
require.Equal(t, "documents_report_owned", payload.Data.Error)
|
||||
require.Equal(t, "report has an active payout", payload.Data.Details)
|
||||
}
|
||||
|
||||
func TestErrorWithDetailsPreservesStructuredPayload(t *testing.T) {
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/actions", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
ErrorWithDetails(zap.NewNop(), mservice.Type("test"), http.StatusConflict, "conflict", map[string]string{
|
||||
"operation_ref": "operation-1",
|
||||
}).ServeHTTP(recorder, req)
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Details map[string]string `json:"details"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
|
||||
require.Equal(t, "operation-1", payload.Data.Details["operation_ref"])
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
Result bool `json:"result"`
|
||||
}
|
||||
|
||||
func Success(logger mlogger.Logger) http.HandlerFunc {
|
||||
return Ok(logger, Result{Result: true})
|
||||
}
|
||||
|
||||
func Failed(logger mlogger.Logger) http.HandlerFunc {
|
||||
return Accepted(logger, Result{Result: false})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package api
|
||||
|
||||
const (
|
||||
MSSuccess string = "success"
|
||||
MSProcessed string = "processed"
|
||||
MSError string = "error"
|
||||
MSRequest string = "request"
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/internal/healthimp"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Health interface {
|
||||
SetStatus(status health.ServiceStatus)
|
||||
SetStatusWithReason(status health.ServiceStatus, reason string)
|
||||
Finish()
|
||||
}
|
||||
|
||||
func NewHealthRouter(logger mlogger.Logger, router chi.Router, endpoint string) (Health, error) {
|
||||
return healthimp.NewRouter(logger, router, endpoint), nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package health
|
||||
|
||||
type ServiceStatus string
|
||||
|
||||
const (
|
||||
SSCreated ServiceStatus = "created"
|
||||
SSStarting ServiceStatus = "starting"
|
||||
SSRunning ServiceStatus = "ok"
|
||||
SSTerminating ServiceStatus = "deactivating"
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
package healthimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
logger mlogger.Logger
|
||||
status *Status
|
||||
}
|
||||
|
||||
func (hr *Router) SetStatus(status health.ServiceStatus) {
|
||||
hr.SetStatusWithReason(status, defaultReason(status))
|
||||
}
|
||||
|
||||
func (hr *Router) SetStatusWithReason(status health.ServiceStatus, reason string) {
|
||||
previous, changed := hr.status.transition(status)
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
reason = defaultReason(status)
|
||||
}
|
||||
hr.logger.Info("Health status changed",
|
||||
zap.String("previous_status", string(previous)),
|
||||
zap.String("status", string(status)),
|
||||
zap.String("reason", reason),
|
||||
)
|
||||
}
|
||||
|
||||
func (hr *Router) Finish() {
|
||||
hr.status.Finish()
|
||||
hr.logger.Debug("Stopped")
|
||||
}
|
||||
|
||||
func (hr *Router) handle(w http.ResponseWriter, r *http.Request) {
|
||||
hr.status.healthHandler()(w, r)
|
||||
}
|
||||
|
||||
func NewRouter(logger mlogger.Logger, router chi.Router, endpoint string) *Router {
|
||||
hr := Router{
|
||||
logger: logger.Named("health_check"),
|
||||
}
|
||||
hr.status = StatusHandler(hr.logger)
|
||||
|
||||
logger.Debug("Installing healthcheck middleware...")
|
||||
router.Group(func(r chi.Router) {
|
||||
ep := endpoint + "/health"
|
||||
r.Get(ep, hr.handle)
|
||||
logger.Info("Health handler installed", zap.String("endpoint", ep))
|
||||
})
|
||||
|
||||
return &hr
|
||||
}
|
||||
|
||||
func defaultReason(status health.ServiceStatus) string {
|
||||
switch status {
|
||||
case health.SSCreated:
|
||||
return "service created"
|
||||
case health.SSStarting:
|
||||
return "service is starting"
|
||||
case health.SSRunning:
|
||||
return "service is ready"
|
||||
case health.SSTerminating:
|
||||
return "service is shutting down"
|
||||
default:
|
||||
return "service health status changed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package healthimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestSetStatusLogsOnlyTransitionsWithReason(t *testing.T) {
|
||||
core, observed := observer.New(zap.InfoLevel)
|
||||
router := NewRouter(zap.New(core), chi.NewRouter(), "")
|
||||
|
||||
router.SetStatusWithReason(health.SSStarting, "observability endpoint initialized")
|
||||
router.SetStatusWithReason(health.SSStarting, "duplicate readiness report")
|
||||
router.SetStatusWithReason(health.SSRunning, "all configured ingestion drivers are ready")
|
||||
router.SetStatus(health.SSRunning)
|
||||
|
||||
entries := observed.FilterMessage("Health status changed").All()
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("health transition log count = %d, want 2", len(entries))
|
||||
}
|
||||
assertTransition(t, entries[0].ContextMap(), "created", "starting", "observability endpoint initialized")
|
||||
assertTransition(t, entries[1].ContextMap(), "starting", "ok", "all configured ingestion drivers are ready")
|
||||
}
|
||||
|
||||
func TestSetStatusUsesNonEmptyDefaultReason(t *testing.T) {
|
||||
core, observed := observer.New(zap.InfoLevel)
|
||||
router := NewRouter(zap.New(core), chi.NewRouter(), "")
|
||||
|
||||
router.SetStatusWithReason(health.SSTerminating, " ")
|
||||
|
||||
entries := observed.FilterMessage("Health status changed").All()
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("health transition log count = %d, want 1", len(entries))
|
||||
}
|
||||
assertTransition(t, entries[0].ContextMap(), "created", "deactivating", "service is shutting down")
|
||||
}
|
||||
|
||||
func assertTransition(t *testing.T, fields map[string]any, previous, status, reason string) {
|
||||
t.Helper()
|
||||
for key, want := range map[string]string{
|
||||
"previous_status": previous,
|
||||
"status": status,
|
||||
"reason": reason,
|
||||
} {
|
||||
if got, ok := fields[key].(string); !ok || got != want {
|
||||
t.Fatalf("%s = %v, want %q", key, fields[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package healthimp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/api/routers/health"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
logger mlogger.Logger
|
||||
mu sync.RWMutex
|
||||
status health.ServiceStatus
|
||||
}
|
||||
|
||||
func (hs *Status) healthHandler() http.HandlerFunc {
|
||||
hs.mu.RLock()
|
||||
status := hs.status
|
||||
hs.mu.RUnlock()
|
||||
return response.Ok(hs.logger, struct {
|
||||
Status health.ServiceStatus `json:"status"`
|
||||
}{
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
func (hr *Status) Finish() {
|
||||
hr.logger.Info("Finished")
|
||||
}
|
||||
|
||||
func (hs *Status) transition(status health.ServiceStatus) (health.ServiceStatus, bool) {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
previous := hs.status
|
||||
if previous == status {
|
||||
return previous, false
|
||||
}
|
||||
hs.status = status
|
||||
return previous, true
|
||||
}
|
||||
|
||||
func StatusHandler(logger mlogger.Logger) *Status {
|
||||
hs := Status{
|
||||
status: health.SSCreated,
|
||||
logger: logger.Named("status"),
|
||||
}
|
||||
return &hs
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package anyobject
|
||||
|
||||
const ID = "*"
|
||||
@@ -0,0 +1,35 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// ArchivableDB implements archive operations with permission checking
|
||||
type ArchivableDB[T model.PermissionBoundStorable] interface {
|
||||
// SetArchived sets the archived status of an entity with permission checking
|
||||
SetArchived(ctx context.Context, accountRef, objectRef bson.ObjectID, archived bool) error
|
||||
// IsArchived checks if an entity is archived with permission checking
|
||||
IsArchived(ctx context.Context, accountRef, objectRef bson.ObjectID) (bool, error)
|
||||
|
||||
// Archive archives an entity with permission checking (sets archived to true)
|
||||
Archive(ctx context.Context, accountRef, objectRef bson.ObjectID) error
|
||||
|
||||
// Unarchive unarchives an entity with permission checking (sets archived to false)
|
||||
Unarchive(ctx context.Context, accountRef, objectRef bson.ObjectID) error
|
||||
}
|
||||
|
||||
// NewArchivableDB creates a new auth.ArchivableDB instance
|
||||
func NewArchivableDB[T model.PermissionBoundStorable](
|
||||
dbImp *template.DBImp[T],
|
||||
logger mlogger.Logger,
|
||||
enforcer Enforcer,
|
||||
createEmpty func() T,
|
||||
getArchivable func(T) model.Archivable,
|
||||
) ArchivableDB[T] {
|
||||
return newArchivableDBImp(dbImp, logger, enforcer, createEmpty, getArchivable)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ArchivableDB implements archive operations with permission checking
|
||||
type ArchivableDBImp[T model.PermissionBoundStorable] struct {
|
||||
dbImp *template.DBImp[T]
|
||||
logger mlogger.Logger
|
||||
enforcer Enforcer
|
||||
createEmpty func() T
|
||||
getArchivable func(T) model.Archivable
|
||||
}
|
||||
|
||||
// NewArchivableDB creates a new auth.ArchivableDB instance
|
||||
func newArchivableDBImp[T model.PermissionBoundStorable](
|
||||
dbImp *template.DBImp[T],
|
||||
logger mlogger.Logger,
|
||||
enforcer Enforcer,
|
||||
createEmpty func() T,
|
||||
getArchivable func(T) model.Archivable,
|
||||
) ArchivableDB[T] {
|
||||
return &ArchivableDBImp[T]{
|
||||
dbImp: dbImp,
|
||||
logger: logger.Named("archivable"),
|
||||
enforcer: enforcer,
|
||||
createEmpty: createEmpty,
|
||||
getArchivable: getArchivable,
|
||||
}
|
||||
}
|
||||
|
||||
// SetArchived sets the archived status of an entity with permission checking
|
||||
func (db *ArchivableDBImp[T]) SetArchived(ctx context.Context, accountRef, objectRef bson.ObjectID, archived bool) error {
|
||||
// Check permissions using enforceObject helper
|
||||
if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionUpdate, accountRef, objectRef); err != nil {
|
||||
db.logger.Warn("Failed to enforce object permission", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived))
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the object to check current archived status
|
||||
obj := db.createEmpty()
|
||||
if err := db.dbImp.Get(ctx, objectRef, obj); err != nil {
|
||||
db.logger.Warn("Failed to get object for setting archived status", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived))
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract archivable from the object
|
||||
archivable := db.getArchivable(obj)
|
||||
currentArchived := archivable.IsArchived()
|
||||
if currentArchived == archived {
|
||||
db.logger.Debug("No change needed - same archived status", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived))
|
||||
return nil // No change needed
|
||||
}
|
||||
|
||||
// Set the archived status
|
||||
patch := repository.Patch().Set(repository.IsArchivedField(), archived)
|
||||
if err := db.dbImp.Patch(ctx, objectRef, patch); err != nil {
|
||||
db.logger.Warn("Failed to set archived status on object", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived))
|
||||
return err
|
||||
}
|
||||
|
||||
db.logger.Debug("Successfully set archived status on object", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived))
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsArchived checks if an entity is archived with permission checking
|
||||
func (db *ArchivableDBImp[T]) IsArchived(ctx context.Context, accountRef, objectRef bson.ObjectID) (bool, error) {
|
||||
// // Check permissions using single Enforce
|
||||
if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionRead, accountRef, objectRef); err != nil {
|
||||
db.logger.Debug("Permission denied for checking archived status", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionRead)))
|
||||
return false, merrors.AccessDenied("read", "object", objectRef)
|
||||
}
|
||||
obj := db.createEmpty()
|
||||
if err := db.dbImp.Get(ctx, objectRef, obj); err != nil {
|
||||
db.logger.Warn("Failed to get object for checking archived status", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return false, err
|
||||
}
|
||||
archivable := db.getArchivable(obj)
|
||||
return archivable.IsArchived(), nil
|
||||
}
|
||||
|
||||
// Archive archives an entity with permission checking (sets archived to true)
|
||||
func (db *ArchivableDBImp[T]) Archive(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
return db.SetArchived(ctx, accountRef, objectRef, true)
|
||||
}
|
||||
|
||||
// Unarchive unarchives an entity with permission checking (sets archived to false)
|
||||
func (db *ArchivableDBImp[T]) Unarchive(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
return db.SetArchived(ctx, accountRef, objectRef, false)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type MembershipChecker interface {
|
||||
IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error)
|
||||
}
|
||||
|
||||
type ScopedAuthorizer struct {
|
||||
// Logger receives Debug/Warn messages from the enforcement path.
|
||||
Logger mlogger.Logger
|
||||
Membership MembershipChecker
|
||||
Enforcer Enforcer
|
||||
}
|
||||
|
||||
// NewScopedAuthorizer constructs a ScopedAuthorizer and validates required dependencies.
|
||||
// Logger defaults to zap.NewNop() if nil.
|
||||
func NewScopedAuthorizer(membership MembershipChecker, enforcer Enforcer, logger mlogger.Logger) *ScopedAuthorizer {
|
||||
if membership == nil {
|
||||
panic("auth.NewScopedAuthorizer: MembershipChecker must not be nil")
|
||||
}
|
||||
if enforcer == nil {
|
||||
panic("auth.NewScopedAuthorizer: Enforcer must not be nil")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &ScopedAuthorizer{
|
||||
Logger: logger,
|
||||
Membership: membership,
|
||||
Enforcer: enforcer,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ScopedAuthorizer) Authorize(ctx context.Context, p ScopedAccessParams) error {
|
||||
a.Logger.Debug("scoped access check started",
|
||||
mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef),
|
||||
mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action)))
|
||||
|
||||
// Personal resources are guarded by ownership alone — membership is not consulted.
|
||||
// Checking membership first would lock the owner out after org membership is revoked.
|
||||
if p.OwnerKind == model.OwnerKindAccount {
|
||||
err := enforceOwnershipAccess(ctx, a.Enforcer, p)
|
||||
if err != nil && errors.Is(err, merrors.ErrAccessDenied) {
|
||||
a.Logger.Debug("access denied: not the owner",
|
||||
mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef),
|
||||
zap.String("action", string(p.Action)))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
ok, err := a.Membership.IsMember(ctx, p.OrgRef, p.CallerRef)
|
||||
if err != nil {
|
||||
a.Logger.Warn("membership check failed",
|
||||
zap.Error(err), mzap.AccRef(p.CallerRef), mzap.OrgRef(p.OrgRef))
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
a.Logger.Debug("access denied: caller is not a member of the organization",
|
||||
mzap.AccRef(p.CallerRef), mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action)))
|
||||
return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef)
|
||||
}
|
||||
|
||||
err = enforceOwnershipAccess(ctx, a.Enforcer, p)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
a.Logger.Debug("access denied",
|
||||
mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef),
|
||||
mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action)))
|
||||
} else {
|
||||
a.Logger.Warn("enforcer returned error during scoped access check",
|
||||
zap.Error(err), mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef),
|
||||
zap.String("action", string(p.Action)))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type fakeMembershipChecker struct {
|
||||
isMember bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeMembershipChecker) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) {
|
||||
return f.isMember, f.err
|
||||
}
|
||||
|
||||
func TestScopedAuthorizer_Authorize(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
other := bson.NewObjectID()
|
||||
org := bson.NewObjectID()
|
||||
object := bson.NewObjectID()
|
||||
policy := bson.NewObjectID()
|
||||
|
||||
base := func(kind model.OwnerKind, owner bson.ObjectID) ScopedAccessParams {
|
||||
return ScopedAccessParams{
|
||||
Service: mservice.Recipients,
|
||||
PolicyRef: policy,
|
||||
CallerRef: caller,
|
||||
OrgRef: org,
|
||||
ObjectRef: object,
|
||||
OwnerKind: kind,
|
||||
OwnerRef: owner,
|
||||
Action: model.ActionRead,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("membership checker error propagates for org-owned resource and is not masked as denied", func(t *testing.T) {
|
||||
boom := merrors.Define("checker down")
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{err: boom},
|
||||
Enforcer: &fakeEnforcer{},
|
||||
}
|
||||
err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org))
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected underlying error, got %v", err)
|
||||
}
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatal("checker error must not be reported as access denied")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-member owning account-owned resource is allowed", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: false},
|
||||
Enforcer: &fakeEnforcer{},
|
||||
}
|
||||
if err := a.Authorize(context.Background(), base(model.OwnerKindAccount, caller)); err != nil {
|
||||
t.Fatalf("expected allow for personal resource owner regardless of membership, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-member is denied for org-owned resource", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: false},
|
||||
Enforcer: &fakeEnforcer{allow: true},
|
||||
}
|
||||
err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied for non-member on org resource, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member owning account-owned resource is allowed", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: true},
|
||||
Enforcer: &fakeEnforcer{},
|
||||
}
|
||||
if err := a.Authorize(context.Background(), base(model.OwnerKindAccount, caller)); err != nil {
|
||||
t.Fatalf("expected allow, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member accessing another account's resource is denied", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: true},
|
||||
Enforcer: &fakeEnforcer{},
|
||||
}
|
||||
err := a.Authorize(context.Background(), base(model.OwnerKindAccount, other))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member with org-owned resource allowed when enforcer approves", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: true},
|
||||
Enforcer: &fakeEnforcer{allow: true},
|
||||
}
|
||||
if err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)); err != nil {
|
||||
t.Fatalf("expected allow, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("member with org-owned resource denied when enforcer refuses", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: true},
|
||||
Enforcer: &fakeEnforcer{allow: false},
|
||||
}
|
||||
err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enforcer error propagates and is not masked as denied", func(t *testing.T) {
|
||||
boom := merrors.Define("policy backend down")
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: true},
|
||||
Enforcer: &fakeEnforcer{err: boom},
|
||||
}
|
||||
err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org))
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected underlying error, got %v", err)
|
||||
}
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatal("enforcer error must not be reported as access denied")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown owner kind is denied", func(t *testing.T) {
|
||||
a := &ScopedAuthorizer{Logger: zap.NewNop(),
|
||||
Membership: &fakeMembershipChecker{isMember: true},
|
||||
Enforcer: &fakeEnforcer{allow: true},
|
||||
}
|
||||
err := a.Authorize(context.Background(), base(model.OwnerKind("garbage"), caller))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied for unknown owner kind, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package auth
|
||||
|
||||
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
|
||||
type EnforcerType string
|
||||
|
||||
const (
|
||||
Casbin EnforcerType = "casbin"
|
||||
Native EnforcerType = "native"
|
||||
)
|
||||
|
||||
type Config = model.DriverConfig[EnforcerType]
|
||||
@@ -0,0 +1,38 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
type ProtectedDB[T model.PermissionBoundStorable] interface {
|
||||
Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error
|
||||
InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error
|
||||
Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error
|
||||
Update(ctx context.Context, accountRef bson.ObjectID, object T) error
|
||||
Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error
|
||||
DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error
|
||||
Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error
|
||||
PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error)
|
||||
Unprotected() template.DB[T]
|
||||
ListIDs(ctx context.Context, action model.Action, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error)
|
||||
}
|
||||
|
||||
func CreateDB[T model.PermissionBoundStorable](
|
||||
ctx context.Context,
|
||||
l mlogger.Logger,
|
||||
pdb policy.DB,
|
||||
enforcer Enforcer,
|
||||
collection mservice.Type,
|
||||
db *mongo.Database,
|
||||
) (ProtectedDB[T], error) {
|
||||
return CreateDBImp[T](ctx, l, pdb, enforcer, collection, db)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AccountBoundDB[T model.AccountBoundStorable] interface {
|
||||
Create(ctx context.Context, accountRef bson.ObjectID, object T) error
|
||||
Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error
|
||||
Update(ctx context.Context, accountRef bson.ObjectID, object T) error
|
||||
Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error
|
||||
Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error
|
||||
DeleteMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query) error
|
||||
FindOne(ctx context.Context, accountRef bson.ObjectID, query builder.Query, result T) error
|
||||
ListIDs(ctx context.Context, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error)
|
||||
ListAccountBound(ctx context.Context, accountRef, organizationRef bson.ObjectID, query builder.Query) ([]model.AccountBoundStorable, error)
|
||||
}
|
||||
|
||||
func CreateAccountBound[T model.AccountBoundStorable](
|
||||
ctx context.Context,
|
||||
logger mlogger.Logger,
|
||||
pdb policy.DB,
|
||||
enforcer Enforcer,
|
||||
collection mservice.Type,
|
||||
db *mongo.Database,
|
||||
) (AccountBoundDB[T], error) {
|
||||
logger = logger.Named("account_bound")
|
||||
var policy model.PolicyDescription
|
||||
if err := pdb.GetBuiltInPolicy(ctx, mservice.Organizations, &policy); err != nil {
|
||||
logger.Warn("Failed to fetch organization policy description", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
res := &AccountBoundDBImp[T]{
|
||||
Logger: logger,
|
||||
DBImp: template.Create[T](logger, collection, db),
|
||||
Enforcer: enforcer,
|
||||
PermissionRef: policy.ID,
|
||||
Collection: collection,
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ProtectedDBImp[T model.PermissionBoundStorable] struct {
|
||||
DBImp *template.DBImp[T]
|
||||
Enforcer Enforcer
|
||||
PermissionRef bson.ObjectID
|
||||
Collection mservice.Type
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) enforce(ctx context.Context, action model.Action, object model.PermissionBoundStorable, accountRef, objectRef bson.ObjectID) error {
|
||||
res, err := db.Enforcer.Enforce(ctx, object.GetPermissionRef(), accountRef, object.GetOrganizationRef(), objectRef, action)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to enforce permission",
|
||||
zap.Error(err), mzap.PermissionRef(object.GetPermissionRef()),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return err
|
||||
}
|
||||
if !res {
|
||||
db.DBImp.Logger.Debug("Access denied", mzap.PermissionRef(object.GetPermissionRef()),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return merrors.AccessDenied(db.Collection, string(action), objectRef)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error {
|
||||
db.DBImp.Logger.Debug("Attempting to create object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), zap.String("collection", db.Collection))
|
||||
|
||||
if object.GetPermissionRef() == bson.NilObjectID {
|
||||
object.SetPermissionRef(db.PermissionRef)
|
||||
}
|
||||
object.SetOrganizationRef(organizationRef)
|
||||
|
||||
if err := db.enforce(ctx, model.ActionCreate, object, accountRef, bson.NilObjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Create(ctx, object); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to create object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully created object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), zap.String("collection", db.Collection))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error {
|
||||
if len(objects) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Attempting to insert many objects", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), zap.String("collection", db.Collection),
|
||||
zap.Int("count", len(objects)))
|
||||
|
||||
// Set permission and organization refs for all objects and enforce permissions
|
||||
for _, object := range objects {
|
||||
if object.GetPermissionRef() == bson.NilObjectID {
|
||||
object.SetPermissionRef(db.PermissionRef)
|
||||
}
|
||||
object.SetOrganizationRef(organizationRef)
|
||||
|
||||
if err := db.enforce(ctx, model.ActionCreate, object, accountRef, bson.NilObjectID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.DBImp.InsertMany(ctx, objects); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to insert many objects", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), zap.String("collection", db.Collection),
|
||||
zap.Int("count", len(objects)))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully inserted many objects", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), zap.String("collection", db.Collection),
|
||||
zap.Int("count", len(objects)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) enforceObject(ctx context.Context, action model.Action, accountRef, objectRef bson.ObjectID) error {
|
||||
l, err := db.ListIDs(ctx, action, accountRef, repository.IDFilter(objectRef))
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("Error occured while checking access rights", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return err
|
||||
}
|
||||
if len(l) == 0 {
|
||||
db.DBImp.Logger.Debug("Access denied", zap.String("action", string(action)), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return merrors.AccessDenied(db.Collection, string(action), objectRef)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error {
|
||||
db.DBImp.Logger.Debug("Attempting to get object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if err := db.enforceObject(ctx, model.ActionRead, accountRef, objectRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Get(ctx, objectRef, result); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to get object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully retrieved object",
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(result.GetOrganizationRef()),
|
||||
mzap.StorableRef(result), mzap.PermissionRef(result.GetPermissionRef()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error {
|
||||
db.DBImp.Logger.Debug("Attempting to update object", mzap.AccRef(accountRef), mzap.StorableRef(object))
|
||||
|
||||
if err := db.enforceObject(ctx, model.ActionUpdate, accountRef, *object.GetID()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Update(ctx, object); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to update object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully updated object",
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()),
|
||||
mzap.StorableRef(object), mzap.PermissionRef(object.GetPermissionRef()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
db.DBImp.Logger.Debug("Attempting to delete object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if err := db.enforceObject(ctx, model.ActionDelete, accountRef, objectRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Delete(ctx, objectRef); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to delete object", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully deleted object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) ListIDs(
|
||||
ctx context.Context,
|
||||
action model.Action,
|
||||
accountRef bson.ObjectID,
|
||||
query builder.Query,
|
||||
) ([]bson.ObjectID, error) {
|
||||
db.DBImp.Logger.Debug("Attempting to list object IDs",
|
||||
mzap.AccRef(accountRef), zap.String("collection", db.Collection), zap.Any("filter", query.BuildQuery()))
|
||||
|
||||
// 1. Fetch all candidate IDs from the underlying DB
|
||||
allIDs, err := db.DBImp.ListPermissionBound(ctx, query)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to list object IDs", zap.Error(err), mzap.AccRef(accountRef),
|
||||
zap.String("collection", db.Collection), zap.String("action", string(action)))
|
||||
return nil, err
|
||||
}
|
||||
if len(allIDs) == 0 {
|
||||
db.DBImp.Logger.Debug("No objects found matching filter", mzap.AccRef(accountRef),
|
||||
zap.String("collection", db.Collection), zap.Any("filter", query.BuildQuery()))
|
||||
return []bson.ObjectID{}, merrors.NoData(fmt.Sprintf("no %s found", db.Collection))
|
||||
}
|
||||
|
||||
// 2. Check read permission for each ID
|
||||
var allowedIDs []bson.ObjectID
|
||||
for _, desc := range allIDs {
|
||||
enforceErr := db.enforce(ctx, action, desc, accountRef, *desc.GetID())
|
||||
if enforceErr == nil {
|
||||
allowedIDs = append(allowedIDs, *desc.GetID())
|
||||
} else if !errors.Is(enforceErr, merrors.ErrAccessDenied) {
|
||||
// If the error is something other than AccessDenied, we want to fail
|
||||
db.DBImp.Logger.Warn("Error while enforcing read permission", zap.Error(enforceErr),
|
||||
mzap.PermissionRef(desc.GetPermissionRef()), zap.String("action", string(action)),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(desc.GetOrganizationRef()),
|
||||
mzap.ObjRef("object_ref", *desc.GetID()), zap.String("collection", db.Collection),
|
||||
)
|
||||
return nil, enforceErr
|
||||
}
|
||||
// If AccessDenied, we simply skip that ID.
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully enforced read permission on IDs", zap.Int("fetched_count", len(allIDs)),
|
||||
zap.Int("allowed_count", len(allowedIDs)), mzap.AccRef(accountRef),
|
||||
zap.String("collection", db.Collection), zap.String("action", string(action)))
|
||||
|
||||
// 3. Return only the IDs that passed permission checks
|
||||
return allowedIDs, nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) Unprotected() template.DB[T] {
|
||||
return db.DBImp
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
if err := db.enforceObject(ctx, model.ActionDelete, accountRef, objectRef); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DBImp.DeleteCascade(ctx, objectRef); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to delete dependent object", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateDBImp[T model.PermissionBoundStorable](
|
||||
ctx context.Context,
|
||||
l mlogger.Logger,
|
||||
pdb policy.DB,
|
||||
enforcer Enforcer,
|
||||
collection mservice.Type,
|
||||
db *mongo.Database,
|
||||
) (*ProtectedDBImp[T], error) {
|
||||
logger := l.Named("protected")
|
||||
var policy model.PolicyDescription
|
||||
if err := pdb.GetBuiltInPolicy(ctx, collection, &policy); err != nil {
|
||||
logger.Warn("Failed to fetch policy description", zap.Error(err), zap.String("resource_type", collection))
|
||||
return nil, err
|
||||
}
|
||||
p := &ProtectedDBImp[T]{
|
||||
DBImp: template.Create[T](logger, collection, db),
|
||||
PermissionRef: policy.ID,
|
||||
Collection: collection,
|
||||
Enforcer: enforcer,
|
||||
}
|
||||
if err := p.DBImp.Repository.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: storable.OrganizationRefField, Sort: ri.Asc}},
|
||||
}); err != nil {
|
||||
logger.Warn("Failed to create index", zap.Error(err), zap.String("resource_type", collection))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error {
|
||||
db.DBImp.Logger.Debug("Attempting to patch object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if err := db.enforceObject(ctx, model.ActionUpdate, accountRef, objectRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Repository.Patch(ctx, objectRef, patch); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to patch object", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully patched object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *ProtectedDBImp[T]) PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) {
|
||||
db.DBImp.Logger.Debug("Attempting to patch many objects",
|
||||
mzap.AccRef(accountRef), zap.Any("filter", query.BuildQuery()))
|
||||
|
||||
ids, err := db.ListIDs(ctx, model.ActionUpdate, accountRef, query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
values := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
values[i] = id
|
||||
}
|
||||
idFilter := repository.Query().In(repository.IDField(), values...)
|
||||
finalQuery := query.And(idFilter)
|
||||
|
||||
modified, err := db.DBImp.Repository.PatchMany(ctx, finalQuery, patch)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to patch many objects", zap.Error(err),
|
||||
mzap.AccRef(accountRef))
|
||||
return 0, err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully patched many objects",
|
||||
mzap.AccRef(accountRef), zap.Int("modified_count", modified))
|
||||
return modified, nil
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AccountBoundDBImp[T model.AccountBoundStorable] struct {
|
||||
Logger mlogger.Logger
|
||||
DBImp *template.DBImp[T]
|
||||
Enforcer Enforcer
|
||||
PermissionRef bson.ObjectID
|
||||
Collection mservice.Type
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) enforce(ctx context.Context, action model.Action, object model.AccountBoundStorable, accountRef bson.ObjectID) error {
|
||||
// FIRST: Check if the object's AccountRef equals the calling accountRef - if so, ALLOW
|
||||
objectAccountRef := object.GetAccountRef()
|
||||
if objectAccountRef != nil && *objectAccountRef == accountRef {
|
||||
db.Logger.Debug("Access granted - object belongs to calling account",
|
||||
mzap.ObjRef("object_account_ref", *objectAccountRef),
|
||||
mzap.ObjRef("calling_account_ref", accountRef),
|
||||
zap.String("action", string(action)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SECOND: If not owned by calling account, check organization-level permissions
|
||||
organizationRef := object.GetOrganizationRef()
|
||||
res, err := db.Enforcer.Enforce(ctx, db.PermissionRef, accountRef, organizationRef, organizationRef, action)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to enforce permission",
|
||||
zap.Error(err), mzap.PermissionRef(db.PermissionRef),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.String("action", string(action)))
|
||||
return err
|
||||
}
|
||||
if !res {
|
||||
db.Logger.Debug("Access denied", mzap.PermissionRef(db.PermissionRef),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.String("action", string(action)))
|
||||
return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) enforceInterface(ctx context.Context, action model.Action, object model.AccountBoundStorable, accountRef bson.ObjectID) error {
|
||||
return db.enforce(ctx, action, object, accountRef)
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) Create(ctx context.Context, accountRef bson.ObjectID, object T) error {
|
||||
orgRef := object.GetOrganizationRef()
|
||||
db.Logger.Debug("Attempting to create object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(orgRef), zap.String("collection", db.Collection))
|
||||
|
||||
// Check organization update permission for create operations
|
||||
if err := db.enforce(ctx, model.ActionUpdate, object, accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Create(ctx, object); err != nil {
|
||||
db.Logger.Warn("Failed to create object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(orgRef), zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully created object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(orgRef), zap.String("collection", db.Collection))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error {
|
||||
db.Logger.Debug("Attempting to get object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
// First get the object to check its organization
|
||||
if err := db.DBImp.Get(ctx, objectRef, result); err != nil {
|
||||
db.Logger.Warn("Failed to get object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check organization read permission
|
||||
if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully retrieved object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(result.GetOrganizationRef()), zap.String("collection", db.Collection))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error {
|
||||
db.Logger.Debug("Attempting to update object", mzap.AccRef(accountRef), mzap.StorableRef(object))
|
||||
|
||||
// Check organization update permission
|
||||
if err := db.enforce(ctx, model.ActionUpdate, object, accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Update(ctx, object); err != nil {
|
||||
db.Logger.Warn("Failed to update object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object))
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully updated object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error {
|
||||
db.Logger.Debug("Attempting to patch object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
// First get the object to check its organization
|
||||
objs, err := db.DBImp.Repository.ListAccountBound(ctx, repository.IDFilter(objectRef))
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to get object for permission check when deleting", zap.Error(err), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
if len(objs) == 0 {
|
||||
db.Logger.Debug("Permission denied for deletion", mzap.ObjRef("object_ref", objectRef), mzap.AccRef(accountRef))
|
||||
return merrors.AccessDenied(db.Collection, string(model.ActionDelete), objectRef)
|
||||
}
|
||||
|
||||
// Check organization update permission
|
||||
if err := db.enforce(ctx, model.ActionUpdate, objs[0], accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil {
|
||||
db.Logger.Warn("Failed to patch object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully patched object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
db.Logger.Debug("Attempting to delete object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
// First get the object to check its organization
|
||||
objs, err := db.DBImp.Repository.ListAccountBound(ctx, repository.IDFilter(objectRef))
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to get object for permission check when deleting", zap.Error(err), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
if len(objs) == 0 {
|
||||
db.Logger.Debug("Permission denied for deletion", mzap.ObjRef("object_ref", objectRef), mzap.AccRef(accountRef))
|
||||
return merrors.AccessDenied(db.Collection, string(model.ActionDelete), objectRef)
|
||||
}
|
||||
// Check organization update permission for delete operations
|
||||
if err := db.enforce(ctx, model.ActionUpdate, objs[0], accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.DBImp.Delete(ctx, objectRef); err != nil {
|
||||
db.Logger.Warn("Failed to delete object", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully deleted object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) DeleteMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query) error {
|
||||
db.Logger.Debug("Attempting to delete many objects", mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
|
||||
// Get all candidate objects for batch permission checking
|
||||
allObjects, err := db.DBImp.Repository.ListPermissionBound(ctx, query)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to list objects for delete many", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Use batch enforcement for efficiency
|
||||
allowedResults, err := db.Enforcer.EnforceBatch(ctx, allObjects, accountRef, model.ActionUpdate)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to enforce batch permissions for delete many", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Build query for objects that passed permission check
|
||||
var allowedIDs []bson.ObjectID
|
||||
for _, obj := range allObjects {
|
||||
if allowedResults[*obj.GetID()] {
|
||||
allowedIDs = append(allowedIDs, *obj.GetID())
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowedIDs) == 0 {
|
||||
db.Logger.Debug("No objects allowed for deletion", mzap.AccRef(accountRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete only the allowed objects
|
||||
allowedQuery := query.And(repository.Query().In(repository.IDField(), allowedIDs))
|
||||
if err := db.DBImp.DeleteMany(ctx, allowedQuery); err != nil {
|
||||
db.Logger.Warn("Failed to delete many objects", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully deleted many objects", mzap.AccRef(accountRef), zap.Int("count", len(allowedIDs)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) FindOne(ctx context.Context, accountRef bson.ObjectID, query builder.Query, result T) error {
|
||||
db.Logger.Debug("Attempting to find one object", mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
|
||||
// For FindOne, we need to check read permission after finding the object
|
||||
if err := db.DBImp.FindOne(ctx, query, result); err != nil {
|
||||
db.Logger.Warn("Failed to find one object", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check organization read permission for the found object
|
||||
if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully found one object", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(result.GetOrganizationRef()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) ListIDs(ctx context.Context, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) {
|
||||
db.Logger.Debug("Attempting to list object IDs", mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
|
||||
// Get all candidate objects for batch permission checking
|
||||
allObjects, err := db.DBImp.Repository.ListPermissionBound(ctx, query)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to list objects for ID filtering", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use batch enforcement for efficiency
|
||||
allowedResults, err := db.Enforcer.EnforceBatch(ctx, allObjects, accountRef, model.ActionRead)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to enforce batch permissions for ID listing", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter to only allowed object IDs
|
||||
var allowedIDs []bson.ObjectID
|
||||
for _, obj := range allObjects {
|
||||
if allowedResults[*obj.GetID()] {
|
||||
allowedIDs = append(allowedIDs, *obj.GetID())
|
||||
}
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully filtered object IDs", zap.Int("total_count", len(allObjects)),
|
||||
zap.Int("allowed_count", len(allowedIDs)), mzap.AccRef(accountRef))
|
||||
return allowedIDs, nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) ListAccountBound(ctx context.Context, accountRef, organizationRef bson.ObjectID, query builder.Query) ([]model.AccountBoundStorable, error) {
|
||||
db.Logger.Debug("Attempting to list account bound objects", mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
|
||||
// Build query to find objects where accountRef matches OR is null/absent
|
||||
accountQuery := repository.WithOrg(accountRef, organizationRef)
|
||||
|
||||
// Combine with the provided query
|
||||
finalQuery := query.And(accountQuery)
|
||||
|
||||
// Get all candidate objects
|
||||
allObjects, err := db.DBImp.Repository.ListAccountBound(ctx, finalQuery)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to list account bound objects", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter objects based on read permissions (AccountBoundStorable doesn't have permission info, so we check organization level)
|
||||
var allowedObjects []model.AccountBoundStorable
|
||||
for _, obj := range allObjects {
|
||||
if err := db.enforceInterface(ctx, model.ActionRead, obj, accountRef); err == nil {
|
||||
allowedObjects = append(allowedObjects, obj)
|
||||
} else if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
// If the error is something other than AccessDenied, we want to fail
|
||||
db.Logger.Warn("Error while enforcing read permission", zap.Error(err), mzap.ObjRef("object_ref", *obj.GetID()))
|
||||
return nil, err
|
||||
}
|
||||
// If AccessDenied, we simply skip that object
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully filtered account bound objects", zap.Int("total_count", len(allObjects)),
|
||||
zap.Int("allowed_count", len(allowedObjects)), mzap.AccRef(accountRef))
|
||||
return allowedObjects, nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) GetByAccountRef(ctx context.Context, accountRef bson.ObjectID, result T) error {
|
||||
db.Logger.Debug("Attempting to get object by account ref", mzap.AccRef(accountRef))
|
||||
|
||||
// Build query to find objects where accountRef matches OR is null/absent
|
||||
query := repository.WithoutOrg(accountRef)
|
||||
|
||||
if err := db.DBImp.FindOne(ctx, query, result); err != nil {
|
||||
db.Logger.Warn("Failed to get object by account ref", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check organization read permission for the found object
|
||||
if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully retrieved object by account ref", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(result.GetOrganizationRef()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) DeleteByAccountRef(ctx context.Context, accountRef bson.ObjectID) error {
|
||||
db.Logger.Debug("Attempting to delete objects by account ref", mzap.AccRef(accountRef))
|
||||
|
||||
// Build query to find objects where accountRef matches OR is null/absent
|
||||
query := repository.WithoutOrg(accountRef)
|
||||
|
||||
// Get all candidate objects for individual permission checking
|
||||
allObjects, err := db.DBImp.Repository.ListAccountBound(ctx, query)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to list objects for delete by account ref", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check permissions for each object individually (AccountBoundStorable doesn't have permission info)
|
||||
var allowedIDs []bson.ObjectID
|
||||
for _, obj := range allObjects {
|
||||
if err := db.enforceInterface(ctx, model.ActionUpdate, obj, accountRef); err == nil {
|
||||
allowedIDs = append(allowedIDs, *obj.GetID())
|
||||
} else if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
// If the error is something other than AccessDenied, we want to fail
|
||||
db.Logger.Warn("Error while enforcing update permission", zap.Error(err), mzap.ObjRef("object_ref", *obj.GetID()))
|
||||
return err
|
||||
}
|
||||
// If AccessDenied, we simply skip that object
|
||||
}
|
||||
|
||||
if len(allowedIDs) == 0 {
|
||||
db.Logger.Debug("No objects allowed for deletion by account ref", mzap.AccRef(accountRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete only the allowed objects
|
||||
allowedQuery := query.And(repository.Query().In(repository.IDField(), allowedIDs))
|
||||
if err := db.DBImp.DeleteMany(ctx, allowedQuery); err != nil {
|
||||
db.Logger.Warn("Failed to delete objects by account ref", zap.Error(err), mzap.AccRef(accountRef))
|
||||
return err
|
||||
}
|
||||
|
||||
db.Logger.Debug("Successfully deleted objects by account ref", mzap.AccRef(accountRef), zap.Int("count", len(allowedIDs)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *AccountBoundDBImp[T]) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error {
|
||||
return db.DBImp.DeleteCascade(ctx, objectRef)
|
||||
}
|
||||
|
||||
// CreateAccountBoundImp creates a concrete AccountBoundDBImp instance for internal use
|
||||
func CreateAccountBoundImp[T model.AccountBoundStorable](
|
||||
ctx context.Context,
|
||||
logger mlogger.Logger,
|
||||
pdb policy.DB,
|
||||
enforcer Enforcer,
|
||||
collection mservice.Type,
|
||||
db *mongo.Database,
|
||||
) (*AccountBoundDBImp[T], error) {
|
||||
logger = logger.Named("account_bound")
|
||||
var policy model.PolicyDescription
|
||||
if err := pdb.GetBuiltInPolicy(ctx, mservice.Organizations, &policy); err != nil {
|
||||
logger.Warn("Failed to fetch organization policy description", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
res := &AccountBoundDBImp[T]{
|
||||
Logger: logger,
|
||||
DBImp: template.Create[T](logger, collection, db),
|
||||
Enforcer: enforcer,
|
||||
PermissionRef: policy.ID,
|
||||
Collection: collection,
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TestAccountBoundDBImp_Enforce tests the enforce method
|
||||
func TestAccountBoundDBImp_Enforce(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
db := &AccountBoundDBImp[model.AccountBoundStorable]{
|
||||
Logger: logger,
|
||||
PermissionRef: bson.NewObjectID(),
|
||||
Collection: "test_collection",
|
||||
}
|
||||
|
||||
t.Run("EnforceMethodExists", func(t *testing.T) {
|
||||
// Test that the enforce method exists and can be called
|
||||
// This is a basic test to ensure the method signature is correct
|
||||
assert.NotNil(t, db.enforce)
|
||||
})
|
||||
|
||||
t.Run("PermissionRefSet", func(t *testing.T) {
|
||||
// Test that PermissionRef is properly set
|
||||
assert.NotEqual(t, bson.NilObjectID, db.PermissionRef)
|
||||
})
|
||||
|
||||
t.Run("CollectionSet", func(t *testing.T) {
|
||||
// Test that Collection is properly set
|
||||
assert.Equal(t, "test_collection", db.Collection)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAccountBoundDBImp_InterfaceCompliance tests that the struct implements required interfaces
|
||||
func TestAccountBoundDBImp_InterfaceCompliance(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
db := &AccountBoundDBImp[model.AccountBoundStorable]{
|
||||
Logger: logger,
|
||||
PermissionRef: bson.NewObjectID(),
|
||||
Collection: "test_collection",
|
||||
}
|
||||
|
||||
t.Run("StructInitialization", func(t *testing.T) {
|
||||
// Test that the struct can be initialized
|
||||
assert.NotNil(t, db)
|
||||
assert.NotNil(t, db.Logger)
|
||||
assert.NotEqual(t, bson.NilObjectID, db.PermissionRef)
|
||||
assert.NotEmpty(t, db.Collection)
|
||||
})
|
||||
|
||||
t.Run("LoggerInitialization", func(t *testing.T) {
|
||||
// Test that logger is properly initialized
|
||||
assert.NotNil(t, db.Logger)
|
||||
})
|
||||
}
|
||||
|
||||
// TestAccountBoundDBImp_ErrorHandling tests error handling patterns
|
||||
func TestAccountBoundDBImp_ErrorHandling(t *testing.T) {
|
||||
t.Run("AccessDeniedError", func(t *testing.T) {
|
||||
// Test that AccessDenied error is properly created
|
||||
err := merrors.AccessDenied("test_collection", "read", bson.NilObjectID)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrAccessDenied))
|
||||
})
|
||||
|
||||
t.Run("ErrorTypeChecking", func(t *testing.T) {
|
||||
// Test error type checking
|
||||
accessDeniedErr := merrors.AccessDenied("test", "read", bson.NilObjectID)
|
||||
otherErr := merrors.Define("other error")
|
||||
|
||||
assert.True(t, errors.Is(accessDeniedErr, merrors.ErrAccessDenied))
|
||||
assert.False(t, errors.Is(otherErr, merrors.ErrAccessDenied))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ownerKindField and ownerRefField are the canonical BSON paths produced by
|
||||
// model.Owner embedded with bson:"owner". Resources managed by ScopedDBImp
|
||||
// must use these same tags for BuildScopedQuery to produce correct filters.
|
||||
const (
|
||||
ownerKindField = "owner.kind"
|
||||
ownerRefField = "owner.ref"
|
||||
)
|
||||
|
||||
// ScopedDBImp provides generic CRUD, patch, and bulk operations for resources
|
||||
// that follow the personal-vs-org dual-ownership model (ownerKind + ownerRef).
|
||||
//
|
||||
// Unlike ProtectedDBImp, it never uses permissionRef as a routing sentinel.
|
||||
// Every read/mutate operation calls LoadAndEnforce, which reads ownerKind and
|
||||
// ownerRef from the authoritative database record before making the access
|
||||
// decision:
|
||||
// - personal (ownerKind=account): caller must be the owner (no policy consulted)
|
||||
// - org (ownerKind=organization): caller must pass the org policy check
|
||||
//
|
||||
// This eliminates the permissionRef==NilObjectID pattern that would otherwise
|
||||
// be required for personal-owned resources under ProtectedDBImp.
|
||||
type ScopedDBImp[T model.ScopedStorable] struct {
|
||||
DBImp *template.DBImp[T]
|
||||
scoped *ScopedDB[T]
|
||||
Enforcer Enforcer
|
||||
OrgPolicyRef bson.ObjectID
|
||||
Collection mservice.Type
|
||||
}
|
||||
|
||||
// NewScopedDBImp wires a ScopedDBImp. Panics on nil required dependencies to
|
||||
// surface misconfiguration at startup, matching the contract of NewScopedDB
|
||||
// and CreateDBImp.
|
||||
func NewScopedDBImp[T model.ScopedStorable](
|
||||
dbImp *template.DBImp[T],
|
||||
enforcer Enforcer,
|
||||
membership MembershipChecker,
|
||||
orgPolicyRef bson.ObjectID,
|
||||
collection mservice.Type,
|
||||
newFn func() T,
|
||||
logger mlogger.Logger,
|
||||
) *ScopedDBImp[T] {
|
||||
if dbImp == nil {
|
||||
panic("auth.NewScopedDBImp: DBImp must not be nil")
|
||||
}
|
||||
if enforcer == nil {
|
||||
panic("auth.NewScopedDBImp: Enforcer must not be nil")
|
||||
}
|
||||
if membership == nil {
|
||||
panic("auth.NewScopedDBImp: MembershipChecker must not be nil")
|
||||
}
|
||||
if newFn == nil {
|
||||
panic("auth.NewScopedDBImp: New must not be nil")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &ScopedDBImp[T]{
|
||||
DBImp: dbImp,
|
||||
Enforcer: enforcer,
|
||||
OrgPolicyRef: orgPolicyRef,
|
||||
Collection: collection,
|
||||
scoped: NewScopedDB(
|
||||
dbImp,
|
||||
NewScopedAuthorizer(membership, enforcer, logger.Named("scoped")),
|
||||
orgPolicyRef,
|
||||
collection,
|
||||
newFn,
|
||||
logger.Named("scoped"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (db *ScopedDBImp[T]) loadAndEnforce(ctx context.Context, accountRef, objectRef bson.ObjectID, action model.Action) (T, error) {
|
||||
return db.scoped.LoadAndEnforce(ctx, accountRef, objectRef, action)
|
||||
}
|
||||
|
||||
// Unprotected returns the raw DBImp for operations that bypass scoped auth.
|
||||
// Callers are responsible for their own authorization.
|
||||
func (db *ScopedDBImp[T]) Unprotected() template.DB[T] {
|
||||
return db.DBImp
|
||||
}
|
||||
|
||||
// BuildScopedQuery returns the ownership-aware OR-query for use in List
|
||||
// implementations. It mirrors the access model used by all other ScopedDBImp
|
||||
// operations: membership is checked first, then org policy read access.
|
||||
//
|
||||
// The returned query always includes personal resources owned by accountRef,
|
||||
// regardless of the organization they were created in — personal resources
|
||||
// follow their owner across every org the account is a member of. Org-owned
|
||||
// resources are bounded to organizationRef and added only when the caller is
|
||||
// a member of the org AND the org policy grants read access.
|
||||
//
|
||||
// Callers must compose the returned query with resource-specific filters
|
||||
// (additional field conditions, cursor pagination, etc.).
|
||||
//
|
||||
// Resources must embed model.Owner with bson:"owner" so the generated field
|
||||
// paths (owner.kind, owner.ref) match the stored documents.
|
||||
func (db *ScopedDBImp[T]) BuildScopedQuery(ctx context.Context, accountRef, organizationRef bson.ObjectID) (builder.Query, error) {
|
||||
personalQ := repository.Query().
|
||||
Filter(repository.Field(ownerKindField), model.OwnerKindAccount).
|
||||
Filter(repository.Field(ownerRefField), accountRef)
|
||||
|
||||
conditions := []builder.Query{personalQ}
|
||||
|
||||
isMember, err := db.scoped.Authorizer.Membership.IsMember(ctx, organizationRef, accountRef)
|
||||
if err != nil {
|
||||
// Membership backend failure degrades org access but must not prevent
|
||||
// the caller from seeing their own personal resources.
|
||||
db.scoped.Logger.Warn("membership check failed, returning personal-only results",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
} else if isMember {
|
||||
hasOrgAccess, enforceErr := db.Enforcer.Enforce(
|
||||
ctx, db.OrgPolicyRef, accountRef, organizationRef, bson.NilObjectID, model.ActionRead,
|
||||
)
|
||||
if enforceErr != nil {
|
||||
return nil, enforceErr
|
||||
}
|
||||
if hasOrgAccess {
|
||||
conditions = append(conditions, repository.Query().And(
|
||||
repository.OrgFilter(organizationRef),
|
||||
repository.Filter(ownerKindField, model.OwnerKindOrganization),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return repository.Query().Or(conditions...), nil
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder"
|
||||
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/transaction"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// testScopedObj is a minimal model.ScopedStorable used exclusively by
|
||||
// ScopedDBImp tests.
|
||||
type testScopedObj struct {
|
||||
id bson.ObjectID
|
||||
orgRef bson.ObjectID
|
||||
ownerKind model.OwnerKind
|
||||
ownerRef bson.ObjectID
|
||||
permissionRef bson.ObjectID
|
||||
}
|
||||
|
||||
func (o *testScopedObj) GetID() *bson.ObjectID { return &o.id }
|
||||
func (o *testScopedObj) SetID(id bson.ObjectID) { o.id = id }
|
||||
func (o *testScopedObj) Update() {}
|
||||
func (o *testScopedObj) Collection() string { return "test_scoped" }
|
||||
func (o *testScopedObj) GetOrganizationRef() bson.ObjectID { return o.orgRef }
|
||||
func (o *testScopedObj) SetOrganizationRef(ref bson.ObjectID) { o.orgRef = ref }
|
||||
func (o *testScopedObj) GetOwnerKind() model.OwnerKind { return o.ownerKind }
|
||||
func (o *testScopedObj) GetOwnerRef() bson.ObjectID { return o.ownerRef }
|
||||
func (o *testScopedObj) SetOwnerKind(k model.OwnerKind) { o.ownerKind = k }
|
||||
func (o *testScopedObj) SetOwnerRef(ref bson.ObjectID) { o.ownerRef = ref }
|
||||
func (o *testScopedObj) GetPermissionRef() bson.ObjectID { return o.permissionRef }
|
||||
func (o *testScopedObj) SetPermissionRef(ref bson.ObjectID) { o.permissionRef = ref }
|
||||
|
||||
var _ model.ScopedStorable = (*testScopedObj)(nil)
|
||||
|
||||
// fakeScopedRepository is a minimal in-memory repository.Repository that
|
||||
// supports Insert, InsertMany, Get, Update, Patch, Delete, and ListIDs.
|
||||
// All other methods return merrors.NotImplemented.
|
||||
type fakeScopedRepository struct {
|
||||
objects map[bson.ObjectID]*testScopedObj
|
||||
insertErr error
|
||||
getErr error
|
||||
updateErr error
|
||||
patchErr error
|
||||
deleteErr error
|
||||
listIDsErr error
|
||||
patchedIDs []bson.ObjectID
|
||||
// patchManyIDs is the set of IDs that PatchMany will record as affected.
|
||||
// Tests using PatchMany must set this to the expected authorized IDs so the
|
||||
// fake can simulate per-ID tracking without parsing the bulk filter.
|
||||
patchManyIDs []bson.ObjectID
|
||||
}
|
||||
|
||||
func newFakeScopedRepository() *fakeScopedRepository {
|
||||
return &fakeScopedRepository{objects: make(map[bson.ObjectID]*testScopedObj)}
|
||||
}
|
||||
|
||||
func (f *fakeScopedRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error {
|
||||
if f.insertErr != nil {
|
||||
return f.insertErr
|
||||
}
|
||||
o := obj.(*testScopedObj)
|
||||
if o.id == bson.NilObjectID {
|
||||
o.id = bson.NewObjectID()
|
||||
}
|
||||
clone := *o
|
||||
f.objects[o.id] = &clone
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeScopedRepository) InsertMany(_ context.Context, objects []storable.Storable) error {
|
||||
if f.insertErr != nil {
|
||||
return f.insertErr
|
||||
}
|
||||
for _, obj := range objects {
|
||||
o := obj.(*testScopedObj)
|
||||
if o.id == bson.NilObjectID {
|
||||
o.id = bson.NewObjectID()
|
||||
}
|
||||
clone := *o
|
||||
f.objects[o.id] = &clone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeScopedRepository) Get(_ context.Context, id bson.ObjectID, result storable.Storable) error {
|
||||
if f.getErr != nil {
|
||||
return f.getErr
|
||||
}
|
||||
obj, ok := f.objects[id]
|
||||
if !ok {
|
||||
return merrors.NoData(fmt.Sprintf("test_scoped %s not found", id.Hex()))
|
||||
}
|
||||
*result.(*testScopedObj) = *obj
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeScopedRepository) Update(_ context.Context, obj storable.Storable) error {
|
||||
if f.updateErr != nil {
|
||||
return f.updateErr
|
||||
}
|
||||
o := obj.(*testScopedObj)
|
||||
clone := *o
|
||||
f.objects[*o.GetID()] = &clone
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeScopedRepository) Patch(_ context.Context, id bson.ObjectID, _ builder.Patch) error {
|
||||
if f.patchErr != nil {
|
||||
return f.patchErr
|
||||
}
|
||||
f.patchedIDs = append(f.patchedIDs, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeScopedRepository) Delete(_ context.Context, id bson.ObjectID) error {
|
||||
if f.deleteErr != nil {
|
||||
return f.deleteErr
|
||||
}
|
||||
delete(f.objects, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeScopedRepository) ListIDs(_ context.Context, _ builder.Query) ([]bson.ObjectID, error) {
|
||||
if f.listIDsErr != nil {
|
||||
return nil, f.listIDsErr
|
||||
}
|
||||
ids := make([]bson.ObjectID, 0, len(f.objects))
|
||||
for id := range f.objects {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// Unimplemented repository.Repository stubs.
|
||||
func (f *fakeScopedRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error {
|
||||
return merrors.NotImplemented("aggregate")
|
||||
}
|
||||
func (f *fakeScopedRepository) FindOneByFilter(context.Context, builder.Query, storable.Storable) error {
|
||||
return merrors.NotImplemented("FindOneByFilter")
|
||||
}
|
||||
func (f *fakeScopedRepository) FindFirstByFilter(context.Context, builder.Query, storable.Storable) error {
|
||||
return merrors.NotImplemented("FindFirstByFilter")
|
||||
}
|
||||
func (f *fakeScopedRepository) FindManyByFilter(context.Context, builder.Query, rd.DecodingFunc) error {
|
||||
return merrors.NotImplemented("FindManyByFilter")
|
||||
}
|
||||
func (f *fakeScopedRepository) FindAllByFilter(context.Context, builder.Query, any) error {
|
||||
return merrors.NotImplemented("FindAllByFilter")
|
||||
}
|
||||
func (f *fakeScopedRepository) CountByFilter(context.Context, builder.Query) (int64, error) {
|
||||
return 0, merrors.NotImplemented("CountByFilter")
|
||||
}
|
||||
func (f *fakeScopedRepository) ReplaceOneByFilter(context.Context, builder.Query, storable.Storable) (bool, error) {
|
||||
return false, merrors.NotImplemented("ReplaceOneByFilter")
|
||||
}
|
||||
func (f *fakeScopedRepository) Upsert(context.Context, storable.Storable) error {
|
||||
return merrors.NotImplemented("Upsert")
|
||||
}
|
||||
func (f *fakeScopedRepository) PatchOneByFilter(context.Context, builder.Query, builder.Patch, storable.Storable) (bool, error) {
|
||||
return false, merrors.NotImplemented("PatchOneByFilter")
|
||||
}
|
||||
func (f *fakeScopedRepository) PatchMany(_ context.Context, _ builder.Query, _ builder.Patch) (int, error) {
|
||||
f.patchedIDs = append(f.patchedIDs, f.patchManyIDs...)
|
||||
return len(f.patchManyIDs), nil
|
||||
}
|
||||
func (f *fakeScopedRepository) DeleteMany(context.Context, builder.Query) error {
|
||||
return merrors.NotImplemented("DeleteMany")
|
||||
}
|
||||
func (f *fakeScopedRepository) CreateIndex(*ri.Definition) error { return nil }
|
||||
func (f *fakeScopedRepository) DropIndex(context.Context, string) error {
|
||||
return merrors.NotImplemented("DropIndex")
|
||||
}
|
||||
func (f *fakeScopedRepository) ListIndexDefinitions(context.Context) ([]*ri.Definition, error) {
|
||||
return nil, merrors.NotImplemented("ListIndexDefinitions")
|
||||
}
|
||||
func (f *fakeScopedRepository) ListPermissionBound(context.Context, builder.Query) ([]model.PermissionBoundStorable, error) {
|
||||
return nil, merrors.NotImplemented("ListPermissionBound")
|
||||
}
|
||||
func (f *fakeScopedRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) {
|
||||
return nil, merrors.NotImplemented("ListAccountBound")
|
||||
}
|
||||
func (f *fakeScopedRepository) WithTransaction(_ context.Context, cb transaction.Callback) (any, error) {
|
||||
return cb(context.Background())
|
||||
}
|
||||
func (f *fakeScopedRepository) Collection() string { return "test_scoped" }
|
||||
|
||||
// newTestScopedDBImp wires a ScopedDBImp backed by the given in-memory repo,
|
||||
// membership, and enforcer. The orgPolicyRef is returned so callers can verify
|
||||
// it was applied correctly.
|
||||
func newTestScopedDBImp(repo *fakeScopedRepository, membership *fakeMembership, enforcer Enforcer) (*ScopedDBImp[*testScopedObj], bson.ObjectID) {
|
||||
orgPolicyRef := bson.NewObjectID()
|
||||
dbImp := &template.DBImp[*testScopedObj]{
|
||||
Logger: zap.NewNop(),
|
||||
Repository: repo,
|
||||
}
|
||||
dbImp.SetDeleter(func(_ context.Context, id bson.ObjectID) error {
|
||||
return repo.Delete(context.Background(), id)
|
||||
})
|
||||
imp := NewScopedDBImp[*testScopedObj](
|
||||
dbImp,
|
||||
enforcer,
|
||||
membership,
|
||||
orgPolicyRef,
|
||||
mservice.Type("test_scoped"),
|
||||
func() *testScopedObj { return &testScopedObj{} },
|
||||
zap.NewNop(),
|
||||
)
|
||||
return imp, orgPolicyRef
|
||||
}
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestScopedDBImp_Create_Personal_SkipsPolicy(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
repo := newFakeScopedRepository()
|
||||
enf := &fakeEnforcer{}
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
||||
|
||||
obj := &testScopedObj{ownerKind: model.OwnerKindAccount}
|
||||
if err := db.Create(context.Background(), caller, orgRef, obj); err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("personal create must not consult the enforcer")
|
||||
}
|
||||
if obj.GetOwnerRef() != caller {
|
||||
t.Fatalf("expected ownerRef=%s, got %s", caller.Hex(), obj.GetOwnerRef().Hex())
|
||||
}
|
||||
if obj.GetOrganizationRef() != orgRef {
|
||||
t.Fatal("organizationRef must be set on create")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_Create_Personal_DefaultsToAccountWhenKindMissing(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
repo := newFakeScopedRepository()
|
||||
enf := &fakeEnforcer{}
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
||||
|
||||
obj := &testScopedObj{} // ownerKind is zero value (invalid)
|
||||
if err := db.Create(context.Background(), caller, orgRef, obj); err != nil {
|
||||
t.Fatalf("expected success with defaulted ownerKind, got %v", err)
|
||||
}
|
||||
if obj.GetOwnerKind() != model.OwnerKindAccount {
|
||||
t.Fatalf("expected ownerKind=account, got %s", obj.GetOwnerKind())
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("must not consult enforcer when defaulting to personal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_Create_Personal_DeniedWhenCallerIsNotMember(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
repo := newFakeScopedRepository()
|
||||
membership := &fakeMembership{member: false}
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
db, _ := newTestScopedDBImp(repo, membership, enf)
|
||||
|
||||
obj := &testScopedObj{ownerKind: model.OwnerKindAccount}
|
||||
err := db.Create(context.Background(), caller, orgRef, obj)
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied for non-member personal create, got %v", err)
|
||||
}
|
||||
if !membership.called {
|
||||
t.Fatal("personal create must check organization membership before insert")
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("personal create must not consult policy when caller is not an organization member")
|
||||
}
|
||||
if len(repo.objects) != 0 {
|
||||
t.Fatal("object must not be inserted when membership denies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_Create_Org_CallsPolicyAndSetsFields(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
repo := newFakeScopedRepository()
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
db, orgPolicyRef := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
||||
|
||||
obj := &testScopedObj{ownerKind: model.OwnerKindOrganization}
|
||||
if err := db.Create(context.Background(), caller, orgRef, obj); err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if !enf.called {
|
||||
t.Fatal("org create must consult the enforcer")
|
||||
}
|
||||
if enf.gotAction != model.ActionCreate {
|
||||
t.Fatalf("expected ActionCreate, got %s", enf.gotAction)
|
||||
}
|
||||
if obj.GetOwnerRef() != orgRef {
|
||||
t.Fatalf("expected ownerRef=orgRef for org object, got %s", obj.GetOwnerRef().Hex())
|
||||
}
|
||||
if obj.GetPermissionRef() != orgPolicyRef {
|
||||
t.Fatal("permissionRef must be set to OrgPolicyRef for org objects")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_Create_Org_DeniedByPolicy(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
repo := newFakeScopedRepository()
|
||||
enf := &fakeEnforcer{allow: false}
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
||||
|
||||
obj := &testScopedObj{ownerKind: model.OwnerKindOrganization}
|
||||
err := db.Create(context.Background(), caller, orgRef, obj)
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
if len(repo.objects) != 0 {
|
||||
t.Fatal("object must not be inserted when policy denies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_Create_Org_DeniedWhenCallerIsNotMember(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
repo := newFakeScopedRepository()
|
||||
membership := &fakeMembership{member: false}
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
db, _ := newTestScopedDBImp(repo, membership, enf)
|
||||
|
||||
obj := &testScopedObj{ownerKind: model.OwnerKindOrganization}
|
||||
err := db.Create(context.Background(), caller, orgRef, obj)
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied for non-member org create, got %v", err)
|
||||
}
|
||||
if !membership.called {
|
||||
t.Fatal("org create must check organization membership before policy")
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("org create must not consult policy when caller is not an organization member")
|
||||
}
|
||||
if len(repo.objects) != 0 {
|
||||
t.Fatal("object must not be inserted when membership denies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_Create_Org_MembershipErrorFailsClosed(t *testing.T) {
|
||||
boom := merrors.Define("membership backend down")
|
||||
repo := newFakeScopedRepository()
|
||||
membership := &fakeMembership{err: boom}
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
db, _ := newTestScopedDBImp(repo, membership, enf)
|
||||
|
||||
obj := &testScopedObj{ownerKind: model.OwnerKindOrganization}
|
||||
err := db.Create(context.Background(), bson.NewObjectID(), bson.NewObjectID(), obj)
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected membership backend error propagated, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("org create must fail closed and not consult policy on membership error")
|
||||
}
|
||||
if len(repo.objects) != 0 {
|
||||
t.Fatal("object must not be inserted when the membership check errors")
|
||||
}
|
||||
}
|
||||
|
||||
// ── InsertMany ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestScopedDBImp_InsertMany_Empty_IsNoOp(t *testing.T) {
|
||||
repo := newFakeScopedRepository()
|
||||
enf := &fakeEnforcer{}
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
||||
|
||||
if err := db.InsertMany(context.Background(), bson.NewObjectID(), bson.NewObjectID(), nil); err != nil {
|
||||
t.Fatalf("empty InsertMany must succeed, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("empty InsertMany must not call enforcer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_InsertMany_AllPersonal_SkipsPolicy(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
repo := newFakeScopedRepository()
|
||||
enf := &fakeEnforcer{}
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
||||
|
||||
objects := []*testScopedObj{
|
||||
{ownerKind: model.OwnerKindAccount},
|
||||
{ownerKind: model.OwnerKindAccount},
|
||||
}
|
||||
if err := db.InsertMany(context.Background(), caller, orgRef, objects); err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("all-personal InsertMany must not consult the enforcer")
|
||||
}
|
||||
if len(repo.objects) != 2 {
|
||||
t.Fatalf("expected 2 inserted objects, got %d", len(repo.objects))
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_InsertMany_AllPersonalDeniedWhenCallerIsNotMember(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
repo := newFakeScopedRepository()
|
||||
membership := &fakeMembership{member: false}
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
db, _ := newTestScopedDBImp(repo, membership, enf)
|
||||
|
||||
objects := []*testScopedObj{
|
||||
{ownerKind: model.OwnerKindAccount},
|
||||
{ownerKind: model.OwnerKindAccount},
|
||||
}
|
||||
err := db.InsertMany(context.Background(), caller, orgRef, objects)
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied for non-member personal InsertMany, got %v", err)
|
||||
}
|
||||
if !membership.called {
|
||||
t.Fatal("personal InsertMany must check organization membership before insert")
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("personal InsertMany must not consult policy when caller is not an organization member")
|
||||
}
|
||||
if len(repo.objects) != 0 {
|
||||
t.Fatal("objects must not be inserted when membership denies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_InsertMany_MixedBatch_OrgPolicyCheckedOnce(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
repo := newFakeScopedRepository()
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
||||
|
||||
objects := []*testScopedObj{
|
||||
{ownerKind: model.OwnerKindAccount},
|
||||
{ownerKind: model.OwnerKindOrganization},
|
||||
{ownerKind: model.OwnerKindOrganization},
|
||||
}
|
||||
if err := db.InsertMany(context.Background(), caller, orgRef, objects); err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if !enf.called {
|
||||
t.Fatal("enforcer must be called for mixed batch with org objects")
|
||||
}
|
||||
// Enforcer is called only once regardless of how many org objects are in the batch.
|
||||
callCount := 0
|
||||
if enf.called {
|
||||
callCount = 1
|
||||
}
|
||||
if callCount != 1 {
|
||||
t.Fatal("org policy must be checked exactly once for the entire batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_InsertMany_OrgDenied_NoObjectsInsertedOrMutated(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
repo := newFakeScopedRepository()
|
||||
enf := &fakeEnforcer{allow: false}
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
||||
|
||||
personal := &testScopedObj{}
|
||||
org := &testScopedObj{
|
||||
orgRef: bson.NewObjectID(),
|
||||
ownerKind: model.OwnerKindOrganization,
|
||||
ownerRef: bson.NewObjectID(),
|
||||
permissionRef: bson.NewObjectID(),
|
||||
}
|
||||
originalPersonal := *personal
|
||||
originalOrg := *org
|
||||
objects := []*testScopedObj{personal, org}
|
||||
err := db.InsertMany(context.Background(), caller, orgRef, objects)
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
if len(repo.objects) != 0 {
|
||||
t.Fatal("objects must not be inserted when policy denies")
|
||||
}
|
||||
if *personal != originalPersonal {
|
||||
t.Fatalf("personal object was mutated on denied InsertMany: got %+v want %+v", *personal, originalPersonal)
|
||||
}
|
||||
if *org != originalOrg {
|
||||
t.Fatalf("org object was mutated on denied InsertMany: got %+v want %+v", *org, originalOrg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_InsertMany_OrgDeniedWhenCallerIsNotMember(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
repo := newFakeScopedRepository()
|
||||
membership := &fakeMembership{member: false}
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
db, _ := newTestScopedDBImp(repo, membership, enf)
|
||||
|
||||
objects := []*testScopedObj{{ownerKind: model.OwnerKindOrganization}}
|
||||
err := db.InsertMany(context.Background(), caller, orgRef, objects)
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied for non-member org InsertMany, got %v", err)
|
||||
}
|
||||
if !membership.called {
|
||||
t.Fatal("org InsertMany must check organization membership before policy")
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("org InsertMany must not consult policy when caller is not an organization member")
|
||||
}
|
||||
if len(repo.objects) != 0 {
|
||||
t.Fatal("objects must not be inserted when membership denies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_InsertMany_OrgMembershipErrorFailsClosed(t *testing.T) {
|
||||
boom := merrors.Define("membership backend down")
|
||||
repo := newFakeScopedRepository()
|
||||
membership := &fakeMembership{err: boom}
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
db, _ := newTestScopedDBImp(repo, membership, enf)
|
||||
|
||||
objects := []*testScopedObj{{ownerKind: model.OwnerKindOrganization}}
|
||||
err := db.InsertMany(context.Background(), bson.NewObjectID(), bson.NewObjectID(), objects)
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected membership backend error propagated, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("org InsertMany must fail closed and not consult policy on membership error")
|
||||
}
|
||||
if len(repo.objects) != 0 {
|
||||
t.Fatal("objects must not be inserted when the membership check errors")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestScopedDBImp_Update_RestoresOwnershipFields(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
orgPolicyRef := bson.NewObjectID()
|
||||
|
||||
// Seed the repo with a personal object
|
||||
repo := newFakeScopedRepository()
|
||||
repo.objects[objectRef] = &testScopedObj{
|
||||
id: objectRef,
|
||||
orgRef: orgRef,
|
||||
ownerKind: model.OwnerKindAccount,
|
||||
ownerRef: caller,
|
||||
permissionRef: orgPolicyRef,
|
||||
}
|
||||
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{})
|
||||
|
||||
// Submit an update that tries to change ownership
|
||||
updated := &testScopedObj{
|
||||
id: objectRef,
|
||||
ownerKind: model.OwnerKindOrganization, // attempt to escalate
|
||||
ownerRef: bson.NewObjectID(), // attempt to hijack owner
|
||||
}
|
||||
if err := db.Update(context.Background(), caller, updated); err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
// After update the ownership fields must match the DB, not the submitted payload
|
||||
if updated.GetOwnerKind() != model.OwnerKindAccount {
|
||||
t.Fatalf("ownerKind must not change: got %s", updated.GetOwnerKind())
|
||||
}
|
||||
if updated.GetOwnerRef() != caller {
|
||||
t.Fatalf("ownerRef must not change: got %s", updated.GetOwnerRef().Hex())
|
||||
}
|
||||
if updated.GetOrganizationRef() != orgRef {
|
||||
t.Fatal("organizationRef must not change")
|
||||
}
|
||||
if updated.GetPermissionRef() != orgPolicyRef {
|
||||
t.Fatal("permissionRef must not change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_Update_DeniedForNonOwner(t *testing.T) {
|
||||
owner := bson.NewObjectID()
|
||||
attacker := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
|
||||
repo := newFakeScopedRepository()
|
||||
repo.objects[objectRef] = &testScopedObj{
|
||||
id: objectRef,
|
||||
orgRef: orgRef,
|
||||
ownerKind: model.OwnerKindAccount,
|
||||
ownerRef: owner,
|
||||
}
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{})
|
||||
|
||||
err := db.Update(context.Background(), attacker, &testScopedObj{id: objectRef})
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied for non-owner, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListIDs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestScopedDBImp_ListIDs_FiltersUnauthorized(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
other := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
|
||||
id1 := bson.NewObjectID()
|
||||
id2 := bson.NewObjectID()
|
||||
id3 := bson.NewObjectID()
|
||||
|
||||
repo := newFakeScopedRepository()
|
||||
repo.objects = map[bson.ObjectID]*testScopedObj{
|
||||
id1: {id: id1, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller},
|
||||
id2: {id: id2, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: other}, // unauthorized
|
||||
id3: {id: id3, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller},
|
||||
}
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{})
|
||||
|
||||
ids, err := db.ListIDs(context.Background(), model.ActionRead, caller, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("expected 2 authorized IDs, got %d", len(ids))
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == id2 {
|
||||
t.Fatal("unauthorized ID must not be returned")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_ListIDs_PropagatesNonAuthError(t *testing.T) {
|
||||
boom := merrors.Define("enforcer backend down")
|
||||
objectRef := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
|
||||
repo := newFakeScopedRepository()
|
||||
repo.objects[objectRef] = &testScopedObj{
|
||||
id: objectRef,
|
||||
orgRef: orgRef,
|
||||
ownerKind: model.OwnerKindOrganization,
|
||||
ownerRef: orgRef,
|
||||
}
|
||||
enf := &fakeEnforcer{err: boom}
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
||||
|
||||
_, err := db.ListIDs(context.Background(), model.ActionRead, bson.NewObjectID(), nil)
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected underlying error propagated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_ListIDs_RawRepoErrorPropagated(t *testing.T) {
|
||||
boom := merrors.Define("mongo down")
|
||||
repo := newFakeScopedRepository()
|
||||
repo.listIDsErr = boom
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{})
|
||||
|
||||
_, err := db.ListIDs(context.Background(), model.ActionRead, bson.NewObjectID(), nil)
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected raw list error propagated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── PatchMany ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestScopedDBImp_PatchMany_PatchesOnlyAuthorizedIDs(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
other := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
|
||||
id1 := bson.NewObjectID()
|
||||
id2 := bson.NewObjectID()
|
||||
|
||||
repo := newFakeScopedRepository()
|
||||
repo.objects = map[bson.ObjectID]*testScopedObj{
|
||||
id1: {id: id1, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller},
|
||||
id2: {id: id2, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: other},
|
||||
}
|
||||
// patchManyIDs declares which IDs the bulk DB call will report as affected.
|
||||
// Only id1 is authorized (ownerRef==caller), so the IN filter contains only id1.
|
||||
repo.patchManyIDs = []bson.ObjectID{id1}
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{})
|
||||
|
||||
count, err := db.PatchMany(context.Background(), caller, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 patched object, got %d", count)
|
||||
}
|
||||
if len(repo.patchedIDs) != 1 || repo.patchedIDs[0] != id1 {
|
||||
t.Fatal("only the authorized ID must be patched")
|
||||
}
|
||||
}
|
||||
|
||||
// ── BuildScopedQuery ──────────────────────────────────────────────────────────
|
||||
|
||||
// bsonHasPair reports whether the key/value pair occurs anywhere within the
|
||||
// bson.D/bson.A tree produced by builder.Query.BuildQuery().
|
||||
func bsonHasPair(node any, key string, value any) bool {
|
||||
switch n := node.(type) {
|
||||
case bson.D:
|
||||
for _, e := range n {
|
||||
if e.Key == key && e.Value == value {
|
||||
return true
|
||||
}
|
||||
if bsonHasPair(e.Value, key, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case bson.A:
|
||||
for _, item := range n {
|
||||
if bsonHasPair(item, key, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bsonHasKey reports whether the key occurs anywhere within the tree.
|
||||
func bsonHasKey(node any, key string) bool {
|
||||
switch n := node.(type) {
|
||||
case bson.D:
|
||||
for _, e := range n {
|
||||
if e.Key == key {
|
||||
return true
|
||||
}
|
||||
if bsonHasKey(e.Value, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case bson.A:
|
||||
for _, item := range n {
|
||||
if bsonHasKey(item, key) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestScopedDBImp_BuildScopedQuery_WithOrgAccess_PersonalBranchIsCrossOrg(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{member: true}, enf)
|
||||
|
||||
q, err := db.BuildScopedQuery(context.Background(), caller, orgRef)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if !enf.called {
|
||||
t.Fatal("enforcer must be called to check org access")
|
||||
}
|
||||
|
||||
built := q.BuildQuery()
|
||||
if len(built) != 1 || built[0].Key != "$or" {
|
||||
t.Fatalf("expected a single top-level $or, got %v", built)
|
||||
}
|
||||
branches, ok := built[0].Value.(bson.A)
|
||||
if !ok || len(branches) != 2 {
|
||||
t.Fatalf("expected 2 $or branches, got %v", built[0].Value)
|
||||
}
|
||||
|
||||
personal := branches[0]
|
||||
if !bsonHasPair(personal, ownerKindField, model.OwnerKindAccount) ||
|
||||
!bsonHasPair(personal, ownerRefField, caller) {
|
||||
t.Fatalf("personal branch must match owner kind/ref, got %v", personal)
|
||||
}
|
||||
if bsonHasKey(personal, storable.OrganizationRefField) {
|
||||
t.Fatalf("personal branch must not be org-bounded, got %v", personal)
|
||||
}
|
||||
|
||||
org := branches[1]
|
||||
if !bsonHasPair(org, storable.OrganizationRefField, orgRef) ||
|
||||
!bsonHasPair(org, ownerKindField, model.OwnerKindOrganization) {
|
||||
t.Fatalf("org branch must be org-bounded and org-owned, got %v", org)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_BuildScopedQuery_WithoutOrgAccess_PersonalOnly(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
enf := &fakeEnforcer{allow: false}
|
||||
db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{member: true}, enf)
|
||||
|
||||
q, err := db.BuildScopedQuery(context.Background(), caller, orgRef)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
|
||||
built := q.BuildQuery()
|
||||
if bsonHasKey(built, storable.OrganizationRefField) {
|
||||
t.Fatalf("query must be personal-only without org access, got %v", built)
|
||||
}
|
||||
if !bsonHasPair(built, ownerKindField, model.OwnerKindAccount) ||
|
||||
!bsonHasPair(built, ownerRefField, caller) {
|
||||
t.Fatalf("query must match personal owner kind/ref, got %v", built)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_BuildScopedQuery_MembershipError_PersonalOnly(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{err: merrors.Define("membership down")}, enf)
|
||||
|
||||
q, err := db.BuildScopedQuery(context.Background(), caller, orgRef)
|
||||
if err != nil {
|
||||
t.Fatalf("membership failure must degrade to personal-only, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("enforcer must not be consulted when the membership check fails")
|
||||
}
|
||||
|
||||
built := q.BuildQuery()
|
||||
if bsonHasKey(built, storable.OrganizationRefField) {
|
||||
t.Fatalf("query must be personal-only on membership failure, got %v", built)
|
||||
}
|
||||
if !bsonHasPair(built, ownerKindField, model.OwnerKindAccount) ||
|
||||
!bsonHasPair(built, ownerRefField, caller) {
|
||||
t.Fatalf("query must match personal owner kind/ref, got %v", built)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDBImp_BuildScopedQuery_EnforcerError_Propagated(t *testing.T) {
|
||||
boom := merrors.Define("enforcer down")
|
||||
repo := newFakeScopedRepository()
|
||||
enf := &fakeEnforcer{err: boom}
|
||||
db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf)
|
||||
|
||||
_, err := db.BuildScopedQuery(context.Background(), bson.NewObjectID(), bson.NewObjectID())
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected enforcer error propagated, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Create inserts a new resource. If ownerKind is absent or invalid it defaults
|
||||
// to account.
|
||||
//
|
||||
// Personal path (ownerKind=account): requires the caller to be a member of
|
||||
// organizationRef (so a non-member cannot plant personal objects in a foreign
|
||||
// org namespace), then sets ownerRef=accountRef and inserts. The org policy is
|
||||
// not consulted — after creation, access is governed by ownership alone.
|
||||
//
|
||||
// Org path (ownerKind=organization): checks org membership and the org policy
|
||||
// (via authorizeOrgMutation) first, then stamps ownerRef/permissionRef and
|
||||
// inserts. Ownership fields are only mutated after the access check so a denied
|
||||
// caller's object is unchanged.
|
||||
func (db *ScopedDBImp[T]) Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error {
|
||||
db.DBImp.Logger.Debug("attempting to create scoped object",
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.String("collection", db.Collection))
|
||||
|
||||
if !object.GetOwnerKind().IsValid() {
|
||||
object.SetOwnerKind(model.OwnerKindAccount)
|
||||
}
|
||||
|
||||
switch object.GetOwnerKind() {
|
||||
case model.OwnerKindAccount:
|
||||
if err := db.authorizeOrgMembership(ctx, accountRef, organizationRef, model.ActionCreate); err != nil {
|
||||
return err
|
||||
}
|
||||
object.SetOwnerRef(accountRef)
|
||||
object.SetOrganizationRef(organizationRef)
|
||||
if err := db.DBImp.Create(ctx, object); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to create personal scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
case model.OwnerKindOrganization:
|
||||
if err := db.authorizeOrgMutation(ctx, accountRef, organizationRef, model.ActionCreate); err != nil {
|
||||
return err
|
||||
}
|
||||
object.SetOwnerRef(organizationRef)
|
||||
object.SetOrganizationRef(organizationRef)
|
||||
object.SetPermissionRef(db.OrgPolicyRef)
|
||||
if err := db.DBImp.Create(ctx, object); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to create org scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
db.DBImp.Logger.Warn("unsupported ownerKind on create",
|
||||
zap.String("ownerKind", string(object.GetOwnerKind())),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return merrors.InvalidArgument("unsupported ownerKind", string(object.GetOwnerKind()))
|
||||
}
|
||||
}
|
||||
|
||||
// InsertMany bulk-inserts resources. Ownership fields are normalized per
|
||||
// object (defaulting to account when ownerKind is absent). Org membership, and
|
||||
// any required org policy check, run before any object is mutated.
|
||||
func (db *ScopedDBImp[T]) InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error {
|
||||
if len(objects) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("attempting to insert many scoped objects",
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.Int("count", len(objects)), zap.String("collection", db.Collection))
|
||||
|
||||
if err := db.authorizeOrgMembership(ctx, accountRef, organizationRef, model.ActionCreate); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ownerKinds := make([]model.OwnerKind, len(objects))
|
||||
var hasOrgObjects bool
|
||||
for i, obj := range objects {
|
||||
ownerKind := obj.GetOwnerKind()
|
||||
if !ownerKind.IsValid() {
|
||||
ownerKind = model.OwnerKindAccount
|
||||
}
|
||||
switch ownerKind {
|
||||
case model.OwnerKindAccount:
|
||||
case model.OwnerKindOrganization:
|
||||
hasOrgObjects = true
|
||||
default:
|
||||
return merrors.InvalidArgument("unsupported ownerKind in InsertMany", string(ownerKind))
|
||||
}
|
||||
ownerKinds[i] = ownerKind
|
||||
}
|
||||
|
||||
if hasOrgObjects {
|
||||
if err := db.authorizeOrgPolicy(ctx, accountRef, organizationRef, model.ActionCreate); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for i, obj := range objects {
|
||||
switch ownerKinds[i] {
|
||||
case model.OwnerKindAccount:
|
||||
obj.SetOwnerKind(model.OwnerKindAccount)
|
||||
obj.SetOwnerRef(accountRef)
|
||||
obj.SetOrganizationRef(organizationRef)
|
||||
case model.OwnerKindOrganization:
|
||||
obj.SetOwnerKind(model.OwnerKindOrganization)
|
||||
obj.SetOwnerRef(organizationRef)
|
||||
obj.SetOrganizationRef(organizationRef)
|
||||
obj.SetPermissionRef(db.OrgPolicyRef)
|
||||
default:
|
||||
return merrors.InvalidArgument("unsupported ownerKind in InsertMany", string(ownerKinds[i]))
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.DBImp.InsertMany(ctx, objects); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to insert many scoped objects",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.Int("count", len(objects)))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorizeOrgMembership requires the caller to be a member of the
|
||||
// organization. It is the create-time gate shared by the personal and org
|
||||
// paths: since no record exists yet to read ownership from, membership in the
|
||||
// target org is the minimum proof that the caller may write into its
|
||||
// namespace. It fails closed — a membership backend error denies the mutation.
|
||||
func (db *ScopedDBImp[T]) authorizeOrgMembership(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error {
|
||||
isMember, err := db.scoped.Authorizer.Membership.IsMember(ctx, organizationRef, accountRef)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("membership check failed during create-time mutation",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.String("collection", db.Collection))
|
||||
return err
|
||||
}
|
||||
if !isMember {
|
||||
db.DBImp.Logger.Debug("access denied: caller is not a member of the organization",
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.String("collection", db.Collection), zap.String("action", string(action)))
|
||||
return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorizeOrgPolicy checks that the org policy allows the caller to perform
|
||||
// the action. It assumes membership has already been verified — callers must
|
||||
// go through authorizeOrgMembership (or authorizeOrgMutation) first.
|
||||
func (db *ScopedDBImp[T]) authorizeOrgPolicy(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error {
|
||||
ok, err := db.Enforcer.Enforce(ctx, db.OrgPolicyRef, accountRef, organizationRef, bson.NilObjectID, action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
db.DBImp.Logger.Debug("access denied: org mutation policy",
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef),
|
||||
zap.String("collection", db.Collection), zap.String("action", string(action)))
|
||||
return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorizeOrgMutation gates a create-time org mutation: the caller must be a
|
||||
// member of the organization (authorizeOrgMembership) AND the org policy must
|
||||
// allow the action (authorizeOrgPolicy). Because no record exists yet to read
|
||||
// ownerKind from, it cannot go through loadAndEnforce; this mirrors the
|
||||
// membership-first check ScopedAuthorizer applies to read/update/delete of
|
||||
// org-owned resources.
|
||||
func (db *ScopedDBImp[T]) authorizeOrgMutation(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error {
|
||||
isMemberErr := db.authorizeOrgMembership(ctx, accountRef, organizationRef, action)
|
||||
if isMemberErr != nil {
|
||||
return isMemberErr
|
||||
}
|
||||
if isPolicyErr := db.authorizeOrgPolicy(ctx, accountRef, organizationRef, action); isPolicyErr != nil {
|
||||
return isPolicyErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get fetches a resource after verifying ActionRead via LoadAndEnforce.
|
||||
func (db *ScopedDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error {
|
||||
db.DBImp.Logger.Debug("attempting to get scoped object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionRead); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DBImp.Get(ctx, objectRef, result); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to get scoped object after auth",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update replaces a resource after verifying ActionUpdate. Immutable ownership
|
||||
// fields (ownerKind, ownerRef, orgRef, permissionRef) are always restored from
|
||||
// the database record so callers cannot change resource ownership via the payload.
|
||||
func (db *ScopedDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error {
|
||||
db.DBImp.Logger.Debug("attempting to update scoped object",
|
||||
mzap.AccRef(accountRef), mzap.StorableRef(object))
|
||||
|
||||
existing, err := db.loadAndEnforce(ctx, accountRef, *object.GetID(), model.ActionUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
object.SetOwnerKind(existing.GetOwnerKind())
|
||||
object.SetOwnerRef(existing.GetOwnerRef())
|
||||
object.SetOrganizationRef(existing.GetOrganizationRef())
|
||||
object.SetPermissionRef(existing.GetPermissionRef())
|
||||
|
||||
if err := db.DBImp.Update(ctx, object); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to update scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.StorableRef(object))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a resource after verifying ActionDelete.
|
||||
func (db *ScopedDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
db.DBImp.Logger.Debug("attempting to delete scoped object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionDelete); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DBImp.Delete(ctx, objectRef); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to delete scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCascadeAuth removes a resource (with cascade via the registered deleter)
|
||||
// after verifying ActionDelete.
|
||||
func (db *ScopedDBImp[T]) DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error {
|
||||
db.DBImp.Logger.Debug("attempting cascade-delete scoped object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionDelete); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DBImp.DeleteCascade(ctx, objectRef); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to cascade-delete scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Patch applies a partial update after verifying ActionUpdate.
|
||||
func (db *ScopedDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error {
|
||||
db.DBImp.Logger.Debug("attempting to patch scoped object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
|
||||
if err := denyImmutablePatch(patch); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionUpdate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to patch scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetArchived patches the isArchived field after verifying ActionUpdate.
|
||||
//
|
||||
// organizationRef is accepted for interface compatibility but not used for
|
||||
// enforcement — ownership is read from the DB via LoadAndEnforce.
|
||||
// cascade is accepted but not applied; resources that require cascading archived
|
||||
// status must implement cascade logic in a dedicated wrapper.
|
||||
func (db *ScopedDBImp[T]) SetArchived(ctx context.Context, accountRef, _ bson.ObjectID, objectRef bson.ObjectID, isArchived, _ bool) error {
|
||||
db.DBImp.Logger.Debug("attempting to change archive status of scoped object",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.Bool("archived", isArchived))
|
||||
|
||||
if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionUpdate); err != nil {
|
||||
return err
|
||||
}
|
||||
patch := repository.Patch().Set(repository.IsArchivedField(), isArchived)
|
||||
if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil {
|
||||
db.DBImp.Logger.Warn("failed to change archive status of scoped object",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListIDs returns the authorized subset of IDs matching query for the given
|
||||
// action. Each candidate is checked via LoadAndEnforce; unauthorized IDs are
|
||||
// silently skipped. Non-access-denied errors abort the call.
|
||||
func (db *ScopedDBImp[T]) ListIDs(ctx context.Context, action model.Action, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) {
|
||||
db.DBImp.Logger.Debug("attempting to list scoped object IDs",
|
||||
mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
|
||||
rawIDs, err := db.DBImp.ListIDs(ctx, query)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("failed to list raw object IDs",
|
||||
zap.Error(err), mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowed := make([]bson.ObjectID, 0, len(rawIDs))
|
||||
for _, id := range rawIDs {
|
||||
if _, authErr := db.loadAndEnforce(ctx, accountRef, id, action); authErr == nil {
|
||||
allowed = append(allowed, id)
|
||||
} else if !errors.Is(authErr, merrors.ErrAccessDenied) {
|
||||
db.DBImp.Logger.Warn("enforcer error during ListIDs",
|
||||
zap.Error(authErr), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", id))
|
||||
return nil, authErr
|
||||
}
|
||||
// ErrAccessDenied: silently skip
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("completed ListIDs",
|
||||
mzap.AccRef(accountRef), zap.Int("total", len(rawIDs)), zap.Int("allowed", len(allowed)))
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
// PatchMany applies patch to all resources in query that accountRef is
|
||||
// authorized to update. Authorization is resolved via ListIDs; the write is a
|
||||
// single bulk operation using originalQuery AND _id IN (authorizedIDs) so
|
||||
// records that leave the original query between the read and write are not
|
||||
// inadvertently mutated.
|
||||
func (db *ScopedDBImp[T]) PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) {
|
||||
db.DBImp.Logger.Debug("attempting to patch many scoped objects",
|
||||
mzap.AccRef(accountRef), zap.String("collection", db.Collection))
|
||||
|
||||
if err := denyImmutablePatch(patch); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
ids, err := db.ListIDs(ctx, model.ActionUpdate, accountRef, query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
values := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
values[i] = id
|
||||
}
|
||||
idFilter := repository.Query().In(repository.IDField(), values...)
|
||||
finalQuery := idFilter
|
||||
if query != nil {
|
||||
finalQuery = query.And(idFilter)
|
||||
}
|
||||
modified, err := db.DBImp.PatchMany(ctx, finalQuery, patch)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("failed to patch many scoped objects",
|
||||
zap.Error(err), mzap.AccRef(accountRef))
|
||||
return 0, err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("patched many scoped objects",
|
||||
mzap.AccRef(accountRef), zap.Int("count", modified))
|
||||
return modified, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Enforcer interface {
|
||||
// Enforce checks if accountRef can do `action` on objectRef in an org (domainRef).
|
||||
Enforce(
|
||||
ctx context.Context,
|
||||
permissionRef, accountRef, orgRef, objectRef bson.ObjectID,
|
||||
action model.Action,
|
||||
) (bool, error)
|
||||
|
||||
// Enforce batch of objects
|
||||
EnforceBatch(
|
||||
ctx context.Context,
|
||||
objectRefs []model.PermissionBoundStorable,
|
||||
accountRef bson.ObjectID,
|
||||
action model.Action,
|
||||
) (map[bson.ObjectID]bool, error)
|
||||
|
||||
// GetRoles returns the user's roles in a given org domain, plus any partial scopes if relevant.
|
||||
GetRoles(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, error)
|
||||
|
||||
// GetPermissions returns all effective permissions (with effect, object scoping) for a user in org domain.
|
||||
// Merges from all roles the user holds, plus any denies/exceptions.
|
||||
GetPermissions(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, []model.Permission, error)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/role"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func CreateAuth(
|
||||
logger mlogger.Logger,
|
||||
client *mongo.Client,
|
||||
db *mongo.Database,
|
||||
pdb policy.DB,
|
||||
rdb role.DB,
|
||||
config *Config,
|
||||
) (Enforcer, Manager, error) {
|
||||
lg := logger.Named("auth")
|
||||
if config == nil || config.Driver == "" {
|
||||
lg.Warn("Permissions enforcer config is missing, defaulting to native enforcer")
|
||||
config = &Config{
|
||||
Driver: Native,
|
||||
}
|
||||
}
|
||||
lg.Debug("Creating enforcer...", zap.String("driver", string(config.Driver)))
|
||||
l := lg.Named(string(config.Driver))
|
||||
if config.Driver == Casbin {
|
||||
enforcer, err := casbin.NewEnforcer(l, client, config.Settings)
|
||||
if err != nil {
|
||||
lg.Warn("Failed to create enforcer", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
manager, err := casbin.NewManager(l, pdb, rdb, enforcer, config.Settings)
|
||||
if err != nil {
|
||||
lg.Warn("Failed to create managment interface", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
return enforcer, manager, nil
|
||||
}
|
||||
if config.Driver == Native {
|
||||
enforcer, err := native.NewEnforcer(l, db)
|
||||
if err != nil {
|
||||
lg.Warn("Failed to create enforcer", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
manager, err := native.NewManager(l, pdb, rdb, enforcer)
|
||||
if err != nil {
|
||||
lg.Warn("Failed to create managment interface", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
return enforcer, manager, nil
|
||||
}
|
||||
return nil, nil, merrors.InvalidArgument("Unknown enforcer type: "+string(config.Driver), "config.driver")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func enforceObject[T model.PermissionBoundStorable](ctx context.Context, db *template.DBImp[T], enforcer Enforcer, action model.Action, accountRef bson.ObjectID, query builder.Query) error {
|
||||
l, err := db.ListPermissionBound(ctx, query)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Error occured while checking access rights", zap.Error(err),
|
||||
mzap.AccRef(accountRef), zap.String("action", string(action)))
|
||||
return err
|
||||
}
|
||||
if len(l) == 0 {
|
||||
db.Logger.Debug("Access denied", mzap.AccRef(accountRef), zap.String("action", string(action)))
|
||||
return merrors.AccessDenied(db.Repository.Collection(), string(action), bson.NilObjectID)
|
||||
}
|
||||
for _, item := range l {
|
||||
db.Logger.Debug("Object found", mzap.ObjRef("object_ref", *item.GetID()),
|
||||
mzap.OrgRef(item.GetOrganizationRef()),
|
||||
mzap.PermissionRef(item.GetPermissionRef()),
|
||||
zap.String("collection", item.Collection()))
|
||||
}
|
||||
res, err := enforcer.EnforceBatch(ctx, l, accountRef, action)
|
||||
if err != nil {
|
||||
db.Logger.Warn("Failed to enforce permission", zap.Error(err),
|
||||
mzap.AccRef(accountRef), zap.String("action", string(action)))
|
||||
}
|
||||
for objectRef, hasPermission := range res {
|
||||
if !hasPermission {
|
||||
db.Logger.Info("Permission denied for object during reordering", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionUpdate)))
|
||||
return merrors.AccessDenied(db.Repository.Collection(), string(action), objectRef)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func enforceObjectByRef[T model.PermissionBoundStorable](ctx context.Context, db *template.DBImp[T], enforcer Enforcer, action model.Action, accountRef, objectRef bson.ObjectID) error {
|
||||
err := enforceObject(ctx, db, enforcer, action, accountRef, repository.IDFilter(objectRef))
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
db.Logger.Debug("Access denied", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return merrors.AccessDenied(db.Repository.Collection(), string(action), objectRef)
|
||||
} else {
|
||||
db.Logger.Warn("Error occurred while checking permissions", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// IndexableDB implements reordering with permission checking
|
||||
type IndexableDB[T storable.Storable] interface {
|
||||
// Reorder implements reordering with permission checking using EnforceBatch
|
||||
Reorder(ctx context.Context, accountRef, objectRef bson.ObjectID, newIndex int, filter builder.Query) error
|
||||
}
|
||||
|
||||
// NewIndexableDB creates a new auth.IndexableDB instance
|
||||
func NewIndexableDB[T storable.Storable](
|
||||
repo repository.Repository,
|
||||
logger mlogger.Logger,
|
||||
enforcer Enforcer,
|
||||
createEmpty func() T,
|
||||
getIndexable func(T) *model.Indexable,
|
||||
) IndexableDB[T] {
|
||||
return newIndexableDBImp(repo, logger, enforcer, createEmpty, getIndexable)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// IndexableDB implements reordering with permission checking
|
||||
type indexableDBImp[T storable.Storable] struct {
|
||||
repo repository.Repository
|
||||
logger mlogger.Logger
|
||||
enforcer Enforcer
|
||||
createEmpty func() T
|
||||
getIndexable func(T) *model.Indexable
|
||||
}
|
||||
|
||||
// NewIndexableDB creates a new auth.IndexableDB instance
|
||||
func newIndexableDBImp[T storable.Storable](
|
||||
repo repository.Repository,
|
||||
logger mlogger.Logger,
|
||||
enforcer Enforcer,
|
||||
createEmpty func() T,
|
||||
getIndexable func(T) *model.Indexable,
|
||||
) IndexableDB[T] {
|
||||
return &indexableDBImp[T]{
|
||||
repo: repo,
|
||||
logger: logger.Named("indexable"),
|
||||
enforcer: enforcer,
|
||||
createEmpty: createEmpty,
|
||||
getIndexable: getIndexable,
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder implements reordering with permission checking using EnforceBatch
|
||||
func (db *indexableDBImp[T]) Reorder(ctx context.Context, accountRef, objectRef bson.ObjectID, newIndex int, filter builder.Query) error {
|
||||
// Get current object to find its index
|
||||
obj := db.createEmpty()
|
||||
if err := db.repo.Get(ctx, objectRef, obj); err != nil {
|
||||
db.logger.Warn("Failed to get object for reordering", zap.Error(err), zap.Int("new_index", newIndex),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract index from the object
|
||||
indexable := db.getIndexable(obj)
|
||||
currentIndex := indexable.Index
|
||||
if currentIndex == newIndex {
|
||||
db.logger.Debug("No reordering needed - same index", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex))
|
||||
return nil // No change needed
|
||||
}
|
||||
|
||||
// Determine which objects will be affected by the reordering
|
||||
var affectedObjects []model.PermissionBoundStorable
|
||||
|
||||
if currentIndex < newIndex {
|
||||
// Moving down: items between currentIndex+1 and newIndex will be shifted up by -1
|
||||
reorderFilter := filter.
|
||||
And(repository.IndexOpFilter(currentIndex+1, builder.Gte)).
|
||||
And(repository.IndexOpFilter(newIndex, builder.Lte))
|
||||
|
||||
// Get all affected objects using ListPermissionBound
|
||||
objects, err := db.repo.ListPermissionBound(ctx, reorderFilter)
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to get affected objects for reordering (moving down)",
|
||||
zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex))
|
||||
return err
|
||||
}
|
||||
affectedObjects = append(affectedObjects, objects...)
|
||||
db.logger.Debug("Found affected objects for moving down",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(objects)))
|
||||
} else {
|
||||
// Moving up: items between newIndex and currentIndex-1 will be shifted down by +1
|
||||
reorderFilter := filter.
|
||||
And(repository.IndexOpFilter(newIndex, builder.Gte)).
|
||||
And(repository.IndexOpFilter(currentIndex-1, builder.Lte))
|
||||
|
||||
// Get all affected objects using ListPermissionBound
|
||||
objects, err := db.repo.ListPermissionBound(ctx, reorderFilter)
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to get affected objects for reordering (moving up)", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex))
|
||||
return err
|
||||
}
|
||||
affectedObjects = append(affectedObjects, objects...)
|
||||
db.logger.Debug("Found affected objects for moving up", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(objects)))
|
||||
}
|
||||
|
||||
// Add the target object to the list of objects that need permission checking
|
||||
targetObjects, err := db.repo.ListPermissionBound(ctx, repository.IDFilter(objectRef))
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to get target object for permission checking", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef))
|
||||
return err
|
||||
}
|
||||
if len(targetObjects) > 0 {
|
||||
affectedObjects = append(affectedObjects, targetObjects[0])
|
||||
}
|
||||
|
||||
// Check permissions for all affected objects using EnforceBatch
|
||||
db.logger.Debug("Checking permissions for reordering", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(affectedObjects)),
|
||||
zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex))
|
||||
|
||||
permissions, err := db.enforcer.EnforceBatch(ctx, affectedObjects, accountRef, model.ActionUpdate)
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to check permissions for reordering", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(affectedObjects)))
|
||||
return merrors.Internal("failed to check permissions for reordering")
|
||||
}
|
||||
|
||||
// Verify all objects have update permission
|
||||
for resObjectRef, hasPermission := range permissions {
|
||||
if !hasPermission {
|
||||
db.logger.Info("Permission denied for object during reordering", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionUpdate)))
|
||||
return merrors.AccessDenied(db.repo.Collection(), string(model.ActionUpdate), resObjectRef)
|
||||
}
|
||||
}
|
||||
|
||||
db.logger.Debug("All permissions granted, proceeding with reordering", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("permission_count", len(permissions)))
|
||||
|
||||
// All permissions checked, proceed with reordering
|
||||
if currentIndex < newIndex {
|
||||
// Moving down: shift items between currentIndex+1 and newIndex up by -1
|
||||
patch := repository.Patch().Inc(repository.IndexField(), -1)
|
||||
reorderFilter := filter.
|
||||
And(repository.IndexOpFilter(currentIndex+1, builder.Gte)).
|
||||
And(repository.IndexOpFilter(newIndex, builder.Lte))
|
||||
|
||||
updatedCount, err := db.repo.PatchMany(ctx, reorderFilter, patch)
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to shift objects during reordering (moving down)", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex), zap.Int("updated_count", updatedCount))
|
||||
return err
|
||||
}
|
||||
db.logger.Debug("Successfully shifted objects (moving down)", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("updated_count", updatedCount))
|
||||
} else {
|
||||
// Moving up: shift items between newIndex and currentIndex-1 down by +1
|
||||
patch := repository.Patch().Inc(repository.IndexField(), 1)
|
||||
reorderFilter := filter.
|
||||
And(repository.IndexOpFilter(newIndex, builder.Gte)).
|
||||
And(repository.IndexOpFilter(currentIndex-1, builder.Lte))
|
||||
|
||||
updatedCount, err := db.repo.PatchMany(ctx, reorderFilter, patch)
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to shift objects during reordering (moving up)", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex), zap.Int("updated_count", updatedCount))
|
||||
return err
|
||||
}
|
||||
db.logger.Debug("Successfully shifted objects (moving up)", mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("updated_count", updatedCount))
|
||||
}
|
||||
|
||||
// Update the target object to new index
|
||||
if err := db.repo.Patch(ctx, objectRef, repository.Patch().Set(repository.IndexField(), newIndex)); err != nil {
|
||||
db.logger.Warn("Failed to update target object index", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex))
|
||||
return err
|
||||
}
|
||||
|
||||
db.logger.Debug("Successfully reordered object with permission checking",
|
||||
mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("old_index", currentIndex),
|
||||
zap.Int("new_index", newIndex), zap.Int("affected_count", len(affectedObjects)))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package casbin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/mongoadapter"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AdapterConfig struct {
|
||||
DatabaseName *string `mapstructure:"database_name"`
|
||||
DatabaseNameEnv *string `mapstructure:"database_name_env"`
|
||||
CollectionName *string `mapstructure:"collection_name"`
|
||||
CollectionNameEnv *string `mapstructure:"collection_name_env"`
|
||||
TimeoutSeconds *int `mapstructure:"timeout_seconds"`
|
||||
TimeoutSecondsEnv *string `mapstructure:"timeout_seconds_env"`
|
||||
IsFiltered *bool `mapstructure:"is_filtered"`
|
||||
IsFilteredEnv *string `mapstructure:"is_filtered_env"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ModelPath *string `mapstructure:"model_path"`
|
||||
ModelPathEnv *string `mapstructure:"model_path_env"`
|
||||
Adapter *AdapterConfig `mapstructure:"adapter"`
|
||||
}
|
||||
|
||||
type EnforcerConfig struct {
|
||||
ModelPath string
|
||||
Adapter *mongoadapter.Config
|
||||
}
|
||||
|
||||
func getEnvValue(logger mlogger.Logger, varName, envVarName string, value, envValue *string) string {
|
||||
if value != nil && envValue != nil {
|
||||
logger.Warn("Both variable and environment variable are set, using environment variable value",
|
||||
zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.String("value", *value), zap.String("env_value", os.Getenv(*envValue)))
|
||||
}
|
||||
|
||||
if envValue != nil {
|
||||
return os.Getenv(*envValue)
|
||||
}
|
||||
|
||||
if value != nil {
|
||||
return *value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func getEnvIntValue(logger mlogger.Logger, varName, envVarName string, value *int, envValue *string) int {
|
||||
if value != nil && envValue != nil {
|
||||
logger.Warn("Both variable and environment variable are set, using environment variable value",
|
||||
zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.Int("value", *value), zap.String("env_value", os.Getenv(*envValue)))
|
||||
}
|
||||
|
||||
if envValue != nil {
|
||||
envStr := os.Getenv(*envValue)
|
||||
if envStr != "" {
|
||||
if parsed, err := time.ParseDuration(envStr + "s"); err == nil {
|
||||
return int(parsed.Seconds())
|
||||
}
|
||||
logger.Warn("Invalid environment variable value for timeout", zap.String("environment_variable", envVarName), zap.String("value", envStr))
|
||||
}
|
||||
}
|
||||
|
||||
if value != nil {
|
||||
return *value
|
||||
}
|
||||
|
||||
return 30 // Default timeout in seconds
|
||||
}
|
||||
|
||||
func getEnvBoolValue(logger mlogger.Logger, varName, envVarName string, value *bool, envValue *string) bool {
|
||||
if value != nil && envValue != nil {
|
||||
logger.Warn("Both variable and environment variable are set, using environment variable value",
|
||||
zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.Bool("value", *value), zap.String("env_value", os.Getenv(*envValue)))
|
||||
}
|
||||
|
||||
if envValue != nil {
|
||||
envStr := os.Getenv(*envValue)
|
||||
switch envStr {
|
||||
case "true", "1":
|
||||
return true
|
||||
case "false", "0":
|
||||
return false
|
||||
}
|
||||
logger.Warn("Invalid environment variable value for boolean", zap.String("environment_variable", envVarName), zap.String("value", envStr))
|
||||
}
|
||||
|
||||
if value != nil {
|
||||
return *value
|
||||
}
|
||||
|
||||
return false // Default for boolean
|
||||
}
|
||||
|
||||
func PrepareConfig(logger mlogger.Logger, config *Config) (*EnforcerConfig, error) {
|
||||
if config == nil {
|
||||
return nil, merrors.Internal("No configuration provided")
|
||||
}
|
||||
|
||||
adapter := &mongoadapter.Config{
|
||||
DatabaseName: getEnvValue(logger, "database_name", "database_name_env", config.Adapter.DatabaseName, config.Adapter.DatabaseNameEnv),
|
||||
CollectionName: getEnvValue(logger, "collection_name", "collection_name_env", config.Adapter.CollectionName, config.Adapter.CollectionNameEnv),
|
||||
Timeout: time.Duration(getEnvIntValue(logger, "timeout_seconds", "timeout_seconds_env", config.Adapter.TimeoutSeconds, config.Adapter.TimeoutSecondsEnv)) * time.Second,
|
||||
IsFiltered: getEnvBoolValue(logger, "is_filtered", "is_filtered_env", config.Adapter.IsFiltered, config.Adapter.IsFilteredEnv),
|
||||
}
|
||||
|
||||
if len(adapter.DatabaseName) == 0 {
|
||||
logger.Error("Database name is not set")
|
||||
return nil, merrors.InvalidArgument("database name must be provided", "adapter.databaseName")
|
||||
}
|
||||
|
||||
path := getEnvValue(logger, "model_path", "model_path_env", config.ModelPath, config.ModelPathEnv)
|
||||
|
||||
logger.Info("Configuration prepared",
|
||||
zap.String("model_path", path),
|
||||
zap.String("database_name", adapter.DatabaseName),
|
||||
zap.String("collection_name", adapter.CollectionName),
|
||||
zap.Duration("timeout", adapter.Timeout),
|
||||
zap.Bool("is_filtered", adapter.IsFiltered),
|
||||
)
|
||||
|
||||
return &EnforcerConfig{ModelPath: path, Adapter: adapter}, nil
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// casbin_enforcer.go
|
||||
package casbin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject"
|
||||
cc "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/config"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"github.com/casbin/casbin/v3"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CasbinEnforcer implements the Enforcer interface using Casbin.
|
||||
type CasbinEnforcer struct {
|
||||
logger mlogger.Logger
|
||||
enforcer *casbin.Enforcer
|
||||
roleSerializer serialization.Role
|
||||
permissionSerializer serialization.Policy
|
||||
}
|
||||
|
||||
// NewCasbinEnforcer initializes a new CasbinEnforcer with a MongoDB adapter, logger, and PolicySerializer.
|
||||
// The 'domain' parameter is no longer stored internally, as the interface requires passing a domainRef per method call.
|
||||
func NewEnforcer(
|
||||
logger mlogger.Logger,
|
||||
client *mongo.Client,
|
||||
settings model.SettingsT,
|
||||
) (*CasbinEnforcer, error) {
|
||||
var config cc.Config
|
||||
if err := mapstructure.Decode(settings, &config); err != nil {
|
||||
logger.Warn("Failed to decode Casbin configuration", zap.Error(err), zap.Any("settings", settings))
|
||||
return nil, merrors.Internal("failed to decode Casbin configuration")
|
||||
}
|
||||
|
||||
// Create a Casbin adapter + enforcer from your config and client.
|
||||
l := logger.Named("enforcer")
|
||||
e, err := createAdapter(l, &config, client)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create Casbin enforcer", zap.Error(err))
|
||||
return nil, merrors.Internal("failed to create Casbin enforcer")
|
||||
}
|
||||
|
||||
logger.Info("Casbin enforcer created")
|
||||
return &CasbinEnforcer{
|
||||
logger: l,
|
||||
enforcer: e,
|
||||
permissionSerializer: serialization.NewPolicySerializer(),
|
||||
roleSerializer: serialization.NewRoleSerializer(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Enforce checks if a user has the specified action permission on an object within a domain.
|
||||
func (c *CasbinEnforcer) Enforce(
|
||||
_ context.Context,
|
||||
permissionRef, accountRef, organizationRef, objectRef bson.ObjectID,
|
||||
action model.Action,
|
||||
) (bool, error) {
|
||||
// Convert ObjectIDs to strings for Casbin
|
||||
account := accountRef.Hex()
|
||||
organization := organizationRef.Hex()
|
||||
permission := permissionRef.Hex()
|
||||
object := anyobject.ID
|
||||
if objectRef != bson.NilObjectID {
|
||||
object = objectRef.Hex()
|
||||
}
|
||||
act := string(action)
|
||||
|
||||
c.logger.Debug("Enforcing policy",
|
||||
zap.String("account", account), zap.String("organization", organization),
|
||||
zap.String("permission", permission), zap.String("object", object),
|
||||
zap.String("action", act))
|
||||
|
||||
// Perform the enforcement
|
||||
result, err := c.enforcer.Enforce(account, organization, permission, object, act)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to enforce policy", zap.Error(err),
|
||||
zap.String("account", account), zap.String("organization", organization),
|
||||
zap.String("permission", permission), zap.String("object", object),
|
||||
zap.String("action", act))
|
||||
return false, err
|
||||
}
|
||||
|
||||
c.logger.Debug("Policy enforcement result", zap.Bool("result", result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// EnforceBatch checks a user’s permission for multiple objects at once.
|
||||
// It returns a map from objectRef -> boolean indicating whether access is granted.
|
||||
func (c *CasbinEnforcer) EnforceBatch(
|
||||
ctx context.Context,
|
||||
objectRefs []model.PermissionBoundStorable,
|
||||
accountRef bson.ObjectID,
|
||||
action model.Action,
|
||||
) (map[bson.ObjectID]bool, error) {
|
||||
results := make(map[bson.ObjectID]bool, len(objectRefs))
|
||||
for _, desc := range objectRefs {
|
||||
ok, err := c.Enforce(ctx, desc.GetPermissionRef(), accountRef, desc.GetOrganizationRef(), *desc.GetID(), action)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to enforce", zap.Error(err), mzap.PermissionRef(desc.GetPermissionRef()),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(desc.GetOrganizationRef()),
|
||||
mzap.StorableRef(desc), zap.String("action", string(action)))
|
||||
return nil, err
|
||||
}
|
||||
results[*desc.GetID()] = ok
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetRoles retrieves all roles assigned to the user within the domain.
|
||||
func (c *CasbinEnforcer) GetRoles(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, error) {
|
||||
sub := accountRef.Hex()
|
||||
dom := orgRef.Hex()
|
||||
|
||||
c.logger.Debug("Fetching roles for user", zap.String("subject", sub), zap.String("domain", dom))
|
||||
|
||||
// Get all roles for the user in the domain
|
||||
sroles, err := c.enforcer.GetFilteredGroupingPolicy(0, sub, "", dom)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to get roles from policies", zap.Error(err),
|
||||
zap.String("account_ref", sub), zap.String("organization_ref", dom),
|
||||
)
|
||||
return nil, merrors.Internal("failed to fetch roles from policies")
|
||||
}
|
||||
|
||||
roles := make([]model.Role, 0, len(sroles))
|
||||
for _, srole := range sroles {
|
||||
role, err := c.roleSerializer.Deserialize(srole)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to deserialize role", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
roles = append(roles, *role)
|
||||
}
|
||||
|
||||
c.logger.Debug("Roles fetched successfully", zap.Int("count", len(roles)))
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
// GetPermissions retrieves all effective policies for the user within the domain.
|
||||
func (c *CasbinEnforcer) GetPermissions(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, []model.Permission, error) {
|
||||
c.logger.Debug("Fetching policies for user", mzap.AccRef(accountRef), mzap.OrgRef(orgRef))
|
||||
|
||||
// Step 1: Retrieve all roles assigned to the user within the domain
|
||||
roles, err := c.GetRoles(ctx, accountRef, orgRef)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to get roles", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Map to hold unique policies
|
||||
permissionsMap := make(map[string]*model.Permission)
|
||||
for _, role := range roles {
|
||||
// Step 2a: Retrieve all policies associated with the role within the domain
|
||||
policies, err := c.enforcer.GetFilteredPolicy(0, role.DescriptionRef.Hex())
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to get policies for role", zap.Error(err), mzap.ObjRef("role_ref", role.DescriptionRef))
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 2b: Process each policy to extract Permission, Action, and Effect
|
||||
for _, policy := range policies {
|
||||
|
||||
if len(policy) < 5 {
|
||||
c.logger.Warn("Incomplete policy encountered", zap.Strings("policy", policy))
|
||||
continue // Ensure the policy line has enough fields
|
||||
}
|
||||
|
||||
// Deserialize the policy using
|
||||
deserializedPolicy, err := c.permissionSerializer.Deserialize(policy)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to deserialize policy", zap.Error(err), zap.Strings("policy", policy))
|
||||
continue
|
||||
}
|
||||
|
||||
// Construct a unique key combining Permission ID and Action to prevent duplicates
|
||||
policyKey := deserializedPolicy.DescriptionRef.Hex() + ":" + string(deserializedPolicy.Effect.Action)
|
||||
if _, exists := permissionsMap[policyKey]; exists {
|
||||
continue // Policy-action pair already accounted for
|
||||
}
|
||||
|
||||
// Add the Policy to the map
|
||||
permissionsMap[policyKey] = &model.Permission{
|
||||
RolePolicy: *deserializedPolicy,
|
||||
AccountRef: accountRef,
|
||||
}
|
||||
c.logger.Debug("Policy added to policyMap", zap.Any("policy_key", policyKey))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the map to a slice
|
||||
permissions := make([]model.Permission, 0, len(permissionsMap))
|
||||
for _, permission := range permissionsMap {
|
||||
permissions = append(permissions, *permission)
|
||||
}
|
||||
|
||||
c.logger.Debug("Permissions fetched successfully", zap.Int("count", len(permissions)))
|
||||
return roles, permissions, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package casbin
|
||||
|
||||
import (
|
||||
cc "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/config"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/mongoadapter"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"github.com/casbin/casbin/v3"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func createAdapter(logger mlogger.Logger, config *cc.Config, client *mongo.Client) (*casbin.Enforcer, error) {
|
||||
dbc, err := cc.PrepareConfig(logger, config)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to prepare database configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
adapter, err := mongoadapter.New(client, dbc.Adapter)
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create DB adapter", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e, err := casbin.NewEnforcer(dbc.ModelPath, adapter, NewCasbinLogger(logger))
|
||||
if err != nil {
|
||||
logger.Warn("Failed to create permissions enforcer", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
e.EnableAutoSave(true)
|
||||
|
||||
// No need to manually load policy. Casbin does it for us
|
||||
return e, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package casbin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CasbinZapLogger wraps a zap.Logger to implement Casbin's Logger interface.
|
||||
type CasbinZapLogger struct {
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
// NewCasbinLogger constructs a new CasbinZapLogger.
|
||||
func NewCasbinLogger(logger mlogger.Logger) *CasbinZapLogger {
|
||||
return &CasbinZapLogger{
|
||||
logger: logger.Named("driver"),
|
||||
}
|
||||
}
|
||||
|
||||
// EnableLog enables or disables logging.
|
||||
func (l *CasbinZapLogger) EnableLog(_ bool) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// IsEnabled returns whether logging is currently enabled.
|
||||
func (l *CasbinZapLogger) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// LogModel is called by Casbin when loading model settings (you can customize if you want).
|
||||
func (l *CasbinZapLogger) LogModel(m [][]string) {
|
||||
l.logger.Info("Model loaded", zap.Any("model", m))
|
||||
}
|
||||
|
||||
func (l *CasbinZapLogger) LogPolicy(m map[string][][]string) {
|
||||
l.logger.Info("Policy loaded", zap.Int("entries", len(m)))
|
||||
}
|
||||
|
||||
func (l *CasbinZapLogger) LogError(err error, msg ...string) {
|
||||
// If no custom message was passed, log a generic one
|
||||
if len(msg) == 0 {
|
||||
l.logger.Warn("Error occurred", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, join any provided messages and include them
|
||||
l.logger.Warn(strings.Join(msg, " "), zap.Error(err))
|
||||
}
|
||||
|
||||
// LogEnforce is called by Casbin to log each Enforce() call if logging is enabled.
|
||||
func (l *CasbinZapLogger) LogEnforce(matcher string, request []any, result bool, explains [][]string) {
|
||||
l.logger.Debug("Enforcing policy...", zap.String("matcher", matcher), zap.Any("request", request),
|
||||
zap.Bool("result", result), zap.Any("explains", explains))
|
||||
}
|
||||
|
||||
// LogRole is called by Casbin when role manager adds or deletes a role.
|
||||
func (l *CasbinZapLogger) LogRole(roles []string) {
|
||||
l.logger.Debug("Changing roles...", zap.Strings("roles", roles))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// package casbin
|
||||
|
||||
package casbin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/role"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CasbinManager implements the auth.Manager interface by aggregating Role and Permission managers.
|
||||
type CasbinManager struct {
|
||||
logger mlogger.Logger
|
||||
roleManager management.Role
|
||||
permManager management.Permission
|
||||
}
|
||||
|
||||
// NewManager creates a new CasbinManager with specified domains and role-domain mappings.
|
||||
func NewManager(
|
||||
l mlogger.Logger,
|
||||
pdb policy.DB,
|
||||
rdb role.DB,
|
||||
enforcer *CasbinEnforcer,
|
||||
settings model.SettingsT,
|
||||
) (*CasbinManager, error) {
|
||||
logger := l.Named("manager")
|
||||
|
||||
var pdesc model.PolicyDescription
|
||||
if err := pdb.GetBuiltInPolicy(context.Background(), "roles", &pdesc); err != nil {
|
||||
logger.Warn("Failed to fetch roles permission reference", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &CasbinManager{
|
||||
logger: logger,
|
||||
roleManager: NewRoleManager(logger, enforcer, pdesc.ID, rdb),
|
||||
permManager: NewPermissionManager(logger, enforcer),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Permission returns the Permission manager.
|
||||
func (m *CasbinManager) Permission() management.Permission {
|
||||
return m.permManager
|
||||
}
|
||||
|
||||
// Role returns the Role manager.
|
||||
func (m *CasbinManager) Role() management.Role {
|
||||
return m.roleManager
|
||||
}
|
||||
|
||||
// TransactionalGrants reports that casbin grants live in the enforcer's memory
|
||||
// until Save rewrites the whole policy store: they neither respect a Mongo
|
||||
// transaction context nor survive concurrent Save calls from other instances.
|
||||
func (m *CasbinManager) TransactionalGrants() bool {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package mongoadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"time"
|
||||
|
||||
"github.com/casbin/casbin/v3/model"
|
||||
"github.com/casbin/casbin/v3/persist"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDatabaseName = "casbin"
|
||||
defaultCollectionName = "casbin_rule"
|
||||
defaultTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
_ persist.BatchAdapter = (*Adapter)(nil)
|
||||
_ persist.FilteredAdapter = (*Adapter)(nil)
|
||||
_ persist.UpdatableAdapter = (*Adapter)(nil)
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DatabaseName string
|
||||
CollectionName string
|
||||
Timeout time.Duration
|
||||
IsFiltered bool
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
PType string
|
||||
V0 string
|
||||
V1 string
|
||||
V2 string
|
||||
V3 string
|
||||
V4 string
|
||||
V5 string
|
||||
}
|
||||
|
||||
type Adapter struct {
|
||||
collection *mongo.Collection
|
||||
timeout time.Duration
|
||||
filtered bool
|
||||
}
|
||||
|
||||
func New(client *mongo.Client, config *Config) (*Adapter, error) {
|
||||
if config == nil {
|
||||
config = &Config{}
|
||||
}
|
||||
|
||||
databaseName := config.DatabaseName
|
||||
if databaseName == "" {
|
||||
databaseName = defaultDatabaseName
|
||||
}
|
||||
collectionName := config.CollectionName
|
||||
if collectionName == "" {
|
||||
collectionName = defaultCollectionName
|
||||
}
|
||||
timeout := config.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
|
||||
adapter := &Adapter{
|
||||
collection: client.Database(databaseName).Collection(collectionName),
|
||||
timeout: timeout,
|
||||
filtered: config.IsFiltered,
|
||||
}
|
||||
if err := adapter.prepareIndexes(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return adapter, nil
|
||||
}
|
||||
|
||||
func (a *Adapter) prepareIndexes() error {
|
||||
keys := bson.D{
|
||||
{Key: "ptype", Value: 1},
|
||||
{Key: "v0", Value: 1},
|
||||
{Key: "v1", Value: 1},
|
||||
{Key: "v2", Value: 1},
|
||||
{Key: "v3", Value: 1},
|
||||
{Key: "v4", Value: 1},
|
||||
{Key: "v5", Value: 1},
|
||||
}
|
||||
|
||||
_, err := a.collection.Indexes().CreateOne(
|
||||
context.Background(),
|
||||
mongo.IndexModel{
|
||||
Keys: keys,
|
||||
Options: options.Index().SetUnique(true),
|
||||
},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) withTimeout() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), a.timeout)
|
||||
}
|
||||
|
||||
func loadPolicyLine(line Rule, m model.Model) error {
|
||||
policy := line.policyWithType()
|
||||
for len(policy) > 0 && policy[len(policy)-1] == "" {
|
||||
policy = policy[:len(policy)-1]
|
||||
}
|
||||
if len(policy) == 0 {
|
||||
return nil
|
||||
}
|
||||
return persist.LoadPolicyArray(policy, m)
|
||||
}
|
||||
|
||||
func (r Rule) policyWithType() []string {
|
||||
return []string{r.PType, r.V0, r.V1, r.V2, r.V3, r.V4, r.V5}
|
||||
}
|
||||
|
||||
func (r Rule) policyValues() []string {
|
||||
values := []string{r.V0, r.V1, r.V2, r.V3, r.V4, r.V5}
|
||||
for len(values) > 0 && values[len(values)-1] == "" {
|
||||
values = values[:len(values)-1]
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func ruleFromPolicy(ptype string, policy []string) Rule {
|
||||
rule := Rule{PType: ptype}
|
||||
if len(policy) > 0 {
|
||||
rule.V0 = policy[0]
|
||||
}
|
||||
if len(policy) > 1 {
|
||||
rule.V1 = policy[1]
|
||||
}
|
||||
if len(policy) > 2 {
|
||||
rule.V2 = policy[2]
|
||||
}
|
||||
if len(policy) > 3 {
|
||||
rule.V3 = policy[3]
|
||||
}
|
||||
if len(policy) > 4 {
|
||||
rule.V4 = policy[4]
|
||||
}
|
||||
if len(policy) > 5 {
|
||||
rule.V5 = policy[5]
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
func rulesFromPolicies(ptype string, policies [][]string) []Rule {
|
||||
rules := make([]Rule, 0, len(policies))
|
||||
for _, policy := range policies {
|
||||
rules = append(rules, ruleFromPolicy(ptype, policy))
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
func rulesAsDocuments(rules []Rule) []any {
|
||||
docs := make([]any, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
docs = append(docs, rule)
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
func selectorForPolicy(ptype string, fieldIndex int, fieldValues ...string) bson.M {
|
||||
selector := bson.M{"ptype": ptype}
|
||||
fields := []string{"v0", "v1", "v2", "v3", "v4", "v5"}
|
||||
for i, fieldValue := range fieldValues {
|
||||
if fieldValue == "" {
|
||||
continue
|
||||
}
|
||||
field := fieldIndex + i
|
||||
if field < 0 || field >= len(fields) {
|
||||
continue
|
||||
}
|
||||
selector[fields[field]] = fieldValue
|
||||
}
|
||||
return selector
|
||||
}
|
||||
|
||||
func (a *Adapter) LoadPolicy(m model.Model) error {
|
||||
return a.LoadFilteredPolicy(m, nil)
|
||||
}
|
||||
|
||||
func (a *Adapter) LoadFilteredPolicy(m model.Model, filter any) error {
|
||||
if filter == nil {
|
||||
a.filtered = false
|
||||
filter = bson.D{}
|
||||
} else {
|
||||
a.filtered = true
|
||||
}
|
||||
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
cursor, err := a.collection.Find(ctx, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = cursor.Close(ctx)
|
||||
}()
|
||||
|
||||
for cursor.Next(ctx) {
|
||||
var line Rule
|
||||
if err := cursor.Decode(&line); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadPolicyLine(line, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return cursor.Err()
|
||||
}
|
||||
|
||||
func (a *Adapter) IsFiltered() bool {
|
||||
return a.filtered
|
||||
}
|
||||
|
||||
func (a *Adapter) SavePolicy(m model.Model) error {
|
||||
if a.filtered {
|
||||
return merrors.Define("cannot save a filtered policy")
|
||||
}
|
||||
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
if _, err := a.collection.DeleteMany(ctx, bson.D{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var docs []any
|
||||
for ptype, ast := range m["p"] {
|
||||
docs = append(docs, rulesAsDocuments(rulesFromPolicies(ptype, ast.Policy))...)
|
||||
}
|
||||
for ptype, ast := range m["g"] {
|
||||
docs = append(docs, rulesAsDocuments(rulesFromPolicies(ptype, ast.Policy))...)
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := a.collection.InsertMany(ctx, docs)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) AddPolicy(_ string, ptype string, policy []string) error {
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
_, err := a.collection.InsertOne(ctx, ruleFromPolicy(ptype, policy))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) AddPolicies(_ string, ptype string, policies [][]string) error {
|
||||
if len(policies) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
_, err := a.collection.InsertMany(ctx, rulesAsDocuments(rulesFromPolicies(ptype, policies)))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) RemovePolicy(_ string, ptype string, policy []string) error {
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
_, err := a.collection.DeleteOne(ctx, ruleFromPolicy(ptype, policy))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) RemovePolicies(_ string, ptype string, policies [][]string) error {
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
for _, rule := range rulesFromPolicies(ptype, policies) {
|
||||
if _, err := a.collection.DeleteOne(ctx, rule); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) RemoveFilteredPolicy(_ string, ptype string, fieldIndex int, fieldValues ...string) error {
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
_, err := a.collection.DeleteMany(ctx, selectorForPolicy(ptype, fieldIndex, fieldValues...))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) UpdatePolicy(_ string, ptype string, oldPolicy, newPolicy []string) error {
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
_, err := a.collection.ReplaceOne(ctx, ruleFromPolicy(ptype, oldPolicy), ruleFromPolicy(ptype, newPolicy))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Adapter) UpdatePolicies(_ string, ptype string, oldPolicies, newPolicies [][]string) error {
|
||||
if len(oldPolicies) != len(newPolicies) {
|
||||
return merrors.Define("old and new policy counts differ")
|
||||
}
|
||||
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
for i := range oldPolicies {
|
||||
if _, err := a.collection.ReplaceOne(ctx, ruleFromPolicy(ptype, oldPolicies[i]), ruleFromPolicy(ptype, newPolicies[i])); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) UpdateFilteredPolicies(_ string, ptype string, newPolicies [][]string, fieldIndex int, fieldValues ...string) ([][]string, error) {
|
||||
ctx, cancel := a.withTimeout()
|
||||
defer cancel()
|
||||
|
||||
selector := selectorForPolicy(ptype, fieldIndex, fieldValues...)
|
||||
cursor, err := a.collection.Find(ctx, selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var oldPolicies [][]string
|
||||
for cursor.Next(ctx) {
|
||||
var line Rule
|
||||
if err := cursor.Decode(&line); err != nil {
|
||||
_ = cursor.Close(ctx)
|
||||
return nil, err
|
||||
}
|
||||
oldPolicies = append(oldPolicies, line.policyValues())
|
||||
}
|
||||
if err := cursor.Close(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cursor.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := a.collection.DeleteMany(ctx, selector); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(newPolicies) == 0 {
|
||||
return oldPolicies, nil
|
||||
}
|
||||
|
||||
if _, err := a.collection.InsertMany(ctx, rulesAsDocuments(rulesFromPolicies(ptype, newPolicies))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return oldPolicies, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package mongoadapter
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/casbin/casbin/v3/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestRuleFromPolicyTrimsOnlyTrailingEmptyValues(t *testing.T) {
|
||||
rule := ruleFromPolicy("p", []string{"role", "org", "", "object", ""})
|
||||
|
||||
if got, want := rule.PType, "p"; got != want {
|
||||
t.Fatalf("PType = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := rule.policyValues(), []string{"role", "org", "", "object"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("policyValues() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectorForPolicySkipsEmptyAndOutOfRangeFields(t *testing.T) {
|
||||
got := selectorForPolicy("p", 1, "role", "", "object", "read", "ignored", "ignored")
|
||||
want := bson.M{
|
||||
"ptype": "p",
|
||||
"v1": "role",
|
||||
"v3": "object",
|
||||
"v4": "read",
|
||||
"v5": "ignored",
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("selectorForPolicy() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPolicyLineAddsPolicyToModel(t *testing.T) {
|
||||
m, err := model.NewModelFromString(`
|
||||
[request_definition]
|
||||
r = sub, obj, act
|
||||
|
||||
[policy_definition]
|
||||
p = sub, obj, act
|
||||
|
||||
[policy_effect]
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("NewModelFromString() error = %v", err)
|
||||
}
|
||||
|
||||
err = loadPolicyLine(Rule{PType: "p", V0: "alice", V1: "data1", V2: "read"}, m)
|
||||
if err != nil {
|
||||
t.Fatalf("loadPolicyLine() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := m.GetPolicy("p", "p")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPolicy() error = %v", err)
|
||||
}
|
||||
want := [][]string{{"alice", "data1", "read"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("GetPolicy() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package casbin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CasbinPermissionManager manages permissions using Casbin.
|
||||
type CasbinPermissionManager struct {
|
||||
logger mlogger.Logger // Logger for logging operations
|
||||
enforcer *CasbinEnforcer // Casbin enforcer for managing policies
|
||||
serializer serialization.Policy // Serializer for converting policies to/from Casbin
|
||||
}
|
||||
|
||||
// GrantToRole adds a permission to a role in Casbin.
|
||||
func (m *CasbinPermissionManager) GrantToRole(ctx context.Context, policy *model.RolePolicy) error {
|
||||
objRef := anyobject.ID
|
||||
if (policy.ObjectRef != nil) && (*policy.ObjectRef != bson.NilObjectID) {
|
||||
objRef = policy.ObjectRef.Hex()
|
||||
}
|
||||
|
||||
m.logger.Debug("Granting permission to role",
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
zap.String("object_ref", objRef),
|
||||
zap.String("action", string(policy.Effect.Action)),
|
||||
zap.String("effect", string(policy.Effect.Effect)),
|
||||
)
|
||||
|
||||
// Serialize permission
|
||||
serializedPolicy, err := m.serializer.Serialize(policy)
|
||||
if err != nil {
|
||||
m.logger.Error("Failed to serialize permission while granting permission", zap.Error(err),
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
mzap.OrgRef(policy.OrganizationRef),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Add policy to Casbin
|
||||
added, err := m.enforcer.enforcer.AddPolicy(serializedPolicy...)
|
||||
if err != nil {
|
||||
m.logger.Error("Failed to add policy to Casbin", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if added {
|
||||
m.logger.Info("Policy added to Casbin",
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
zap.String("object_ref", objRef),
|
||||
)
|
||||
} else {
|
||||
m.logger.Warn("Policy already exists in Casbin",
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
zap.String("object_ref", objRef),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeFromRole removes a permission from a role in Casbin.
|
||||
func (m *CasbinPermissionManager) RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error {
|
||||
objRef := anyobject.ID
|
||||
if policy.ObjectRef != nil {
|
||||
objRef = policy.ObjectRef.Hex()
|
||||
}
|
||||
m.logger.Debug("Revoking permission from role",
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
zap.String("object_ref", objRef),
|
||||
zap.String("action", string(policy.Effect.Action)),
|
||||
zap.String("effect", string(policy.Effect.Effect)),
|
||||
)
|
||||
|
||||
// Serialize policy
|
||||
serializedPolicy, err := m.serializer.Serialize(policy)
|
||||
if err != nil {
|
||||
m.logger.Error("Failed to serialize policy while revoking permission from role",
|
||||
zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.ObjRef("policy_ref", policy.DescriptionRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove policy from Casbin
|
||||
removed, err := m.enforcer.enforcer.RemovePolicy(serializedPolicy...)
|
||||
if err != nil {
|
||||
m.logger.Error("Failed to remove policy from Casbin", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if removed {
|
||||
m.logger.Info("Policy removed from Casbin",
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
zap.String("object_ref", objRef),
|
||||
)
|
||||
} else {
|
||||
m.logger.Warn("Policy does not exist in Casbin",
|
||||
mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef),
|
||||
zap.String("object_ref", objRef),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPolicies retrieves all policies for a specific role.
|
||||
func (m *CasbinPermissionManager) GetPolicies(
|
||||
ctx context.Context,
|
||||
roleRef bson.ObjectID,
|
||||
) ([]model.RolePolicy, error) {
|
||||
m.logger.Debug("Fetching policies for role", mzap.ObjRef("role_ref", roleRef))
|
||||
|
||||
// Retrieve Casbin policies for the role
|
||||
policies, err := m.enforcer.enforcer.GetFilteredPolicy(0, roleRef.Hex())
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to get policies", zap.Error(err), mzap.ObjRef("role_ref", roleRef))
|
||||
return nil, err
|
||||
}
|
||||
if len(policies) == 0 {
|
||||
m.logger.Info("No policies found for role", mzap.ObjRef("role_ref", roleRef))
|
||||
return nil, merrors.NoData("no policies")
|
||||
}
|
||||
|
||||
// Deserialize policies
|
||||
var result []model.RolePolicy
|
||||
for _, policy := range policies {
|
||||
permission, err := m.serializer.Deserialize(policy)
|
||||
if err != nil {
|
||||
m.logger.Warn("Failed to deserialize policy", zap.Error(err), zap.String("policy", policy[0]))
|
||||
continue
|
||||
}
|
||||
result = append(result, *permission)
|
||||
}
|
||||
|
||||
m.logger.Debug("Policies fetched successfully", mzap.ObjRef("role_ref", roleRef), zap.Int("count", len(result)))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Save persists changes to the Casbin policy store.
|
||||
func (m *CasbinPermissionManager) Save() error {
|
||||
if err := m.enforcer.enforcer.SavePolicy(); err != nil {
|
||||
m.logger.Error("Failed to save policies in Casbin", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
m.logger.Info("Policies successfully saved in Casbin")
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewPermissionManager(logger mlogger.Logger, enforcer *CasbinEnforcer) *CasbinPermissionManager {
|
||||
return &CasbinPermissionManager{
|
||||
logger: logger.Named("permission"),
|
||||
enforcer: enforcer,
|
||||
serializer: serialization.NewPolicySerializer(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package casbin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/role"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RoleManager manages roles using Casbin.
|
||||
type RoleManager struct {
|
||||
logger mlogger.Logger
|
||||
enforcer *CasbinEnforcer
|
||||
rdb role.DB
|
||||
rolePermissionRef bson.ObjectID
|
||||
}
|
||||
|
||||
// NewRoleManager creates a new RoleManager.
|
||||
func NewRoleManager(logger mlogger.Logger, enforcer *CasbinEnforcer, rolePermissionRef bson.ObjectID, rdb role.DB) *RoleManager {
|
||||
return &RoleManager{
|
||||
logger: logger.Named("role"),
|
||||
enforcer: enforcer,
|
||||
rdb: rdb,
|
||||
rolePermissionRef: rolePermissionRef,
|
||||
}
|
||||
}
|
||||
|
||||
// validateObjectIDs ensures that all provided ObjectIDs are non-zero.
|
||||
func (rm *RoleManager) validateObjectIDs(ids ...bson.ObjectID) error {
|
||||
for _, id := range ids {
|
||||
if id.IsZero() {
|
||||
return merrors.InvalidArgument("Object references cannot be zero", "objectRef")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// removePolicies removes policies based on the provided filter and logs the results.
|
||||
func (rm *RoleManager) removePolicies(policyType, role string, roleRef bson.ObjectID) error {
|
||||
filterIndex := 1
|
||||
if policyType == "permission" {
|
||||
filterIndex = 0
|
||||
}
|
||||
policies, err := rm.enforcer.enforcer.GetFilteredPolicy(filterIndex, role)
|
||||
if err != nil {
|
||||
rm.logger.Warn("Failed to fetch "+policyType+" policies", zap.Error(err), mzap.ObjRef("role_ref", roleRef))
|
||||
return err
|
||||
}
|
||||
|
||||
for _, policy := range policies {
|
||||
args := make([]any, len(policy))
|
||||
for i, v := range policy {
|
||||
args[i] = v
|
||||
}
|
||||
var removed bool
|
||||
var removeErr error
|
||||
if policyType == "grouping" {
|
||||
removed, removeErr = rm.enforcer.enforcer.RemoveGroupingPolicy(args...)
|
||||
} else {
|
||||
removed, removeErr = rm.enforcer.enforcer.RemovePolicy(args...)
|
||||
}
|
||||
|
||||
if removeErr != nil {
|
||||
rm.logger.Warn("Failed to remove "+policyType+" policy for role", zap.Error(removeErr), mzap.ObjRef("role_ref", roleRef), zap.Strings("policy", policy))
|
||||
return removeErr
|
||||
}
|
||||
if removed {
|
||||
rm.logger.Info("Removed "+policyType+" policy for role", mzap.ObjRef("role_ref", roleRef), zap.Strings("policy", policy))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchRolesFromPolicies retrieves and converts policies to roles.
|
||||
func (rm *RoleManager) fetchRolesFromPolicies(policies [][]string, orgRef bson.ObjectID) []model.RoleDescription {
|
||||
roles := make([]model.RoleDescription, 0, len(policies))
|
||||
for _, policy := range policies {
|
||||
if len(policy) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
roleID, err := bson.ObjectIDFromHex(policy[1])
|
||||
if err != nil {
|
||||
rm.logger.Warn("Invalid role ID", zap.String("roleID", policy[1]))
|
||||
continue
|
||||
}
|
||||
roles = append(roles, model.RoleDescription{Base: storable.Base{ID: roleID}, OrganizationRef: orgRef})
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
// Create creates a new role in an organization.
|
||||
func (rm *RoleManager) Create(ctx context.Context, orgRef bson.ObjectID, description *model.Describable) (*model.RoleDescription, error) {
|
||||
if err := rm.validateObjectIDs(orgRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role := &model.RoleDescription{
|
||||
Describable: *description,
|
||||
OrganizationRef: orgRef,
|
||||
}
|
||||
if err := rm.rdb.Create(ctx, role); err != nil {
|
||||
rm.logger.Warn("Failed to create role", zap.Error(err), mzap.ObjRef("organiztion_ref", orgRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rm.logger.Info("Role created successfully", mzap.StorableRef(role), mzap.OrgRef(orgRef))
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// Assign assigns a role to a user in the given organization.
|
||||
func (rm *RoleManager) Assign(ctx context.Context, role *model.Role) error {
|
||||
if err := rm.validateObjectIDs(role.DescriptionRef, role.AccountRef, role.OrganizationRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sub := role.AccountRef.Hex()
|
||||
roleID := role.DescriptionRef.Hex()
|
||||
domain := role.OrganizationRef.Hex()
|
||||
|
||||
added, err := rm.enforcer.enforcer.AddGroupingPolicy(sub, roleID, domain)
|
||||
return rm.logPolicyResult("assign", added, err, role.DescriptionRef, role.AccountRef, role.OrganizationRef)
|
||||
}
|
||||
|
||||
// Delete removes a role entirely and cleans up associated Casbin policies.
|
||||
func (rm *RoleManager) Delete(ctx context.Context, roleRef bson.ObjectID) error {
|
||||
if err := rm.validateObjectIDs(roleRef); err != nil {
|
||||
rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rm.rdb.Delete(ctx, roleRef); err != nil {
|
||||
rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef))
|
||||
return err
|
||||
}
|
||||
|
||||
role := roleRef.Hex()
|
||||
|
||||
// Remove grouping policies
|
||||
if err := rm.removePolicies("grouping", role, roleRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove permission policies
|
||||
if err := rm.removePolicies("permission", role, roleRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// // Save changes
|
||||
// if err := rm.enforcer.enforcer.SavePolicy(); err != nil {
|
||||
// rm.logger.Warn("Failed to save Casbin policies after role deletion",
|
||||
// zap.Error(err),
|
||||
// mzap.ObjRef("role_ref", roleRef),
|
||||
// )
|
||||
// return err
|
||||
// }
|
||||
|
||||
rm.logger.Info("Role deleted successfully along with associated policies", mzap.ObjRef("role_ref", roleRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Revoke removes a role from a user.
|
||||
func (rm *RoleManager) Revoke(ctx context.Context, roleRef, accountRef, orgRef bson.ObjectID) error {
|
||||
if err := rm.validateObjectIDs(roleRef, accountRef, orgRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sub := accountRef.Hex()
|
||||
role := roleRef.Hex()
|
||||
domain := orgRef.Hex()
|
||||
|
||||
removed, err := rm.enforcer.enforcer.RemoveGroupingPolicy(sub, role, domain)
|
||||
return rm.logPolicyResult("revoke", removed, err, roleRef, accountRef, orgRef)
|
||||
}
|
||||
|
||||
// logPolicyResult logs results for Assign and Revoke.
|
||||
func (rm *RoleManager) logPolicyResult(action string, result bool, err error, roleRef, accountRef, orgRef bson.ObjectID) error {
|
||||
if err != nil {
|
||||
rm.logger.Warn("Failed to "+action+" role", zap.Error(err), mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(orgRef))
|
||||
return err
|
||||
}
|
||||
msg := "Role " + action + "ed successfully"
|
||||
if !result {
|
||||
msg = "Role already " + action + "ed"
|
||||
}
|
||||
rm.logger.Info(msg, mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(orgRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List retrieves all roles in an organization or all roles if orgRef is zero.
|
||||
func (rm *RoleManager) List(ctx context.Context, orgRef bson.ObjectID) ([]model.RoleDescription, error) {
|
||||
domain := orgRef.Hex()
|
||||
groupingPolicies, err := rm.enforcer.enforcer.GetFilteredGroupingPolicy(2, domain)
|
||||
if err != nil {
|
||||
rm.logger.Warn("Failed to fetch grouping policies", zap.Error(err), mzap.OrgRef(orgRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roles := rm.fetchRolesFromPolicies(groupingPolicies, orgRef)
|
||||
|
||||
rm.logger.Info("Retrieved roles for organization", mzap.OrgRef(orgRef), zap.Int("count", len(roles)))
|
||||
return roles, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package serializationimp
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// PolicySerializer implements CasbinSerializer for Permission.
|
||||
type PolicySerializer struct{}
|
||||
|
||||
// Serialize converts a Permission object into a Casbin policy.
|
||||
func (s *PolicySerializer) Serialize(entity *model.RolePolicy) ([]any, error) {
|
||||
if entity.RoleDescriptionRef.IsZero() ||
|
||||
entity.OrganizationRef.IsZero() ||
|
||||
entity.DescriptionRef.IsZero() || // Ensure permissionRef is valid
|
||||
entity.Effect.Action == "" || // Ensure action is not empty
|
||||
entity.Effect.Effect == "" { // Ensure effect (eft) is not empty
|
||||
return nil, merrors.InvalidArgument("permission contains invalid object references or missing fields")
|
||||
}
|
||||
|
||||
objectRef := anyobject.ID
|
||||
if entity.ObjectRef != nil {
|
||||
objectRef = entity.ObjectRef.Hex()
|
||||
}
|
||||
|
||||
return []any{
|
||||
entity.RoleDescriptionRef.Hex(), // Maps to p.roleRef
|
||||
entity.OrganizationRef.Hex(), // Maps to p.organizationRef
|
||||
entity.DescriptionRef.Hex(), // Maps to p.permissionRef
|
||||
objectRef, // Maps to p.objectRef (wildcard if empty)
|
||||
string(entity.Effect.Action), // Maps to p.action
|
||||
string(entity.Effect.Effect), // Maps to p.eft
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Deserialize converts a Casbin policy into a Permission object.
|
||||
func (s *PolicySerializer) Deserialize(policy []string) (*model.RolePolicy, error) {
|
||||
if len(policy) != 6 { // Ensure policy has the correct number of fields
|
||||
return nil, merrors.Internal("invalid policy format")
|
||||
}
|
||||
|
||||
roleRef, err := bson.ObjectIDFromHex(policy[0])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid roleRef in policy")
|
||||
}
|
||||
|
||||
organizationRef, err := bson.ObjectIDFromHex(policy[1])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid organizationRef in policy")
|
||||
}
|
||||
|
||||
permissionRef, err := bson.ObjectIDFromHex(policy[2])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid permissionRef in policy")
|
||||
}
|
||||
|
||||
// Handle wildcard for ObjectRef
|
||||
var objectRef *bson.ObjectID
|
||||
if policy[3] != anyobject.ID {
|
||||
ref, err := bson.ObjectIDFromHex(policy[3])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid objectRef in policy")
|
||||
}
|
||||
objectRef = &ref
|
||||
}
|
||||
|
||||
return &model.RolePolicy{
|
||||
RoleDescriptionRef: roleRef,
|
||||
Policy: model.Policy{
|
||||
OrganizationRef: organizationRef,
|
||||
DescriptionRef: permissionRef,
|
||||
ObjectRef: objectRef,
|
||||
Effect: model.ActionEffect{
|
||||
Action: model.Action(policy[4]),
|
||||
Effect: model.Effect(policy[5]),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package serializationimp
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// RoleSerializer implements CasbinSerializer for Role.
|
||||
type RoleSerializer struct{}
|
||||
|
||||
// Serialize converts a Role object into a Casbin grouping policy.
|
||||
func (s *RoleSerializer) Serialize(entity *model.Role) ([]any, error) {
|
||||
// Validate required fields
|
||||
if entity.AccountRef.IsZero() || entity.DescriptionRef.IsZero() || entity.OrganizationRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("role contains invalid object references")
|
||||
}
|
||||
|
||||
return []any{
|
||||
entity.AccountRef.Hex(), // Maps to g(_, _, _) accountRef
|
||||
entity.DescriptionRef.Hex(), // Maps to g(_, _, _) roleRef
|
||||
entity.OrganizationRef.Hex(), // Maps to g(_, _, _) organizationRef
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Deserialize converts a Casbin grouping policy into a Role object.
|
||||
func (s *RoleSerializer) Deserialize(policy []string) (*model.Role, error) {
|
||||
// Ensure the policy has exactly 3 fields
|
||||
if len(policy) != 3 {
|
||||
return nil, merrors.Internal("invalid grouping policy format")
|
||||
}
|
||||
|
||||
// Parse accountRef
|
||||
accountRef, err := bson.ObjectIDFromHex(policy[0])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid accountRef in grouping policy")
|
||||
}
|
||||
|
||||
// Parse roleDescriptionRef (roleRef)
|
||||
roleDescriptionRef, err := bson.ObjectIDFromHex(policy[1])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid roleRef in grouping policy")
|
||||
}
|
||||
|
||||
// Parse organizationRef
|
||||
organizationRef, err := bson.ObjectIDFromHex(policy[2])
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgument("invalid organizationRef in grouping policy")
|
||||
}
|
||||
|
||||
// Return the constructed Role object
|
||||
return &model.Role{
|
||||
AccountRef: accountRef,
|
||||
DescriptionRef: roleDescriptionRef,
|
||||
OrganizationRef: organizationRef,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package serialization
|
||||
|
||||
import (
|
||||
serializationimp "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization/internal"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
type Policy = CasbinSerializer[model.RolePolicy]
|
||||
|
||||
func NewPolicySerializer() Policy {
|
||||
return &serializationimp.PolicySerializer{}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package serialization
|
||||
|
||||
import (
|
||||
serializationimp "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization/internal"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
type Role = CasbinSerializer[model.Role]
|
||||
|
||||
func NewRoleSerializer() Role {
|
||||
return &serializationimp.RoleSerializer{}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package serialization
|
||||
|
||||
// CasbinSerializer defines methods for serializing and deserializing any Casbin-compatible entity.
|
||||
type CasbinSerializer[T any] interface {
|
||||
// Serialize converts an entity (Role or Permission) into a Casbin policy.
|
||||
Serialize(entity *T) ([]any, error)
|
||||
|
||||
// Deserialize converts a Casbin policy into an entity (Role or Permission).
|
||||
Deserialize(policy []string) (*T, error)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type PermissionsDBImp struct {
|
||||
template.DBImp[*nstructures.PolicyAssignment]
|
||||
}
|
||||
|
||||
func (db *PermissionsDBImp) Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) {
|
||||
return mutil.GetObjects[nstructures.PolicyAssignment](
|
||||
ctx,
|
||||
db.Logger,
|
||||
repository.Query().And(
|
||||
repository.Filter("policy.organizationRef", object.GetOrganizationRef()),
|
||||
repository.Filter("policy.descriptionRef", object.GetPermissionRef()),
|
||||
repository.Filter("policy.effect.action", action),
|
||||
repository.Query().Or(
|
||||
repository.Filter("policy.objectRef", *object.GetID()),
|
||||
repository.Filter("policy.objectRef", nil),
|
||||
),
|
||||
),
|
||||
nil,
|
||||
db.Repository,
|
||||
)
|
||||
}
|
||||
|
||||
func (db *PermissionsDBImp) PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) {
|
||||
return mutil.GetObjects[nstructures.PolicyAssignment](
|
||||
ctx,
|
||||
db.Logger,
|
||||
repository.Query().And(
|
||||
repository.Filter("roleRef", roleRef),
|
||||
repository.Filter("policy.descriptionRef", permissionRef),
|
||||
repository.Filter("policy.effect.action", action),
|
||||
),
|
||||
nil,
|
||||
db.Repository,
|
||||
)
|
||||
}
|
||||
|
||||
func (db *PermissionsDBImp) Remove(ctx context.Context, policy *model.RolePolicy) error {
|
||||
objRefFilter := repository.Query().Or(
|
||||
repository.Filter("policy.objectRef", nil),
|
||||
repository.Filter("policy.objectRef", bson.NilObjectID),
|
||||
)
|
||||
if policy.ObjectRef != nil {
|
||||
objRefFilter = repository.Filter("policy.objectRef", *policy.ObjectRef)
|
||||
}
|
||||
return db.Repository.DeleteMany(
|
||||
ctx,
|
||||
repository.Query().And(
|
||||
repository.Filter("roleRef", policy.RoleDescriptionRef),
|
||||
repository.Filter("policy.organizationRef", policy.OrganizationRef),
|
||||
repository.Filter("policy.descriptionRef", policy.DescriptionRef),
|
||||
objRefFilter,
|
||||
repository.Filter("policy.effect.action", policy.Effect.Action),
|
||||
repository.Filter("policy.effect.effect", policy.Effect.Effect),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (db *PermissionsDBImp) PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) {
|
||||
return mutil.GetObjects[nstructures.PolicyAssignment](
|
||||
ctx,
|
||||
db.Logger,
|
||||
repository.Filter("roleRef", roleRef),
|
||||
nil,
|
||||
db.Repository,
|
||||
)
|
||||
}
|
||||
|
||||
func (db *PermissionsDBImp) PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) {
|
||||
if len(roleRefs) == 0 {
|
||||
db.Logger.Debug("Empty role references list provided, returning empty resposnse")
|
||||
return []nstructures.PolicyAssignment{}, nil
|
||||
}
|
||||
return mutil.GetObjects[nstructures.PolicyAssignment](
|
||||
ctx,
|
||||
db.Logger,
|
||||
repository.Query().And(
|
||||
repository.Query().In(repository.Field("roleRef"), roleRefs),
|
||||
repository.Filter("policy.effect.action", action),
|
||||
),
|
||||
nil,
|
||||
db.Repository,
|
||||
)
|
||||
}
|
||||
|
||||
func NewPoliciesDB(logger mlogger.Logger, db *mongo.Database) (*PermissionsDBImp, error) {
|
||||
p := &PermissionsDBImp{
|
||||
DBImp: *template.Create[*nstructures.PolicyAssignment](logger, mservice.PolicyAssignements, db),
|
||||
}
|
||||
|
||||
// faster
|
||||
// harder
|
||||
// index
|
||||
policiesQueryIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "policy.organizationRef", Sort: ri.Asc},
|
||||
{Field: "policy.descriptionRef", Sort: ri.Asc},
|
||||
{Field: "policy.effect.action", Sort: ri.Asc},
|
||||
{Field: "policy.objectRef", Sort: ri.Asc},
|
||||
},
|
||||
}
|
||||
if err := p.Repository.CreateIndex(policiesQueryIndex); err != nil {
|
||||
p.Logger.Warn("Failed to prepare policies query index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roleBasedQueriesIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "roleRef", Sort: ri.Asc},
|
||||
{Field: "policy.effect.action", Sort: ri.Asc},
|
||||
},
|
||||
}
|
||||
if err := p.Repository.CreateIndex(roleBasedQueriesIndex); err != nil {
|
||||
p.Logger.Warn("Failed to prepare role based query index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uniquePolicyConstaint := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "policy.organizationRef", Sort: ri.Asc},
|
||||
{Field: "roleRef", Sort: ri.Asc},
|
||||
{Field: "policy.descriptionRef", Sort: ri.Asc},
|
||||
{Field: "policy.effect.action", Sort: ri.Asc},
|
||||
{Field: "policy.objectRef", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
}
|
||||
if err := p.Repository.CreateIndex(uniquePolicyConstaint); err != nil {
|
||||
p.Logger.Warn("Failed to unique policy assignment index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type RolesDBImp struct {
|
||||
template.DBImp[*nstructures.RoleAssignment]
|
||||
}
|
||||
|
||||
func (db *RolesDBImp) Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) {
|
||||
return mutil.GetObjects[nstructures.RoleAssignment](
|
||||
ctx,
|
||||
db.Logger,
|
||||
repository.Query().And(
|
||||
repository.Filter("role.accountRef", accountRef),
|
||||
repository.Filter("role.organizationRef", organizationRef),
|
||||
),
|
||||
nil,
|
||||
db.Repository,
|
||||
)
|
||||
}
|
||||
|
||||
func (db *RolesDBImp) RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) {
|
||||
return mutil.GetObjects[nstructures.RoleAssignment](
|
||||
ctx,
|
||||
db.Logger,
|
||||
repository.Query().And(
|
||||
repository.Filter("role.organizationRef", organizationRef),
|
||||
),
|
||||
nil,
|
||||
db.Repository,
|
||||
)
|
||||
}
|
||||
|
||||
func (db *RolesDBImp) DeleteRole(ctx context.Context, roleRef bson.ObjectID) error {
|
||||
return db.DeleteMany(
|
||||
ctx,
|
||||
repository.Query().And(
|
||||
repository.Filter("role.descriptionRef", roleRef),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (db *RolesDBImp) RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error {
|
||||
return db.DeleteMany(
|
||||
ctx,
|
||||
repository.Query().And(
|
||||
repository.Filter("role.accountRef", accountRef),
|
||||
repository.Filter("role.organizationRef", organizationRef),
|
||||
repository.Filter("role.descriptionRef", roleRef),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func NewRolesDB(logger mlogger.Logger, db *mongo.Database) (*RolesDBImp, error) {
|
||||
p := &RolesDBImp{
|
||||
DBImp: *template.Create[*nstructures.RoleAssignment](logger, "role_assignments", db),
|
||||
}
|
||||
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: "role.organizationRef", Sort: ri.Asc}},
|
||||
}); err != nil {
|
||||
p.Logger.Warn("Failed to prepare venue index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: "role.descriptionRef", Sort: ri.Asc}},
|
||||
}); err != nil {
|
||||
p.Logger.Warn("Failed to prepare role description index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uniqueRoleConstaint := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "role.organizationRef", Sort: ri.Asc},
|
||||
{Field: "role.accountRef", Sort: ri.Asc},
|
||||
{Field: "role.descriptionRef", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
}
|
||||
if err := p.Repository.CreateIndex(uniqueRoleConstaint); err != nil {
|
||||
p.Logger.Warn("Failed to prepare role assignment index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
type PoliciesDB interface {
|
||||
template.DB[*nstructures.PolicyAssignment]
|
||||
// plenty of interfaces for performance reasons
|
||||
Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error)
|
||||
PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error)
|
||||
PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error)
|
||||
PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error)
|
||||
Remove(ctx context.Context, policy *model.RolePolicy) error
|
||||
}
|
||||
|
||||
func NewPoliciesDBDB(logger mlogger.Logger, conn *mongo.Database) (PoliciesDB, error) {
|
||||
return db.NewPoliciesDB(logger, conn)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/db"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
type RolesDB interface {
|
||||
template.DB[*nstructures.RoleAssignment]
|
||||
Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error)
|
||||
RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error)
|
||||
RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error
|
||||
DeleteRole(ctx context.Context, roleRef bson.ObjectID) error
|
||||
}
|
||||
|
||||
func NewRolesDB(logger mlogger.Logger, conn *mongo.Database) (RolesDB, error) {
|
||||
return db.NewRolesDB(logger, conn)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Enforcer struct {
|
||||
logger mlogger.Logger
|
||||
pdb PoliciesDB
|
||||
rdb RolesDB
|
||||
}
|
||||
|
||||
func NewEnforcer(
|
||||
logger mlogger.Logger,
|
||||
db *mongo.Database,
|
||||
) (*Enforcer, error) {
|
||||
e := &Enforcer{logger: logger.Named("enforcer")}
|
||||
|
||||
var err error
|
||||
if e.pdb, err = NewPoliciesDBDB(e.logger, db); err != nil {
|
||||
e.logger.Warn("Failed to create permission assignments database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if e.rdb, err = NewRolesDB(e.logger, db); err != nil {
|
||||
e.logger.Warn("Failed to create role assignments database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Info("Native enforcer created")
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Enforce checks if a user has the specified action permission on an object within a domain.
|
||||
func (n *Enforcer) Enforce(
|
||||
ctx context.Context,
|
||||
permissionRef, accountRef, organizationRef, objectRef bson.ObjectID,
|
||||
action model.Action,
|
||||
) (bool, error) {
|
||||
if organizationRef.IsZero() {
|
||||
n.logger.Warn("Missing organization context", mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object", objectRef), zap.String("action", string(action)))
|
||||
return false, merrors.InvalidArgument("organization context missing", "organizationRef")
|
||||
}
|
||||
roleAssignments, err := n.rdb.Roles(ctx, accountRef, organizationRef)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
n.logger.Debug("No roles defined for account", mzap.AccRef(accountRef))
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
n.logger.Warn("Failed to fetch roles while checking permissions", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object", objectRef), zap.String("action", string(action)))
|
||||
return false, err
|
||||
}
|
||||
if len(roleAssignments) == 0 {
|
||||
n.logger.Warn("No roles found for account", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return false, merrors.Internal("No roles found for account " + accountRef.Hex())
|
||||
}
|
||||
allowFound := false // Track if any allow is found across roles
|
||||
|
||||
for _, roleAssignment := range roleAssignments {
|
||||
policies, err := n.pdb.PoliciesForPermissionAction(ctx, roleAssignment.DescriptionRef, permissionRef, action)
|
||||
if err != nil && !errors.Is(err, merrors.ErrNoData) {
|
||||
n.logger.Warn("Failed to fetch permissions", zap.Error(err), mzap.AccRef(accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, permission := range policies {
|
||||
if !policyAppliesToObject(permission.ObjectRef, objectRef) {
|
||||
continue // Scoped to a different object; it says nothing about this one.
|
||||
}
|
||||
|
||||
if permission.Effect.Effect == model.EffectDeny {
|
||||
n.logger.Debug("Found denying policy", mzap.ObjRef("account", accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
return false, nil // Deny takes precedence immediately
|
||||
}
|
||||
|
||||
if permission.Effect.Effect == model.EffectAllow {
|
||||
n.logger.Debug("Allowing policy found", mzap.ObjRef("account", accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
allowFound = true // At least one allow found
|
||||
} else {
|
||||
n.logger.Warn("Corrupted policy", mzap.StorableRef(&permission))
|
||||
return false, merrors.Internal("Corrupted action effect data for permissions entry " + permission.ID.Hex() + ": " + string(permission.Effect.Effect))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final decision based on whether any allow was found
|
||||
if allowFound {
|
||||
return true, nil // At least one allow and no deny
|
||||
}
|
||||
|
||||
n.logger.Debug("No allowing policy found", mzap.ObjRef("account", accountRef),
|
||||
mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef),
|
||||
mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action)))
|
||||
|
||||
return false, nil // No allow found, default deny
|
||||
}
|
||||
|
||||
// EnforceBatch checks a user’s permission for multiple objects at once.
|
||||
// It returns a map from objectRef -> boolean indicating whether access is granted.
|
||||
func (n *Enforcer) EnforceBatch(
|
||||
ctx context.Context,
|
||||
objectRefs []model.PermissionBoundStorable,
|
||||
accountRef bson.ObjectID,
|
||||
action model.Action,
|
||||
) (map[bson.ObjectID]bool, error) {
|
||||
results := make(map[bson.ObjectID]bool, len(objectRefs))
|
||||
|
||||
// Group objectRefs by organizationRef.
|
||||
objectsByVenue := make(map[bson.ObjectID][]model.PermissionBoundStorable)
|
||||
for _, obj := range objectRefs {
|
||||
organizationRef := obj.GetOrganizationRef()
|
||||
objectsByVenue[organizationRef] = append(objectsByVenue[organizationRef], obj)
|
||||
}
|
||||
|
||||
// Process each venue group separately.
|
||||
for organizationRef, objs := range objectsByVenue {
|
||||
// 1. Fetch roles once for this account and venue.
|
||||
roles, err := n.rdb.Roles(ctx, accountRef, organizationRef)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
n.logger.Debug("No roles defined for account", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
// With no roles, mark all objects in this venue as denied.
|
||||
for _, obj := range objs {
|
||||
results[*obj.GetID()] = false
|
||||
}
|
||||
// Continue to next venue
|
||||
continue
|
||||
}
|
||||
n.logger.Warn("Failed to fetch roles", zap.Error(err),
|
||||
mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Extract role description references
|
||||
var roleRefs []bson.ObjectID
|
||||
for _, role := range roles {
|
||||
roleRefs = append(roleRefs, role.DescriptionRef)
|
||||
}
|
||||
|
||||
// 3. Fetch all policies for these roles and the given action in one call.
|
||||
allPolicies, err := n.pdb.PoliciesForRoles(ctx, roleRefs, action)
|
||||
if err != nil {
|
||||
n.logger.Warn("Failed to fetch policies", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Build a lookup map keyed by PermissionRef.
|
||||
policyMap := make(map[bson.ObjectID][]nstructures.PolicyAssignment)
|
||||
for _, policy := range allPolicies {
|
||||
policyMap[policy.DescriptionRef] = append(policyMap[policy.DescriptionRef], policy)
|
||||
}
|
||||
|
||||
// 5. Evaluate permissions for each object in this venue group.
|
||||
for _, obj := range objs {
|
||||
permRef := obj.GetPermissionRef()
|
||||
allow := false
|
||||
if policies, ok := policyMap[permRef]; ok {
|
||||
for _, policy := range policies {
|
||||
if !policyAppliesToObject(policy.ObjectRef, *obj.GetID()) {
|
||||
continue // Scoped to a different object.
|
||||
}
|
||||
// Deny takes precedence.
|
||||
if policy.Effect.Effect == model.EffectDeny {
|
||||
allow = false
|
||||
break
|
||||
}
|
||||
if policy.Effect.Effect == model.EffectAllow {
|
||||
allow = true
|
||||
// Continue checking in case a deny exists among policies.
|
||||
} else {
|
||||
// should never get here
|
||||
return nil, merrors.Internal("Corrupted permissions effect in policy assignment '" + policy.GetID().Hex() + "': " + string(policy.Effect.Effect))
|
||||
}
|
||||
}
|
||||
}
|
||||
results[*obj.GetID()] = allow
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetRoles retrieves all roles assigned to the user within the domain.
|
||||
func (n *Enforcer) GetRoles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]model.Role, error) {
|
||||
n.logger.Debug("Fetching roles for user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
ra, err := n.rdb.Roles(ctx, accountRef, organizationRef)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
n.logger.Debug("No roles assigned to user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return []model.Role{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
n.logger.Warn("Failed to fetch roles", zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roles := make([]model.Role, len(ra))
|
||||
for i, roleAssignement := range ra {
|
||||
roles[i] = roleAssignement.Role
|
||||
}
|
||||
|
||||
n.logger.Debug("Fetched roles", zap.Int("roles_count", len(roles)))
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
func (n *Enforcer) Reload() error {
|
||||
n.logger.Info("Policies reloaded") // do nothing actually
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPermissions retrieves all effective policies for the user within the domain.
|
||||
func (n *Enforcer) GetPermissions(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]model.Role, []model.Permission, error) {
|
||||
n.logger.Debug("Fetching policies for user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
|
||||
roles, err := n.GetRoles(ctx, accountRef, organizationRef)
|
||||
if err != nil {
|
||||
n.logger.Warn("Failed to get roles", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
uniquePermissions := make(map[bson.ObjectID]model.Permission)
|
||||
for _, role := range roles {
|
||||
perms, err := n.pdb.PoliciesForRole(ctx, role.DescriptionRef)
|
||||
if err != nil {
|
||||
n.logger.Warn("Failed to get policies for role", zap.Error(err), mzap.ObjRef("role_ref", role.DescriptionRef))
|
||||
continue
|
||||
}
|
||||
n.logger.Debug("Policies fetched for role", mzap.ObjRef("role_ref", role.DescriptionRef), zap.Int("count", len(perms)))
|
||||
for _, p := range perms {
|
||||
uniquePermissions[*p.GetID()] = model.Permission{
|
||||
RolePolicy: model.RolePolicy{
|
||||
Policy: p.Policy,
|
||||
RoleDescriptionRef: p.RoleRef,
|
||||
},
|
||||
AccountRef: accountRef,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
permissionsSlice := make([]model.Permission, 0, len(uniquePermissions))
|
||||
for _, permission := range uniquePermissions {
|
||||
permissionsSlice = append(permissionsSlice, permission)
|
||||
}
|
||||
|
||||
n.logger.Debug("Policies fetched successfully", zap.Int("count", len(permissionsSlice)))
|
||||
return roles, permissionsSlice, nil
|
||||
}
|
||||
|
||||
// policyAppliesToObject reports whether a policy governs the object being
|
||||
// checked. A policy without an object reference — nil or the zero value — is a
|
||||
// wildcard covering every object of its resource type. A policy naming a
|
||||
// specific object says nothing about any other one, so it is skipped rather
|
||||
// than counted: otherwise an allow on one object would leak onto its siblings,
|
||||
// and a deny on one object would block them.
|
||||
func policyAppliesToObject(policyObjectRef *bson.ObjectID, objectRef bson.ObjectID) bool {
|
||||
if policyObjectRef == nil || policyObjectRef.IsZero() {
|
||||
return true
|
||||
}
|
||||
return *policyObjectRef == objectRef
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
factory "git.sendico.io/tech/sxchnge/bff/pkg/mlogger/factory"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// Mock implementations for testing
|
||||
type MockPoliciesDB struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) {
|
||||
args := m.Called(ctx, roleRef, permissionRef, action)
|
||||
return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) {
|
||||
args := m.Called(ctx, roleRef)
|
||||
return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) {
|
||||
args := m.Called(ctx, roleRefs, action)
|
||||
return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) {
|
||||
args := m.Called(ctx, object, action)
|
||||
return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Remove(ctx context.Context, policy *model.RolePolicy) error {
|
||||
args := m.Called(ctx, policy)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// Template DB methods - implement as needed for testing
|
||||
func (m *MockPoliciesDB) Create(ctx context.Context, assignment *nstructures.PolicyAssignment) error {
|
||||
args := m.Called(ctx, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Get(ctx context.Context, id bson.ObjectID, assignment *nstructures.PolicyAssignment) error {
|
||||
args := m.Called(ctx, id, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Update(ctx context.Context, assignment *nstructures.PolicyAssignment) error {
|
||||
args := m.Called(ctx, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error {
|
||||
args := m.Called(ctx, objectRef, patch)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Delete(ctx context.Context, id bson.ObjectID) error {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) DeleteMany(ctx context.Context, query builder.Query) error {
|
||||
args := m.Called(ctx, query)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) ListPermissionBound(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) {
|
||||
args := m.Called(ctx, accountRef, organizationRef)
|
||||
return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) ListIDs(ctx context.Context, query interface{}) ([]bson.ObjectID, error) {
|
||||
args := m.Called(ctx, query)
|
||||
return args.Get(0).([]bson.ObjectID), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) FindOne(ctx context.Context, query builder.Query, assignment *nstructures.PolicyAssignment) error {
|
||||
args := m.Called(ctx, query, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) List(ctx context.Context, query builder.Query) ([]nstructures.PolicyAssignment, error) {
|
||||
args := m.Called(ctx, query)
|
||||
return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) Name() string {
|
||||
return "mock_policies"
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) DeleteCascade(ctx context.Context, id bson.ObjectID) error {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPoliciesDB) InsertMany(ctx context.Context, objects []*nstructures.PolicyAssignment) error {
|
||||
args := m.Called(ctx, objects)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
type MockRolesDB struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) {
|
||||
args := m.Called(ctx, accountRef, organizationRef)
|
||||
return args.Get(0).([]nstructures.RoleAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) {
|
||||
args := m.Called(ctx, organizationRef)
|
||||
return args.Get(0).([]nstructures.RoleAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error {
|
||||
args := m.Called(ctx, roleRef, organizationRef, accountRef)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) DeleteRole(ctx context.Context, roleRef bson.ObjectID) error {
|
||||
args := m.Called(ctx, roleRef)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// Template DB methods - implement as needed for testing
|
||||
func (m *MockRolesDB) Create(ctx context.Context, assignment *nstructures.RoleAssignment) error {
|
||||
args := m.Called(ctx, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) Get(ctx context.Context, id bson.ObjectID, assignment *nstructures.RoleAssignment) error {
|
||||
args := m.Called(ctx, id, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) Update(ctx context.Context, assignment *nstructures.RoleAssignment) error {
|
||||
args := m.Called(ctx, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error {
|
||||
args := m.Called(ctx, objectRef, patch)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) Delete(ctx context.Context, id bson.ObjectID) error {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) DeleteMany(ctx context.Context, query builder.Query) error {
|
||||
args := m.Called(ctx, query)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) ListPermissionBound(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) {
|
||||
args := m.Called(ctx, accountRef, organizationRef)
|
||||
return args.Get(0).([]nstructures.RoleAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) ListIDs(ctx context.Context, query interface{}) ([]bson.ObjectID, error) {
|
||||
args := m.Called(ctx, query)
|
||||
return args.Get(0).([]bson.ObjectID), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) FindOne(ctx context.Context, query builder.Query, assignment *nstructures.RoleAssignment) error {
|
||||
args := m.Called(ctx, query, assignment)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) List(ctx context.Context, query builder.Query) ([]nstructures.RoleAssignment, error) {
|
||||
args := m.Called(ctx, query)
|
||||
return args.Get(0).([]nstructures.RoleAssignment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) Name() string {
|
||||
return "mock_roles"
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) DeleteCascade(ctx context.Context, id bson.ObjectID) error {
|
||||
args := m.Called(ctx, id)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockRolesDB) InsertMany(ctx context.Context, objects []*nstructures.RoleAssignment) error {
|
||||
args := m.Called(ctx, objects)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// Test helper functions
|
||||
func createTestObjectID() bson.ObjectID {
|
||||
return bson.NewObjectID()
|
||||
}
|
||||
|
||||
func createTestRoleAssignment(roleRef, accountRef, organizationRef bson.ObjectID) nstructures.RoleAssignment {
|
||||
return nstructures.RoleAssignment{
|
||||
Role: model.Role{
|
||||
AccountRef: accountRef,
|
||||
DescriptionRef: roleRef,
|
||||
OrganizationRef: organizationRef,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestPolicyAssignment(roleRef bson.ObjectID, effect model.Effect, organizationRef, descriptionRef bson.ObjectID, objectRef *bson.ObjectID) nstructures.PolicyAssignment {
|
||||
return nstructures.PolicyAssignment{
|
||||
Policy: model.Policy{
|
||||
OrganizationRef: organizationRef,
|
||||
DescriptionRef: descriptionRef,
|
||||
ObjectRef: objectRef,
|
||||
Effect: model.ActionEffect{
|
||||
Action: model.ActionRead,
|
||||
Effect: effect,
|
||||
},
|
||||
},
|
||||
RoleRef: roleRef,
|
||||
}
|
||||
}
|
||||
|
||||
func createTestEnforcer(pdb PoliciesDB, rdb RolesDB) *Enforcer {
|
||||
logger := factory.NewLogger(true)
|
||||
enforcer := &Enforcer{
|
||||
logger: logger.Named("test"),
|
||||
pdb: pdb,
|
||||
rdb: rdb,
|
||||
}
|
||||
return enforcer
|
||||
}
|
||||
|
||||
func TestEnforcer_Enforce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test data
|
||||
accountRef := createTestObjectID()
|
||||
organizationRef := createTestObjectID()
|
||||
permissionRef := createTestObjectID()
|
||||
objectRef := createTestObjectID()
|
||||
roleRef := createTestObjectID()
|
||||
|
||||
t.Run("Allow_SingleRole_SinglePolicy", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock policy assignment with ALLOW effect
|
||||
policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectRef)
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil)
|
||||
|
||||
// Create enforcer
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.True(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Deny_SingleRole_SinglePolicy", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock policy assignment with DENY effect
|
||||
policyAssignment := createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectRef)
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("DenyTakesPrecedence_MultipleRoles", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
role1Ref := createTestObjectID()
|
||||
role2Ref := createTestObjectID()
|
||||
|
||||
// Mock multiple role assignments
|
||||
roleAssignment1 := createTestRoleAssignment(role1Ref, accountRef, organizationRef)
|
||||
roleAssignment2 := createTestRoleAssignment(role2Ref, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment1, roleAssignment2}, nil)
|
||||
|
||||
// First role has ALLOW policy
|
||||
allowPolicy := createTestPolicyAssignment(role1Ref, model.EffectAllow, organizationRef, permissionRef, &objectRef)
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, role1Ref, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{allowPolicy}, nil)
|
||||
|
||||
// Second role has DENY policy - should take precedence
|
||||
denyPolicy := createTestPolicyAssignment(role2Ref, model.EffectDeny, organizationRef, permissionRef, &objectRef)
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, role2Ref, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{denyPolicy}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify - DENY should take precedence
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("NoRoles_ReturnsFalse", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock no roles found
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("EmptyRoles_ReturnsError", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock empty roles list (not NoData error)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.Error(t, err)
|
||||
assert.False(t, allowed)
|
||||
assert.Contains(t, err.Error(), "No roles found for account")
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("DatabaseError_RolesDB", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock database error
|
||||
dbError := merrors.Define("database connection failed")
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.Error(t, err)
|
||||
assert.False(t, allowed)
|
||||
assert.Equal(t, dbError, err)
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("DatabaseError_PoliciesDB", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock database error in policies
|
||||
dbError := merrors.Define("policies database error")
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{}, dbError)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.Error(t, err)
|
||||
assert.False(t, allowed)
|
||||
assert.Equal(t, dbError, err)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("NoPolicies_ReturnsFalse", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock no policies found
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{}, merrors.ErrNoData)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("CorruptedPolicy_ReturnsError", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock corrupted policy with invalid effect
|
||||
corruptedPolicy := createTestPolicyAssignment(roleRef, "invalid_effect", organizationRef, permissionRef, &objectRef)
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{corruptedPolicy}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.Error(t, err)
|
||||
assert.False(t, allowed)
|
||||
assert.Contains(t, err.Error(), "Corrupted action effect data")
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
// Mock implementation for PermissionBoundStorable
|
||||
type MockPermissionBoundStorable struct {
|
||||
id bson.ObjectID
|
||||
permissionRef bson.ObjectID
|
||||
organizationRef bson.ObjectID
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) GetID() *bson.ObjectID {
|
||||
return &m.id
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) GetPermissionRef() bson.ObjectID {
|
||||
return m.permissionRef
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) GetOrganizationRef() bson.ObjectID {
|
||||
return m.organizationRef
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) Collection() string {
|
||||
return "test_objects"
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) SetID(objID bson.ObjectID) {
|
||||
m.id = objID
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) Update() {
|
||||
// Do nothing for mock
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) SetPermissionRef(permissionRef bson.ObjectID) {
|
||||
m.permissionRef = permissionRef
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) SetOrganizationRef(organizationRef bson.ObjectID) {
|
||||
m.organizationRef = organizationRef
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) IsArchived() bool {
|
||||
return false // Default to not archived for testing
|
||||
}
|
||||
|
||||
func (m *MockPermissionBoundStorable) SetArchived(archived bool) {
|
||||
// No-op for testing
|
||||
}
|
||||
|
||||
func TestEnforcer_EnforceBatch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test data
|
||||
accountRef := createTestObjectID()
|
||||
organizationRef := createTestObjectID()
|
||||
permissionRef := createTestObjectID()
|
||||
roleRef := createTestObjectID()
|
||||
|
||||
// Create test objects
|
||||
object1 := &MockPermissionBoundStorable{
|
||||
id: createTestObjectID(),
|
||||
permissionRef: permissionRef,
|
||||
organizationRef: organizationRef,
|
||||
}
|
||||
object2 := &MockPermissionBoundStorable{
|
||||
id: createTestObjectID(),
|
||||
permissionRef: permissionRef,
|
||||
organizationRef: organizationRef,
|
||||
}
|
||||
|
||||
t.Run("BatchEnforce_MultipleObjects_SameVenue", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock policy assignment with ALLOW effect
|
||||
policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil)
|
||||
mockPDB.On("PoliciesForRoles", ctx, []bson.ObjectID{roleRef}, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute batch enforcement
|
||||
objects := []model.PermissionBoundStorable{object1, object2}
|
||||
results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 2)
|
||||
assert.True(t, results[object1.id])
|
||||
assert.True(t, results[object2.id])
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("BatchEnforce_NoRoles_AllObjectsDenied", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock no roles found
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute batch enforcement
|
||||
objects := []model.PermissionBoundStorable{object1, object2}
|
||||
results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 2)
|
||||
assert.False(t, results[object1.id])
|
||||
assert.False(t, results[object2.id])
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("BatchEnforce_DatabaseError", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock database error
|
||||
dbError := merrors.Define("database connection failed")
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute batch enforcement
|
||||
objects := []model.PermissionBoundStorable{object1, object2}
|
||||
results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead)
|
||||
|
||||
// Verify
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, results)
|
||||
assert.Equal(t, dbError, err)
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnforcer_GetRoles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test data
|
||||
accountRef := createTestObjectID()
|
||||
organizationRef := createTestObjectID()
|
||||
roleRef := createTestObjectID()
|
||||
|
||||
t.Run("GetRoles_Success", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
roles, err := enforcer.GetRoles(ctx, accountRef, organizationRef)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, roles, 1)
|
||||
assert.Equal(t, roleRef, roles[0].DescriptionRef)
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("GetRoles_NoRoles", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock no roles found
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
roles, err := enforcer.GetRoles(ctx, accountRef, organizationRef)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, roles, 0)
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnforcer_GetPermissions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test data
|
||||
accountRef := createTestObjectID()
|
||||
organizationRef := createTestObjectID()
|
||||
roleRef := createTestObjectID()
|
||||
|
||||
t.Run("GetPermissions_Success", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock policy assignment
|
||||
policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, createTestObjectID(), nil)
|
||||
mockPDB.On("PoliciesForRole", ctx, roleRef).Return([]nstructures.PolicyAssignment{policyAssignment}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
roles, permissions, err := enforcer.GetPermissions(ctx, accountRef, organizationRef)
|
||||
|
||||
// Verify
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, roles, 1)
|
||||
assert.Len(t, permissions, 1)
|
||||
assert.Equal(t, accountRef, permissions[0].AccountRef)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
// Security-focused test scenarios
|
||||
func TestEnforcer_SecurityScenarios(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test data
|
||||
accountRef := createTestObjectID()
|
||||
organizationRef := createTestObjectID()
|
||||
permissionRef := createTestObjectID()
|
||||
objectRef := createTestObjectID()
|
||||
roleRef := createTestObjectID()
|
||||
|
||||
t.Run("Security_DenyAlwaysWins", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock role assignment
|
||||
roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil)
|
||||
|
||||
// Mock multiple policies: both ALLOW and DENY
|
||||
allowPolicy := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectRef)
|
||||
denyPolicy := createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectRef)
|
||||
mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{allowPolicy, denyPolicy}, nil)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify - DENY should always win
|
||||
require.NoError(t, err)
|
||||
assert.False(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
mockPDB.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Security_InvalidObjectID", func(t *testing.T) {
|
||||
mockPDB := &MockPoliciesDB{}
|
||||
mockRDB := &MockRolesDB{}
|
||||
|
||||
// Mock database error for invalid ObjectID
|
||||
dbError := merrors.Define("invalid ObjectID")
|
||||
mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError)
|
||||
|
||||
enforcer := createTestEnforcer(mockPDB, mockRDB)
|
||||
|
||||
// Execute with invalid ObjectID
|
||||
allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead)
|
||||
|
||||
// Verify - should fail securely
|
||||
require.Error(t, err)
|
||||
assert.False(t, allowed)
|
||||
mockRDB.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
// Note: This test provides comprehensive coverage of the native enforcer including:
|
||||
// 1. Basic enforcement logic with deny-takes-precedence
|
||||
// 2. Batch operations for performance
|
||||
// 3. Role and permission retrieval
|
||||
// 4. Security scenarios and edge cases
|
||||
// 5. Error handling and database failures
|
||||
// 6. All critical security paths are tested
|
||||
@@ -0,0 +1,57 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/role"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// NativeManager implements the auth.Manager interface by aggregating Role and Permission managers.
|
||||
type NativeManager struct {
|
||||
logger mlogger.Logger
|
||||
roleManager management.Role
|
||||
permManager management.Permission
|
||||
}
|
||||
|
||||
// NewManager creates a new CasbinManager with specified domains and role-domain mappings.
|
||||
func NewManager(
|
||||
l mlogger.Logger,
|
||||
pdb policy.DB,
|
||||
rdb role.DB,
|
||||
enforcer *Enforcer,
|
||||
) (*NativeManager, error) {
|
||||
logger := l.Named("manager")
|
||||
|
||||
var pdesc model.PolicyDescription
|
||||
if err := pdb.GetBuiltInPolicy(context.Background(), "roles", &pdesc); err != nil {
|
||||
logger.Warn("Failed to fetch roles permission reference", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &NativeManager{
|
||||
logger: logger,
|
||||
roleManager: NewRoleManager(logger, enforcer, pdesc.ID, rdb),
|
||||
permManager: NewPermissionManager(logger, enforcer),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Permission returns the Permission manager.
|
||||
func (m *NativeManager) Permission() management.Permission {
|
||||
return m.permManager
|
||||
}
|
||||
|
||||
// Role returns the Role manager.
|
||||
func (m *NativeManager) Role() management.Role {
|
||||
return m.roleManager
|
||||
}
|
||||
|
||||
// TransactionalGrants reports that native grants are plain database writes:
|
||||
// they join the caller's transaction context and roll back with it.
|
||||
func (m *NativeManager) TransactionalGrants() bool {
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package nstructures
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type PolicyAssignment struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.Policy `bson:"policy" json:"policy"`
|
||||
RoleRef bson.ObjectID `bson:"roleRef" json:"roleRef"`
|
||||
}
|
||||
|
||||
func (*PolicyAssignment) Collection() string {
|
||||
return "permission_assignments"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package nstructures
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
type RoleAssignment struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.Role `bson:"role" json:"role"`
|
||||
}
|
||||
|
||||
func (*RoleAssignment) Collection() string {
|
||||
return "role_assignments"
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestEnforcer_EnforceHonorsObjectScope(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
organizationRef := bson.NewObjectID()
|
||||
permissionRef := bson.NewObjectID()
|
||||
roleRef := bson.NewObjectID()
|
||||
objectA := bson.NewObjectID()
|
||||
objectB := bson.NewObjectID()
|
||||
wildcard := bson.NilObjectID
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
policies []nstructures.PolicyAssignment
|
||||
objectRef bson.ObjectID
|
||||
wantAccess bool
|
||||
}{
|
||||
{
|
||||
name: "matching object allow",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA),
|
||||
},
|
||||
objectRef: objectA,
|
||||
wantAccess: true,
|
||||
},
|
||||
{
|
||||
name: "different object does not inherit allow",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA),
|
||||
},
|
||||
objectRef: objectB,
|
||||
wantAccess: false,
|
||||
},
|
||||
{
|
||||
name: "nil object is wildcard",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil),
|
||||
},
|
||||
objectRef: objectB,
|
||||
wantAccess: true,
|
||||
},
|
||||
{
|
||||
name: "zero object is wildcard",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &wildcard),
|
||||
},
|
||||
objectRef: objectB,
|
||||
wantAccess: true,
|
||||
},
|
||||
{
|
||||
name: "unrelated object deny does not override wildcard allow",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil),
|
||||
createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA),
|
||||
},
|
||||
objectRef: objectB,
|
||||
wantAccess: true,
|
||||
},
|
||||
{
|
||||
name: "matching object deny overrides wildcard allow",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil),
|
||||
createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA),
|
||||
},
|
||||
objectRef: objectA,
|
||||
wantAccess: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard deny overrides matching object allow",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA),
|
||||
createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, nil),
|
||||
},
|
||||
objectRef: objectA,
|
||||
wantAccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
roles := &MockRolesDB{}
|
||||
policies := &MockPoliciesDB{}
|
||||
role := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
roles.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{role}, nil)
|
||||
policies.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return(test.policies, nil)
|
||||
|
||||
allowed, err := createTestEnforcer(policies, roles).Enforce(
|
||||
ctx,
|
||||
permissionRef,
|
||||
accountRef,
|
||||
organizationRef,
|
||||
test.objectRef,
|
||||
model.ActionRead,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.wantAccess, allowed)
|
||||
roles.AssertExpectations(t)
|
||||
policies.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforcer_EnforceBatchHonorsObjectScope(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
accountRef := bson.NewObjectID()
|
||||
organizationRef := bson.NewObjectID()
|
||||
permissionRef := bson.NewObjectID()
|
||||
roleRef := bson.NewObjectID()
|
||||
objectA := &MockPermissionBoundStorable{
|
||||
id: bson.NewObjectID(),
|
||||
permissionRef: permissionRef,
|
||||
organizationRef: organizationRef,
|
||||
}
|
||||
objectB := &MockPermissionBoundStorable{
|
||||
id: bson.NewObjectID(),
|
||||
permissionRef: permissionRef,
|
||||
organizationRef: organizationRef,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
policies []nstructures.PolicyAssignment
|
||||
want map[bson.ObjectID]bool
|
||||
}{
|
||||
{
|
||||
name: "specific allow applies only to matching object",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA.id),
|
||||
},
|
||||
want: map[bson.ObjectID]bool{objectA.id: true, objectB.id: false},
|
||||
},
|
||||
{
|
||||
name: "specific deny affects only matching object",
|
||||
policies: []nstructures.PolicyAssignment{
|
||||
createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil),
|
||||
createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA.id),
|
||||
},
|
||||
want: map[bson.ObjectID]bool{objectA.id: false, objectB.id: true},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
roles := &MockRolesDB{}
|
||||
policies := &MockPoliciesDB{}
|
||||
role := createTestRoleAssignment(roleRef, accountRef, organizationRef)
|
||||
roles.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{role}, nil)
|
||||
policies.On("PoliciesForRoles", ctx, []bson.ObjectID{roleRef}, model.ActionRead).Return(test.policies, nil)
|
||||
|
||||
result, err := createTestEnforcer(policies, roles).EnforceBatch(
|
||||
ctx,
|
||||
[]model.PermissionBoundStorable{objectA, objectB},
|
||||
accountRef,
|
||||
model.ActionRead,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.want, result)
|
||||
roles.AssertExpectations(t)
|
||||
policies.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// PermissionManager manages permissions using Casbin.
|
||||
type PermissionManager struct {
|
||||
logger mlogger.Logger
|
||||
enforcer *Enforcer
|
||||
}
|
||||
|
||||
// GrantToRole adds a permission to a role in Casbin.
|
||||
func (m *PermissionManager) GrantToRole(ctx context.Context, policy *model.RolePolicy) error {
|
||||
objRef := "any"
|
||||
if (policy.ObjectRef != nil) && (*policy.ObjectRef != bson.NilObjectID) {
|
||||
objRef = policy.ObjectRef.Hex()
|
||||
}
|
||||
|
||||
m.logger.Debug("Granting permission to role", mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef),
|
||||
zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)),
|
||||
)
|
||||
|
||||
assignment := nstructures.PolicyAssignment{
|
||||
Policy: policy.Policy,
|
||||
RoleRef: policy.RoleDescriptionRef,
|
||||
}
|
||||
if err := m.enforcer.pdb.Create(ctx, &assignment); err != nil {
|
||||
m.logger.Warn("Failed to grant policy", zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef),
|
||||
zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeFromRole removes a permission from a role in Casbin.
|
||||
func (m *PermissionManager) RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error {
|
||||
objRef := "*"
|
||||
if policy.ObjectRef != nil {
|
||||
objRef = policy.ObjectRef.Hex()
|
||||
}
|
||||
m.logger.Debug("Revoking permission from role", mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef),
|
||||
zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)),
|
||||
)
|
||||
if err := m.enforcer.pdb.Remove(ctx, policy); err != nil {
|
||||
m.logger.Warn("Failed to revoke policy", zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef),
|
||||
mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef),
|
||||
zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPolicies retrieves all policies for a specific role.
|
||||
func (m *PermissionManager) GetPolicies(
|
||||
ctx context.Context,
|
||||
roleRef bson.ObjectID,
|
||||
) ([]model.RolePolicy, error) {
|
||||
m.logger.Debug("Fetching policies for role", mzap.ObjRef("role_ref", roleRef))
|
||||
|
||||
assinments, err := m.enforcer.pdb.PoliciesForRole(ctx, roleRef)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
m.logger.Debug("No policies found", mzap.ObjRef("role_ref", roleRef))
|
||||
return []model.RolePolicy{}, nil
|
||||
}
|
||||
policies := make([]model.RolePolicy, len(assinments))
|
||||
for i, assinment := range assinments {
|
||||
policies[i] = model.RolePolicy{
|
||||
Policy: assinment.Policy,
|
||||
RoleDescriptionRef: assinment.RoleRef,
|
||||
}
|
||||
}
|
||||
m.logger.Debug("Policies fetched successfully", mzap.ObjRef("role_ref", roleRef), zap.Int("count", len(policies)))
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// Save persists changes to the Casbin policy store.
|
||||
func (m *PermissionManager) Save() error {
|
||||
m.logger.Info("Policies successfully saved") // do nothing
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewPermissionManager(logger mlogger.Logger, enforcer *Enforcer) *PermissionManager {
|
||||
return &PermissionManager{
|
||||
logger: logger.Named("permission"),
|
||||
enforcer: enforcer,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/role"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RoleManager manages roles using Casbin.
|
||||
type RoleManager struct {
|
||||
logger mlogger.Logger
|
||||
enforcer *Enforcer
|
||||
rdb role.DB
|
||||
rolePermissionRef bson.ObjectID
|
||||
}
|
||||
|
||||
// NewRoleManager creates a new RoleManager.
|
||||
func NewRoleManager(logger mlogger.Logger, enforcer *Enforcer, rolePermissionRef bson.ObjectID, rdb role.DB) *RoleManager {
|
||||
return &RoleManager{
|
||||
logger: logger.Named("role"),
|
||||
enforcer: enforcer,
|
||||
rdb: rdb,
|
||||
rolePermissionRef: rolePermissionRef,
|
||||
}
|
||||
}
|
||||
|
||||
// validateObjectIDs ensures that all provided ObjectIDs are non-zero.
|
||||
func (rm *RoleManager) validateObjectIDs(ids ...bson.ObjectID) error {
|
||||
for _, id := range ids {
|
||||
if id.IsZero() {
|
||||
return merrors.InvalidArgument("Object references cannot be zero", "objectRef")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchRolesFromPolicies retrieves and converts policies to roles.
|
||||
func (rm *RoleManager) fetchRolesFromPolicies(roles []nstructures.RoleAssignment, organizationRef bson.ObjectID) []model.RoleDescription {
|
||||
result := make([]model.RoleDescription, len(roles))
|
||||
for i, role := range roles {
|
||||
result[i] = model.RoleDescription{
|
||||
Base: storable.Base{ID: *role.GetID()},
|
||||
OrganizationRef: organizationRef,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Create creates a new role in an organization.
|
||||
func (rm *RoleManager) Create(ctx context.Context, organizationRef bson.ObjectID, description *model.Describable) (*model.RoleDescription, error) {
|
||||
if err := rm.validateObjectIDs(organizationRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role := &model.RoleDescription{
|
||||
OrganizationRef: organizationRef,
|
||||
Describable: *description,
|
||||
}
|
||||
if err := rm.rdb.Create(ctx, role); err != nil {
|
||||
rm.logger.Warn("Failed to create role", zap.Error(err), mzap.OrgRef(organizationRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rm.logger.Info("Role created successfully", mzap.StorableRef(role), mzap.OrgRef(organizationRef))
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// Assign assigns a role to a user in the given organization.
|
||||
func (rm *RoleManager) Assign(ctx context.Context, role *model.Role) error {
|
||||
if err := rm.validateObjectIDs(role.DescriptionRef, role.AccountRef, role.OrganizationRef); err != nil {
|
||||
return err
|
||||
}
|
||||
assogment := nstructures.RoleAssignment{Role: *role}
|
||||
err := rm.enforcer.rdb.Create(ctx, &assogment)
|
||||
return rm.logPolicyResult("assign", err == nil, err, role.DescriptionRef, role.AccountRef, role.OrganizationRef)
|
||||
}
|
||||
|
||||
// Delete removes a role entirely and cleans up associated Casbin policies.
|
||||
func (rm *RoleManager) Delete(ctx context.Context, roleRef bson.ObjectID) error {
|
||||
if err := rm.validateObjectIDs(roleRef); err != nil {
|
||||
rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rm.rdb.Delete(ctx, roleRef); err != nil {
|
||||
rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rm.enforcer.rdb.DeleteRole(ctx, roleRef); err != nil {
|
||||
rm.logger.Warn("Failed to remove role", zap.Error(err), mzap.ObjRef("role_ref", roleRef))
|
||||
return err
|
||||
}
|
||||
|
||||
rm.logger.Info("Role deleted successfully along with associated policies", mzap.ObjRef("role_ref", roleRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Revoke removes a role from a user.
|
||||
func (rm *RoleManager) Revoke(ctx context.Context, roleRef, accountRef, organizationRef bson.ObjectID) error {
|
||||
if err := rm.validateObjectIDs(roleRef, accountRef, organizationRef); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := rm.enforcer.rdb.RemoveRole(ctx, roleRef, organizationRef, accountRef)
|
||||
return rm.logPolicyResult("revoke", err == nil, err, roleRef, accountRef, organizationRef)
|
||||
}
|
||||
|
||||
// logPolicyResult logs results for Assign and Revoke.
|
||||
func (rm *RoleManager) logPolicyResult(action string, result bool, err error, roleRef, accountRef, organizationRef bson.ObjectID) error {
|
||||
if err != nil {
|
||||
rm.logger.Warn("Failed to "+action+" role", zap.Error(err), mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return err
|
||||
}
|
||||
msg := "Role " + action + "ed successfully"
|
||||
if !result {
|
||||
msg = "Role already " + action + "ed"
|
||||
}
|
||||
rm.logger.Info(msg, mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List retrieves all roles in an organization or all roles if organizationRef is zero.
|
||||
func (rm *RoleManager) List(ctx context.Context, organizationRef bson.ObjectID) ([]model.RoleDescription, error) {
|
||||
roles4Venues, err := rm.enforcer.rdb.RolesForVenue(ctx, organizationRef)
|
||||
if err != nil {
|
||||
rm.logger.Warn("Failed to fetch grouping policies", zap.Error(err), mzap.OrgRef(organizationRef))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
roles := rm.fetchRolesFromPolicies(roles4Venues, organizationRef)
|
||||
rm.logger.Info("Retrieved roles for organization", mzap.OrgRef(organizationRef), zap.Int("count", len(roles)))
|
||||
return roles, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Permission interface {
|
||||
// Grant a permission to a role with an optional object scope and specified effect.
|
||||
// Use bson.NilObjectID for 'any' objectRef.
|
||||
GrantToRole(ctx context.Context, policy *model.RolePolicy) error
|
||||
|
||||
// Revoke a permission from a role with an optional object scope and specified effect.
|
||||
// Use bson.NilObjectID for 'any' objectRef.
|
||||
RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error
|
||||
|
||||
// Retrieve all policies assigned to a specific role, including scope and effects.
|
||||
GetPolicies(
|
||||
ctx context.Context,
|
||||
roleRef bson.ObjectID,
|
||||
) ([]model.RolePolicy, error)
|
||||
|
||||
// Persist any changes made to permissions.
|
||||
Save() error
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type Role interface {
|
||||
// Create a new role in an organization (returns the created Role with its ID).
|
||||
Create(
|
||||
ctx context.Context,
|
||||
orgRef bson.ObjectID,
|
||||
description *model.Describable,
|
||||
) (*model.RoleDescription, error)
|
||||
|
||||
// Delete a role entirely. This will cascade and remove all associated
|
||||
Delete(
|
||||
ctx context.Context,
|
||||
roleRef bson.ObjectID,
|
||||
) error
|
||||
|
||||
// Assign a role to a user in a specific organization.
|
||||
Assign(
|
||||
ctx context.Context,
|
||||
role *model.Role,
|
||||
) error
|
||||
|
||||
// Revoke a role from a user in a specific organization.
|
||||
Revoke(
|
||||
ctx context.Context,
|
||||
roleRef, accountRef, orgRef bson.ObjectID,
|
||||
) error
|
||||
|
||||
// List all roles in an organization or globally if orgRef is bson.NilObjectID.
|
||||
List(
|
||||
ctx context.Context,
|
||||
orgRef bson.ObjectID,
|
||||
) ([]model.RoleDescription, error)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
|
||||
)
|
||||
|
||||
// Manager provides access to domain-aware Permission and Role managers.
|
||||
type Manager interface {
|
||||
// Permission returns a manager that handles permission grants/revokes
|
||||
// for a specific resource type. (You might add domainRef here if desired.)
|
||||
Permission() management.Permission
|
||||
|
||||
// Role returns the domain-aware Role manager.
|
||||
Role() management.Role
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// denyImmutablePatch returns ErrInvalidArg if patch touches any field whose
|
||||
// mutation would silently undermine future access decisions.
|
||||
func denyImmutablePatch(patch builder.Patch) error {
|
||||
if patch == nil {
|
||||
return nil
|
||||
}
|
||||
for _, op := range patch.Build() {
|
||||
fields, ok := op.Value.(bson.D)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, f := range fields {
|
||||
if f.Key == "owner" || strings.HasPrefix(f.Key, "owner.") ||
|
||||
f.Key == "organizationRef" || f.Key == "permissionRef" {
|
||||
return merrors.InvalidArgumentf("patch field %q is immutable", f.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
)
|
||||
|
||||
type Provider interface {
|
||||
Enforcer() Enforcer
|
||||
Manager() Manager
|
||||
GetPolicyDescription(ctx context.Context, resource mservice.Type) (*model.PolicyDescription, error)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// ScopedAccessParams bundles everything needed to authorize an action against a
|
||||
// resource that follows the personal-vs-org ownership model.
|
||||
//
|
||||
// It is deliberately a plain struct (not an interface over a model) so it works
|
||||
// both for locally-stored objects and for objects whose ownership is learned
|
||||
// from a downstream service response.
|
||||
type ScopedAccessParams struct {
|
||||
// Service is the resource type, used only to build the access-denied error.
|
||||
Service mservice.Type
|
||||
// PolicyRef is the org policy consulted when the object is org-owned.
|
||||
PolicyRef bson.ObjectID
|
||||
// CallerRef is the subject taken from the auth token. Never from the request body.
|
||||
CallerRef bson.ObjectID
|
||||
// OrgRef is the organization the object belongs to. For org-owned objects
|
||||
// caller membership in it is checked before policy enforcement. Personal
|
||||
// objects are deliberately exempt from the membership check so the owner
|
||||
// keeps access after leaving the organization; membership for personal
|
||||
// objects is enforced once, at create time (see ScopedDBImp.Create).
|
||||
OrgRef bson.ObjectID
|
||||
// ObjectRef is the concrete object being accessed. Passing it enables
|
||||
// object-level checks in the Enforcer; use bson.NilObjectID for scope-only.
|
||||
ObjectRef bson.ObjectID
|
||||
// OwnerKind / OwnerRef describe the object's actual ownership, read FROM the
|
||||
// object — not derived from the request.
|
||||
OwnerKind model.OwnerKind
|
||||
OwnerRef bson.ObjectID
|
||||
// Action is the operation being authorized.
|
||||
Action model.Action
|
||||
}
|
||||
|
||||
// enforceOwnershipAccess decides access based on ownership and policy only.
|
||||
// It does NOT check organization membership — the caller must verify membership
|
||||
// before invoking this. Use ScopedAuthorizer.Authorize for the full
|
||||
// membership-aware access check.
|
||||
//
|
||||
// - personal (OwnerKindAccount): allowed iff OwnerRef == CallerRef.
|
||||
// - org (OwnerKindOrganization): allowed iff the Enforcer grants Action within OrgRef.
|
||||
// - anything else: denied by default.
|
||||
func enforceOwnershipAccess(ctx context.Context, enf Enforcer, p ScopedAccessParams) error {
|
||||
switch p.OwnerKind {
|
||||
case model.OwnerKindAccount:
|
||||
if p.OwnerRef == p.CallerRef {
|
||||
return nil
|
||||
}
|
||||
return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef)
|
||||
|
||||
case model.OwnerKindOrganization:
|
||||
ok, err := enf.Enforce(ctx, p.PolicyRef, p.CallerRef, p.OrgRef, p.ObjectRef, p.Action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef)
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// fakeEnforcer records the last call and returns canned results, so the scoped
|
||||
// access rules can be exercised without a real policy backend.
|
||||
type fakeEnforcer struct {
|
||||
allow bool
|
||||
err error
|
||||
called bool
|
||||
|
||||
gotPolicyRef bson.ObjectID
|
||||
gotAccount bson.ObjectID
|
||||
gotOrg bson.ObjectID
|
||||
gotObject bson.ObjectID
|
||||
gotAction model.Action
|
||||
}
|
||||
|
||||
func (f *fakeEnforcer) Enforce(_ context.Context, permissionRef, accountRef, orgRef, objectRef bson.ObjectID, action model.Action) (bool, error) {
|
||||
f.called = true
|
||||
f.gotPolicyRef, f.gotAccount, f.gotOrg, f.gotObject, f.gotAction = permissionRef, accountRef, orgRef, objectRef, action
|
||||
return f.allow, f.err
|
||||
}
|
||||
|
||||
func (f *fakeEnforcer) EnforceBatch(context.Context, []model.PermissionBoundStorable, bson.ObjectID, model.Action) (map[bson.ObjectID]bool, error) {
|
||||
return map[bson.ObjectID]bool{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeEnforcer) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeEnforcer) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func TestEnforceOwnershipAccess(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
other := bson.NewObjectID()
|
||||
org := bson.NewObjectID()
|
||||
object := bson.NewObjectID()
|
||||
policy := bson.NewObjectID()
|
||||
|
||||
base := func(kind model.OwnerKind, owner bson.ObjectID) ScopedAccessParams {
|
||||
return ScopedAccessParams{
|
||||
Service: mservice.Recipients,
|
||||
PolicyRef: policy,
|
||||
CallerRef: caller,
|
||||
OrgRef: org,
|
||||
ObjectRef: object,
|
||||
OwnerKind: kind,
|
||||
OwnerRef: owner,
|
||||
Action: model.ActionRead,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("personal owner is allowed without policy", func(t *testing.T) {
|
||||
enf := &fakeEnforcer{}
|
||||
if err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindAccount, caller)); err != nil {
|
||||
t.Fatalf("expected allow, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("personal access must not consult the enforcer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("personal non-owner is denied without policy", func(t *testing.T) {
|
||||
enf := &fakeEnforcer{}
|
||||
err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindAccount, other))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("personal access must not consult the enforcer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("org owner consults policy and passes object ref", func(t *testing.T) {
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
if err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org)); err != nil {
|
||||
t.Fatalf("expected allow, got %v", err)
|
||||
}
|
||||
if !enf.called {
|
||||
t.Fatal("org access must consult the enforcer")
|
||||
}
|
||||
if enf.gotObject != object || enf.gotOrg != org || enf.gotAccount != caller || enf.gotPolicyRef != policy {
|
||||
t.Fatalf("enforcer received unexpected args: %+v", enf)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("org owner denied when policy refuses", func(t *testing.T) {
|
||||
enf := &fakeEnforcer{allow: false}
|
||||
err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("org policy error is propagated, not masked as denial", func(t *testing.T) {
|
||||
boom := merrors.Define("backend down")
|
||||
enf := &fakeEnforcer{err: boom}
|
||||
err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org))
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected underlying error, got %v", err)
|
||||
}
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatal("internal error must not be reported as access denied")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown owner kind is denied by default", func(t *testing.T) {
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKind("garbage"), caller))
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("unknown kind must not consult the enforcer")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ownerLoader is the minimal DB contract that ScopedDB needs: fetch a resource
|
||||
// by ID and populate the caller-supplied result. *template.DBImp[T] satisfies
|
||||
// this interface without any adapter code.
|
||||
type ownerLoader[T any] interface {
|
||||
Get(ctx context.Context, objectRef bson.ObjectID, result T) error
|
||||
}
|
||||
|
||||
// ScopedDB[T] provides loadAndEnforce semantics for resources that implement
|
||||
// model.OwnerBound. It loads the resource from the database (so ownership is
|
||||
// always read from the authoritative source, never from request fields) and
|
||||
// then delegates access control to ScopedAuthorizer, which checks membership
|
||||
// before ownership/policy enforcement.
|
||||
//
|
||||
// Use ProtectedDB for resources that carry no ownerKind/ownerRef and rely
|
||||
// solely on org-level policy checks.
|
||||
type ScopedDB[T model.OwnerBound] struct {
|
||||
// DB is the raw repository accessor. *template.DBImp[T] satisfies this.
|
||||
DB ownerLoader[T]
|
||||
// Logger receives Debug/Warn messages from the enforcement path.
|
||||
Logger mlogger.Logger
|
||||
// Authorizer performs the full membership-aware access check.
|
||||
Authorizer *ScopedAuthorizer
|
||||
// OrgPolicyRef is the built-in policy checked when ownerKind==organization.
|
||||
OrgPolicyRef bson.ObjectID
|
||||
// Collection identifies the resource type in access-denied errors and logs.
|
||||
Collection mservice.Type
|
||||
// New allocates a zero-value T ready to be filled by DB.Get.
|
||||
New func() T
|
||||
}
|
||||
|
||||
// NewScopedDB constructs a ScopedDB and validates required dependencies.
|
||||
// Logger defaults to zap.NewNop() if nil.
|
||||
func NewScopedDB[T model.OwnerBound](
|
||||
db ownerLoader[T],
|
||||
authorizer *ScopedAuthorizer,
|
||||
orgPolicyRef bson.ObjectID,
|
||||
collection mservice.Type,
|
||||
newFn func() T,
|
||||
logger mlogger.Logger,
|
||||
) *ScopedDB[T] {
|
||||
if db == nil {
|
||||
panic("auth.NewScopedDB: DB must not be nil")
|
||||
}
|
||||
if authorizer == nil {
|
||||
panic("auth.NewScopedDB: Authorizer must not be nil")
|
||||
}
|
||||
if newFn == nil {
|
||||
panic("auth.NewScopedDB: New must not be nil")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &ScopedDB[T]{
|
||||
DB: db,
|
||||
Logger: logger,
|
||||
Authorizer: authorizer,
|
||||
OrgPolicyRef: orgPolicyRef,
|
||||
Collection: collection,
|
||||
New: newFn,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAndEnforce fetches the resource identified by objectRef, reads the
|
||||
// ownerKind and ownerRef fields from the database record, and enforces access
|
||||
// via ScopedAuthorizer (membership check → ownership/policy check). It never
|
||||
// trusts ownership data from the caller.
|
||||
//
|
||||
// Logging contract:
|
||||
// - Debug on access-check start and after ownership is loaded from DB.
|
||||
// - Debug when access is denied (ErrAccessDenied) — intentional, not an error.
|
||||
// - Warn when the Enforcer itself returns a non-nil error (unexpected failure).
|
||||
// - No log on success.
|
||||
func (s *ScopedDB[T]) LoadAndEnforce(ctx context.Context, callerRef, objectRef bson.ObjectID, action model.Action) (T, error) {
|
||||
var zero T
|
||||
|
||||
s.Logger.Debug("access check started",
|
||||
mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.String("collection", s.Collection), zap.String("action", string(action)))
|
||||
|
||||
obj := s.New()
|
||||
if err := s.DB.Get(ctx, objectRef, obj); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
|
||||
s.Logger.Debug("ownership loaded from db",
|
||||
mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.String("ownerKind", string(obj.GetOwnerKind())))
|
||||
|
||||
err := s.Authorizer.Authorize(ctx, ScopedAccessParams{
|
||||
Service: s.Collection,
|
||||
PolicyRef: s.OrgPolicyRef,
|
||||
CallerRef: callerRef,
|
||||
OrgRef: obj.GetOrganizationRef(),
|
||||
ObjectRef: objectRef,
|
||||
OwnerKind: obj.GetOwnerKind(),
|
||||
OwnerRef: obj.GetOwnerRef(),
|
||||
Action: action,
|
||||
})
|
||||
if err == nil {
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
s.Logger.Debug("access denied",
|
||||
mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.String("ownerKind", string(obj.GetOwnerKind())),
|
||||
zap.String("action", string(action)))
|
||||
} else {
|
||||
s.Logger.Warn("enforcer returned error during scoped access check",
|
||||
zap.Error(err), mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef),
|
||||
zap.String("action", string(action)))
|
||||
}
|
||||
return zero, err
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// testOwned is a minimal model.OwnerBound used exclusively by ScopedDB tests.
|
||||
type testOwned struct {
|
||||
id bson.ObjectID
|
||||
orgRef bson.ObjectID
|
||||
kind model.OwnerKind
|
||||
ownerRef bson.ObjectID
|
||||
}
|
||||
|
||||
func (o *testOwned) GetID() *bson.ObjectID { return &o.id }
|
||||
func (o *testOwned) SetID(id bson.ObjectID) { o.id = id }
|
||||
func (o *testOwned) Update() {}
|
||||
func (o *testOwned) Collection() string { return "test_owned" }
|
||||
func (o *testOwned) GetOrganizationRef() bson.ObjectID { return o.orgRef }
|
||||
func (o *testOwned) SetOrganizationRef(ref bson.ObjectID) { o.orgRef = ref }
|
||||
func (o *testOwned) GetOwnerKind() model.OwnerKind { return o.kind }
|
||||
func (o *testOwned) GetOwnerRef() bson.ObjectID { return o.ownerRef }
|
||||
|
||||
var _ model.OwnerBound = (*testOwned)(nil)
|
||||
|
||||
// fakeOwnerLoader returns the pre-built object or the configured error.
|
||||
type fakeOwnerLoader struct {
|
||||
obj *testOwned
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeOwnerLoader) Get(_ context.Context, _ bson.ObjectID, result *testOwned) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
*result = *f.obj
|
||||
return nil
|
||||
}
|
||||
|
||||
// fakeMembership controls whether IsMember returns true/false/error.
|
||||
type fakeMembership struct {
|
||||
member bool
|
||||
err error
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeMembership) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) {
|
||||
f.called = true
|
||||
return f.member, f.err
|
||||
}
|
||||
|
||||
func newTestScopedDB(loader *fakeOwnerLoader, membership *fakeMembership, enf Enforcer) *ScopedDB[*testOwned] {
|
||||
authorizer := &ScopedAuthorizer{
|
||||
Logger: zap.NewNop(),
|
||||
Membership: membership,
|
||||
Enforcer: enf,
|
||||
}
|
||||
return &ScopedDB[*testOwned]{
|
||||
DB: loader,
|
||||
Logger: zap.NewNop(),
|
||||
Authorizer: authorizer,
|
||||
OrgPolicyRef: bson.NewObjectID(),
|
||||
Collection: mservice.Type("test_owned"),
|
||||
New: func() *testOwned { return &testOwned{} },
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDB_PersonalOwner_Allowed(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: bson.NewObjectID(),
|
||||
kind: model.OwnerKindAccount,
|
||||
ownerRef: caller, // owner == caller
|
||||
}
|
||||
enf := &fakeEnforcer{}
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf)
|
||||
|
||||
result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if err != nil {
|
||||
t.Fatalf("expected allow, got %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result on success")
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("personal access must not consult the enforcer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDB_PersonalOwner_Denied(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
other := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: bson.NewObjectID(),
|
||||
kind: model.OwnerKindAccount,
|
||||
ownerRef: other, // owner != caller
|
||||
}
|
||||
enf := &fakeEnforcer{}
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf)
|
||||
|
||||
_, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("personal access must not consult the enforcer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDB_OrgOwner_Allowed(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: orgRef,
|
||||
kind: model.OwnerKindOrganization,
|
||||
ownerRef: orgRef,
|
||||
}
|
||||
enf := &fakeEnforcer{allow: true}
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf)
|
||||
|
||||
result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if err != nil {
|
||||
t.Fatalf("expected allow, got %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result on success")
|
||||
}
|
||||
if !enf.called {
|
||||
t.Fatal("org access must consult the enforcer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDB_OrgOwner_Denied(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: orgRef,
|
||||
kind: model.OwnerKindOrganization,
|
||||
ownerRef: orgRef,
|
||||
}
|
||||
enf := &fakeEnforcer{allow: false}
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf)
|
||||
|
||||
_, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied, got %v", err)
|
||||
}
|
||||
if !enf.called {
|
||||
t.Fatal("org access must consult the enforcer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDB_OrgOwner_EnforcerError_Propagated(t *testing.T) {
|
||||
boom := merrors.Define("enforcer backend down")
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: orgRef,
|
||||
kind: model.OwnerKindOrganization,
|
||||
ownerRef: orgRef,
|
||||
}
|
||||
enf := &fakeEnforcer{err: boom}
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf)
|
||||
|
||||
_, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected underlying error propagated, got %v", err)
|
||||
}
|
||||
if errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatal("enforcer internal error must not be masked as access denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedDB_DBError_Propagated(t *testing.T) {
|
||||
dbErr := merrors.Define("mongo unavailable")
|
||||
db := newTestScopedDB(&fakeOwnerLoader{err: dbErr}, &fakeMembership{member: true}, &fakeEnforcer{})
|
||||
|
||||
_, err := db.LoadAndEnforce(context.Background(), bson.NewObjectID(), bson.NewObjectID(), model.ActionRead)
|
||||
if !errors.Is(err, dbErr) {
|
||||
t.Fatalf("expected DB error propagated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopedDB_PersonalOwner_NonMember_Denied is the main regression case:
|
||||
// even when ownerRef == callerRef, a non-member of the organization must be
|
||||
// denied before ownership is checked, so the tenant boundary is enforced.
|
||||
// TestScopedDB_PersonalOwner_NonMember_Allowed: personal resource, caller is the
|
||||
// owner but is NOT a member of the org. Membership is not consulted for
|
||||
// account-owned resources — ownership alone is sufficient.
|
||||
func TestScopedDB_PersonalOwner_NonMember_Allowed(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: bson.NewObjectID(),
|
||||
kind: model.OwnerKindAccount,
|
||||
ownerRef: caller, // owner == caller, membership is irrelevant
|
||||
}
|
||||
membership := &fakeMembership{member: false}
|
||||
enf := &fakeEnforcer{}
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, membership, enf)
|
||||
|
||||
result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if err != nil {
|
||||
t.Fatalf("expected allow for personal resource owner regardless of membership, got %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result on success")
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("enforcer must not be called for account-owned resource")
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopedDB_OrgOwner_NonMember_Denied: org-owned object, caller is not a
|
||||
// member — must be denied before the policy enforcer is consulted.
|
||||
func TestScopedDB_OrgOwner_NonMember_Denied(t *testing.T) {
|
||||
caller := bson.NewObjectID()
|
||||
orgRef := bson.NewObjectID()
|
||||
objectRef := bson.NewObjectID()
|
||||
obj := &testOwned{
|
||||
id: objectRef,
|
||||
orgRef: orgRef,
|
||||
kind: model.OwnerKindOrganization,
|
||||
ownerRef: orgRef,
|
||||
}
|
||||
membership := &fakeMembership{member: false}
|
||||
enf := &fakeEnforcer{allow: true} // would allow if reached
|
||||
db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, membership, enf)
|
||||
|
||||
_, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead)
|
||||
if !errors.Is(err, merrors.ErrAccessDenied) {
|
||||
t.Fatalf("expected access denied for non-member org-owned object, got %v", err)
|
||||
}
|
||||
if enf.called {
|
||||
t.Fatal("enforcer must not be called when membership check fails")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
// DB is the interface which must be implemented by all db drivers
|
||||
type DB interface {
|
||||
template.DB[*model.Account]
|
||||
GetByEmail(ctx context.Context, email string) (*model.Account, error)
|
||||
GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
mongoimpl "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type DBDriver string
|
||||
|
||||
const (
|
||||
Mongo DBDriver = "mongodb"
|
||||
)
|
||||
|
||||
type Config = model.DriverConfig[DBDriver]
|
||||
|
||||
// PermissionsDatabaseConfig is the explicit public contract for services that
|
||||
// read authorization metadata from the central permissions database.
|
||||
type PermissionsDatabaseConfig Config
|
||||
|
||||
// AsDatabaseConfig converts the permissions DB contract to the existing DB
|
||||
// config type without duplicating driver parsing or connection behavior.
|
||||
func (c *PermissionsDatabaseConfig) AsDatabaseConfig() *Config {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
converted := Config(*c)
|
||||
return &converted
|
||||
}
|
||||
|
||||
// ResolvedDatabaseName identifies the database selected by a DB config.
|
||||
type ResolvedDatabaseName struct {
|
||||
Name string
|
||||
Source string
|
||||
}
|
||||
|
||||
// ResolveDatabaseName resolves only the database name portion of a DB config.
|
||||
// It intentionally mirrors the existing env-over-literal behavior and leaves
|
||||
// low-level connection validation to the normal DB connection path.
|
||||
func ResolveDatabaseName(config *Config) ResolvedDatabaseName {
|
||||
if config == nil {
|
||||
return ResolvedDatabaseName{}
|
||||
}
|
||||
return resolveDatabaseName(config)
|
||||
}
|
||||
|
||||
// ResolvePermissionsDatabaseName resolves only the database name portion of a
|
||||
// permissions DB config.
|
||||
func ResolvePermissionsDatabaseName(config *PermissionsDatabaseConfig) ResolvedDatabaseName {
|
||||
return ResolveDatabaseName(config.AsDatabaseConfig())
|
||||
}
|
||||
|
||||
// ValidateSplitDomainPermissionsDatabase verifies the generic split-domain
|
||||
// invariant for services that must keep authorization metadata separate from
|
||||
// domain storage.
|
||||
func ValidateSplitDomainPermissionsDatabase(service string, domain *Config, permissions *PermissionsDatabaseConfig) (ResolvedDatabaseName, ResolvedDatabaseName, error) {
|
||||
domainName := ResolveDatabaseName(domain)
|
||||
permissionsName := ResolvePermissionsDatabaseName(permissions)
|
||||
|
||||
if permissions == nil {
|
||||
return domainName, permissionsName, merrors.InvalidArgument(service+" permissions_database configuration is required", "permissions_database")
|
||||
}
|
||||
if strings.TrimSpace(domainName.Name) == "" {
|
||||
return domainName, permissionsName, merrors.InvalidArgument(service+" domain database name is required for split-domain validation", "database")
|
||||
}
|
||||
if strings.TrimSpace(permissionsName.Name) == "" {
|
||||
return domainName, permissionsName, merrors.InvalidArgument(service+" permissions database name is required for split-domain validation", "permissions_database")
|
||||
}
|
||||
if domainName.Name == permissionsName.Name {
|
||||
return domainName, permissionsName, merrors.InvalidArgument(service+" permissions_database must resolve to a different database than the domain database", "permissions_database")
|
||||
}
|
||||
|
||||
return domainName, permissionsName, nil
|
||||
}
|
||||
|
||||
func resolveDatabaseName(config *Config) ResolvedDatabaseName {
|
||||
if config == nil || config.Driver != Mongo {
|
||||
return ResolvedDatabaseName{}
|
||||
}
|
||||
var mongoConfig mongoimpl.Config
|
||||
if err := mapstructure.Decode(config.Settings, &mongoConfig); err != nil {
|
||||
return ResolvedDatabaseName{}
|
||||
}
|
||||
dbSettings := mongoimpl.Config2DBSettings(zap.NewNop(), &mongoConfig)
|
||||
source := ""
|
||||
if mongoConfig.DatabaseEnv != nil {
|
||||
source = "env:" + *mongoConfig.DatabaseEnv
|
||||
} else if mongoConfig.Database != nil {
|
||||
source = "settings.database"
|
||||
}
|
||||
if source != "" {
|
||||
return ResolvedDatabaseName{
|
||||
Name: dbSettings.Database,
|
||||
Source: source,
|
||||
}
|
||||
}
|
||||
|
||||
return ResolvedDatabaseName{}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
mongoimpl "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||
)
|
||||
|
||||
// Connection represents a low-level database connection lifecycle.
|
||||
type Connection interface {
|
||||
Disconnect(ctx context.Context) error
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
// MongoConnection provides direct access to the underlying mongo client.
|
||||
type MongoConnection struct {
|
||||
client *mongo.Client
|
||||
database string
|
||||
}
|
||||
|
||||
func (c *MongoConnection) Client() *mongo.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (c *MongoConnection) Database() *mongo.Database {
|
||||
return c.client.Database(c.database)
|
||||
}
|
||||
|
||||
func (c *MongoConnection) Disconnect(ctx context.Context) error {
|
||||
return c.client.Disconnect(ctx)
|
||||
}
|
||||
|
||||
func (c *MongoConnection) Ping(ctx context.Context) error {
|
||||
return c.client.Ping(ctx, readpref.Primary())
|
||||
}
|
||||
|
||||
// ConnectMongo returns a low-level MongoDB connection without constructing repositories.
|
||||
func ConnectMongo(logger mlogger.Logger, config *Config) (*MongoConnection, error) {
|
||||
if config == nil {
|
||||
return nil, merrors.InvalidArgument("database configuration is nil", "config")
|
||||
}
|
||||
if config.Driver != Mongo {
|
||||
return nil, merrors.InvalidArgument("unsupported database driver: "+string(config.Driver), "config.driver")
|
||||
}
|
||||
|
||||
client, _, settings, err := mongoimpl.ConnectClient(logger, config.Settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MongoConnection{
|
||||
client: client,
|
||||
database: settings.Database,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
|
||||
mongoimpl "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/invitation"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/role"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/transaction"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Factory exposes high-level repositories used by application services.
|
||||
type Factory interface {
|
||||
NewAccountDB() (account.DB, error)
|
||||
NewAccountDBWithCollection(collection string) (account.DB, error)
|
||||
NewInvitationsDB() (invitation.DB, error)
|
||||
|
||||
NewRolesDB() (role.DB, error)
|
||||
NewPoliciesDB() (policy.DB, error)
|
||||
|
||||
TransactionFactory() transaction.Factory
|
||||
|
||||
Permissions() auth.Provider
|
||||
|
||||
CloseConnection()
|
||||
}
|
||||
|
||||
// NewConnection builds a Factory backed by the configured driver.
|
||||
func NewConnection(logger mlogger.Logger, config *Config) (Factory, error) {
|
||||
if config == nil {
|
||||
return nil, merrors.InvalidArgument("database configuration is required", "config")
|
||||
}
|
||||
if config.Driver == Mongo {
|
||||
return mongoimpl.NewConnection(logger, config.Settings)
|
||||
}
|
||||
return nil, merrors.InvalidArgument("unknown database driver: "+string(config.Driver), "config.driver")
|
||||
}
|
||||
|
||||
// NewCentralPermissionsConnection opens the explicitly configured central
|
||||
// permissions database. It intentionally has no fallback to a service domain DB.
|
||||
func NewCentralPermissionsConnection(logger mlogger.Logger, config *PermissionsDatabaseConfig) (Factory, error) {
|
||||
if config == nil {
|
||||
return nil, merrors.InvalidArgument("permissions_database configuration is required", "permissions_database")
|
||||
}
|
||||
conn, err := NewConnection(logger, config.AsDatabaseConfig())
|
||||
if err != nil {
|
||||
return nil, merrors.Wrap(err, "permissions_database")
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package accountdb
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AccountDB struct {
|
||||
template.DBImp[*model.Account]
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, db *mongo.Database) (*AccountDB, error) {
|
||||
return CreateWithCollection(logger, db, mservice.Accounts)
|
||||
}
|
||||
|
||||
func CreateWithCollection(logger mlogger.Logger, db *mongo.Database, collection string) (*AccountDB, error) {
|
||||
if normalized := strings.TrimSpace(collection); normalized != "" {
|
||||
collection = normalized
|
||||
} else {
|
||||
collection = mservice.Accounts
|
||||
}
|
||||
|
||||
p := &AccountDB{
|
||||
DBImp: *template.Create[*model.Account](logger, collection, db),
|
||||
}
|
||||
|
||||
if err := p.Repository.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: "login", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
}); err != nil {
|
||||
p.Logger.Error("Failed to create account database", zap.Error(err), zap.String("collection", collection))
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package accountdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (db *AccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) {
|
||||
filter := repository.Query().Comparison(repository.IDField(), builder.In, refs)
|
||||
return mutil.GetObjects[model.Account](ctx, db.Logger, filter, nil, db.Repository)
|
||||
}
|
||||
|
||||
func (db *AccountDB) GetByEmail(ctx context.Context, email string) (*model.Account, error) {
|
||||
var account model.Account
|
||||
return &account, db.FindOne(ctx, repository.Filter("login", email), &account)
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/accountdb"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/invitationdb"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/policiesdb"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/rolesdb"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/transactionimp"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/invitation"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/role"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/transaction"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/config"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Config represents configuration
|
||||
type Config struct {
|
||||
Port *string `mapstructure:"port"`
|
||||
PortEnv *string `mapstructure:"port_env"`
|
||||
User *string `mapstructure:"user"`
|
||||
UserEnv *string `mapstructure:"user_env"`
|
||||
PasswordEnv string `mapstructure:"password_env"`
|
||||
Database *string `mapstructure:"database"`
|
||||
DatabaseEnv *string `mapstructure:"database_env"`
|
||||
Host *string `mapstructure:"host"`
|
||||
HostEnv *string `mapstructure:"host_env"`
|
||||
Hosts []string `mapstructure:"hosts,omitempty"`
|
||||
HostsEnvPrefix *string `mapstructure:"hosts_env_prefix,omitempty"`
|
||||
HostsEnvPrefixEnv *string `mapstructure:"hosts_env_prefix_env,omitempty"`
|
||||
PortsEnvPrefix *string `mapstructure:"ports_env_prefix,omitempty"`
|
||||
PortsEnvPrefixEnv *string `mapstructure:"ports_env_prefix_env,omitempty"`
|
||||
URI *string `mapstructure:"uri,omitempty"`
|
||||
URIEnv *string `mapstructure:"uri_env,omitempty"`
|
||||
AuthSource *string `mapstructure:"auth_source,omitempty"`
|
||||
AuthSourceEnv *string `mapstructure:"auth_source_env,omitempty"`
|
||||
AuthMechanism *string `mapstructure:"auth_mechanism,omitempty"`
|
||||
AuthMechanismEnv *string `mapstructure:"auth_mechanism_env,omitempty"`
|
||||
ReplicaSet *string `mapstructure:"replica_set,omitempty"`
|
||||
ReplicaSetEnv *string `mapstructure:"replica_set_env,omitempty"`
|
||||
Enforcer *auth.Config `mapstructure:"enforcer"`
|
||||
}
|
||||
|
||||
type DBSettings struct {
|
||||
Host string
|
||||
Hosts []string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
Database string
|
||||
AuthSource string
|
||||
AuthMechanism string
|
||||
ReplicaSet string
|
||||
URI string
|
||||
}
|
||||
|
||||
func newProtectedDB[T any](
|
||||
db *DB,
|
||||
create func(ctx context.Context, logger mlogger.Logger, enforcer auth.Enforcer, pdb policy.DB, client *mongo.Database) (T, error),
|
||||
) (T, error) {
|
||||
pdb, err := db.NewPoliciesDB()
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to create policies database", zap.Error(err))
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
return create(context.Background(), db.logger, db.Enforcer(), pdb, db.db())
|
||||
}
|
||||
|
||||
func Config2DBSettings(logger mlogger.Logger, config *Config) *DBSettings {
|
||||
p := new(DBSettings)
|
||||
p.Port = mutil.GetConfigValue(logger, "port", "port_env", config.Port, config.PortEnv)
|
||||
p.Database = mutil.GetConfigValue(logger, "database", "database_env", config.Database, config.DatabaseEnv)
|
||||
p.Password = os.Getenv(config.PasswordEnv)
|
||||
p.User = mutil.GetConfigValue(logger, "user", "user_env", config.User, config.UserEnv)
|
||||
p.Host = mutil.GetConfigValue(logger, "host", "host_env", config.Host, config.HostEnv)
|
||||
p.AuthSource = mutil.GetConfigValue(logger, "auth_source", "auth_source_env", config.AuthSource, config.AuthSourceEnv)
|
||||
p.AuthMechanism = mutil.GetConfigValue(logger, "auth_mechanism", "auth_mechanism_env", config.AuthMechanism, config.AuthMechanismEnv)
|
||||
p.ReplicaSet = mutil.GetConfigValue(logger, "replica_set", "replica_set_env", config.ReplicaSet, config.ReplicaSetEnv)
|
||||
p.URI = mutil.GetConfigValue(logger, "uri", "uri_env", config.URI, config.URIEnv)
|
||||
|
||||
hostPrefix := mutil.GetConfigValue(logger, "hosts_env_prefix", "hosts_env_prefix_env", config.HostsEnvPrefix, config.HostsEnvPrefixEnv)
|
||||
portPrefix := mutil.GetConfigValue(logger, "ports_env_prefix", "ports_env_prefix_env", config.PortsEnvPrefix, config.PortsEnvPrefixEnv)
|
||||
|
||||
if hostPrefix == "" && p.ReplicaSet != "" {
|
||||
hostPrefix = "MONGO_HOSTS_"
|
||||
}
|
||||
if portPrefix == "" && p.ReplicaSet != "" {
|
||||
portPrefix = "MONGO_PORTS_"
|
||||
}
|
||||
|
||||
p.Hosts = collectReplicaHosts(config.Hosts, p.ReplicaSet, p.Port, hostPrefix, portPrefix)
|
||||
return p
|
||||
}
|
||||
|
||||
func decodeConfig(logger mlogger.Logger, settings model.SettingsT) (*Config, *DBSettings, error) {
|
||||
var config Config
|
||||
if err := mapstructure.Decode(settings, &config); err != nil {
|
||||
logger.Warn("Failed to decode settings", zap.Error(err))
|
||||
return nil, nil, err
|
||||
}
|
||||
dbSettings := Config2DBSettings(logger, &config)
|
||||
return &config, dbSettings, nil
|
||||
}
|
||||
|
||||
func dialMongo(logger mlogger.Logger, dbSettings *DBSettings) (*mongo.Client, error) {
|
||||
opts := buildOptions(dbSettings)
|
||||
|
||||
client, err := mongo.Connect(opts)
|
||||
if err != nil {
|
||||
logger.Error("Unable to connect to database", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dbSettings.URI != "" {
|
||||
logger.Info("Connected successfully", zap.Bool("uri_provided", true))
|
||||
} else {
|
||||
logger.Info("Connected successfully", zap.String("user", dbSettings.User),
|
||||
zap.Strings("hosts", opts.Hosts), zap.String("port", dbSettings.Port),
|
||||
zap.String("database", dbSettings.Database), zap.String("replica_set", dbSettings.ReplicaSet))
|
||||
}
|
||||
|
||||
if err := client.Ping(context.Background(), readpref.Primary()); err != nil {
|
||||
logger.Error("Unable to ping database", zap.Error(err))
|
||||
_ = client.Disconnect(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func ConnectClient(logger mlogger.Logger, settings model.SettingsT) (*mongo.Client, *Config, *DBSettings, error) {
|
||||
config, dbSettings, err := decodeConfig(logger, settings)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
client, err := dialMongo(logger, dbSettings)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
return client, config, dbSettings, nil
|
||||
}
|
||||
|
||||
// DB represents the structure of the database
|
||||
type DB struct {
|
||||
logger mlogger.Logger
|
||||
config *DBSettings
|
||||
client *mongo.Client
|
||||
enforcer auth.Enforcer
|
||||
manager auth.Manager
|
||||
pdb policy.DB
|
||||
}
|
||||
|
||||
func (db *DB) db() *mongo.Database {
|
||||
return db.client.Database(db.config.Database)
|
||||
}
|
||||
|
||||
func (db *DB) MongoDatabase() *mongo.Database {
|
||||
return db.db()
|
||||
}
|
||||
|
||||
func (db *DB) NewAccountDB() (account.DB, error) {
|
||||
return accountdb.Create(db.logger, db.db())
|
||||
}
|
||||
|
||||
func (db *DB) NewAccountDBWithCollection(collection string) (account.DB, error) {
|
||||
return accountdb.CreateWithCollection(db.logger, db.db(), collection)
|
||||
}
|
||||
|
||||
func (db *DB) NewInvitationsDB() (invitation.DB, error) {
|
||||
return newProtectedDB(db, invitationdb.Create)
|
||||
}
|
||||
|
||||
func (db *DB) NewPoliciesDB() (policy.DB, error) {
|
||||
return db.pdb, nil
|
||||
}
|
||||
|
||||
func (db *DB) NewRolesDB() (role.DB, error) {
|
||||
return rolesdb.Create(db.logger, db.db())
|
||||
}
|
||||
|
||||
func (db *DB) TransactionFactory() transaction.Factory {
|
||||
return transactionimp.CreateFactory(db.client)
|
||||
}
|
||||
|
||||
func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, hostPrefix, portPrefix string) []string {
|
||||
normalize := func(host, port string) string {
|
||||
host = strings.TrimSpace(host)
|
||||
port = strings.TrimSpace(port)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
// If host already has a port, keep it; otherwise apply provided/default port.
|
||||
if _, _, err := net.SplitHostPort(host); err == nil {
|
||||
return host
|
||||
}
|
||||
if port != "" {
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
if defaultPort != "" {
|
||||
return net.JoinHostPort(host, defaultPort)
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
appendHost := func(list []string, host, port string) []string {
|
||||
if normalized := normalize(host, port); normalized != "" {
|
||||
return append(list, normalized)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
var hosts []string
|
||||
for _, h := range configuredHosts {
|
||||
hosts = appendHost(hosts, h, "")
|
||||
}
|
||||
|
||||
if replicaSet == "" || hostPrefix == "" {
|
||||
return hosts
|
||||
}
|
||||
|
||||
index := 0
|
||||
for {
|
||||
hostEnv := os.Getenv(fmt.Sprintf("%s%d", hostPrefix, index))
|
||||
portEnv := ""
|
||||
if portPrefix != "" {
|
||||
portEnv = os.Getenv(fmt.Sprintf("%s%d", portPrefix, index))
|
||||
}
|
||||
|
||||
if hostEnv == "" && index == 0 {
|
||||
hostEnv = os.Getenv(fmt.Sprintf("%s%d", hostPrefix, 1))
|
||||
if portPrefix != "" {
|
||||
portEnv = os.Getenv(fmt.Sprintf("%s%d", portPrefix, 1))
|
||||
}
|
||||
if hostEnv == "" {
|
||||
break
|
||||
}
|
||||
index = 1
|
||||
} else if hostEnv == "" {
|
||||
break
|
||||
}
|
||||
|
||||
hosts = appendHost(hosts, hostEnv, portEnv)
|
||||
index++
|
||||
}
|
||||
|
||||
return hosts
|
||||
}
|
||||
|
||||
func (db *DB) Permissions() auth.Provider {
|
||||
return db
|
||||
}
|
||||
|
||||
func (db *DB) Manager() auth.Manager {
|
||||
return db.manager
|
||||
}
|
||||
|
||||
func (db *DB) Enforcer() auth.Enforcer {
|
||||
return db.enforcer
|
||||
}
|
||||
|
||||
func (db *DB) GetPolicyDescription(ctx context.Context, resource mservice.Type) (*model.PolicyDescription, error) {
|
||||
var policyDescription model.PolicyDescription
|
||||
return &policyDescription, db.pdb.FindOne(ctx, repository.Filter("resourceTypes", resource), &policyDescription)
|
||||
}
|
||||
|
||||
func (db *DB) CloseConnection() {
|
||||
if err := db.client.Disconnect(context.Background()); err != nil {
|
||||
db.logger.Warn("Failed to close connection", zap.Error(err))
|
||||
}
|
||||
db.logger.Info("Database connection closed")
|
||||
}
|
||||
|
||||
// NewConnection creates a new database connection
|
||||
func NewConnection(logger mlogger.Logger, settings model.SettingsT) (*DB, error) {
|
||||
client, config, dbSettings, err := ConnectClient(logger, settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := &DB{
|
||||
logger: logger.Named("db"),
|
||||
config: dbSettings,
|
||||
client: client,
|
||||
}
|
||||
|
||||
cleanup := func(ctx context.Context) {
|
||||
if err := client.Disconnect(ctx); err != nil {
|
||||
logger.Warn("Failed to close MongoDB connection", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
rdb, err := db.NewRolesDB()
|
||||
if err != nil {
|
||||
db.logger.Warn("Failed to create roles database", zap.Error(err))
|
||||
cleanup(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
if db.pdb, err = policiesdb.Create(db.logger, db.db()); err != nil {
|
||||
db.logger.Warn("Failed to create policies database", zap.Error(err))
|
||||
cleanup(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
if db.enforcer, db.manager, err = auth.CreateAuth(logger, db.client, db.db(), db.pdb, rdb, config.Enforcer); err != nil {
|
||||
db.logger.Warn("Failed to create permissions enforcer", zap.Error(err))
|
||||
cleanup(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package invitationdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (db *InvitationDB) Accept(ctx context.Context, invitationRef bson.ObjectID) error {
|
||||
return db.updateStatus(ctx, invitationRef, model.InvitationAccepted)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package invitationdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SetArchived sets the archived status of an invitation
|
||||
// Invitation supports archiving through PermissionBound embedding ArchivableBase
|
||||
func (db *InvitationDB) SetArchived(ctx context.Context, accountRef, organizationRef, invitationRef bson.ObjectID, archived, cascade bool) error {
|
||||
db.DBImp.Logger.Debug("Setting invitation archived status", mzap.ObjRef("invitation_ref", invitationRef), zap.Bool("archived", archived), zap.Bool("cascade", cascade))
|
||||
res, err := db.Enforcer.Enforce(ctx, db.PermissionRef, accountRef, organizationRef, invitationRef, model.ActionUpdate)
|
||||
if err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to enforce archivation permission", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return err
|
||||
}
|
||||
if !res {
|
||||
db.DBImp.Logger.Debug("Permission denied for archivation", mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return merrors.AccessDenied(db.Collection, string(model.ActionUpdate), invitationRef)
|
||||
}
|
||||
|
||||
// Get the invitation first
|
||||
var invitation model.Invitation
|
||||
if err := db.Get(ctx, accountRef, invitationRef, &invitation); err != nil {
|
||||
db.DBImp.Logger.Warn("Error retrieving invitation for archival", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the invitation's archived status
|
||||
invitation.SetArchived(archived)
|
||||
if err := db.Update(ctx, accountRef, &invitation); err != nil {
|
||||
db.DBImp.Logger.Warn("Error updating invitation archived status", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return err
|
||||
}
|
||||
|
||||
// Note: Currently no cascade dependencies for invitations
|
||||
// If cascade is enabled, we could add logic here for any future dependencies
|
||||
if cascade {
|
||||
db.DBImp.Logger.Debug("Cascade archiving requested but no dependencies to archive for invitation", mzap.ObjRef("invitation_ref", invitationRef))
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully set invitation archived status", mzap.ObjRef("invitation_ref", invitationRef), zap.Bool("archived", archived))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package invitationdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DeleteCascade deletes an invitation
|
||||
// Invitations don't have cascade dependencies, so this is a simple deletion
|
||||
func (db *InvitationDB) DeleteCascade(ctx context.Context, accountRef, invitationRef bson.ObjectID) error {
|
||||
db.DBImp.Logger.Debug("Starting invitation cascade deletion", mzap.ObjRef("invitation_ref", invitationRef))
|
||||
|
||||
// Delete the invitation itself (no dependencies to cascade delete)
|
||||
if err := db.Delete(ctx, accountRef, invitationRef); err != nil {
|
||||
db.DBImp.Logger.Error("Error deleting invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return err
|
||||
}
|
||||
|
||||
db.DBImp.Logger.Debug("Successfully deleted invitation", mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package invitationdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
|
||||
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type InvitationDB struct {
|
||||
auth.ProtectedDBImp[*model.Invitation]
|
||||
}
|
||||
|
||||
func Create(
|
||||
ctx context.Context,
|
||||
logger mlogger.Logger,
|
||||
enforcer auth.Enforcer,
|
||||
pdb policy.DB,
|
||||
db *mongo.Database,
|
||||
) (*InvitationDB, error) {
|
||||
p, err := auth.CreateDBImp[*model.Invitation](ctx, logger, pdb, enforcer, mservice.Invitations, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := p.DBImp.Repository.CreateIndex(pendingInvitationEmailIndexDefinition()); err != nil {
|
||||
p.DBImp.Logger.Error("Failed to create unique pending invitation email index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ttl index
|
||||
ttl := int32(0) // zero ttl means expiration on date preset when inserting data
|
||||
if err := p.DBImp.Repository.CreateIndex(&ri.Definition{
|
||||
Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}},
|
||||
TTL: &ttl,
|
||||
}); err != nil {
|
||||
p.DBImp.Logger.Error("Failed to create ttl index in the invitations", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &InvitationDB{ProtectedDBImp: *p}, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package invitationdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func TestPendingInvitationEmailIndexBehavior(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
mongoContainer, err := mongodb.Run(ctx,
|
||||
"mongo:latest",
|
||||
mongodb.WithUsername("root"),
|
||||
mongodb.WithPassword("password"),
|
||||
testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
termCtx, termCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer termCancel()
|
||||
_ = mongoContainer.Terminate(termCtx)
|
||||
}()
|
||||
|
||||
mongoURI, err := mongoContainer.ConnectionString(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
client, err := mongo.Connect(options.Client().ApplyURI(mongoURI))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = client.Disconnect(context.Background()) }()
|
||||
|
||||
database := client.Database("test_invitation_pending_email_index")
|
||||
defer func() { _ = database.Drop(context.Background()) }()
|
||||
|
||||
repo := repository.CreateMongoRepository(database, "invitations")
|
||||
require.NoError(t, repo.CreateIndex(pendingInvitationEmailIndexDefinition()))
|
||||
assertPendingEmailIndexDefinition(t, ctx, database.Collection("invitations"))
|
||||
|
||||
orgRef := bson.NewObjectID()
|
||||
email := "invitee@example.com"
|
||||
require.NoError(t, repo.Insert(ctx, testInvitation(orgRef, email, model.InvitationCreated), nil))
|
||||
err = repo.Insert(ctx, testInvitation(orgRef, email, model.InvitationSent), nil)
|
||||
require.ErrorIs(t, err, merrors.ErrDataConflict, "duplicate pending invitation must conflict")
|
||||
|
||||
for _, status := range []model.InvitationStatus{model.InvitationRevoked, model.InvitationDeclined, model.InvitationAccepted} {
|
||||
statusEmail := string(status) + "@example.com"
|
||||
require.NoError(t, repo.Insert(ctx, testInvitation(orgRef, statusEmail, status), nil))
|
||||
require.NoError(t, repo.Insert(ctx, testInvitation(orgRef, statusEmail, model.InvitationCreated), nil))
|
||||
}
|
||||
|
||||
otherOrgRef := bson.NewObjectID()
|
||||
require.NoError(t, repo.Insert(ctx, testInvitation(otherOrgRef, email, model.InvitationCreated), nil))
|
||||
}
|
||||
|
||||
func testInvitation(orgRef bson.ObjectID, email string, status model.InvitationStatus) *model.Invitation {
|
||||
inv := &model.Invitation{
|
||||
OrganizationRef: orgRef,
|
||||
Status: status,
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
}
|
||||
inv.SetID(bson.NewObjectID())
|
||||
inv.SetOrganizationRef(orgRef)
|
||||
inv.Content.Email = email
|
||||
return inv
|
||||
}
|
||||
|
||||
func assertPendingEmailIndexDefinition(t *testing.T, ctx context.Context, collection *mongo.Collection) {
|
||||
t.Helper()
|
||||
cursor, err := collection.Indexes().List(ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = cursor.Close(ctx) }()
|
||||
|
||||
for cursor.Next(ctx) {
|
||||
var index bson.M
|
||||
require.NoError(t, cursor.Decode(&index))
|
||||
if index["name"] != uniquePendingInvitationEmailIndexName {
|
||||
continue
|
||||
}
|
||||
|
||||
require.Equal(t, true, index["unique"])
|
||||
require.Equal(t, bson.D{
|
||||
{Key: "organizationRef", Value: int32(1)},
|
||||
{Key: "description.email", Value: int32(1)},
|
||||
}, index["key"])
|
||||
require.Equal(t, bson.D{
|
||||
{Key: "status", Value: bson.D{{Key: "$in", Value: bson.A{"created", "sent"}}}},
|
||||
{Key: "description.email", Value: bson.D{{Key: "$type", Value: "string"}}},
|
||||
}, index["partialFilterExpression"])
|
||||
return
|
||||
}
|
||||
require.NoError(t, cursor.Err())
|
||||
t.Fatalf("index %q was not created", uniquePendingInvitationEmailIndexName)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package invitationdb
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestPendingInvitationEmailIndexDefinition(t *testing.T) {
|
||||
def := pendingInvitationEmailIndexDefinition()
|
||||
|
||||
expectedKeys := []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
{Field: "description.email", Sort: ri.Asc},
|
||||
}
|
||||
if !reflect.DeepEqual(def.Keys, expectedKeys) {
|
||||
t.Fatalf("keys mismatch: got %#v want %#v", def.Keys, expectedKeys)
|
||||
}
|
||||
if !def.Unique {
|
||||
t.Fatal("index must be unique")
|
||||
}
|
||||
if def.Name != uniquePendingInvitationEmailIndexName {
|
||||
t.Fatalf("index name mismatch: got %q want %q", def.Name, uniquePendingInvitationEmailIndexName)
|
||||
}
|
||||
|
||||
expectedFilter := repository.Query().
|
||||
In(repository.Field("status"), "created", "sent").
|
||||
Comparison(repository.Field("description.email"), builder.Type, "string").
|
||||
BuildQuery()
|
||||
if !reflect.DeepEqual(def.PartialFilter.BuildQuery(), expectedFilter) {
|
||||
t.Fatalf("partial filter mismatch: got %#v want %#v", def.PartialFilter.BuildQuery(), expectedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingInvitationEmailIndexUsesModelPendingStatuses(t *testing.T) {
|
||||
def := pendingInvitationEmailIndexDefinition()
|
||||
filter := def.PartialFilter.BuildQuery()
|
||||
statuses := statusInValues(t, filter)
|
||||
|
||||
expected := model.PendingInvitationStatusValues()
|
||||
if !reflect.DeepEqual(statuses, expected) {
|
||||
t.Fatalf("index statuses drifted from model: got %#v want %#v", statuses, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func statusInValues(t *testing.T, filter bson.D) []string {
|
||||
t.Helper()
|
||||
for _, item := range filter {
|
||||
if item.Key != "status" {
|
||||
continue
|
||||
}
|
||||
comparison, ok := item.Value.(bson.M)
|
||||
if !ok {
|
||||
t.Fatalf("status filter has unexpected type %T", item.Value)
|
||||
}
|
||||
rawValues, ok := comparison["$in"]
|
||||
if !ok {
|
||||
t.Fatal("status filter must use $in")
|
||||
}
|
||||
values, ok := rawValues.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("status $in has unexpected type %T", rawValues)
|
||||
}
|
||||
statuses := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
status, ok := value.(string)
|
||||
if !ok {
|
||||
t.Fatalf("status value has unexpected type %T", value)
|
||||
}
|
||||
statuses = append(statuses, status)
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
t.Fatal("status filter not found")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package invitationdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (db *InvitationDB) Decline(ctx context.Context, invitationRef bson.ObjectID) error {
|
||||
return db.updateStatus(ctx, invitationRef, model.InvitationDeclined)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package invitationdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (db *InvitationDB) GetPublic(ctx context.Context, invitationRef bson.ObjectID) (*model.PublicInvitation, error) {
|
||||
roleField := repository.Field("role")
|
||||
orgField := repository.Field("organization")
|
||||
accField := repository.Field("account")
|
||||
empField := repository.Field("employee")
|
||||
regField := repository.Field("registrationAcc")
|
||||
descEmailField := repository.Field("description").Dot("email")
|
||||
pipeline := repository.Pipeline().
|
||||
// 0) Filter to exactly the invitation(s) you want
|
||||
Match(repository.IDFilter(invitationRef).And(repository.Filter("status", model.InvitationCreated))).
|
||||
// 1) Lookup the role document
|
||||
Lookup(
|
||||
mservice.Roles,
|
||||
repository.Field("roleRef"),
|
||||
repository.IDField(),
|
||||
roleField,
|
||||
).
|
||||
Unwind(repository.Ref(roleField)).
|
||||
// 2) Lookup the organization document
|
||||
Lookup(
|
||||
mservice.Organizations,
|
||||
repository.Field("organizationRef"),
|
||||
repository.IDField(),
|
||||
orgField,
|
||||
).
|
||||
Unwind(repository.Ref(orgField)).
|
||||
// 3) Lookup the account document
|
||||
Lookup(
|
||||
mservice.Accounts,
|
||||
repository.Field("inviterRef"),
|
||||
repository.IDField(),
|
||||
accField,
|
||||
).
|
||||
Unwind(repository.Ref(accField)).
|
||||
/* 4) do we already have an account whose login == invitation.description ? */
|
||||
Lookup(
|
||||
mservice.Accounts,
|
||||
descEmailField, // local field (invitation.description.email)
|
||||
repository.Field("login"), // foreign field (account.login)
|
||||
regField, // array: 0-length or ≥1
|
||||
).
|
||||
// 5) Projection
|
||||
Project(
|
||||
repository.SimpleAlias(
|
||||
empField.Dot("description"),
|
||||
repository.Ref(accField),
|
||||
),
|
||||
repository.SimpleAlias(
|
||||
empField.Dot("avatarUrl"),
|
||||
repository.Ref(accField.Dot("avatarUrl")),
|
||||
),
|
||||
repository.SimpleAlias(
|
||||
orgField.Dot("description"),
|
||||
repository.Ref(orgField),
|
||||
),
|
||||
repository.SimpleAlias(
|
||||
orgField.Dot("logoUrl"),
|
||||
repository.Ref(orgField.Dot("logoUrl")),
|
||||
),
|
||||
repository.SimpleAlias(
|
||||
roleField,
|
||||
repository.Ref(roleField),
|
||||
),
|
||||
repository.SimpleAlias(
|
||||
repository.Field("invitation"), // ← left-hand side
|
||||
repository.Ref(repository.Field("description")), // ← right-hand side (“$description”)
|
||||
),
|
||||
repository.SimpleAlias(
|
||||
repository.Field("storable"), // ← left-hand side
|
||||
repository.RootRef(), // ← right-hand side (“$description”)
|
||||
),
|
||||
repository.ProjectionExpr(
|
||||
repository.Field("registrationRequired"),
|
||||
repository.Eq(
|
||||
repository.Size(repository.Value(repository.Ref(regField).Build())),
|
||||
repository.Literal(0),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
var res model.PublicInvitation
|
||||
haveResult := false
|
||||
decoder := func(cur *mongo.Cursor) error {
|
||||
if haveResult {
|
||||
// should never get here
|
||||
db.DBImp.Logger.Warn("Unexpected extra invitation", mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return merrors.Internal("Unexpected extra invitation found by reference")
|
||||
}
|
||||
if e := cur.Decode(&res); e != nil {
|
||||
db.DBImp.Logger.Warn("Failed to decode entity", zap.Error(e), zap.Any("data", cur.Current.String()))
|
||||
return e
|
||||
}
|
||||
haveResult = true
|
||||
return nil
|
||||
}
|
||||
if err := db.DBImp.Repository.Aggregate(ctx, pipeline, decoder); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to execute aggregation pipeline", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return nil, err
|
||||
}
|
||||
if !haveResult {
|
||||
db.DBImp.Logger.Warn("No results fetched", mzap.ObjRef("invitation_ref", invitationRef))
|
||||
return nil, merrors.NoData(fmt.Sprintf("Invitation %s not found", invitationRef.Hex()))
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package invitationdb
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
)
|
||||
|
||||
const uniquePendingInvitationEmailIndexName = "unique_pending_invitation_email_per_org"
|
||||
|
||||
func pendingInvitationEmailIndexDefinition() *ri.Definition {
|
||||
statusValues := model.PendingInvitationStatusValues()
|
||||
statuses := make([]any, 0, len(statusValues))
|
||||
for _, status := range statusValues {
|
||||
statuses = append(statuses, status)
|
||||
}
|
||||
|
||||
return &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: repository.OrgField().Build(), Sort: ri.Asc},
|
||||
{Field: "description.email", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
Name: uniquePendingInvitationEmailIndexName,
|
||||
PartialFilter: repository.Query().
|
||||
In(repository.Field("status"), statuses...).
|
||||
Comparison(repository.Field("description.email"), builder.Type, "string"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package invitationdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
mauth "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db/auth"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (db *InvitationDB) List(ctx context.Context, accountRef, organizationRef, _ bson.ObjectID, cursor *model.ViewCursor) ([]model.Invitation, error) {
|
||||
res, err := mauth.GetProtectedObjects[model.Invitation](
|
||||
ctx,
|
||||
db.DBImp.Logger,
|
||||
accountRef, organizationRef, model.ActionRead,
|
||||
repository.OrgFilter(organizationRef),
|
||||
cursor,
|
||||
db.Enforcer,
|
||||
db.DBImp.Repository,
|
||||
)
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return []model.Invitation{}, nil
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package invitationdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (db *InvitationDB) updateStatus(ctx context.Context, invitationRef bson.ObjectID, newStatus model.InvitationStatus) error {
|
||||
// db.DBImp.Up
|
||||
var inv model.Invitation
|
||||
if err := db.DBImp.FindOne(ctx, repository.IDFilter(invitationRef), &inv); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to fetch invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef), zap.String("new_status", string(newStatus)))
|
||||
return err
|
||||
}
|
||||
inv.Status = newStatus
|
||||
if err := db.DBImp.Update(ctx, &inv); err != nil {
|
||||
db.DBImp.Logger.Warn("Failed to update invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef), zap.String("new_status", string(newStatus)))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func buildOptions(s *DBSettings) *options.ClientOptions {
|
||||
opts := options.Client()
|
||||
|
||||
if s.URI != "" {
|
||||
return opts.ApplyURI(s.URI)
|
||||
}
|
||||
|
||||
hosts := make([]string, 0, len(s.Hosts)+1)
|
||||
for _, h := range s.Hosts {
|
||||
if trimmed := strings.TrimSpace(h); trimmed != "" {
|
||||
hosts = append(hosts, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
if len(hosts) == 0 && s.Host != "" {
|
||||
host := s.Host
|
||||
if _, _, err := net.SplitHostPort(host); err != nil && s.Port != "" {
|
||||
host = net.JoinHostPort(host, s.Port)
|
||||
}
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
|
||||
if len(hosts) > 0 {
|
||||
opts.SetHosts(hosts)
|
||||
}
|
||||
|
||||
if s.ReplicaSet != "" {
|
||||
opts.SetReplicaSet(s.ReplicaSet)
|
||||
}
|
||||
|
||||
cred := options.Credential{
|
||||
AuthMechanism: s.AuthMechanism,
|
||||
AuthSource: s.AuthSource,
|
||||
Username: s.User,
|
||||
Password: s.Password,
|
||||
}
|
||||
opts.SetAuth(cred)
|
||||
|
||||
return opts
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package policiesdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (db *PoliciesDB) All(ctx context.Context, organizationRef bson.ObjectID) ([]model.PolicyDescription, error) {
|
||||
// all documents
|
||||
filter := repository.Query().Or(
|
||||
repository.Filter(storable.OrganizationRefField, nil),
|
||||
repository.OrgFilter(organizationRef),
|
||||
)
|
||||
return mutil.GetObjects[model.PolicyDescription](ctx, db.Logger, filter, nil, db.Repository)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package policiesdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
)
|
||||
|
||||
func (db *PoliciesDB) GetBuiltInPolicy(ctx context.Context, resourceType mservice.Type, policy *model.PolicyDescription) error {
|
||||
return db.FindOne(ctx, repository.Filter("resourceTypes", resourceType), policy)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package policiesdb
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
type PoliciesDB struct {
|
||||
template.DBImp[*model.PolicyDescription]
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, db *mongo.Database) (*PoliciesDB, error) {
|
||||
p := &PoliciesDB{
|
||||
DBImp: *template.Create[*model.PolicyDescription](logger, mservice.Policies, db),
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package policiesdb_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
// Your internal packages
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/policiesdb"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
|
||||
|
||||
// Model package (contains PolicyDescription + Describable)
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
// Testcontainers
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/mongodb"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Helper to terminate container
|
||||
func terminate(t *testing.T, ctx context.Context, container *mongodb.MongoDBContainer) {
|
||||
err := container.Terminate(ctx)
|
||||
require.NoError(t, err, "failed to terminate MongoDB container")
|
||||
}
|
||||
|
||||
// Helper to disconnect client
|
||||
func disconnect(t *testing.T, ctx context.Context, client *mongo.Client) {
|
||||
err := client.Disconnect(context.Background())
|
||||
require.NoError(t, err, "failed to disconnect from MongoDB")
|
||||
}
|
||||
|
||||
// Helper to drop the Policies collection
|
||||
func cleanupCollection(t *testing.T, ctx context.Context, db *mongo.Database) {
|
||||
// The actual collection name is typically the value returned by
|
||||
// (&model.PolicyDescription{}).Collection(), or something similar.
|
||||
// Make sure it matches what your code uses (often "policies" or "policyDescription").
|
||||
err := db.Collection((&model.PolicyDescription{}).Collection()).Drop(ctx)
|
||||
require.NoError(t, err, "failed to drop collection between sub-tests")
|
||||
}
|
||||
|
||||
func TestPoliciesDB(t *testing.T) {
|
||||
// Create context with reasonable timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Start MongoDB test container
|
||||
mongoC, err := mongodb.Run(ctx,
|
||||
"mongo:latest",
|
||||
mongodb.WithUsername("root"),
|
||||
mongodb.WithPassword("password"),
|
||||
testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")),
|
||||
)
|
||||
require.NoError(t, err, "failed to start MongoDB container")
|
||||
defer terminate(t, ctx, mongoC)
|
||||
|
||||
// Get connection URI
|
||||
mongoURI, err := mongoC.ConnectionString(ctx)
|
||||
require.NoError(t, err, "failed to get connection string")
|
||||
|
||||
// Connect client
|
||||
clientOpts := options.Client().ApplyURI(mongoURI)
|
||||
client, err := mongo.Connect(ctx, clientOpts)
|
||||
require.NoError(t, err, "failed to connect to MongoDB")
|
||||
defer disconnect(t, ctx, client)
|
||||
|
||||
// Create test DB
|
||||
db := client.Database("testdb")
|
||||
|
||||
// Use a no-op logger (or real logger if you prefer)
|
||||
logger := zap.NewNop()
|
||||
|
||||
// Create an instance of PoliciesDB
|
||||
pdb, err := policiesdb.Create(logger, db)
|
||||
require.NoError(t, err, "unexpected error creating PoliciesDB")
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Each sub-test below starts by dropping the collection.
|
||||
// ---------------------------------------------------------
|
||||
|
||||
t.Run("CreateAndGet", func(t *testing.T) {
|
||||
cleanupCollection(t, ctx, db) // ensure no leftover data
|
||||
|
||||
desc := "Test policy description"
|
||||
policy := &model.PolicyDescription{
|
||||
Describable: model.Describable{
|
||||
Name: "TestPolicy",
|
||||
Description: &desc,
|
||||
},
|
||||
}
|
||||
require.NoError(t, pdb.Create(ctx, policy))
|
||||
|
||||
result := &model.PolicyDescription{}
|
||||
err := pdb.Get(ctx, policy.ID, result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, policy.ID, result.ID)
|
||||
assert.Equal(t, "TestPolicy", result.Name)
|
||||
assert.NotNil(t, result.Description)
|
||||
assert.Equal(t, "Test policy description", *result.Description)
|
||||
})
|
||||
|
||||
t.Run("Get_NotFound", func(t *testing.T) {
|
||||
cleanupCollection(t, ctx, db)
|
||||
|
||||
// Attempt to get a non-existent ID
|
||||
nonExistentID := bson.NewObjectID()
|
||||
result := &model.PolicyDescription{}
|
||||
err := pdb.Get(ctx, nonExistentID, result)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrNoData))
|
||||
})
|
||||
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
cleanupCollection(t, ctx, db)
|
||||
|
||||
originalDesc := "Original description"
|
||||
policy := &model.PolicyDescription{
|
||||
Describable: model.Describable{
|
||||
Name: "OriginalName",
|
||||
Description: &originalDesc,
|
||||
},
|
||||
}
|
||||
require.NoError(t, pdb.Create(ctx, policy))
|
||||
|
||||
newDesc := "Updated description"
|
||||
policy.Name = "UpdatedName"
|
||||
policy.Description = &newDesc
|
||||
|
||||
err := pdb.Update(ctx, policy)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := &model.PolicyDescription{}
|
||||
err = pdb.Get(ctx, policy.ID, updated)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "UpdatedName", updated.Name)
|
||||
assert.NotNil(t, updated.Description)
|
||||
assert.Equal(t, "Updated description", *updated.Description)
|
||||
})
|
||||
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
cleanupCollection(t, ctx, db)
|
||||
|
||||
desc := "To be deleted"
|
||||
policy := &model.PolicyDescription{
|
||||
Describable: model.Describable{
|
||||
Name: "WillDelete",
|
||||
Description: &desc,
|
||||
},
|
||||
}
|
||||
require.NoError(t, pdb.Create(ctx, policy))
|
||||
|
||||
err := pdb.Delete(ctx, policy.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
deleted := &model.PolicyDescription{}
|
||||
err = pdb.Get(ctx, policy.ID, deleted)
|
||||
assert.Error(t, err)
|
||||
assert.True(t, errors.Is(err, merrors.ErrNoData))
|
||||
})
|
||||
|
||||
t.Run("DeleteMany", func(t *testing.T) {
|
||||
cleanupCollection(t, ctx, db)
|
||||
|
||||
desc1 := "Will be deleted 1"
|
||||
desc2 := "Will be deleted 2"
|
||||
pol1 := &model.PolicyDescription{
|
||||
Describable: model.Describable{
|
||||
Name: "BatchDelete1",
|
||||
Description: &desc1,
|
||||
},
|
||||
}
|
||||
pol2 := &model.PolicyDescription{
|
||||
Describable: model.Describable{
|
||||
Name: "BatchDelete2",
|
||||
Description: &desc2,
|
||||
},
|
||||
}
|
||||
require.NoError(t, pdb.Create(ctx, pol1))
|
||||
require.NoError(t, pdb.Create(ctx, pol2))
|
||||
|
||||
q := repository.Query().RegEx(repository.Field("description"), "^Will be deleted", "")
|
||||
err := pdb.DeleteMany(ctx, q)
|
||||
require.NoError(t, err)
|
||||
|
||||
res1 := &model.PolicyDescription{}
|
||||
err1 := pdb.Get(ctx, pol1.ID, res1)
|
||||
assert.Error(t, err1)
|
||||
assert.True(t, errors.Is(err1, merrors.ErrNoData))
|
||||
|
||||
res2 := &model.PolicyDescription{}
|
||||
err2 := pdb.Get(ctx, pol2.ID, res2)
|
||||
assert.Error(t, err2)
|
||||
assert.True(t, errors.Is(err2, merrors.ErrNoData))
|
||||
})
|
||||
|
||||
t.Run("FindOne", func(t *testing.T) {
|
||||
cleanupCollection(t, ctx, db)
|
||||
|
||||
desc := "Unique find test"
|
||||
policy := &model.PolicyDescription{
|
||||
Describable: model.Describable{
|
||||
Name: "FindOneTest",
|
||||
Description: &desc,
|
||||
},
|
||||
}
|
||||
require.NoError(t, pdb.Create(ctx, policy))
|
||||
|
||||
// Match by name == "FindOneTest"
|
||||
q := repository.Query().Comparison(repository.Field("name"), builder.Eq, "FindOneTest")
|
||||
|
||||
found := &model.PolicyDescription{}
|
||||
err := pdb.FindOne(ctx, q, found)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, policy.ID, found.ID)
|
||||
assert.Equal(t, "FindOneTest", found.Name)
|
||||
assert.NotNil(t, found.Description)
|
||||
assert.Equal(t, "Unique find test", *found.Description)
|
||||
})
|
||||
|
||||
t.Run("All", func(t *testing.T) {
|
||||
cleanupCollection(t, ctx, db)
|
||||
|
||||
// Insert some policies (orgA, orgB, nil org)
|
||||
orgA := bson.NewObjectID()
|
||||
orgB := bson.NewObjectID()
|
||||
|
||||
descA := "Org A policy"
|
||||
policyA := &model.PolicyDescription{
|
||||
Describable: model.Describable{
|
||||
Name: "PolicyA",
|
||||
Description: &descA,
|
||||
},
|
||||
OrganizationRef: &orgA, // belongs to orgA
|
||||
}
|
||||
descB := "Org B policy"
|
||||
policyB := &model.PolicyDescription{
|
||||
Describable: model.Describable{
|
||||
Name: "PolicyB",
|
||||
Description: &descB,
|
||||
},
|
||||
OrganizationRef: &orgB, // belongs to orgB
|
||||
}
|
||||
descNil := "No org policy"
|
||||
policyNil := &model.PolicyDescription{
|
||||
Describable: model.Describable{
|
||||
Name: "PolicyNil",
|
||||
Description: &descNil,
|
||||
},
|
||||
// nil => built-in
|
||||
}
|
||||
require.NoError(t, pdb.Create(ctx, policyA))
|
||||
require.NoError(t, pdb.Create(ctx, policyB))
|
||||
require.NoError(t, pdb.Create(ctx, policyNil))
|
||||
|
||||
// Suppose the requirement is: "All" returns
|
||||
// - policies for the requested org
|
||||
// - plus built-in (nil) ones
|
||||
resultsA, err := pdb.All(ctx, orgA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resultsA, 2) // orgA + built-in
|
||||
|
||||
var idsA []bson.ObjectID
|
||||
for _, r := range resultsA {
|
||||
idsA = append(idsA, r.ID)
|
||||
}
|
||||
assert.Contains(t, idsA, policyA.ID)
|
||||
assert.Contains(t, idsA, policyNil.ID)
|
||||
assert.NotContains(t, idsA, policyB.ID)
|
||||
|
||||
resultsB, err := pdb.All(ctx, orgB)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resultsB, 2) // orgB + built-in
|
||||
|
||||
var idsB []bson.ObjectID
|
||||
for _, r := range resultsB {
|
||||
idsB = append(idsB, r.ID)
|
||||
}
|
||||
assert.Contains(t, idsB, policyB.ID)
|
||||
assert.Contains(t, idsB, policyNil.ID)
|
||||
assert.NotContains(t, idsB, policyA.ID)
|
||||
})
|
||||
|
||||
t.Run("Policies", func(t *testing.T) {
|
||||
cleanupCollection(t, ctx, db)
|
||||
|
||||
desc1 := "PolicyOne"
|
||||
pol1 := &model.PolicyDescription{
|
||||
Describable: model.Describable{
|
||||
Name: "PolicyOne",
|
||||
Description: &desc1,
|
||||
},
|
||||
}
|
||||
desc2 := "PolicyTwo"
|
||||
pol2 := &model.PolicyDescription{
|
||||
Describable: model.Describable{
|
||||
Name: "PolicyTwo",
|
||||
Description: &desc2,
|
||||
},
|
||||
}
|
||||
desc3 := "PolicyThree"
|
||||
pol3 := &model.PolicyDescription{
|
||||
Describable: model.Describable{
|
||||
Name: "PolicyThree",
|
||||
Description: &desc3,
|
||||
},
|
||||
}
|
||||
require.NoError(t, pdb.Create(ctx, pol1))
|
||||
require.NoError(t, pdb.Create(ctx, pol2))
|
||||
require.NoError(t, pdb.Create(ctx, pol3))
|
||||
|
||||
// 1) Request pol1, pol2
|
||||
results12, err := pdb.Policies(ctx, []bson.ObjectID{pol1.ID, pol2.ID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results12, 2)
|
||||
// IDs might be out of order, so we do a set-like check
|
||||
var set12 []bson.ObjectID
|
||||
for _, r := range results12 {
|
||||
set12 = append(set12, r.ID)
|
||||
}
|
||||
assert.Contains(t, set12, pol1.ID)
|
||||
assert.Contains(t, set12, pol2.ID)
|
||||
|
||||
// 2) Request pol1, pol3, plus a random ID
|
||||
fakeID := bson.NewObjectID()
|
||||
results13Fake, err := pdb.Policies(ctx, []bson.ObjectID{pol1.ID, pol3.ID, fakeID})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results13Fake, 2) // pol1 + pol3 only
|
||||
var set13Fake []bson.ObjectID
|
||||
for _, r := range results13Fake {
|
||||
set13Fake = append(set13Fake, r.ID)
|
||||
}
|
||||
assert.Contains(t, set13Fake, pol1.ID)
|
||||
assert.Contains(t, set13Fake, pol3.ID)
|
||||
|
||||
// 3) Request with empty slice => expect no results
|
||||
resultsEmpty, err := pdb.Policies(ctx, []bson.ObjectID{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, resultsEmpty, 0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package policiesdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/model"
|
||||
mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func (db *PoliciesDB) Policies(ctx context.Context, refs []bson.ObjectID) ([]model.PolicyDescription, error) {
|
||||
if len(refs) == 0 {
|
||||
return []model.PolicyDescription{}, nil
|
||||
}
|
||||
filter := repository.Query().In(repository.IDField(), refs)
|
||||
return mutil.GetObjects[model.PolicyDescription](ctx, db.Logger, filter, nil, db.Repository)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package builderimp
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type literalAccumulatorImp struct {
|
||||
op builder.MongoOperation
|
||||
value any
|
||||
}
|
||||
|
||||
func (a *literalAccumulatorImp) Build() bson.D {
|
||||
return bson.D{{Key: string(a.op), Value: a.value}}
|
||||
}
|
||||
|
||||
func NewAccumulator(op builder.MongoOperation, value any) builder.Accumulator {
|
||||
return &literalAccumulatorImp{op: op, value: value}
|
||||
}
|
||||
|
||||
func AddToSet(value builder.Expression) builder.Expression {
|
||||
return newUnaryExpression(builder.AddToSet, value)
|
||||
}
|
||||
|
||||
func Size(value builder.Expression) builder.Expression {
|
||||
return newUnaryExpression(builder.Size, value)
|
||||
}
|
||||
|
||||
func Ne(left, right builder.Expression) builder.Expression {
|
||||
return newBinaryExpression(builder.Ne, left, right)
|
||||
}
|
||||
|
||||
func Sum(value any) builder.Accumulator {
|
||||
return NewAccumulator(builder.Sum, value)
|
||||
}
|
||||
|
||||
func Avg(value any) builder.Accumulator {
|
||||
return NewAccumulator(builder.Avg, value)
|
||||
}
|
||||
|
||||
func Min(value any) builder.Accumulator {
|
||||
return NewAccumulator(builder.Min, value)
|
||||
}
|
||||
|
||||
func Max(value any) builder.Accumulator {
|
||||
return NewAccumulator(builder.Max, value)
|
||||
}
|
||||
|
||||
func Eq(left, right builder.Expression) builder.Expression {
|
||||
return newBinaryExpression(builder.Eq, left, right)
|
||||
}
|
||||
|
||||
func Gt(left, right builder.Expression) builder.Expression {
|
||||
return newBinaryExpression(builder.Gt, left, right)
|
||||
}
|
||||
|
||||
func Add(left, right builder.Accumulator) builder.Accumulator {
|
||||
return newBinaryAccumulator(builder.Add, left, right)
|
||||
}
|
||||
|
||||
func Subtract(left, right builder.Accumulator) builder.Accumulator {
|
||||
return newBinaryAccumulator(builder.Subtract, left, right)
|
||||
}
|
||||
|
||||
func Multiply(left, right builder.Accumulator) builder.Accumulator {
|
||||
return newBinaryAccumulator(builder.Multiply, left, right)
|
||||
}
|
||||
|
||||
func Divide(left, right builder.Accumulator) builder.Accumulator {
|
||||
return newBinaryAccumulator(builder.Divide, left, right)
|
||||
}
|
||||
|
||||
type binaryAccumulator struct {
|
||||
op builder.MongoOperation
|
||||
left builder.Accumulator
|
||||
right builder.Accumulator
|
||||
}
|
||||
|
||||
func newBinaryAccumulator(op builder.MongoOperation, left, right builder.Accumulator) builder.Accumulator {
|
||||
return &binaryAccumulator{
|
||||
op: op,
|
||||
left: left,
|
||||
right: right,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *binaryAccumulator) Build() bson.D {
|
||||
args := []any{b.left.Build(), b.right.Build()}
|
||||
return bson.D{{Key: string(b.op), Value: args}}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package builderimp
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type aliasImp struct {
|
||||
lhs builder.Field
|
||||
rhs any
|
||||
}
|
||||
|
||||
func (a *aliasImp) Field() builder.Field {
|
||||
return a.lhs
|
||||
}
|
||||
|
||||
func (a *aliasImp) Build() bson.D {
|
||||
return bson.D{{Key: a.lhs.Build(), Value: a.rhs}}
|
||||
}
|
||||
|
||||
// 1. Null alias (_id: null)
|
||||
func NewNullAlias(lhs builder.Field) builder.Alias {
|
||||
return &aliasImp{lhs: lhs, rhs: nil}
|
||||
}
|
||||
|
||||
func NewAlias(lhs builder.Field, rhs any) builder.Alias {
|
||||
return &aliasImp{lhs: lhs, rhs: rhs}
|
||||
}
|
||||
|
||||
// 2. Simple alias (_id: "$taskRef")
|
||||
func NewSimpleAlias(lhs, rhs builder.Field) builder.Alias {
|
||||
return &aliasImp{lhs: lhs, rhs: rhs.Build()}
|
||||
}
|
||||
|
||||
// 3. Complex alias (_id: { aliasName: "$originalField", ... })
|
||||
type ComplexAlias struct {
|
||||
lhs builder.Field
|
||||
rhs []builder.Alias // Correcting handling of slice of aliases
|
||||
}
|
||||
|
||||
func (a *ComplexAlias) Field() builder.Field {
|
||||
return a.lhs
|
||||
}
|
||||
|
||||
func (a *ComplexAlias) Build() bson.D {
|
||||
fieldMap := bson.M{}
|
||||
|
||||
for _, alias := range a.rhs {
|
||||
aliasDoc := alias.Build()
|
||||
for _, field := range aliasDoc {
|
||||
fieldMap[field.Key] = field.Value
|
||||
}
|
||||
}
|
||||
|
||||
return bson.D{{Key: a.lhs.Build(), Value: fieldMap}}
|
||||
}
|
||||
|
||||
func NewComplexAlias(lhs builder.Field, rhs []builder.Alias) builder.Alias {
|
||||
return &ComplexAlias{lhs: lhs, rhs: rhs}
|
||||
}
|
||||
|
||||
type aliasesImp struct {
|
||||
aliases []builder.Alias
|
||||
}
|
||||
|
||||
func (a *aliasesImp) Field() builder.Field {
|
||||
if len(a.aliases) > 0 {
|
||||
return a.aliases[0].Field()
|
||||
}
|
||||
return NewFieldImp("")
|
||||
}
|
||||
|
||||
func (a *aliasesImp) Build() bson.D {
|
||||
results := make([]bson.D, 0)
|
||||
for _, alias := range a.aliases {
|
||||
results = append(results, alias.Build())
|
||||
}
|
||||
aliases := bson.D{}
|
||||
for _, r := range results {
|
||||
aliases = append(aliases, r...)
|
||||
}
|
||||
return aliases
|
||||
}
|
||||
|
||||
func NewAliases(aliases ...builder.Alias) builder.Alias {
|
||||
return &aliasesImp{aliases: aliases}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package builderimp
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type arrayImp struct {
|
||||
elements []builder.Expression
|
||||
}
|
||||
|
||||
// Build renders the literal array:
|
||||
//
|
||||
// [ <expr1>, <expr2>, … ]
|
||||
func (b *arrayImp) Build() bson.A {
|
||||
arr := make(bson.A, len(b.elements))
|
||||
for i, expr := range b.elements {
|
||||
// each expr.Build() returns the raw value or sub‐expression
|
||||
arr[i] = expr.Build()
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
// NewArray constructs a new array expression from the given sub‐expressions.
|
||||
func NewArray(exprs ...builder.Expression) *arrayImp {
|
||||
return &arrayImp{elements: exprs}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package builderimp
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type literalExpression struct {
|
||||
value any
|
||||
}
|
||||
|
||||
func NewLiteralExpression(value any) builder.Expression {
|
||||
return &literalExpression{value: value}
|
||||
}
|
||||
|
||||
func (e *literalExpression) Build() any {
|
||||
return bson.D{{Key: string(builder.Literal), Value: e.value}}
|
||||
}
|
||||
|
||||
type variadicExpression struct {
|
||||
op builder.MongoOperation
|
||||
parts []builder.Expression
|
||||
}
|
||||
|
||||
func (e *variadicExpression) Build() any {
|
||||
args := make([]any, 0, len(e.parts))
|
||||
for _, p := range e.parts {
|
||||
args = append(args, p.Build())
|
||||
}
|
||||
return bson.D{{Key: string(e.op), Value: args}}
|
||||
}
|
||||
|
||||
func newVariadicExpression(op builder.MongoOperation, exprs ...builder.Expression) builder.Expression {
|
||||
return &variadicExpression{
|
||||
op: op,
|
||||
parts: exprs,
|
||||
}
|
||||
}
|
||||
|
||||
func newBinaryExpression(op builder.MongoOperation, left, right builder.Expression) builder.Expression {
|
||||
return &variadicExpression{
|
||||
op: op,
|
||||
parts: []builder.Expression{left, right},
|
||||
}
|
||||
}
|
||||
|
||||
type unaryExpression struct {
|
||||
op builder.MongoOperation
|
||||
rhs builder.Expression
|
||||
}
|
||||
|
||||
func (e *unaryExpression) Build() any {
|
||||
return bson.D{{Key: string(e.op), Value: e.rhs.Build()}}
|
||||
}
|
||||
|
||||
func newUnaryExpression(op builder.MongoOperation, right builder.Expression) builder.Expression {
|
||||
return &unaryExpression{
|
||||
op: op,
|
||||
rhs: right,
|
||||
}
|
||||
}
|
||||
|
||||
type matchExpression struct {
|
||||
op builder.MongoOperation
|
||||
rhs builder.Expression
|
||||
}
|
||||
|
||||
func (e *matchExpression) Build() any {
|
||||
return bson.E{Key: string(e.op), Value: e.rhs.Build()}
|
||||
}
|
||||
|
||||
func newMatchExpression(op builder.MongoOperation, right builder.Expression) builder.Expression {
|
||||
return &matchExpression{
|
||||
op: op,
|
||||
rhs: right,
|
||||
}
|
||||
}
|
||||
|
||||
func InRef(value builder.Field) builder.Expression {
|
||||
return newMatchExpression(builder.In, NewValue(NewRefFieldImp(value).Build()))
|
||||
}
|
||||
|
||||
type inImpl struct {
|
||||
values []any
|
||||
}
|
||||
|
||||
func (e *inImpl) Build() any {
|
||||
return bson.D{{Key: string(builder.In), Value: e.values}}
|
||||
}
|
||||
|
||||
func In(values ...any) builder.Expression {
|
||||
var flattenedValues []any
|
||||
|
||||
for _, v := range values {
|
||||
switch reflect.TypeOf(v).Kind() {
|
||||
case reflect.Slice:
|
||||
slice := reflect.ValueOf(v)
|
||||
for i := range slice.Len() {
|
||||
flattenedValues = append(flattenedValues, slice.Index(i).Interface())
|
||||
}
|
||||
default:
|
||||
flattenedValues = append(flattenedValues, v)
|
||||
}
|
||||
}
|
||||
return &inImpl{values: flattenedValues}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package builderimp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
)
|
||||
|
||||
type FieldImp struct {
|
||||
fields []string
|
||||
}
|
||||
|
||||
func (b *FieldImp) Dot(field string) builder.Field {
|
||||
newFields := make([]string, len(b.fields), len(b.fields)+1)
|
||||
copy(newFields, b.fields)
|
||||
newFields = append(newFields, field)
|
||||
return &FieldImp{fields: newFields}
|
||||
}
|
||||
|
||||
func (b *FieldImp) CopyWith(field string) builder.Field {
|
||||
copiedFields := make([]string, 0, len(b.fields)+1)
|
||||
copiedFields = append(copiedFields, b.fields...)
|
||||
copiedFields = append(copiedFields, field)
|
||||
return &FieldImp{
|
||||
fields: copiedFields,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *FieldImp) Build() string {
|
||||
return strings.Join(b.fields, ".")
|
||||
}
|
||||
|
||||
func NewFieldImp(baseName string) builder.Field {
|
||||
return &FieldImp{
|
||||
fields: []string{baseName},
|
||||
}
|
||||
}
|
||||
|
||||
type RefField struct {
|
||||
imp builder.Field
|
||||
}
|
||||
|
||||
func (b *RefField) Build() string {
|
||||
return "$" + b.imp.Build()
|
||||
}
|
||||
|
||||
func (b *RefField) CopyWith(field string) builder.Field {
|
||||
return &RefField{
|
||||
imp: b.imp.CopyWith(field),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *RefField) Dot(field string) builder.Field {
|
||||
return &RefField{
|
||||
imp: b.imp.Dot(field),
|
||||
}
|
||||
}
|
||||
|
||||
func NewRefFieldImp(field builder.Field) builder.Field {
|
||||
return &RefField{
|
||||
imp: field,
|
||||
}
|
||||
}
|
||||
|
||||
func NewRootRef() builder.Field {
|
||||
return NewFieldImp("$$ROOT")
|
||||
}
|
||||
|
||||
func NewRemoveRef() builder.Field {
|
||||
return NewFieldImp("$$REMOVE")
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package builderimp
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type condImp struct {
|
||||
condition builder.Expression
|
||||
ifTrue any
|
||||
ifFalse any
|
||||
}
|
||||
|
||||
func (c *condImp) Build() any {
|
||||
return bson.D{
|
||||
{Key: string(builder.Cond), Value: bson.D{
|
||||
{Key: "if", Value: c.condition.Build()},
|
||||
{Key: "then", Value: c.ifTrue},
|
||||
{Key: "else", Value: c.ifFalse},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func NewCond(condition builder.Expression, ifTrue, ifFalse any) builder.Expression {
|
||||
return &condImp{
|
||||
condition: condition,
|
||||
ifTrue: ifTrue,
|
||||
ifFalse: ifFalse,
|
||||
}
|
||||
}
|
||||
|
||||
// setUnionImp implements builder.Expression but takes only builder.Array inputs.
|
||||
type setUnionImp struct {
|
||||
inputs []builder.Expression
|
||||
}
|
||||
|
||||
// Build renders the $setUnion stage:
|
||||
//
|
||||
// { $setUnion: [ <array1>, <array2>, … ] }
|
||||
func (s *setUnionImp) Build() any {
|
||||
arr := make(bson.A, len(s.inputs))
|
||||
for i, arrayExpr := range s.inputs {
|
||||
arr[i] = arrayExpr.Build()
|
||||
}
|
||||
return bson.D{
|
||||
{Key: string(builder.SetUnion), Value: arr},
|
||||
}
|
||||
}
|
||||
|
||||
// NewSetUnion constructs a new $setUnion expression from the given Arrays.
|
||||
func NewSetUnion(arrays ...builder.Expression) builder.Expression {
|
||||
return &setUnionImp{inputs: arrays}
|
||||
}
|
||||
|
||||
type assignmentImp struct {
|
||||
field builder.Field
|
||||
expression builder.Expression
|
||||
}
|
||||
|
||||
func (a *assignmentImp) Build() bson.D {
|
||||
// Assign it to the given field name
|
||||
return bson.D{
|
||||
{Key: a.field.Build(), Value: a.expression.Build()},
|
||||
}
|
||||
}
|
||||
|
||||
// NewAssignment creates a projection assignment of the form:
|
||||
//
|
||||
// <field>: <expression>
|
||||
func NewAssignment(field builder.Field, expression builder.Expression) builder.Projection {
|
||||
return &assignmentImp{
|
||||
field: field,
|
||||
expression: expression,
|
||||
}
|
||||
}
|
||||
|
||||
type computeImp struct {
|
||||
field builder.Field
|
||||
expression builder.Expression
|
||||
}
|
||||
|
||||
func (a *computeImp) Build() any {
|
||||
return bson.D{
|
||||
{Key: a.field.Build(), Value: a.expression.Build()},
|
||||
}
|
||||
}
|
||||
|
||||
func NewCompute(field builder.Field, expression builder.Expression) builder.Expression {
|
||||
return &computeImp{
|
||||
field: field,
|
||||
expression: expression,
|
||||
}
|
||||
}
|
||||
|
||||
func NewIfNull(expression, replacement builder.Expression) builder.Expression {
|
||||
return newBinaryExpression(builder.IfNull, expression, replacement)
|
||||
}
|
||||
|
||||
func NewPush(expression builder.Expression) builder.Expression {
|
||||
return newUnaryExpression(builder.Push, expression)
|
||||
}
|
||||
|
||||
func NewAnd(exprs ...builder.Expression) builder.Expression {
|
||||
return newVariadicExpression(builder.And, exprs...)
|
||||
}
|
||||
|
||||
func NewOr(exprs ...builder.Expression) builder.Expression {
|
||||
return newVariadicExpression(builder.Or, exprs...)
|
||||
}
|
||||
|
||||
func NewEach(exprs ...builder.Expression) builder.Expression {
|
||||
return newVariadicExpression(builder.Each, exprs...)
|
||||
}
|
||||
|
||||
func NewLt(left, right builder.Expression) builder.Expression {
|
||||
return newBinaryExpression(builder.Lt, left, right)
|
||||
}
|
||||
|
||||
func NewNot(expression builder.Expression) builder.Expression {
|
||||
return newUnaryExpression(builder.Not, expression)
|
||||
}
|
||||
|
||||
func NewSum(expression builder.Expression) builder.Expression {
|
||||
return newUnaryExpression(builder.Sum, expression)
|
||||
}
|
||||
|
||||
func NewMin(expression builder.Expression) builder.Expression {
|
||||
return newUnaryExpression(builder.Min, expression)
|
||||
}
|
||||
|
||||
func First(expr builder.Expression) builder.Expression {
|
||||
return newUnaryExpression(builder.First, expr)
|
||||
}
|
||||
|
||||
func NewType(expr builder.Expression) builder.Expression {
|
||||
return newUnaryExpression(builder.Type, expr)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package builderimp
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type groupAccumulatorImp struct {
|
||||
field builder.Field
|
||||
acc builder.Accumulator
|
||||
}
|
||||
|
||||
// NewGroupAccumulator creates a new GroupAccumulator for the given field using the specified operator and value.
|
||||
func NewGroupAccumulator(field builder.Field, acc builder.Accumulator) builder.GroupAccumulator {
|
||||
return &groupAccumulatorImp{
|
||||
field: field,
|
||||
acc: acc,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *groupAccumulatorImp) Field() builder.Field {
|
||||
return g.field
|
||||
}
|
||||
|
||||
func (g *groupAccumulatorImp) Accumulator() builder.Accumulator {
|
||||
return g.acc
|
||||
}
|
||||
|
||||
// Build returns a bson.E element for this group accumulator.
|
||||
func (g *groupAccumulatorImp) Build() bson.D {
|
||||
return bson.D{{
|
||||
Key: g.field.Build(),
|
||||
Value: g.acc.Build(),
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package builderimp
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type patchBuilder struct {
|
||||
updates bson.D
|
||||
}
|
||||
|
||||
func set(field builder.Field, value any) bson.E {
|
||||
return bson.E{Key: string(builder.Set), Value: bson.D{{Key: field.Build(), Value: value}}}
|
||||
}
|
||||
|
||||
func (u *patchBuilder) Set(field builder.Field, value any) builder.Patch {
|
||||
u.updates = append(u.updates, set(field, value))
|
||||
return u
|
||||
}
|
||||
|
||||
func (u *patchBuilder) Inc(field builder.Field, value any) builder.Patch {
|
||||
u.updates = append(u.updates, bson.E{Key: string(builder.Inc), Value: bson.D{{Key: field.Build(), Value: value}}})
|
||||
return u
|
||||
}
|
||||
|
||||
func (u *patchBuilder) Unset(field builder.Field) builder.Patch {
|
||||
u.updates = append(u.updates, bson.E{Key: string(builder.Unset), Value: bson.D{{Key: field.Build(), Value: ""}}})
|
||||
return u
|
||||
}
|
||||
|
||||
func (u *patchBuilder) Rename(field builder.Field, newName string) builder.Patch {
|
||||
u.updates = append(u.updates, bson.E{Key: string(builder.Rename), Value: bson.D{{Key: field.Build(), Value: newName}}})
|
||||
return u
|
||||
}
|
||||
|
||||
func (u *patchBuilder) Push(field builder.Field, value any) builder.Patch {
|
||||
u.updates = append(u.updates, bson.E{Key: string(builder.Push), Value: bson.D{{Key: field.Build(), Value: value}}})
|
||||
return u
|
||||
}
|
||||
|
||||
func (u *patchBuilder) Pull(field builder.Field, value any) builder.Patch {
|
||||
u.updates = append(u.updates, bson.E{Key: string(builder.Pull), Value: bson.D{{Key: field.Build(), Value: value}}})
|
||||
return u
|
||||
}
|
||||
|
||||
func (u *patchBuilder) AddToSet(field builder.Field, value any) builder.Patch {
|
||||
u.updates = append(u.updates, bson.E{Key: string(builder.AddToSet), Value: bson.D{{Key: field.Build(), Value: value}}})
|
||||
return u
|
||||
}
|
||||
|
||||
func (u *patchBuilder) Build() bson.D {
|
||||
updates := append(bson.D{}, u.updates...)
|
||||
updates = append(updates, set(NewFieldImp(storable.UpdatedAtField), time.Now()))
|
||||
return mergePatchOperations(updates)
|
||||
}
|
||||
|
||||
func NewPatchImp() builder.Patch {
|
||||
return &patchBuilder{updates: bson.D{}}
|
||||
}
|
||||
|
||||
func mergePatchOperations(updates bson.D) bson.D {
|
||||
operators := make([]string, 0, len(updates))
|
||||
merged := make(map[string]bson.D, len(updates))
|
||||
for _, update := range updates {
|
||||
fields, ok := update.Value.(bson.D)
|
||||
if !ok {
|
||||
operators = appendMissingOperator(operators, update.Key)
|
||||
merged[update.Key] = append(merged[update.Key], bson.E{Key: update.Key, Value: update.Value})
|
||||
continue
|
||||
}
|
||||
operators = appendMissingOperator(operators, update.Key)
|
||||
for _, field := range fields {
|
||||
merged[update.Key] = setPatchField(merged[update.Key], field)
|
||||
}
|
||||
}
|
||||
|
||||
out := make(bson.D, 0, len(operators))
|
||||
for _, operator := range operators {
|
||||
out = append(out, bson.E{Key: operator, Value: merged[operator]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendMissingOperator(operators []string, operator string) []string {
|
||||
for _, existing := range operators {
|
||||
if existing == operator {
|
||||
return operators
|
||||
}
|
||||
}
|
||||
return append(operators, operator)
|
||||
}
|
||||
|
||||
func setPatchField(fields bson.D, field bson.E) bson.D {
|
||||
for i := range fields {
|
||||
if fields[i].Key == field.Key {
|
||||
fields[i] = field
|
||||
return fields
|
||||
}
|
||||
}
|
||||
return append(fields, field)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package builderimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
func TestPatchBuilderBuildMergesSetOperations(t *testing.T) {
|
||||
patch := NewPatchImp().
|
||||
Set(NewFieldImp("name"), "alice").
|
||||
Inc(NewFieldImp("version"), 1).
|
||||
Set(NewFieldImp("status"), "active")
|
||||
|
||||
built := patch.Build()
|
||||
|
||||
if got := countPatchOperator(built, string(builder.Set)); got != 1 {
|
||||
t.Fatalf("$set operator count = %d, want 1: %#v", got, built)
|
||||
}
|
||||
if got := countPatchOperator(built, string(builder.Inc)); got != 1 {
|
||||
t.Fatalf("$inc operator count = %d, want 1: %#v", got, built)
|
||||
}
|
||||
setFields := patchOperatorFields(t, built, string(builder.Set))
|
||||
for _, key := range []string{"name", "status", storable.UpdatedAtField} {
|
||||
if countPatchField(setFields, key) != 1 {
|
||||
t.Fatalf("$set field %s must be present once: %#v", key, setFields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchBuilderBuildOwnsUpdatedAt(t *testing.T) {
|
||||
manualUpdatedAt := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
patch := NewPatchImp().
|
||||
Set(NewFieldImp(storable.UpdatedAtField), manualUpdatedAt).
|
||||
Set(NewFieldImp("status"), "active")
|
||||
|
||||
built := patch.Build()
|
||||
setFields := patchOperatorFields(t, built, string(builder.Set))
|
||||
|
||||
if got := countPatchField(setFields, storable.UpdatedAtField); got != 1 {
|
||||
t.Fatalf("updatedAt field count = %d, want 1: %#v", got, setFields)
|
||||
}
|
||||
value, ok := patchFieldValue(setFields, storable.UpdatedAtField).(time.Time)
|
||||
if !ok {
|
||||
t.Fatalf("updatedAt value type = %T, want time.Time", patchFieldValue(setFields, storable.UpdatedAtField))
|
||||
}
|
||||
if value.Equal(manualUpdatedAt) {
|
||||
t.Fatalf("updatedAt must be repository-owned, got manual value %s", value.Format(time.RFC3339Nano))
|
||||
}
|
||||
}
|
||||
|
||||
func patchOperatorFields(t *testing.T, built bson.D, operator string) bson.D {
|
||||
t.Helper()
|
||||
for _, entry := range built {
|
||||
if entry.Key != operator {
|
||||
continue
|
||||
}
|
||||
fields, ok := entry.Value.(bson.D)
|
||||
if !ok {
|
||||
t.Fatalf("%s value type = %T, want bson.D", operator, entry.Value)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
t.Fatalf("%s operator missing: %#v", operator, built)
|
||||
return nil
|
||||
}
|
||||
|
||||
func countPatchOperator(built bson.D, operator string) int {
|
||||
count := 0
|
||||
for _, entry := range built {
|
||||
if entry.Key == operator {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countPatchField(fields bson.D, key string) int {
|
||||
count := 0
|
||||
for _, field := range fields {
|
||||
if field.Key == key {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func patchFieldValue(fields bson.D, key string) any {
|
||||
for _, field := range fields {
|
||||
if field.Key == key {
|
||||
return field.Value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package builderimp
|
||||
|
||||
import (
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
)
|
||||
|
||||
type unwindOpts = builder.UnwindOpts
|
||||
|
||||
// UnwindOption is the same type defined in the builder package.
|
||||
type UnwindOption = builder.UnwindOption
|
||||
|
||||
// NewUnwindOpts applies all UnwindOption's to a fresh unwindOpts.
|
||||
func NewUnwindOpts(opts ...UnwindOption) *unwindOpts {
|
||||
cfg := &unwindOpts{}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
type PipelineImp struct {
|
||||
pipeline mongo.Pipeline
|
||||
}
|
||||
|
||||
func (b *PipelineImp) Match(filter builder.Query) builder.Pipeline {
|
||||
b.pipeline = append(b.pipeline, filter.BuildPipeline())
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *PipelineImp) Lookup(from mservice.Type, localField, foreignField, as builder.Field) builder.Pipeline {
|
||||
b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Lookup), Value: bson.D{
|
||||
{Key: string(builder.MKFrom), Value: from},
|
||||
{Key: string(builder.MKLocalField), Value: localField.Build()},
|
||||
{Key: string(builder.MKForeignField), Value: foreignField.Build()},
|
||||
{Key: string(builder.MKAs), Value: as.Build()},
|
||||
}}})
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *PipelineImp) LookupWithPipeline(
|
||||
from mservice.Type,
|
||||
nested builder.Pipeline,
|
||||
as builder.Field,
|
||||
let *map[string]builder.Field,
|
||||
) builder.Pipeline {
|
||||
lookupStage := bson.D{
|
||||
{Key: string(builder.MKFrom), Value: from},
|
||||
{Key: string(builder.MKPipeline), Value: nested.Build()},
|
||||
{Key: string(builder.MKAs), Value: as.Build()},
|
||||
}
|
||||
|
||||
// only add "let" if provided and not empty
|
||||
if let != nil && len(*let) > 0 {
|
||||
letDoc := bson.D{}
|
||||
for varName, fld := range *let {
|
||||
letDoc = append(letDoc, bson.E{Key: varName, Value: fld.Build()})
|
||||
}
|
||||
lookupStage = append(lookupStage, bson.E{Key: string(builder.MKLet), Value: letDoc})
|
||||
}
|
||||
|
||||
b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Lookup), Value: lookupStage}})
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *PipelineImp) Unwind(path builder.Field, opts ...UnwindOption) builder.Pipeline {
|
||||
cfg := NewUnwindOpts(opts...)
|
||||
|
||||
var stageValue interface{}
|
||||
// if no options, shorthand
|
||||
if !cfg.PreserveNullAndEmptyArrays && cfg.IncludeArrayIndex == "" {
|
||||
stageValue = path.Build()
|
||||
} else {
|
||||
d := bson.D{{Key: string(builder.MKPath), Value: path.Build()}}
|
||||
if cfg.PreserveNullAndEmptyArrays {
|
||||
d = append(d, bson.E{Key: string(builder.MKPreserveNullAndEmptyArrays), Value: true})
|
||||
}
|
||||
if cfg.IncludeArrayIndex != "" {
|
||||
d = append(d, bson.E{Key: string(builder.MKIncludeArrayIndex), Value: cfg.IncludeArrayIndex})
|
||||
}
|
||||
stageValue = d
|
||||
}
|
||||
|
||||
b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Unwind), Value: stageValue}})
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *PipelineImp) Count(field builder.Field) builder.Pipeline {
|
||||
b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Count), Value: field.Build()}})
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *PipelineImp) Group(groupBy builder.Alias, accumulators ...builder.GroupAccumulator) builder.Pipeline {
|
||||
groupDoc := groupBy.Build()
|
||||
for _, acc := range accumulators {
|
||||
groupDoc = append(groupDoc, acc.Build()...)
|
||||
}
|
||||
|
||||
b.pipeline = append(b.pipeline, bson.D{
|
||||
{Key: string(builder.Group), Value: groupDoc},
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *PipelineImp) Project(projections ...builder.Projection) builder.Pipeline {
|
||||
projDoc := bson.D{}
|
||||
for _, pr := range projections {
|
||||
projDoc = append(projDoc, pr.Build()...)
|
||||
}
|
||||
b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.Project), Value: projDoc}})
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *PipelineImp) ReplaceRoot(newRoot builder.Expression) builder.Pipeline {
|
||||
b.pipeline = append(b.pipeline, bson.D{{Key: string(builder.ReplaceRoot), Value: bson.D{
|
||||
{Key: string(builder.MKNewRoot), Value: newRoot.Build()},
|
||||
}}})
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *PipelineImp) Build() mongo.Pipeline {
|
||||
return b.pipeline
|
||||
}
|
||||
|
||||
func NewPipelineImp(stages ...bson.D) builder.Pipeline {
|
||||
pipeline := make(mongo.Pipeline, len(stages))
|
||||
copy(pipeline, stages)
|
||||
return &PipelineImp{
|
||||
pipeline: pipeline,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
package builderimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
|
||||
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
)
|
||||
|
||||
func TestNewPipelineImp(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
|
||||
assert.NotNil(t, pipeline)
|
||||
assert.IsType(t, &PipelineImp{}, pipeline)
|
||||
|
||||
// Build should return empty pipeline initially
|
||||
built := pipeline.Build()
|
||||
assert.NotNil(t, built)
|
||||
assert.Len(t, built, 0)
|
||||
}
|
||||
|
||||
func TestNewPipelineImpSeedsComposableStages(t *testing.T) {
|
||||
sortStage := bson.D{{Key: "$sort", Value: bson.D{{Key: "createdAt", Value: -1}}}}
|
||||
pipeline := NewPipelineImp(sortStage)
|
||||
|
||||
result := pipeline.Count(&MockField{build: "total"})
|
||||
|
||||
assert.Same(t, pipeline, result)
|
||||
assert.Equal(t, mongo.Pipeline{
|
||||
sortStage,
|
||||
bson.D{{Key: "$count", Value: "total"}},
|
||||
}, pipeline.Build())
|
||||
}
|
||||
|
||||
func TestPipelineImp_Match(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockQuery := &MockQuery{
|
||||
buildPipeline: bson.D{{Key: "$match", Value: bson.D{{Key: "field", Value: "value"}}}},
|
||||
}
|
||||
|
||||
result := pipeline.Match(mockQuery)
|
||||
|
||||
// Should return self for chaining
|
||||
assert.Same(t, pipeline, result)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
assert.Equal(t, bson.D{{Key: "$match", Value: bson.D{{Key: "field", Value: "value"}}}}, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_Lookup(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockLocalField := &MockField{build: "localField"}
|
||||
mockForeignField := &MockField{build: "foreignField"}
|
||||
mockAsField := &MockField{build: "asField"}
|
||||
|
||||
result := pipeline.Lookup(mservice.Site, mockLocalField, mockForeignField, mockAsField)
|
||||
|
||||
// Should return self for chaining
|
||||
assert.Same(t, pipeline, result)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.Lookup), Value: bson.D{
|
||||
{Key: string(builder.MKFrom), Value: mservice.Site},
|
||||
{Key: string(builder.MKLocalField), Value: "localField"},
|
||||
{Key: string(builder.MKForeignField), Value: "foreignField"},
|
||||
{Key: string(builder.MKAs), Value: "asField"},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_LookupWithPipeline_WithoutLet(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockNestedPipeline := &MockPipeline{
|
||||
build: mongo.Pipeline{bson.D{{Key: "$match", Value: bson.D{{Key: "nested", Value: true}}}}},
|
||||
}
|
||||
mockAsField := &MockField{build: "asField"}
|
||||
|
||||
result := pipeline.LookupWithPipeline(mservice.Site, mockNestedPipeline, mockAsField, nil)
|
||||
|
||||
// Should return self for chaining
|
||||
assert.Same(t, pipeline, result)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.Lookup), Value: bson.D{
|
||||
{Key: string(builder.MKFrom), Value: mservice.Site},
|
||||
{Key: string(builder.MKPipeline), Value: mockNestedPipeline.build},
|
||||
{Key: string(builder.MKAs), Value: "asField"},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_LookupWithPipeline_WithLet(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockNestedPipeline := &MockPipeline{
|
||||
build: mongo.Pipeline{bson.D{{Key: "$match", Value: bson.D{{Key: "nested", Value: true}}}}},
|
||||
}
|
||||
mockAsField := &MockField{build: "asField"}
|
||||
mockLetField := &MockField{build: "$_id"}
|
||||
|
||||
letVars := map[string]builder.Field{
|
||||
"projRef": mockLetField,
|
||||
}
|
||||
|
||||
result := pipeline.LookupWithPipeline(mservice.Site, mockNestedPipeline, mockAsField, &letVars)
|
||||
|
||||
// Should return self for chaining
|
||||
assert.Same(t, pipeline, result)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.Lookup), Value: bson.D{
|
||||
{Key: string(builder.MKFrom), Value: mservice.Site},
|
||||
{Key: string(builder.MKPipeline), Value: mockNestedPipeline.build},
|
||||
{Key: string(builder.MKAs), Value: "asField"},
|
||||
{Key: string(builder.MKLet), Value: bson.D{{Key: "projRef", Value: "$_id"}}},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_LookupWithPipeline_WithEmptyLet(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockNestedPipeline := &MockPipeline{
|
||||
build: mongo.Pipeline{bson.D{{Key: "$match", Value: bson.D{{Key: "nested", Value: true}}}}},
|
||||
}
|
||||
mockAsField := &MockField{build: "asField"}
|
||||
|
||||
emptyLetVars := map[string]builder.Field{}
|
||||
|
||||
pipeline.LookupWithPipeline(mservice.Site, mockNestedPipeline, mockAsField, &emptyLetVars)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
// Should not include let field when empty
|
||||
expected := bson.D{{Key: string(builder.Lookup), Value: bson.D{
|
||||
{Key: string(builder.MKFrom), Value: mservice.Site},
|
||||
{Key: string(builder.MKPipeline), Value: mockNestedPipeline.build},
|
||||
{Key: string(builder.MKAs), Value: "asField"},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_Unwind_Simple(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockField := &MockField{build: "$array"}
|
||||
|
||||
result := pipeline.Unwind(mockField)
|
||||
|
||||
// Should return self for chaining
|
||||
assert.Same(t, pipeline, result)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.Unwind), Value: "$array"}}
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_Unwind_WithPreserveNullAndEmptyArrays(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockField := &MockField{build: "$array"}
|
||||
|
||||
// Mock the UnwindOption function
|
||||
preserveOpt := func(opts *builder.UnwindOpts) {
|
||||
opts.PreserveNullAndEmptyArrays = true
|
||||
}
|
||||
|
||||
pipeline.Unwind(mockField, preserveOpt)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.Unwind), Value: bson.D{
|
||||
{Key: string(builder.MKPath), Value: "$array"},
|
||||
{Key: string(builder.MKPreserveNullAndEmptyArrays), Value: true},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_Unwind_WithIncludeArrayIndex(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockField := &MockField{build: "$array"}
|
||||
|
||||
// Mock the UnwindOption function
|
||||
indexOpt := func(opts *builder.UnwindOpts) {
|
||||
opts.IncludeArrayIndex = "arrayIndex"
|
||||
}
|
||||
|
||||
pipeline.Unwind(mockField, indexOpt)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.Unwind), Value: bson.D{
|
||||
{Key: string(builder.MKPath), Value: "$array"},
|
||||
{Key: string(builder.MKIncludeArrayIndex), Value: "arrayIndex"},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_Unwind_WithBothOptions(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockField := &MockField{build: "$array"}
|
||||
|
||||
// Mock the UnwindOption functions
|
||||
preserveOpt := func(opts *builder.UnwindOpts) {
|
||||
opts.PreserveNullAndEmptyArrays = true
|
||||
}
|
||||
indexOpt := func(opts *builder.UnwindOpts) {
|
||||
opts.IncludeArrayIndex = "arrayIndex"
|
||||
}
|
||||
|
||||
pipeline.Unwind(mockField, preserveOpt, indexOpt)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.Unwind), Value: bson.D{
|
||||
{Key: string(builder.MKPath), Value: "$array"},
|
||||
{Key: string(builder.MKPreserveNullAndEmptyArrays), Value: true},
|
||||
{Key: string(builder.MKIncludeArrayIndex), Value: "arrayIndex"},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_Count(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockField := &MockField{build: "totalCount"}
|
||||
|
||||
result := pipeline.Count(mockField)
|
||||
|
||||
// Should return self for chaining
|
||||
assert.Same(t, pipeline, result)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.Count), Value: "totalCount"}}
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_Group(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockAlias := &MockAlias{
|
||||
build: bson.D{{Key: "_id", Value: "$field"}},
|
||||
field: &MockField{build: "_id"},
|
||||
}
|
||||
mockAccumulator := &MockGroupAccumulator{
|
||||
build: bson.D{{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}},
|
||||
}
|
||||
|
||||
result := pipeline.Group(mockAlias, mockAccumulator)
|
||||
|
||||
// Should return self for chaining
|
||||
assert.Same(t, pipeline, result)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.Group), Value: bson.D{
|
||||
{Key: "_id", Value: "$field"},
|
||||
{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_Group_MultipleAccumulators(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockAlias := &MockAlias{
|
||||
build: bson.D{{Key: "_id", Value: "$field"}},
|
||||
field: &MockField{build: "_id"},
|
||||
}
|
||||
mockAccumulator1 := &MockGroupAccumulator{
|
||||
build: bson.D{{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}}},
|
||||
}
|
||||
mockAccumulator2 := &MockGroupAccumulator{
|
||||
build: bson.D{{Key: "total", Value: bson.D{{Key: "$sum", Value: "$amount"}}}},
|
||||
}
|
||||
|
||||
pipeline.Group(mockAlias, mockAccumulator1, mockAccumulator2)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.Group), Value: bson.D{
|
||||
{Key: "_id", Value: "$field"},
|
||||
{Key: "count", Value: bson.D{{Key: "$sum", Value: 1}}},
|
||||
{Key: "total", Value: bson.D{{Key: "$sum", Value: "$amount"}}},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_Project(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockProjection := &MockProjection{
|
||||
build: bson.D{{Key: "field1", Value: 1}},
|
||||
}
|
||||
|
||||
result := pipeline.Project(mockProjection)
|
||||
|
||||
// Should return self for chaining
|
||||
assert.Same(t, pipeline, result)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.Project), Value: bson.D{
|
||||
{Key: "field1", Value: 1},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_Project_MultipleProjections(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockProjection1 := &MockProjection{
|
||||
build: bson.D{{Key: "field1", Value: 1}},
|
||||
}
|
||||
mockProjection2 := &MockProjection{
|
||||
build: bson.D{{Key: "field2", Value: 0}},
|
||||
}
|
||||
|
||||
pipeline.Project(mockProjection1, mockProjection2)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.Project), Value: bson.D{
|
||||
{Key: "field1", Value: 1},
|
||||
{Key: "field2", Value: 0},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_ChainedOperations(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
|
||||
// Create mocks
|
||||
mockQuery := &MockQuery{
|
||||
buildPipeline: bson.D{{Key: "$match", Value: bson.D{{Key: "status", Value: "active"}}}},
|
||||
}
|
||||
mockLocalField := &MockField{build: "userId"}
|
||||
mockForeignField := &MockField{build: "_id"}
|
||||
mockAsField := &MockField{build: "user"}
|
||||
mockUnwindField := &MockField{build: "$user"}
|
||||
mockProjection := &MockProjection{
|
||||
build: bson.D{{Key: "name", Value: "$user.name"}},
|
||||
}
|
||||
|
||||
// Chain operations
|
||||
result := pipeline.
|
||||
Match(mockQuery).
|
||||
Lookup(mservice.Accounts, mockLocalField, mockForeignField, mockAsField).
|
||||
Unwind(mockUnwindField).
|
||||
Project(mockProjection)
|
||||
|
||||
// Should return self for chaining
|
||||
assert.Same(t, pipeline, result)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 4)
|
||||
|
||||
// Verify each stage
|
||||
assert.Equal(t, bson.D{{Key: "$match", Value: bson.D{{Key: "status", Value: "active"}}}}, built[0])
|
||||
|
||||
expectedLookup := bson.D{{Key: string(builder.Lookup), Value: bson.D{
|
||||
{Key: string(builder.MKFrom), Value: mservice.Accounts},
|
||||
{Key: string(builder.MKLocalField), Value: "userId"},
|
||||
{Key: string(builder.MKForeignField), Value: "_id"},
|
||||
{Key: string(builder.MKAs), Value: "user"},
|
||||
}}}
|
||||
assert.Equal(t, expectedLookup, built[1])
|
||||
|
||||
assert.Equal(t, bson.D{{Key: string(builder.Unwind), Value: "$user"}}, built[2])
|
||||
|
||||
expectedProject := bson.D{{Key: string(builder.Project), Value: bson.D{
|
||||
{Key: "name", Value: "$user.name"},
|
||||
}}}
|
||||
assert.Equal(t, expectedProject, built[3])
|
||||
}
|
||||
|
||||
func TestNewUnwindOpts(t *testing.T) {
|
||||
t.Run("NoOptions", func(t *testing.T) {
|
||||
opts := NewUnwindOpts()
|
||||
|
||||
assert.NotNil(t, opts)
|
||||
assert.False(t, opts.PreserveNullAndEmptyArrays)
|
||||
assert.Empty(t, opts.IncludeArrayIndex)
|
||||
})
|
||||
|
||||
t.Run("WithPreserveOption", func(t *testing.T) {
|
||||
preserveOpt := func(opts *builder.UnwindOpts) {
|
||||
opts.PreserveNullAndEmptyArrays = true
|
||||
}
|
||||
|
||||
opts := NewUnwindOpts(preserveOpt)
|
||||
|
||||
assert.True(t, opts.PreserveNullAndEmptyArrays)
|
||||
assert.Empty(t, opts.IncludeArrayIndex)
|
||||
})
|
||||
|
||||
t.Run("WithIndexOption", func(t *testing.T) {
|
||||
indexOpt := func(opts *builder.UnwindOpts) {
|
||||
opts.IncludeArrayIndex = "index"
|
||||
}
|
||||
|
||||
opts := NewUnwindOpts(indexOpt)
|
||||
|
||||
assert.False(t, opts.PreserveNullAndEmptyArrays)
|
||||
assert.Equal(t, "index", opts.IncludeArrayIndex)
|
||||
})
|
||||
|
||||
t.Run("WithBothOptions", func(t *testing.T) {
|
||||
preserveOpt := func(opts *builder.UnwindOpts) {
|
||||
opts.PreserveNullAndEmptyArrays = true
|
||||
}
|
||||
indexOpt := func(opts *builder.UnwindOpts) {
|
||||
opts.IncludeArrayIndex = "index"
|
||||
}
|
||||
|
||||
opts := NewUnwindOpts(preserveOpt, indexOpt)
|
||||
|
||||
assert.True(t, opts.PreserveNullAndEmptyArrays)
|
||||
assert.Equal(t, "index", opts.IncludeArrayIndex)
|
||||
})
|
||||
}
|
||||
|
||||
// Mock implementations for testing
|
||||
|
||||
type MockQuery struct {
|
||||
buildPipeline bson.D
|
||||
}
|
||||
|
||||
func (m *MockQuery) And(filters ...builder.Query) builder.Query { return m }
|
||||
func (m *MockQuery) Or(filters ...builder.Query) builder.Query { return m }
|
||||
func (m *MockQuery) Filter(field builder.Field, value any) builder.Query { return m }
|
||||
func (m *MockQuery) Expression(value builder.Expression) builder.Query { return m }
|
||||
func (m *MockQuery) Comparison(field builder.Field, operator builder.MongoOperation, value any) builder.Query {
|
||||
return m
|
||||
}
|
||||
func (m *MockQuery) RegEx(field builder.Field, pattern, options string) builder.Query { return m }
|
||||
func (m *MockQuery) In(field builder.Field, values ...any) builder.Query { return m }
|
||||
func (m *MockQuery) NotIn(field builder.Field, values ...any) builder.Query { return m }
|
||||
func (m *MockQuery) Sort(field builder.Field, ascending bool) builder.Query { return m }
|
||||
func (m *MockQuery) Limit(limit *int64) builder.Query { return m }
|
||||
func (m *MockQuery) Offset(offset *int64) builder.Query { return m }
|
||||
func (m *MockQuery) Archived(isArchived *bool) builder.Query { return m }
|
||||
func (m *MockQuery) BuildPipeline() bson.D { return m.buildPipeline }
|
||||
func (m *MockQuery) BuildQuery() bson.D { return bson.D{} }
|
||||
func (m *MockQuery) BuildOptions() *options.FindOptionsBuilder { return &options.FindOptionsBuilder{} }
|
||||
|
||||
type MockField struct {
|
||||
build string
|
||||
}
|
||||
|
||||
func (m *MockField) Dot(field string) builder.Field { return &MockField{build: m.build + "." + field} }
|
||||
func (m *MockField) CopyWith(field string) builder.Field { return &MockField{build: field} }
|
||||
func (m *MockField) Build() string { return m.build }
|
||||
|
||||
type MockPipeline struct {
|
||||
build mongo.Pipeline
|
||||
}
|
||||
|
||||
func (m *MockPipeline) Match(filter builder.Query) builder.Pipeline { return m }
|
||||
func (m *MockPipeline) Lookup(from mservice.Type, localField, foreignField, as builder.Field) builder.Pipeline {
|
||||
return m
|
||||
}
|
||||
func (m *MockPipeline) LookupWithPipeline(from mservice.Type, pipeline builder.Pipeline, as builder.Field, let *map[string]builder.Field) builder.Pipeline {
|
||||
return m
|
||||
}
|
||||
func (m *MockPipeline) Unwind(path builder.Field, opts ...UnwindOption) builder.Pipeline { return m }
|
||||
func (m *MockPipeline) Count(field builder.Field) builder.Pipeline { return m }
|
||||
func (m *MockPipeline) Group(groupBy builder.Alias, accumulators ...builder.GroupAccumulator) builder.Pipeline {
|
||||
return m
|
||||
}
|
||||
func (m *MockPipeline) Project(projections ...builder.Projection) builder.Pipeline { return m }
|
||||
func (m *MockPipeline) ReplaceRoot(newRoot builder.Expression) builder.Pipeline { return m }
|
||||
func (m *MockPipeline) Build() mongo.Pipeline { return m.build }
|
||||
|
||||
type MockAlias struct {
|
||||
build bson.D
|
||||
field builder.Field
|
||||
}
|
||||
|
||||
func (m *MockAlias) Field() builder.Field { return m.field }
|
||||
func (m *MockAlias) Build() bson.D { return m.build }
|
||||
|
||||
type MockGroupAccumulator struct {
|
||||
build bson.D
|
||||
}
|
||||
|
||||
func (m *MockGroupAccumulator) Build() bson.D { return m.build }
|
||||
|
||||
type MockProjection struct {
|
||||
build bson.D
|
||||
}
|
||||
|
||||
func (m *MockProjection) Build() bson.D { return m.build }
|
||||
|
||||
func TestPipelineImp_ReplaceRoot(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockExpr := &MockExpression{build: "$newRoot"}
|
||||
|
||||
result := pipeline.ReplaceRoot(mockExpr)
|
||||
|
||||
// Should return self for chaining
|
||||
assert.Same(t, pipeline, result)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.ReplaceRoot), Value: bson.D{
|
||||
{Key: string(builder.MKNewRoot), Value: "$newRoot"},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_ReplaceRoot_WithNestedField(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
mockExpr := &MockExpression{build: "$document.data"}
|
||||
|
||||
pipeline.ReplaceRoot(mockExpr)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.ReplaceRoot), Value: bson.D{
|
||||
{Key: string(builder.MKNewRoot), Value: "$document.data"},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
func TestPipelineImp_ReplaceRoot_WithExpression(t *testing.T) {
|
||||
pipeline := NewPipelineImp()
|
||||
// Mock a complex expression like { $mergeObjects: [...] }
|
||||
mockExpr := &MockExpression{build: bson.D{{Key: "$mergeObjects", Value: bson.A{"$field1", "$field2"}}}}
|
||||
|
||||
pipeline.ReplaceRoot(mockExpr)
|
||||
|
||||
built := pipeline.Build()
|
||||
assert.Len(t, built, 1)
|
||||
|
||||
expected := bson.D{{Key: string(builder.ReplaceRoot), Value: bson.D{
|
||||
{Key: string(builder.MKNewRoot), Value: bson.D{{Key: "$mergeObjects", Value: bson.A{"$field1", "$field2"}}}},
|
||||
}}}
|
||||
|
||||
assert.Equal(t, expected, built[0])
|
||||
}
|
||||
|
||||
type MockExpression struct {
|
||||
build any
|
||||
}
|
||||
|
||||
func (m *MockExpression) Build() any { return m.build }
|
||||
Loaded 100 of 556 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user