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

+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
}