Bootstrap copied BFF and local accounts with public Sendico contracts (#34)

This commit is contained in:
Stephan D committed 2026-09-12 13:37:48 +02:00
1 parent 67dc5b5b1e
commit 7062672a87
556 files changed
+42291 -777

No files matched your search

+36
View File
@@ -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)
}
}
+228
View File
@@ -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"])
}
+19
View File
@@ -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})
}
+8
View File
@@ -0,0 +1,8 @@
package api
const (
MSSuccess string = "success"
MSProcessed string = "processed"
MSError string = "error"
MSRequest string = "request"
)
+18
View File
@@ -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
}
+10
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
package anyobject
const ID = "*"
+35
View File
@@ -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)
}
+107
View File
@@ -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)
}
+87
View File
@@ -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
}
+147
View File
@@ -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)
}
})
}
+12
View File
@@ -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]
+38
View File
@@ -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)
}
+51
View File
@@ -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
}
+319
View File
@@ -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
}
+394
View File
@@ -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
}
+80
View File
@@ -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))
})
}
+142
View File
@@ -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
}
+848
View File
@@ -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)
}
}
+262
View File
@@ -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
}
+129
View File
@@ -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
}
+32
View File
@@ -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)
}
+58
View File
@@ -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")
}
+61
View File
@@ -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
}
+29
View File
@@ -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)
}
+182
View File
@@ -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(),
}
}
+209
View File
@@ -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,
}
}
+142
View File
@@ -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
}
+27
View File
@@ -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
}
+41
View File
@@ -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)
}
+15
View File
@@ -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
}
+30
View File
@@ -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
}
+14
View File
@@ -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)
}
+71
View File
@@ -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)
}
}
+130
View File
@@ -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")
}
})
}
+129
View File
@@ -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
}
+256
View File
@@ -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")
}
}
+16
View File
@@ -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)
}
+103
View File
@@ -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{}
}
+59
View File
@@ -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
}
+53
View File
@@ -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)
}
+328
View File
@@ -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
}
+49
View File
@@ -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