143 lines
5.1 KiB
Go
143 lines
5.1 KiB
Go
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
|
|
}
|