Remove platform tenancy from SXCHNGE customer BFF (#34)

This commit is contained in:
Stephan D committed 2026-09-12 14:00:14 +02:00
1 parent 7062672a87
commit 981e84a4b4
244 files changed
+651 -17130

No files matched your search

@@ -1,19 +1,15 @@
package mongo
import (
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
"git.sendico.io/tech/sxchnge/bff/pkg/db/transaction"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// Dependencies resolves shared DB/auth/transaction primitives for edge-local store wiring only.
// Dependencies resolves local DB/transaction primitives for edge-local store wiring only.
type Dependencies struct {
Database *mongo.Database
Enforcer auth.Enforcer
Policies policy.DB
Transactions transaction.Factory
}
@@ -31,20 +27,8 @@ func ResolveDependencies(dbf db.Factory) (*Dependencies, error) {
return nil, merrors.Internal("mongo database provider is not available for edge-local storage wiring")
}
permissions := dbf.Permissions()
if permissions == nil || permissions.Enforcer() == nil {
return nil, merrors.Internal("permissions provider is not available for edge-local storage wiring")
}
pdb, err := dbf.NewPoliciesDB()
if err != nil {
return nil, err
}
return &Dependencies{
Database: provider.MongoDatabase(),
Enforcer: permissions.Enforcer(),
Policies: pdb,
Transactions: dbf.TransactionFactory(),
}, nil
}
@@ -1,32 +0,0 @@
package organization
import (
"context"
"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 organization and optionally cascades to projects, tasks, comments, and reactions
func (db *OrganizationDB) SetArchived(ctx context.Context, accountRef, organizationRef bson.ObjectID, archived, cascade bool) error {
db.DBImp.Logger.Debug("Setting organization archived status", mzap.OrgRef(organizationRef), zap.Bool("archived", archived), zap.Bool("cascade", cascade))
// Get the organization first
var organization model.Organization
if err := db.Get(ctx, accountRef, organizationRef, &organization); err != nil {
db.DBImp.Logger.Warn("Error retrieving organization for archival", zap.Error(err), mzap.OrgRef(organizationRef))
return err
}
// Update the organization's archived status
organization.SetArchived(archived)
if err := db.Update(ctx, accountRef, &organization); err != nil {
db.DBImp.Logger.Warn("Error updating organization archived status", zap.Error(err), mzap.OrgRef(organizationRef))
return err
}
db.DBImp.Logger.Debug("Successfully set organization archived status", mzap.OrgRef(organizationRef), zap.Bool("archived", archived))
return nil
}
@@ -1,23 +0,0 @@
package organization
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 organization and all its related data (projects, tasks, comments, reactions, statuses)
func (db *OrganizationDB) DeleteCascade(ctx context.Context, organizationRef bson.ObjectID) error {
db.DBImp.Logger.Debug("Starting organization deletion with projects", mzap.OrgRef(organizationRef))
// Delete the organization itself
if err := db.Unprotected().Delete(ctx, organizationRef); err != nil {
db.DBImp.Logger.Warn("Error deleting organization", zap.Error(err), mzap.OrgRef(organizationRef))
return err
}
db.DBImp.Logger.Debug("Successfully deleted organization with projects", mzap.OrgRef(organizationRef))
return nil
}
@@ -1,19 +0,0 @@
package organization
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (db *OrganizationDB) Create(ctx context.Context, _, _ bson.ObjectID, org *model.Organization) error {
if org == nil {
return merrors.InvalidArgument("Organization object is nil", "organization")
}
org.SetID(bson.NewObjectID())
// Organizaiton reference must be set to the same value as own organization reference
org.SetOrganizationRef(*org.GetID())
return db.DBImp.Create(ctx, org)
}
@@ -1,113 +0,0 @@
package organization
import (
"context"
"errors"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"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/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"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
type OrganizationDB struct {
auth.ProtectedDBImp[*model.Organization]
}
func Create(ctx context.Context,
logger mlogger.Logger,
enforcer auth.Enforcer,
pdb policy.DB,
db *mongo.Database,
) (*OrganizationDB, error) {
p, err := auth.CreateDBImp[*model.Organization](ctx, logger, pdb, enforcer, mservice.Organizations, db)
if err != nil {
return nil, err
}
res := &OrganizationDB{
ProtectedDBImp: *p,
}
p.DBImp.SetDeleter(res.DeleteCascade)
if err := ensureProvisioningIndexes(ctx, res); err != nil {
return nil, err
}
return res, nil
}
// ensureProvisioningIndexes guards concurrent operator-driven provisioning:
// two attempts creating an organization for the same owner and name race the
// find-then-create in the provisioner, and the unique index makes the loser
// fail over to the winner's organization instead of duplicating it. The index
// spans every provisioned state, not only pending: activation keeps the
// provisioning provenance, so a retry racing a concurrent finalization is
// stopped here rather than by the pre-create lookups alone. It is keyed by the
// immutable provisioning name snapshot, not the mutable display name, so a
// rename can neither free the key for a duplicate nor collide with a neighbour.
func ensureProvisioningIndexes(ctx context.Context, db *OrganizationDB) error {
definition := &ri.Definition{
Name: "uq_organization_provisioning_owner_name",
Keys: []ri.Key{
{Field: model.OrganizationProvisioningOwnerRefField, Sort: ri.Asc},
{Field: model.OrganizationProvisioningNameField, Sort: ri.Asc},
},
Unique: true,
// Archived organizations leave the index: archiving is how an operator
// retires a name, and a retired name must be reusable. Documents from
// builds that predate the name snapshot stay out as well; sharing the
// missing-name key would otherwise make them collide with each other.
PartialFilter: repository.Query().
Comparison(repository.Field(model.OrganizationProvisioningOwnerRefField), builder.Exists, true).
Comparison(repository.Field(model.OrganizationProvisioningNameField), builder.Exists, true).
Filter(repository.Field(storable.IsArchivedField), false),
}
if err := db.DBImp.Repository.CreateIndex(definition); err != nil {
db.DBImp.Logger.Error("Failed to create provisioned organization index",
zap.String("index", definition.Name), zap.Error(err))
return merrors.InternalWrap(err, "failed to create provisioned organization index")
}
// Predecessors of this index on environments that ran earlier builds of
// this feature: the pending-only variant ignored isArchived, the display
// name variant let a rename detach the organization from its dedup key.
if err := dropSupersededProvisioningIndexes(ctx, db.DBImp.Repository); err != nil {
db.DBImp.Logger.Error("Failed to remove superseded organization provisioning indexes", zap.Error(err))
return err
}
return nil
}
var supersededProvisioningIndexes = [...]string{
"uq_organization_pending_provisioning_owner_name",
"uq_organization_provisioned_owner_name",
}
func dropSupersededProvisioningIndexes(ctx context.Context, repo repository.Repository) error {
for _, name := range supersededProvisioningIndexes {
if err := repo.DropIndex(ctx, name); err != nil {
if provisioningIndexNotFound(err) {
continue
}
return merrors.InternalWrap(err, "failed to drop superseded organization provisioning index "+name)
}
}
return nil
}
const (
mongoNamespaceNotFoundCode = 26
mongoIndexNotFoundCode = 27
)
func provisioningIndexNotFound(err error) bool {
var serverErr mongo.ServerError
return errors.As(err, &serverErr) &&
(serverErr.HasErrorCode(mongoNamespaceNotFoundCode) || serverErr.HasErrorCode(mongoIndexNotFoundCode))
}
@@ -1,12 +0,0 @@
package organization
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (db *OrganizationDB) GetByRef(ctx context.Context, organizationRef bson.ObjectID, org *model.Organization) error {
return db.Unprotected().Get(ctx, organizationRef, org)
}
@@ -1,54 +0,0 @@
package organization
import (
"context"
"errors"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/mongo"
)
func TestDropSupersededProvisioningIndexesIgnoresMissingIndexes(t *testing.T) {
repo := &dropIndexRepository{dropErrors: []error{
mongo.CommandError{Code: mongoIndexNotFoundCode},
mongo.CommandError{Code: mongoNamespaceNotFoundCode},
}}
if err := dropSupersededProvisioningIndexes(context.Background(), repo); err != nil {
t.Fatalf("missing superseded indexes must be ignored: %v", err)
}
if len(repo.dropped) != len(supersededProvisioningIndexes) {
t.Fatalf("drop count mismatch: got=%d want=%d", len(repo.dropped), len(supersededProvisioningIndexes))
}
}
func TestDropSupersededProvisioningIndexesPreservesUnexpectedFailure(t *testing.T) {
wantErr := merrors.ServiceUnavailable("MongoDB is unavailable")
repo := &dropIndexRepository{dropErrors: []error{wantErr}}
err := dropSupersededProvisioningIndexes(context.Background(), repo)
if !errors.Is(err, wantErr) {
t.Fatalf("unexpected drop failure must be preserved: got=%v want=%v", err, wantErr)
}
if len(repo.dropped) != 1 {
t.Fatalf("cleanup must stop after an unexpected failure: dropped=%v", repo.dropped)
}
}
type dropIndexRepository struct {
repository.Repository
dropErrors []error
dropped []string
}
func (r *dropIndexRepository) DropIndex(_ context.Context, name string) error {
r.dropped = append(r.dropped, name)
if len(r.dropErrors) == 0 {
return nil
}
err := r.dropErrors[0]
r.dropErrors = r.dropErrors[1:]
return err
}
@@ -1,16 +0,0 @@
package organization
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 *OrganizationDB) List(ctx context.Context, accountRef bson.ObjectID, cursor *model.ViewCursor) ([]model.Organization, error) {
filter := repository.Query().Comparison(repository.Field("members"), builder.Eq, accountRef)
return mutil.GetObjects[model.Organization](ctx, db.DBImp.Logger, filter, cursor, db.DBImp.Repository)
}
@@ -1,49 +0,0 @@
package organization
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"
)
func (db *OrganizationDB) IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error) {
db.DBImp.Logger.Debug("Checking organization membership", mzap.OrgRef(orgRef), mzap.AccRef(callerRef))
filter := repository.Query().
Filter(repository.IDField(), orgRef).
Comparison(repository.Field("members"), builder.Eq, callerRef)
var org model.Organization
if err := db.Unprotected().FindOne(ctx, filter, &org); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return false, nil
}
db.DBImp.Logger.Warn("Membership check failed", zap.Error(err), mzap.OrgRef(orgRef), mzap.AccRef(callerRef))
return false, err
}
return true, nil
}
// AddMember atomically appends the account to the members list and reports
// false when it already was one. A point patch on purpose: replacing the whole
// document would clobber concurrent writes to fields the caller never read,
// most notably the provisioning lease.
func (db *OrganizationDB) AddMember(ctx context.Context, orgRef, accountRef bson.ObjectID) (bool, error) {
filter := repository.Query().
Filter(repository.IDField(), orgRef).
Comparison(repository.Field("members"), builder.Ne, accountRef)
patch := repository.Patch().AddToSet(repository.Field("members"), accountRef)
matched, err := db.DBImp.PatchMany(ctx, filter, patch)
if err != nil {
db.DBImp.Logger.Warn("Failed to add organization member", zap.Error(err), mzap.OrgRef(orgRef), mzap.AccRef(accountRef))
return false, err
}
return matched > 0, nil
}
@@ -1,153 +0,0 @@
package organization
import (
"context"
"errors"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"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/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/mservice"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
// membershipTestRepository shadows FindOneByFilter with a members-aware implementation.
// When findErr is set, FindOneByFilter returns it immediately.
type membershipTestRepository struct {
*memoryOrganizationRepository
findErr error
}
func (r *membershipTestRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error {
if r.findErr != nil {
return r.findErr
}
r.mu.RLock()
defer r.mu.RUnlock()
for _, id := range r.order {
if org, ok := r.data[id]; ok && memberMatchesQuery(query, org) {
dst, ok := result.(*model.Organization)
if !ok {
return merrors.InvalidDataType("expected organization result")
}
*dst = *cloneOrganization(org)
return nil
}
}
return merrors.ErrNoData
}
func memberMatchesQuery(query builder.Query, org *model.Organization) bool {
if query == nil {
return true
}
for _, elem := range query.BuildQuery() {
switch elem.Key {
case storable.IDField:
id, ok := elem.Value.(bson.ObjectID)
if !ok || *org.GetID() != id {
return false
}
case "members":
cond, ok := elem.Value.(bson.M)
if !ok {
return false
}
ref, ok := cond[string(builder.Eq)].(bson.ObjectID)
if !ok {
return false
}
found := false
for _, m := range org.Members {
if m == ref {
found = true
break
}
}
if !found {
return false
}
}
}
return true
}
func newMemberTestDB(t *testing.T, findErr error) *OrganizationDB {
t.Helper()
repo := &membershipTestRepository{
memoryOrganizationRepository: newMemoryOrganizationRepository(),
findErr: findErr,
}
dbImp := &template.DBImp[*model.Organization]{
Logger: zap.NewNop(),
Repository: repo,
}
dbImp.SetDeleter(func(ctx context.Context, objectRef bson.ObjectID) error {
return repo.Delete(ctx, objectRef)
})
return &OrganizationDB{
ProtectedDBImp: auth.ProtectedDBImp[*model.Organization]{
DBImp: dbImp,
Enforcer: allowAllEnforcer{},
PermissionRef: bson.NewObjectID(),
Collection: mservice.Organizations,
},
}
}
func TestOrganizationDB_IsMember_Member(t *testing.T) {
ctx := context.Background()
db := newMemberTestDB(t, nil)
callerRef := bson.NewObjectID()
org := &model.Organization{}
org.SetID(bson.NewObjectID())
org.Members = []bson.ObjectID{callerRef}
require.NoError(t, db.Create(ctx, bson.NewObjectID(), *org.GetID(), org))
ok, err := db.IsMember(ctx, *org.GetID(), callerRef)
require.NoError(t, err)
assert.True(t, ok)
}
func TestOrganizationDB_IsMember_NonMember(t *testing.T) {
ctx := context.Background()
db := newMemberTestDB(t, nil)
callerRef := bson.NewObjectID()
otherRef := bson.NewObjectID()
org := &model.Organization{}
org.SetID(bson.NewObjectID())
org.Members = []bson.ObjectID{otherRef}
require.NoError(t, db.Create(ctx, bson.NewObjectID(), *org.GetID(), org))
ok, err := db.IsMember(ctx, *org.GetID(), callerRef)
require.NoError(t, err)
assert.False(t, ok)
}
func TestOrganizationDB_IsMember_OrgNotFound(t *testing.T) {
ctx := context.Background()
db := newMemberTestDB(t, nil)
ok, err := db.IsMember(ctx, bson.NewObjectID(), bson.NewObjectID())
require.NoError(t, err)
assert.False(t, ok)
}
func TestOrganizationDB_IsMember_DBError(t *testing.T) {
ctx := context.Background()
boom := merrors.Internal("backend down")
db := newMemberTestDB(t, boom)
ok, err := db.IsMember(ctx, bson.NewObjectID(), bson.NewObjectID())
assert.False(t, ok)
assert.True(t, errors.Is(err, boom))
}
@@ -1,378 +0,0 @@
package organization
import (
"context"
"time"
"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/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (m *memoryOrganizationRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error {
return merrors.NotImplemented("aggregate is not supported in memory repository")
}
func (m *memoryOrganizationRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error {
m.mu.RLock()
defer m.mu.RUnlock()
for _, id := range m.order {
if org, ok := m.data[id]; ok && m.matchesQuery(query, org) {
dst, okCast := result.(*model.Organization)
if !okCast {
return merrors.InvalidDataType("expected organization result")
}
*dst = *cloneOrganization(org)
return nil
}
}
return merrors.ErrNoData
}
func (m *memoryOrganizationRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
return m.FindOneByFilter(ctx, query, result)
}
func (m *memoryOrganizationRepository) FindManyByFilter(context.Context, builder.Query, rd.DecodingFunc) error {
return merrors.NotImplemented("FindManyByFilter is not supported in memory repository")
}
func (m *memoryOrganizationRepository) FindAllByFilter(_ context.Context, query builder.Query, result any) error {
m.mu.RLock()
defer m.mu.RUnlock()
dst, ok := result.(*[]model.Organization)
if !ok {
return merrors.InvalidDataType("expected organization slice result")
}
*dst = (*dst)[:0]
for _, id := range m.order {
if org, ok := m.data[id]; ok && m.matchesQuery(query, org) {
*dst = append(*dst, *cloneOrganization(org))
}
}
return nil
}
func (m *memoryOrganizationRepository) CountByFilter(_ context.Context, query builder.Query) (int64, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var count int64
for _, id := range m.order {
if org, ok := m.data[id]; ok && m.matchesQuery(query, org) {
count++
}
}
return count, nil
}
func (m *memoryOrganizationRepository) Patch(context.Context, bson.ObjectID, builder.Patch) error {
return merrors.NotImplemented("Patch is not supported in memory repository")
}
func (m *memoryOrganizationRepository) PatchOneByFilter(context.Context, builder.Query, builder.Patch, storable.Storable) (bool, error) {
return false, merrors.NotImplemented("PatchOneByFilter is not supported in memory repository")
}
func (m *memoryOrganizationRepository) PatchMany(_ context.Context, query builder.Query, patch builder.Patch) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
matched := 0
for _, id := range m.order {
org, ok := m.data[id]
if !ok || !m.matchesQuery(query, org) {
continue
}
if err := applyOrganizationPatch(org, patch); err != nil {
return matched, err
}
matched++
}
return matched, nil
}
// applyOrganizationPatch supports the operations the provisioning storage
// issues; anything else fails loudly so a test cannot pass vacuously.
func applyOrganizationPatch(org *model.Organization, patch builder.Patch) error {
for _, op := range patch.Build() {
fields, ok := op.Value.(bson.D)
if !ok {
return merrors.NotImplemented("unsupported patch value shape: " + op.Key)
}
for _, field := range fields {
switch op.Key {
case string(builder.Set):
if err := setOrganizationField(org, field.Key, field.Value); err != nil {
return err
}
case string(builder.Unset):
if err := unsetOrganizationField(org, field.Key); err != nil {
return err
}
case string(builder.AddToSet):
if field.Key != "members" {
return merrors.NotImplemented("unsupported $addToSet field: " + field.Key)
}
member, ok := field.Value.(bson.ObjectID)
if !ok {
return merrors.InvalidDataType("expected member object id")
}
if !containsObjectID(org.Members, member) {
org.Members = append(org.Members, member)
}
default:
return merrors.NotImplemented("unsupported patch operation: " + op.Key)
}
}
}
return nil
}
func setOrganizationField(org *model.Organization, field string, value any) error {
switch field {
case storable.UpdatedAtField:
if updatedAt, ok := value.(time.Time); ok {
org.UpdatedAt = updatedAt
}
case model.OrganizationSignupStateField:
switch state := value.(type) {
case model.OrganizationSignupState:
org.SignupState = state
case string:
org.SignupState = model.OrganizationSignupState(state)
default:
return merrors.InvalidDataType("expected signup state value")
}
case model.OrganizationProvisioningLeaseOwnerField:
owner, ok := value.(string)
if !ok || org.Provisioning == nil {
return merrors.InvalidDataType("expected lease owner on a provisioned organization")
}
org.Provisioning.LeaseOwner = owner
case model.OrganizationProvisioningLeaseField:
expiresAt, ok := value.(time.Time)
if !ok || org.Provisioning == nil {
return merrors.InvalidDataType("expected lease expiry on a provisioned organization")
}
org.Provisioning.LeaseExpiresAt = expiresAt
default:
return merrors.NotImplemented("unsupported $set field: " + field)
}
return nil
}
func unsetOrganizationField(org *model.Organization, field string) error {
switch field {
case model.OrganizationProvisioningField:
org.Provisioning = nil
case model.OrganizationProvisioningLeaseOwnerField:
if org.Provisioning != nil {
org.Provisioning.LeaseOwner = ""
}
case model.OrganizationProvisioningLeaseField:
if org.Provisioning != nil {
org.Provisioning.LeaseExpiresAt = time.Time{}
}
default:
return merrors.NotImplemented("unsupported $unset field: " + field)
}
return nil
}
func containsObjectID(ids []bson.ObjectID, id bson.ObjectID) bool {
for _, item := range ids {
if item == id {
return true
}
}
return false
}
func (m *memoryOrganizationRepository) DeleteMany(context.Context, builder.Query) error {
return merrors.NotImplemented("DeleteMany is not supported in memory repository")
}
func (m *memoryOrganizationRepository) CreateIndex(*ri.Definition) error {
return nil
}
func (m *memoryOrganizationRepository) DropIndex(context.Context, string) error {
return nil
}
func (m *memoryOrganizationRepository) ListIndexDefinitions(context.Context) ([]*ri.Definition, error) {
return nil, merrors.NotImplemented("ListIndexDefinitions is not supported in memory repository")
}
func (m *memoryOrganizationRepository) ListIDs(_ context.Context, query builder.Query) ([]bson.ObjectID, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var ids []bson.ObjectID
for _, id := range m.order {
if org, ok := m.data[id]; ok && m.matchesQuery(query, org) {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return nil, merrors.ErrNoData
}
return ids, nil
}
func (m *memoryOrganizationRepository) ListPermissionBound(_ context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var res []model.PermissionBoundStorable
for _, org := range m.data {
if m.matchesQuery(query, org) {
res = append(res, cloneOrganization(org))
}
}
return res, nil
}
func (m *memoryOrganizationRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) {
return nil, merrors.NotImplemented("Account bound list not supported")
}
func (m *memoryOrganizationRepository) matchesQuery(query builder.Query, org *model.Organization) bool {
if query == nil {
return true
}
for _, elem := range query.BuildQuery() {
switch elem.Key {
case storable.IDField:
if !matchesObjectIDConstraint(*org.GetID(), elem.Value) {
return false
}
case storable.IsArchivedField:
value, ok := elem.Value.(bool)
if ok && org.IsArchived() != value {
return false
}
case model.OrganizationSignupStateField:
if !matchesStringConstraint(string(org.SignupState), elem.Value) {
return false
}
case model.NameField:
if !matchesStringConstraint(org.Name, elem.Value) {
return false
}
case model.OrganizationProvisioningOwnerRefField:
if org.Provisioning == nil {
return false
}
if exists, ok := existsConstraint(elem.Value); ok {
if !exists {
return false
}
continue
}
if !matchesObjectIDConstraint(org.Provisioning.OwnerRef, elem.Value) {
return false
}
case model.OrganizationProvisioningNameField:
if org.Provisioning == nil || !matchesStringConstraint(org.Provisioning.Name, elem.Value) {
return false
}
case model.OrganizationProvisioningLeaseOwnerField:
if org.Provisioning == nil || !matchesStringConstraint(org.Provisioning.LeaseOwner, elem.Value) {
return false
}
case "members":
if !matchesMembersConstraint(org.Members, elem.Value) {
return false
}
}
}
return true
}
func matchesStringConstraint(current string, value any) bool {
switch expected := value.(type) {
case string:
return current == expected
case model.OrganizationSignupState:
return current == string(expected)
default:
return false
}
}
func existsConstraint(value any) (bool, bool) {
ops, ok := value.(bson.M)
if !ok {
return false, false
}
exists, ok := ops[string(builder.Exists)].(bool)
return exists, ok
}
func matchesMembersConstraint(members []bson.ObjectID, value any) bool {
ops, ok := value.(bson.M)
if !ok {
return false
}
for op, raw := range ops {
member, ok := raw.(bson.ObjectID)
if !ok {
return false
}
switch op {
case string(builder.Eq):
if !containsObjectID(members, member) {
return false
}
case string(builder.Ne):
if containsObjectID(members, member) {
return false
}
default:
return false
}
}
return true
}
func matchesObjectIDConstraint(id bson.ObjectID, value any) bool {
if direct, ok := value.(bson.ObjectID); ok {
return id == direct
}
ops, ok := value.(bson.M)
if !ok {
return false
}
for op, raw := range ops {
ids, ok := raw.([]any)
if !ok {
return false
}
contains := false
for _, item := range ids {
if itemID, ok := item.(bson.ObjectID); ok && itemID == id {
contains = true
break
}
}
switch op {
case string(builder.In):
if !contains {
return false
}
case string(builder.NotIn):
if contains {
return false
}
default:
return false
}
}
return true
}
@@ -1,149 +0,0 @@
package organization
import (
"context"
"errors"
"sync"
"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/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"
)
type memoryOrganizationRepository struct {
mu sync.RWMutex
data map[bson.ObjectID]*model.Organization
order []bson.ObjectID
}
func newMemoryOrganizationRepository() *memoryOrganizationRepository {
return &memoryOrganizationRepository{
data: make(map[bson.ObjectID]*model.Organization),
}
}
func (m *memoryOrganizationRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error {
m.mu.Lock()
defer m.mu.Unlock()
org, ok := obj.(*model.Organization)
if !ok {
return merrors.InvalidDataType("expected organization")
}
id := org.GetID()
if id == nil || *id == bson.NilObjectID {
return merrors.InvalidArgument("organization ID must be set")
}
if _, exists := m.data[*id]; exists {
return merrors.DataConflict("organization already exists")
}
m.data[*id] = cloneOrganization(org)
m.order = append(m.order, *id)
return nil
}
func (m *memoryOrganizationRepository) InsertMany(ctx context.Context, objects []storable.Storable) error {
for _, obj := range objects {
if err := m.Insert(ctx, obj, nil); err != nil {
return err
}
}
return nil
}
func (m *memoryOrganizationRepository) Get(_ context.Context, id bson.ObjectID, result storable.Storable) error {
m.mu.RLock()
defer m.mu.RUnlock()
org, ok := m.data[id]
if !ok {
return merrors.ErrNoData
}
dst, ok := result.(*model.Organization)
if !ok {
return merrors.InvalidDataType("expected organization result")
}
*dst = *cloneOrganization(org)
return nil
}
func (m *memoryOrganizationRepository) Update(_ context.Context, obj storable.Storable) error {
m.mu.Lock()
defer m.mu.Unlock()
org, ok := obj.(*model.Organization)
if !ok {
return merrors.InvalidDataType("expected organization")
}
id := org.GetID()
if id == nil {
return merrors.InvalidArgument("organization ID must be set")
}
if _, exists := m.data[*id]; !exists {
return merrors.ErrNoData
}
m.data[*id] = cloneOrganization(org)
return nil
}
func (m *memoryOrganizationRepository) ReplaceOneByFilter(_ context.Context, query builder.Query, obj storable.Storable) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
org, ok := obj.(*model.Organization)
if !ok {
return false, merrors.InvalidDataType("expected organization")
}
id := org.GetID()
if id == nil {
return false, merrors.InvalidArgument("organization ID must be set")
}
current, exists := m.data[*id]
if !exists || !m.matchesQuery(query, current) {
return false, nil
}
m.data[*id] = cloneOrganization(org)
return true, nil
}
func (m *memoryOrganizationRepository) Upsert(ctx context.Context, obj storable.Storable) error {
if err := m.Update(ctx, obj); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return m.Insert(ctx, obj, nil)
}
return err
}
return nil
}
func (m *memoryOrganizationRepository) Delete(_ context.Context, id bson.ObjectID) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.data[id]; !exists {
return merrors.ErrNoData
}
delete(m.data, id)
return nil
}
func (m *memoryOrganizationRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
return cb(ctx)
}
func (m *memoryOrganizationRepository) Collection() string {
return mservice.Organizations
}
func cloneOrganization(src *model.Organization) *model.Organization {
dst := *src
if len(src.Members) > 0 {
dst.Members = append([]bson.ObjectID{}, src.Members...)
}
return &dst
}
@@ -1,19 +0,0 @@
package organization
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"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 {
auth.ProtectedDB[*model.Organization]
List(ctx context.Context, accountRef bson.ObjectID, cursor *model.ViewCursor) ([]model.Organization, error)
ListOwned(ctx context.Context, accountRef bson.ObjectID) ([]model.Organization, error)
SetArchived(ctx context.Context, accountRef, organizationRef bson.ObjectID, archived, cascade bool) error
IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error)
AddMember(ctx context.Context, orgRef, accountRef bson.ObjectID) (bool, error)
}
@@ -1,14 +0,0 @@
package organization
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 *OrganizationDB) ListOwned(ctx context.Context, accountRef bson.ObjectID) ([]model.Organization, error) {
return mutil.GetObjects[model.Organization](ctx, db.DBImp.Logger, repository.Filter("ownerRef", accountRef), nil, db.DBImp.Repository)
}
@@ -1,14 +0,0 @@
package organization
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"
)
func (db *OrganizationDB) ListProvider(ctx context.Context, query builder.Query, cursor *model.ViewCursor) ([]model.Organization, error) {
orgs := make([]model.Organization, 0)
return orgs, db.DBImp.Repository.FindAllByFilter(ctx, repository.ApplyCursor(query, cursor), &orgs)
}
@@ -1,166 +0,0 @@
package organization
import (
"context"
"errors"
"time"
"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/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
// AcquireProvisioningLease claims an organization that is still being
// provisioned. The update is atomic: it only matches a pending organization
// that carries a provisioning record and whose lease has expired or is already
// owned by this attempt, so two concurrent attempts cannot provision the same
// organization. Requiring the pending state here (not only in the caller's
// earlier read) closes the race with a concurrent finalization: an organization
// that just went active stops matching even though its lease fields are gone.
func (db *OrganizationDB) AcquireProvisioningLease(
ctx context.Context,
organizationRef bson.ObjectID,
leaseOwner string,
now time.Time,
expiresAt time.Time,
) (bool, error) {
// A freshly created attempt carries no lease yet, so "missing" counts as free
// alongside "expired"; otherwise the first attempt would block itself.
unset := repository.Exists(repository.Field(model.OrganizationProvisioningLeaseField), false)
expired := repository.Query().Comparison(repository.Field(model.OrganizationProvisioningLeaseField), builder.Lte, now)
mine := repository.Query().Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner)
filter := repository.Query().
Filter(repository.IDField(), organizationRef).
Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)).
Comparison(repository.Field(model.OrganizationProvisioningOwnerRefField), builder.Exists, true).
Or(unset, expired, mine)
patch := repository.Patch().
Set(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner).
Set(repository.Field(model.OrganizationProvisioningLeaseField), expiresAt)
matched, err := db.DBImp.PatchMany(ctx, filter, patch)
if err != nil {
db.DBImp.Logger.Warn("Failed to acquire organization provisioning lease",
zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner))
return false, err
}
return matched > 0, nil
}
// ReleaseProvisioningLease frees the lease so another attempt can take over
// immediately instead of waiting out the expiry. It only matches the lease of
// the given owner: a lease taken over by a newer attempt stays untouched.
func (db *OrganizationDB) ReleaseProvisioningLease(
ctx context.Context,
organizationRef bson.ObjectID,
leaseOwner string,
) error {
filter := repository.Query().
Filter(repository.IDField(), organizationRef).
Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner)
patch := repository.Patch().
Unset(repository.Field(model.OrganizationProvisioningLeaseOwnerField)).
Unset(repository.Field(model.OrganizationProvisioningLeaseField))
if _, err := db.DBImp.PatchMany(ctx, filter, patch); err != nil {
db.DBImp.Logger.Warn("Failed to release organization provisioning lease",
zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner))
return err
}
return nil
}
// FinalizeProvisioning atomically activates a pending organization while the
// given attempt still owns its provisioning lease. It reports false when the
// organization is no longer pending or the lease was taken over, so a slow
// attempt cannot finalize an organization another attempt now works on.
// Only the lease is cleared: the provisioning provenance stays on the document
// so a retry after a lost success response can recognize the activated
// organization instead of creating a duplicate.
func (db *OrganizationDB) FinalizeProvisioning(
ctx context.Context,
organizationRef bson.ObjectID,
leaseOwner string,
) (bool, error) {
filter := repository.Query().
Filter(repository.IDField(), organizationRef).
Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)).
Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner)
patch := repository.Patch().
Set(repository.Field(model.OrganizationSignupStateField), model.OrganizationSignupStateActive).
Unset(repository.Field(model.OrganizationProvisioningLeaseOwnerField)).
Unset(repository.Field(model.OrganizationProvisioningLeaseField))
matched, err := db.DBImp.PatchMany(ctx, filter, patch)
if err != nil {
db.DBImp.Logger.Warn("Failed to finalize organization provisioning",
zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner))
return false, err
}
return matched > 0, nil
}
// FindPendingProvisioning returns the organization that a previous attempt left
// pending for the same owner and name, if any. The name matches the immutable
// provisioning snapshot, not the mutable display name, so a rename cannot
// detach the organization from the retries of the create that produced it.
func (db *OrganizationDB) FindPendingProvisioning(
ctx context.Context,
ownerRef bson.ObjectID,
name string,
) (*model.Organization, error) {
query := repository.Query().
Filter(repository.Field(model.OrganizationProvisioningOwnerRefField), ownerRef).
Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)).
Filter(repository.Field(model.OrganizationProvisioningNameField), name)
org := &model.Organization{}
if err := db.DBImp.FindOne(ctx, query, org); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
db.DBImp.Logger.Warn("Failed to look up pending organization provisioning",
zap.Error(err), mzap.AccRef(ownerRef), zap.String("organization_name", name))
return nil, err
}
return org, nil
}
// FindActiveProvisioned returns the already activated organization a previous
// attempt provisioned for the same owner and name, if any. It is the retry
// barrier for the success that got lost on the wire: without it a repeated
// create after a timed out but committed finalization would provision a
// duplicate organization. The name matches the immutable provisioning
// snapshot, so the barrier survives a rename of the activated organization.
func (db *OrganizationDB) FindActiveProvisioned(
ctx context.Context,
ownerRef bson.ObjectID,
name string,
) (*model.Organization, error) {
// Archived organizations release their name: they are excluded here and in
// the unique index alike, so the checks and the constraint stay in step.
query := repository.Query().
Filter(repository.Field(model.OrganizationProvisioningOwnerRefField), ownerRef).
Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStateActive)).
Filter(repository.Field(model.OrganizationProvisioningNameField), name).
Filter(repository.Field(storable.IsArchivedField), false)
org := &model.Organization{}
if err := db.DBImp.FindOne(ctx, query, org); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
db.DBImp.Logger.Warn("Failed to look up activated organization provisioning",
zap.Error(err), mzap.AccRef(ownerRef), zap.String("organization_name", name))
return nil, err
}
return org, nil
}
@@ -1,180 +0,0 @@
package organization
import (
"context"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
func provisionedOrganization(t *testing.T, db *OrganizationDB, ownerRef bson.ObjectID, name, leaseOwner string) *model.Organization {
t.Helper()
org := &model.Organization{Members: []bson.ObjectID{}}
org.SetID(bson.NewObjectID())
org.Name = name
org.SignupState = model.OrganizationSignupStatePending
org.Provisioning = &model.OrganizationProvisioning{
OwnerRef: ownerRef,
Name: name,
Origin: "backoffice",
StartedAt: time.Now().UTC(),
LeaseOwner: leaseOwner,
LeaseExpiresAt: time.Now().UTC().Add(10 * time.Minute),
}
if err := db.Unprotected().Create(context.Background(), org); err != nil {
t.Fatalf("failed to store organization: %v", err)
}
return org
}
func TestAddMemberIsAPointPatch(t *testing.T) {
db := newTestOrganizationDB(t)
ownerRef := bson.NewObjectID()
org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1")
memberRef := bson.NewObjectID()
added, err := db.AddMember(context.Background(), org.ID, memberRef)
if err != nil || !added {
t.Fatalf("first add must report success: added=%v err=%v", added, err)
}
// The second add must be a no-op, not an error: transaction replays and
// concurrent joins both hit this path.
added, err = db.AddMember(context.Background(), org.ID, memberRef)
if err != nil || added {
t.Fatalf("second add must report an existing member: added=%v err=%v", added, err)
}
stored := &model.Organization{}
if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil {
t.Fatalf("failed to read organization back: %v", err)
}
if len(stored.Members) != 1 || stored.Members[0] != memberRef {
t.Fatalf("members mismatch: got=%v", stored.Members)
}
// The membership write must not touch the provisioning lease: the fence in
// FinalizeProvisioning checks the stored lease, not this attempt's copy.
if stored.Provisioning == nil || stored.Provisioning.LeaseOwner != "operator/attempt-1" {
t.Fatalf("membership write clobbered the provisioning lease: %+v", stored.Provisioning)
}
}
func TestFinalizeProvisioningKeepsProvenance(t *testing.T) {
db := newTestOrganizationDB(t)
ownerRef := bson.NewObjectID()
org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1")
finalized, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1")
if err != nil || !finalized {
t.Fatalf("finalization by the lease owner must succeed: finalized=%v err=%v", finalized, err)
}
stored := &model.Organization{}
if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil {
t.Fatalf("failed to read organization back: %v", err)
}
if stored.SignupState != model.OrganizationSignupStateActive {
t.Fatalf("organization must be active, got=%s", stored.SignupState)
}
// Provenance survives activation so a retry after a lost success response
// can recognize the organization instead of provisioning a duplicate.
if stored.Provisioning == nil || stored.Provisioning.OwnerRef != ownerRef {
t.Fatalf("provisioning provenance must survive activation: %+v", stored.Provisioning)
}
if stored.Provisioning.LeaseOwner != "" || !stored.Provisioning.LeaseExpiresAt.IsZero() {
t.Fatalf("lease must be cleared on activation: %+v", stored.Provisioning)
}
// A replayed finalization must not match: the organization left pending.
finalized, err = db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1")
if err != nil || finalized {
t.Fatalf("an active organization must not finalize again: finalized=%v err=%v", finalized, err)
}
}
func TestFinalizeProvisioningRejectsTakenOverLease(t *testing.T) {
db := newTestOrganizationDB(t)
org := provisionedOrganization(t, db, bson.NewObjectID(), "ProCorp", "operator/attempt-2")
finalized, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1")
if err != nil || finalized {
t.Fatalf("an attempt whose lease was taken over must not finalize: finalized=%v err=%v", finalized, err)
}
}
func TestFindActiveProvisioned(t *testing.T) {
db := newTestOrganizationDB(t)
ownerRef := bson.NewObjectID()
org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1")
// Pending organizations belong to FindPendingProvisioning, not here.
found, err := db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp")
if err != nil || found != nil {
t.Fatalf("a pending organization must not be reported as activated: found=%v err=%v", found, err)
}
if _, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1"); err != nil {
t.Fatalf("failed to finalize: %v", err)
}
found, err = db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp")
if err != nil || found == nil || found.ID != org.ID {
t.Fatalf("the activated organization must be found: found=%v err=%v", found, err)
}
if found, err = db.FindActiveProvisioned(context.Background(), ownerRef, "Other"); err != nil || found != nil {
t.Fatalf("a different name must not match: found=%v err=%v", found, err)
}
}
// The dedup key is the provisioning name snapshot: renaming an activated
// organization must not detach it from a retry of the create that produced it,
// or the retry would provision a duplicate.
func TestFindActiveProvisionedSurvivesRename(t *testing.T) {
db := newTestOrganizationDB(t)
ownerRef := bson.NewObjectID()
org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1")
if _, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1"); err != nil {
t.Fatalf("failed to finalize: %v", err)
}
stored := &model.Organization{}
if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil {
t.Fatalf("failed to read organization back: %v", err)
}
stored.Name = "Renamed Corp"
if err := db.Unprotected().Update(context.Background(), stored); err != nil {
t.Fatalf("failed to rename organization: %v", err)
}
found, err := db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp")
if err != nil || found == nil || found.ID != org.ID {
t.Fatalf("the renamed organization must still match its provisioning name: found=%v err=%v", found, err)
}
}
// Archiving is how an operator retires a name: an archived organization must
// stop blocking the creation of a fresh one with the same owner and name.
func TestFindActiveProvisionedReleasesArchivedName(t *testing.T) {
db := newTestOrganizationDB(t)
ownerRef := bson.NewObjectID()
org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1")
if _, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1"); err != nil {
t.Fatalf("failed to finalize: %v", err)
}
stored := &model.Organization{}
if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil {
t.Fatalf("failed to read organization back: %v", err)
}
stored.Archived = true
if err := db.Unprotected().Update(context.Background(), stored); err != nil {
t.Fatalf("failed to archive organization: %v", err)
}
found, err := db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp")
if err != nil || found != nil {
t.Fatalf("an archived organization must release its name: found=%v err=%v", found, err)
}
}
@@ -1,51 +0,0 @@
package organization
import (
"context"
"errors"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestOrganizationDB_SetArchived_TogglesState(t *testing.T) {
ctx := context.Background()
accountRef := bson.NewObjectID()
orgDB := newTestOrganizationDB(t)
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Sendico"},
TimeZone: "UTC",
},
}
org.SetID(bson.NewObjectID())
require.NoError(t, orgDB.Create(ctx, accountRef, *org.GetID(), org))
var stored model.Organization
require.NoError(t, orgDB.Get(ctx, accountRef, *org.GetID(), &stored))
assert.False(t, stored.IsArchived())
require.NoError(t, orgDB.SetArchived(ctx, accountRef, *org.GetID(), true, false))
require.NoError(t, orgDB.Get(ctx, accountRef, *org.GetID(), &stored))
assert.True(t, stored.IsArchived())
require.NoError(t, orgDB.SetArchived(ctx, accountRef, *org.GetID(), false, false))
require.NoError(t, orgDB.Get(ctx, accountRef, *org.GetID(), &stored))
assert.False(t, stored.IsArchived())
}
func TestOrganizationDB_SetArchived_UnknownOrganization(t *testing.T) {
ctx := context.Background()
accountRef := bson.NewObjectID()
orgDB := newTestOrganizationDB(t)
err := orgDB.SetArchived(ctx, accountRef, bson.NewObjectID(), true, false)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
}
@@ -1,58 +0,0 @@
package organization
import (
"context"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
"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"
)
// newTestOrganizationDB wires the real OrganizationDB implementation to an in-memory repository.
func newTestOrganizationDB(t *testing.T) *OrganizationDB {
t.Helper()
repo := newMemoryOrganizationRepository()
dbImp := &template.DBImp[*model.Organization]{
Logger: zap.NewNop(),
Repository: repo,
}
dbImp.SetDeleter(func(ctx context.Context, objectRef bson.ObjectID) error {
return repo.Delete(ctx, objectRef)
})
return &OrganizationDB{
ProtectedDBImp: auth.ProtectedDBImp[*model.Organization]{
DBImp: dbImp,
Enforcer: allowAllEnforcer{},
PermissionRef: bson.NewObjectID(),
Collection: mservice.Organizations,
},
}
}
type allowAllEnforcer struct{}
func (allowAllEnforcer) Enforce(context.Context, bson.ObjectID, bson.ObjectID, bson.ObjectID, bson.ObjectID, model.Action) (bool, error) {
return true, nil
}
func (allowAllEnforcer) EnforceBatch(_ context.Context, objects []model.PermissionBoundStorable, _ bson.ObjectID, _ model.Action) (map[bson.ObjectID]bool, error) {
result := make(map[bson.ObjectID]bool, len(objects))
for _, obj := range objects {
result[*obj.GetID()] = true
}
return result, nil
}
func (allowAllEnforcer) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) {
return nil, nil
}
func (allowAllEnforcer) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) {
return nil, nil, nil
}
@@ -164,7 +164,7 @@ func (db *RefreshTokenDB) GetByCRT(ctx context.Context, t *model.ClientRefreshTo
db.Logger.Debug("Refresh token expired", mzap.StorableRef(&rt),
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID),
zap.Time("expires_at", rt.ExpiresAt))
return nil, merrors.AccessDenied(mservice.RefreshTokens, string(model.ActionRead), *rt.GetID())
return nil, merrors.AccessDenied(mservice.RefreshTokens, "read", *rt.GetID())
}
// Check if token is revoked
-35
View File
@@ -1,35 +0,0 @@
package s3key
import (
"path"
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
)
// Join confines an object reference to a relative S3 prefix.
func Join(prefix, reference string) (string, error) {
cleanPrefix, err := cleanRelative(prefix, "prefix")
if err != nil {
return "", err
}
cleanReference, err := cleanRelative(reference, "obj_ref")
if err != nil {
return "", err
}
return path.Join(cleanPrefix, cleanReference), nil
}
func cleanRelative(value, argument string) (string, error) {
value = strings.TrimSpace(value)
for _, segment := range strings.Split(value, "/") {
if segment == ".." {
return "", merrors.InvalidArgument(argument+" is invalid", argument)
}
}
clean := path.Clean(value)
if value == "" || clean == "." || path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") {
return "", merrors.InvalidArgument(argument+" is invalid", argument)
}
return clean, nil
}
@@ -1,42 +0,0 @@
package s3key
import "testing"
func TestJoinConfinesReferenceToPrefix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
prefix string
reference string
want string
wantError bool
}{
{name: "nested reference", prefix: "compliance-evidence", reference: "account/evidence", want: "compliance-evidence/account/evidence"},
{name: "empty reference", prefix: "accounts", wantError: true},
{name: "absolute reference", prefix: "accounts", reference: "/private", wantError: true},
{name: "parent reference", prefix: "compliance-evidence", reference: "../accounts/avatar", wantError: true},
{name: "normalized parent reference", prefix: "accounts", reference: "customer/../avatar", wantError: true},
{name: "nested parent escape", prefix: "compliance-evidence", reference: "account/../../accounts/avatar", wantError: true},
{name: "invalid prefix", prefix: "../private", reference: "object", wantError: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got, err := Join(test.prefix, test.reference)
if test.wantError {
if err == nil {
t.Fatalf("Join(%q, %q) error = nil", test.prefix, test.reference)
}
return
}
if err != nil {
t.Fatalf("Join(%q, %q) error = %v", test.prefix, test.reference, err)
}
if got != test.want {
t.Fatalf("Join(%q, %q) = %q, want %q", test.prefix, test.reference, got, test.want)
}
})
}
}
@@ -309,14 +309,6 @@ func (m *memoryTokenRepository) ListIDs(context.Context, builder.Query) ([]bson.
return nil, merrors.NotImplemented("not needed")
}
func (m *memoryTokenRepository) ListPermissionBound(context.Context, builder.Query) ([]model.PermissionBoundStorable, error) {
return nil, merrors.NotImplemented("not needed")
}
func (m *memoryTokenRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) {
return nil, merrors.NotImplemented("not needed")
}
func (m *memoryTokenRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
return cb(ctx)
}