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 }