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

@@ -0,0 +1,130 @@
package accountserviceimp
import (
"context"
"net/url"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
edgemongo "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"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/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func customer(login string) *model.Account {
return (&model.AccountData{LoginData: model.LoginData{UserDataBase: model.UserDataBase{Login: login}, Password: "CustomerPassword1!"}, Describable: model.Describable{Name: "Customer"}}).ToAccount()
}
// Uses the actual copied repositories against an empty SXCHNGE database.
// There is deliberately no seed, organization, role, policy or Sendico connection.
func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
endpoint, err := url.Parse(uri)
require.NoError(t, err)
query := endpoint.Query()
query.Set("directConnection", "true")
endpoint.RawQuery = query.Encode()
uri = endpoint.String()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: model.SettingsT{"uri": uri, "database": "sxchnge_customers"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
// Custom collections prove lifecycle and login storage select the same data.
config := &api.AuthStorageConfig{AccountsCollection: "customers", VerificationTokensCollection: "customer_verification", RefreshTokensCollection: "customer_sessions"}
svc, err := NewAccountService(zap.NewNop(), factory, &middleware.PasswordConfig{Check: middleware.PasswordChecks{MinLength: 12}}, config)
require.NoError(t, err)
first, second := customer("first@example.test"), customer("second@example.test")
firstToken, err := svc.CreateAccount(ctx, first)
require.NoError(t, err)
require.NotEmpty(t, firstToken)
require.Equal(t, model.AccountPendingVerification, first.Status)
require.True(t, first.MatchPassword("CustomerPassword1!"))
_, err = svc.CreateAccount(ctx, second)
require.NoError(t, err)
require.NotEqual(t, first.ID, second.ID)
// Activation tokens cannot be used as another customer's token.
_, err = svc.vdb.Consume(ctx, second.ID, model.PurposeAccountActivation, "", firstToken)
require.Error(t, err)
_, err = svc.vdb.Consume(ctx, first.ID, model.PurposeAccountActivation, "", firstToken)
require.NoError(t, err)
_, err = svc.CreateAccount(ctx, customer("first@example.test"))
require.ErrorIs(t, err, merrors.ErrDataConflict)
require.NoError(t, svc.ValidateAccount(customer("validate@example.test")))
require.ErrorIs(t, svc.ValidateAccount(nil), merrors.ErrInvalidArg)
_, err = svc.CreateAccount(ctx, nil)
require.ErrorIs(t, err, merrors.ErrInvalidArg)
require.ErrorIs(t, svc.DeleteAccount(ctx, bson.NilObjectID), merrors.ErrInvalidArg)
for _, c := range []*model.Account{first, second} {
require.NoError(t, svc.sessions.Create(ctx, &model.RefreshToken{AccountRef: &c.ID, ExpiresAt: time.Now().Add(time.Hour), ClientRefreshToken: model.ClientRefreshToken{SessionIdentifier: model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}, RefreshToken: c.ID.Hex()}}))
}
require.NoError(t, svc.DeleteAccount(ctx, first.ID))
var stored model.Account
require.ErrorIs(t, svc.accountDB.Get(ctx, first.ID, &stored), merrors.ErrNoData)
require.NoError(t, svc.accountDB.Get(ctx, second.ID, &stored))
session := &model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}
_, err = svc.sessions.GetSession(ctx, first.ID, session)
require.Error(t, err)
_, err = svc.sessions.GetSession(ctx, second.ID, session)
require.NoError(t, err)
// The final local customer can be deleted; there is no sole-member restriction.
require.NoError(t, svc.DeleteAccount(ctx, second.ID))
deps, err := edgemongo.ResolveDependencies(factory)
require.NoError(t, err)
collections, err := deps.Database.ListCollectionNames(ctx, bson.D{})
require.NoError(t, err)
for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} {
require.NotContains(t, collections, forbidden)
}
// A token issuance failure must roll back the preceding customer insert.
realVerification := svc.vdb
failure := merrors.Internal("injected verification failure")
svc.vdb = &failingVerification{DB: realVerification, failure: failure}
candidate := customer("rollback@example.test")
_, err = svc.CreateAccount(ctx, candidate)
require.ErrorIs(t, err, failure)
require.True(t, candidate.ID.IsZero())
_, err = svc.accountDB.GetByEmail(ctx, candidate.Login)
require.ErrorIs(t, err, merrors.ErrNoData)
svc.vdb = realVerification
// If an outer workflow fails after token issuance, neither write may commit.
outerFailure := merrors.Internal("injected outer failure")
_, err = factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
_, e := svc.CreateAccount(tx, customer("outer@example.test"))
if e != nil {
return nil, e
}
return nil, outerFailure
})
require.ErrorIs(t, err, outerFailure)
_, err = svc.accountDB.GetByEmail(ctx, "outer@example.test")
require.ErrorIs(t, err, merrors.ErrNoData)
count, err := deps.Database.Collection(config.VerificationTokensCollection).CountDocuments(ctx, bson.D{})
require.NoError(t, err)
require.EqualValues(t, 2, count)
}
type failingVerification struct {
verification.DB
failure error
}
func (f *failingVerification) Create(context.Context, *verification.Request) (string, error) {
return "", f.failure
}
@@ -1,119 +0,0 @@
package accountserviceimp
import (
"context"
"testing"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
// fakeMembershipOrgDB answers membership from its own state, standing in for
// the transaction-scoped reads a real repository would serve.
type fakeMembershipOrgDB struct {
organization.DB
member bool
isMemberCalls int
addMemberCalls int
addedMember bson.ObjectID
}
func (db *fakeMembershipOrgDB) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) {
db.isMemberCalls++
return db.member, nil
}
func (db *fakeMembershipOrgDB) AddMember(_ context.Context, _, accountRef bson.ObjectID) (bool, error) {
db.addMemberCalls++
db.addedMember = accountRef
db.member = true
return true, nil
}
type recordingRoleManager struct {
assigned []*model.Role
}
func (m *recordingRoleManager) Create(_ context.Context, orgRef bson.ObjectID, _ *model.Describable) (*model.RoleDescription, error) {
description := &model.RoleDescription{}
description.SetID(bson.NewObjectID())
description.OrganizationRef = orgRef
return description, nil
}
func (m *recordingRoleManager) Delete(_ context.Context, _ bson.ObjectID) error { return nil }
func (m *recordingRoleManager) Assign(_ context.Context, role *model.Role) error {
m.assigned = append(m.assigned, role)
return nil
}
func (m *recordingRoleManager) Revoke(_ context.Context, _, _, _ bson.ObjectID) error { return nil }
func (m *recordingRoleManager) List(_ context.Context, _ bson.ObjectID) ([]model.RoleDescription, error) {
return nil, nil
}
func joinTestService(orgDB *fakeMembershipOrgDB, roles *recordingRoleManager) *service {
return &service{
logger: zap.NewNop(),
orgDB: orgDB,
roleManager: roles,
}
}
func joinTestOrganization(members ...bson.ObjectID) *model.Organization {
org := &model.Organization{Members: members}
org.SetID(bson.NewObjectID())
return org
}
func joinTestAccount() *model.Account {
account := &model.Account{}
account.SetID(bson.NewObjectID())
return account
}
// A transaction replay rolls back the database but leaves marks on the shared
// in-memory organization. Membership decisions must come from the database,
// otherwise the replay skips the role assignment and the membership write and
// commits an organization without its owner.
func TestJoinOrganizationDecidesMembershipByDatabase(t *testing.T) {
account := joinTestAccount()
// The in-memory list already carries the account: exactly the state a
// first, aborted transaction attempt leaves behind.
org := joinTestOrganization(account.ID)
orgDB := &fakeMembershipOrgDB{member: false}
roles := &recordingRoleManager{}
svc := joinTestService(orgDB, roles)
if err := svc.JoinOrganization(context.Background(), org, account, bson.NewObjectID()); err != nil {
t.Fatalf("join failed: %v", err)
}
if len(roles.assigned) != 1 {
t.Fatalf("role assignment mismatch: got=%d want=1", len(roles.assigned))
}
if orgDB.addMemberCalls != 1 || orgDB.addedMember != account.ID {
t.Fatalf("membership write mismatch: calls=%d member=%s", orgDB.addMemberCalls, orgDB.addedMember.Hex())
}
if len(org.Members) != 1 {
t.Fatalf("in-memory members must stay deduplicated: got=%v", org.Members)
}
}
func TestJoinOrganizationSkipsExistingMember(t *testing.T) {
account := joinTestAccount()
org := joinTestOrganization()
orgDB := &fakeMembershipOrgDB{member: true}
roles := &recordingRoleManager{}
svc := joinTestService(orgDB, roles)
if err := svc.JoinOrganization(context.Background(), org, account, bson.NewObjectID()); err != nil {
t.Fatalf("join failed: %v", err)
}
if len(roles.assigned) != 0 || orgDB.addMemberCalls != 0 {
t.Fatalf("an existing member must not be re-joined: roles=%d adds=%d", len(roles.assigned), orgDB.addMemberCalls)
}
}
@@ -4,19 +4,17 @@ import (
"context"
"errors"
"fmt"
"slices"
"time"
"unicode"
authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
edgestorage "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"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"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
@@ -26,18 +24,19 @@ import (
)
type service struct {
logger mlogger.Logger
accountDB account.DB
orgDB organization.DB
enforcer auth.Enforcer
roleManager management.Role
config *middleware.PasswordConfig
logger mlogger.Logger
accountDB account.DB
config *middleware.PasswordConfig
policyDB policy.DB
vdb verification.DB
vdb verification.DB
sessions refreshtokens.DB
transactions transaction.Factory
}
func validateUserRequest(u *model.Account) error {
if u == nil {
return merrors.InvalidArgument("account is required")
}
if u.Name == "" {
return merrors.InvalidArgument("Name must not be empty")
}
@@ -99,17 +98,17 @@ func (s *service) ValidatePassword(
func (s *service) ValidateAccount(acct *model.Account) error {
if err := validateUserRequest(acct); err != nil {
s.logger.Warn("Invalid signup acccount received", zap.Error(err), zap.String("account", acct.Login))
s.logger.Warn("Invalid signup account received", zap.Error(err))
return err
}
if err := s.ValidatePassword(acct.Password, nil); err != nil {
s.logger.Warn("Password validation failed", zap.Error(err), zap.String("account", acct.Login))
s.logger.Warn("Password validation failed", zap.Error(err), mzap.Login(acct))
return err
}
if err := acct.HashPassword(); err != nil {
s.logger.Warn("Failed to hash password", zap.Error(err), zap.String("account", acct.Login))
s.logger.Warn("Failed to hash password", zap.Error(err), mzap.Login(acct))
return err
}
@@ -118,45 +117,52 @@ func (s *service) ValidateAccount(acct *model.Account) error {
func (s *service) CreateAccount(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID bson.ObjectID,
) (string, error) {
if org == nil {
return "", merrors.InvalidArgument("Organization must not be nil")
if acct == nil {
return "", merrors.InvalidArgument("account is required")
}
if acct == nil || len(acct.Login) == 0 {
return "", merrors.InvalidArgument("Account must have a non-empty login")
if !acct.ID.IsZero() {
return "", merrors.InvalidArgument("new account must not have an ID")
}
if roleDescID == bson.NilObjectID {
return "", merrors.InvalidArgument("Role description must be provided")
candidate := acct.Copy()
if err := s.ValidateAccount(candidate); err != nil {
return "", err
}
// 1) Create the account
acct.Status = model.AccountPendingVerification
if err := s.accountDB.Create(ctx, acct); err != nil {
candidate.Status = model.AccountPendingVerification
// The customer and activation token either both commit or neither does.
var saved *model.Account
result, err := s.transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
saved = candidate.Copy()
if err := s.accountDB.Create(tx, saved); err != nil {
return nil, err
}
return s.VerifyAccount(tx, saved)
})
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
s.logger.Info("Username is already taken", zap.String("login", acct.Login))
s.logger.Info("Customer login already exists")
} else {
s.logger.Warn("Failed to signup a user", zap.Error(err), zap.String("login", acct.Login))
s.logger.Warn("Customer creation failed", zap.Error(err))
}
return "", err
}
// 2) Add to organization
if err := s.JoinOrganization(ctx, org, acct, roleDescID); err != nil {
s.logger.Warn("Failed to register new organization member", zap.Error(err), mzap.StorableRef(acct))
return "", err
token, ok := result.(string)
if !ok {
return "", merrors.Internal("unexpected account creation result")
}
// 3) Issue verification token
return s.VerifyAccount(ctx, acct)
*acct = *saved
s.logger.Info("Customer created pending verification", mzap.AccRef(acct.ID))
return token, nil
}
func (s *service) VerifyAccount(
ctx context.Context,
acct *model.Account,
) (string, error) {
if acct == nil || acct.ID.IsZero() {
return "", merrors.InvalidArgument("customer reference is required")
}
token, err := s.vdb.Create(
ctx,
verification.NewLinkRequest(*acct.GetID(), model.PurposeAccountActivation, "").
@@ -173,62 +179,22 @@ func (s *service) VerifyAccount(
func (s *service) DeleteAccount(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error {
// Check if this is the only member in the organization
if len(org.Members) <= 1 {
s.logger.Warn("Cannot delete account - it's the only member in the organization",
mzap.AccRef(accountRef), mzap.StorableRef(org))
return merrors.InvalidArgument("Cannot delete the only member of an organization")
if accountRef.IsZero() {
return merrors.InvalidArgument("account reference is required")
}
// 1) Remove from organization
if err := s.RemoveAccountFromOrganization(ctx, org, accountRef); err != nil {
s.logger.Warn("Failed to revoke account role", zap.Error(err), mzap.AccRef(accountRef))
return err
}
// 2) Delete the account document
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
return err
}
return nil
}
func (s *service) RemoveAccountFromOrganization(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error {
if org == nil {
return merrors.InvalidArgument("Organization must not be nil")
}
roles, err := s.enforcer.GetRoles(ctx, accountRef, org.ID)
_, err := s.transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
if err := s.sessions.RevokeAll(tx, accountRef, ""); err != nil {
return nil, err
}
return nil, s.accountDB.Delete(tx, accountRef)
})
if err != nil {
s.logger.Warn("Failed to fetch account permissions", zap.Error(err), mzap.StorableRef(org),
mzap.AccRef(accountRef))
s.logger.Warn("Customer deletion failed", zap.Error(err), mzap.AccRef(accountRef))
return err
}
for _, role := range roles {
if err := s.roleManager.Revoke(ctx, role.DescriptionRef, accountRef, org.ID); err != nil {
s.logger.Warn("Failed to revoke account role", zap.Error(err),
mzap.AccRef(accountRef), mzap.ObjRef("role_ref", role.DescriptionRef))
return err
}
}
for i, member := range org.Members {
if member == accountRef {
// Remove the member by slicing it out
org.Members = append(org.Members[:i], org.Members[i+1:]...)
if err := s.orgDB.Update(ctx, accountRef, org); err != nil {
s.logger.Warn("Failed to remove member from organization", zap.Error(err), mzap.AccRef(accountRef))
return err
}
break
}
}
s.logger.Info("Customer deleted and sessions revoked", mzap.AccRef(accountRef))
return nil
}
@@ -236,6 +202,9 @@ func (s *service) ResetPassword(
ctx context.Context,
acct *model.Account,
) (string, error) {
if acct == nil || acct.ID.IsZero() {
return "", merrors.InvalidArgument("customer reference is required")
}
return s.vdb.Create(
ctx,
verification.NewOTPRequest(*acct.GetID(), model.PurposePasswordReset, "").
@@ -248,6 +217,9 @@ func (s *service) UpdateLogin(
acct *model.Account,
newLogin string,
) (string, error) {
if acct == nil || acct.ID.IsZero() {
return "", merrors.InvalidArgument("customer reference is required")
}
return s.vdb.Create(
ctx,
verification.NewOTPRequest(*acct.GetID(), model.PurposeEmailChange, newLogin).
@@ -255,196 +227,22 @@ func (s *service) UpdateLogin(
)
}
func (s *service) JoinOrganization(
ctx context.Context,
org *model.Organization,
account *model.Account,
roleDescID bson.ObjectID,
) error {
// Membership is decided by the database, not the in-memory copy: this method
// runs inside provisioning transactions whose retries replay the callback,
// and a stale in-memory list would silently skip the role assignment.
member, err := s.orgDB.IsMember(ctx, org.ID, account.ID)
if err != nil {
return err
}
if member {
s.logger.Debug("Account is already a member", mzap.StorableRef(org), mzap.StorableRef(account))
return nil
}
role := &model.Role{
DescriptionRef: roleDescID,
OrganizationRef: org.ID,
AccountRef: account.ID,
}
if err := s.roleManager.Assign(ctx, role); err != nil {
s.logger.Warn("Failed to assign role to account", zap.Error(err), mzap.StorableRef(account),
mzap.StorableRef(org), mzap.ObjRef("role_description_ref", roleDescID))
return err
}
// A point patch on purpose: replacing the whole document would clobber
// concurrent writes, most notably the provisioning lease that guards
// finalization against attempts whose lease was taken over.
if _, err := s.orgDB.AddMember(ctx, org.ID, account.ID); err != nil {
s.logger.Warn("Failed to update organization members list",
zap.Error(err), mzap.StorableRef(account), mzap.StorableRef(org))
return err
}
if !slices.Contains(org.Members, account.ID) {
org.Members = append(org.Members, account.ID)
}
return nil
}
func (s *service) deleteOrganizationRoles(ctx context.Context, orgRef bson.ObjectID) error {
return s.deleteOrganizationEntities(ctx, orgRef, "roles", "role", "role_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) {
roles, err := s.roleManager.List(ctx, orgRef)
if err != nil {
return nil, err
}
roleIDs := make([]bson.ObjectID, 0, len(roles))
for _, role := range roles {
roleIDs = append(roleIDs, role.ID)
}
return roleIDs, nil
}, s.roleManager.Delete)
}
func (s *service) deleteOrganizationPolicies(ctx context.Context, orgRef bson.ObjectID) error {
return s.deleteOrganizationEntities(ctx, orgRef, "policies", "policy", "policy_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) {
policies, err := s.policyDB.All(ctx, orgRef)
if err != nil {
return nil, err
}
policyIDs := make([]bson.ObjectID, 0, len(policies))
for _, policy := range policies {
policyIDs = append(policyIDs, policy.ID)
}
return policyIDs, nil
}, s.policyDB.Delete)
}
func (s *service) deleteOrganizationEntities(
ctx context.Context,
orgRef bson.ObjectID,
entityName string,
entityLabel string,
entityRefField string,
load func(context.Context, bson.ObjectID) ([]bson.ObjectID, error),
deleteFn func(context.Context, bson.ObjectID) error,
) error {
s.logger.Info(fmt.Sprintf("Deleting %s for organization", entityName), mzap.OrgRef(orgRef))
entityIDs, err := load(ctx, orgRef)
if err != nil && !errors.Is(err, merrors.ErrNoData) {
s.logger.Warn(fmt.Sprintf("Failed to fetch %s for deletion", entityName), zap.Error(err), mzap.OrgRef(orgRef))
return err
}
for _, entityID := range entityIDs {
if err := deleteFn(ctx, entityID); err != nil {
s.logger.Warn(fmt.Sprintf("Failed to delete %s", entityLabel), zap.Error(err), mzap.ObjRef(entityRefField, entityID))
return err
}
}
s.logger.Info(fmt.Sprintf("Successfully deleted %s", entityName), zap.Int("count", len(entityIDs)), mzap.OrgRef(orgRef))
return nil
}
func (s *service) DeleteOrganization(
ctx context.Context,
org *model.Organization,
) error {
s.logger.Info("Starting organization deletion", mzap.StorableRef(org))
// Use transaction to ensure atomicity
// 8. Delete all roles and role descriptions in the organization
if err := s.deleteOrganizationRoles(ctx, org.ID); err != nil {
return err
}
// 9. Delete all policies in the organization
if err := s.deleteOrganizationPolicies(ctx, org.ID); err != nil {
return err
}
// 10. Finally, delete the organization itself
if err := s.orgDB.Delete(ctx, bson.NilObjectID, org.ID); err != nil {
s.logger.Warn("Failed to delete organization", zap.Error(err), mzap.StorableRef(org))
return err
}
s.logger.Info("Organization deleted successfully", mzap.StorableRef(org))
return nil
}
func (s *service) DeleteAll(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error {
s.logger.Info("Starting complete deletion (organization + account)",
mzap.StorableRef(org), mzap.AccRef(accountRef))
// 1. First delete the organization and all its data
if err := s.DeleteOrganization(ctx, org); err != nil {
return err
}
// 2. Then delete the account
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
return err
}
s.logger.Info("Complete deletion successful", mzap.StorableRef(org), mzap.AccRef(accountRef))
return nil
}
// NewAccountService wires in your logger plus the three dependencies.
func NewAccountService(
l mlogger.Logger,
dbf db.Factory,
enforcer auth.Enforcer,
ra management.Role,
config *middleware.PasswordConfig,
storageConfig *api.AuthStorageConfig,
) (*service, error) {
logger := l.Named("account_service")
if config == nil {
return nil, merrors.Internal("Invalid account service configuration provides")
return nil, merrors.Internal("password configuration is required")
}
res := &service{
logger: logger,
enforcer: enforcer,
roleManager: ra,
config: config,
}
deps, err := edgestorage.ResolveDependencies(dbf)
stores, err := authstorage.OpenStores(logger, dbf, storageConfig)
if err != nil {
return nil, err
}
if res.accountDB, err = dbf.NewAccountDB(); err != nil {
logger.Error("Failed to create accounts database", zap.Error(err))
return nil, err
}
if res.orgDB, err = organization.Create(context.Background(), logger, deps.Enforcer, deps.Policies, deps.Database); err != nil {
logger.Error("Failed to create organizations database", zap.Error(err))
return nil, err
}
// Initialize database dependencies for cascade deletion
if res.policyDB, err = dbf.NewPoliciesDB(); err != nil {
logger.Error("Failed to create policies database", zap.Error(err))
return nil, err
}
if res.vdb, err = verification.Create(logger, deps.Database, deps.Transactions); err != nil {
logger.Error("Failed to create verification database", zap.Error(err))
return nil, err
}
return res, nil
return &service{logger: logger, config: config, accountDB: stores.Accounts,
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil
}
@@ -1,156 +0,0 @@
package accountserviceimp
import (
"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 TestDeleteAccount_Validation(t *testing.T) {
t.Run("DeleteAccount_LastMemberFails", func(t *testing.T) {
orgID := bson.NewObjectID()
accountID := bson.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Single Member Org"},
},
Members: []bson.ObjectID{accountID}, // Only one member
}
org.ID = orgID
// This should fail because it's the only member
err := validateDeleteAccount(org)
require.Error(t, err)
assert.Contains(t, err.Error(), "Cannot delete the only member")
})
t.Run("DeleteAccount_MultipleMembersSuccess", func(t *testing.T) {
orgID := bson.NewObjectID()
accountID := bson.NewObjectID()
otherAccountID := bson.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Multi Member Org"},
},
Members: []bson.ObjectID{accountID, otherAccountID}, // Multiple members
}
org.ID = orgID
// This should succeed because there are multiple members
err := validateDeleteAccount(org)
require.NoError(t, err)
})
t.Run("DeleteAccount_EmptyMembersList", func(t *testing.T) {
orgID := bson.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Empty Org"},
},
Members: []bson.ObjectID{}, // No members
}
org.ID = orgID
// This should fail because there are no members
err := validateDeleteAccount(org)
require.Error(t, err)
assert.Contains(t, err.Error(), "Cannot delete the only member")
})
}
func TestDeleteOrganization_Validation(t *testing.T) {
t.Run("DeleteOrganization_NilOrganization", func(t *testing.T) {
err := validateDeleteOrganization(nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "organization cannot be nil")
})
t.Run("DeleteOrganization_EmptyOrganization", func(t *testing.T) {
org := &model.Organization{}
err := validateDeleteOrganization(org)
require.Error(t, err)
assert.Contains(t, err.Error(), "organization ID cannot be empty")
})
t.Run("DeleteOrganization_ValidOrganization", func(t *testing.T) {
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Valid Organization"},
},
}
org.ID = bson.NewObjectID()
err := validateDeleteOrganization(org)
require.NoError(t, err)
})
}
func TestDeleteAll_Validation(t *testing.T) {
t.Run("DeleteAll_NilOrganization", func(t *testing.T) {
accountID := bson.NewObjectID()
err := validateDeleteAll(nil, accountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "organization cannot be nil")
})
t.Run("DeleteAll_EmptyAccountID", func(t *testing.T) {
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Valid Organization"},
},
}
org.ID = bson.NewObjectID()
err := validateDeleteAll(org, bson.NilObjectID)
require.Error(t, err)
assert.Contains(t, err.Error(), "account ID cannot be empty")
})
t.Run("DeleteAll_ValidInput", func(t *testing.T) {
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Valid Organization"},
},
}
org.ID = bson.NewObjectID()
accountID := bson.NewObjectID()
err := validateDeleteAll(org, accountID)
require.NoError(t, err)
})
}
// Helper functions that implement the validation logic from the service
func validateDeleteAccount(org *model.Organization) error {
if len(org.Members) <= 1 {
return merrors.InvalidArgument("Cannot delete the only member of an organization")
}
return nil
}
func validateDeleteOrganization(org *model.Organization) error {
if org == nil {
return merrors.InvalidArgument("organization cannot be nil")
}
if org.ID == bson.NilObjectID {
return merrors.InvalidArgument("organization ID cannot be empty")
}
return nil
}
func validateDeleteAll(org *model.Organization, accountRef bson.ObjectID) error {
if org == nil {
return merrors.InvalidArgument("organization cannot be nil")
}
if accountRef == bson.NilObjectID {
return merrors.InvalidArgument("account ID cannot be empty")
}
return nil
}
@@ -4,9 +4,8 @@ import (
"context"
accountserviceimp "git.sendico.io/tech/sxchnge/bff/edge/product/interface/accountservice/internal"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
@@ -15,14 +14,6 @@ import (
// AccountService defines all account-related workflows.
type AccountService interface {
// ValidateAccount will:
// 1) check it's completeness
// 2) hash password
// 3) prepare verification token
ValidateAccount(
acct *model.Account,
) error
// ValidatePassword will:
// 1) check passsword conformance
ValidatePassword(
@@ -38,14 +29,11 @@ type AccountService interface {
) (verificationToken string, err error)
// CreateAccount will:
// 1) create the account
// 2) add it to the org’s member list
// 3) assign the given role description to it
// 1) validate and hash the password, then create the account
// 2) issue an activation token
CreateAccount(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID bson.ObjectID,
) (verificationToken string, err error)
VerifyAccount(
@@ -53,42 +41,15 @@ type AccountService interface {
acct *model.Account,
) (verificationToken string, err error)
JoinOrganization(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID bson.ObjectID,
) error
UpdateLogin(
ctx context.Context,
acct *model.Account,
newLogin string,
) (verificationToken string, err error)
// DeleteAccount deletes the account and removes it from the org.
// DeleteAccount deletes the local customer account.
DeleteAccount(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error
// RemoveAccountFromOrganization just drops it from the member slice.
RemoveAccountFromOrganization(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error
DeleteOrganization(
ctx context.Context,
org *model.Organization,
) error
// DeleteAll deletes both the organization and the account.
DeleteAll(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error
}
@@ -96,9 +57,8 @@ type AccountService interface {
func NewAccountService(
logger mlogger.Logger,
dbf db.Factory,
enforcer auth.Enforcer,
roleManeger management.Role,
config *middleware.PasswordConfig,
storageConfig *api.AuthStorageConfig,
) (AccountService, error) {
return accountserviceimp.NewAccountService(logger, dbf, enforcer, roleManeger, config)
return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig)
}
-2
View File
@@ -3,7 +3,6 @@ package api
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/domainprovider"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
@@ -17,7 +16,6 @@ type API interface {
Config() *Config
ProfileConfig() ConfigProvider
DBFactory() db.Factory
Permissions() auth.Provider
RuntimeComponent(name string) any
Register() Register
}
+3 -5
View File
@@ -2,14 +2,12 @@ package api
import (
mwa "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
storageconfig "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/storage"
)
type Config struct {
Mw *mwa.Config `yaml:"middleware"`
AuthStorage *AuthStorageConfig `yaml:"auth_storage"`
Verification *VerificationConfig `yaml:"verification"`
Storage *storageconfig.Config `yaml:"storage"`
Mw *mwa.Config `yaml:"middleware"`
AuthStorage *AuthStorageConfig `yaml:"auth_storage"`
Verification *VerificationConfig `yaml:"verification"`
}
func (c *Config) SharedConfig() *Config {
@@ -2,7 +2,6 @@ package api
import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
)
@@ -11,5 +10,4 @@ type Register interface {
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
WSHandler(messageType string, handler ws.HandlerFunc)
}
+5 -6
View File
@@ -12,12 +12,11 @@ type RuntimeBootstrapFunc func(API) (RuntimeComponents, error)
type RuntimeFinishFunc func(API) error
type RuntimeConfig struct {
Name string
Services []ServiceFactory
EnableWebSocket bool
ConfigFactory ConfigFactory
Bootstrap RuntimeBootstrapFunc
Finish RuntimeFinishFunc
Name string
Services []ServiceFactory
ConfigFactory ConfigFactory
Bootstrap RuntimeBootstrapFunc
Finish RuntimeFinishFunc
}
type ServiceFactory struct {
@@ -1,12 +0,0 @@
package srequest
import (
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ChangePolicies struct {
RoleRef bson.ObjectID `json:"roleRef"`
Add *[]model.RolePolicy `json:"add,omitempty"`
Remove *[]model.RolePolicy `json:"remove,omitempty"`
}
@@ -1,8 +0,0 @@
package srequest
import "go.mongodb.org/mongo-driver/v2/bson"
type ChangeRole struct {
AccountRef bson.ObjectID `json:"accountRef"`
NewRoleDescriptionRef bson.ObjectID `json:"newRoleDescriptionRef"`
}
@@ -6,12 +6,15 @@ import (
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AccountData struct {
model.AccountPublic `json:",inline"`
IsAnonymous bool `json:"isAnonymous"`
ID string `json:"id"`
Name string `json:"name"`
LastName string `json:"lastName"`
Login string `json:"login"`
Locale string `json:"locale"`
AvatarURL *string `json:"avatarUrl,omitempty"`
}
type accountResponse struct {
@@ -19,15 +22,10 @@ type accountResponse struct {
Account AccountData `json:"account"`
}
type accountsResponse struct {
authResponse `json:",inline"`
Accounts []AccountData `json:"accounts"`
}
func NewAccountData(account *model.Account, isAnonymous bool) *AccountData {
func NewAccountData(account *model.Account) *AccountData {
return &AccountData{
AccountPublic: account.AccountPublic,
IsAnonymous: isAnonymous,
ID: account.ID.Hex(), Name: account.Name, LastName: account.LastName,
Login: account.Login, Locale: account.Locale, AvatarURL: account.AvatarURL,
}
}
@@ -35,25 +33,7 @@ func Account(logger mlogger.Logger, account *model.Account, accessToken *TokenDa
return response.Ok(
logger,
&accountResponse{
Account: *NewAccountData(account, false),
authResponse: authResponse{AccessToken: *accessToken},
},
)
}
func Accounts(logger mlogger.Logger, accounts []model.Account, orgRef bson.ObjectID, accessToken *TokenData) http.HandlerFunc {
publicAccounts := make([]AccountData, len(accounts))
for i := range accounts {
publicAccounts[i] = *NewAccountData(
&accounts[i],
model.AccountIsAnonymous(&accounts[i].UserDataBase, orgRef),
)
}
return response.Ok(
logger,
&accountsResponse{
Accounts: publicAccounts,
Account: *NewAccountData(account),
authResponse: authResponse{AccessToken: *accessToken},
},
)
@@ -25,7 +25,7 @@ func Login(logger mlogger.Logger, account *model.Account, accessToken, refreshTo
logger,
&loginResponse{
accountResponse: accountResponse{
Account: *NewAccountData(account, false),
Account: *NewAccountData(account),
authResponse: authResponse{AccessToken: *accessToken},
},
RefreshToken: responseRefreshToken,
@@ -19,7 +19,7 @@ func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *T
logger,
&pendingLoginResponse{
Account: accountResponse{
Account: *NewAccountData(account, false),
Account: *NewAccountData(account),
authResponse: authResponse{},
},
PendingToken: *pendingToken,
@@ -44,3 +44,15 @@ func TestNativeLoginKeepsRefreshCredentialInResponse(t *testing.T) {
require.Contains(t, recorder.Body.String(), refresh.Token)
require.Empty(t, recorder.Header().Get("Set-Cookie"))
}
func TestCustomerResponseExcludesStorageAndPlatformFields(t *testing.T) {
account := &model.Account{Password: "private-password-hash", Status: model.AccountActive}
account.Login = "customer@example.test"
account.Name = "Customer"
recorder := httptest.NewRecorder()
Account(zap.NewNop(), account, &TokenData{}).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/accounts/me", nil))
require.Contains(t, recorder.Body.String(), "customer@example.test")
for _, field := range []string{"private-password-hash", "password", "status\":\"active", "organization", "role", "policy", "isAnonymous", "createdAt", "isArchived"} {
require.NotContains(t, recorder.Body.String(), field)
}
}
@@ -1,35 +0,0 @@
package sresponse
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
)
type organizationsResponse struct {
authResponse `json:",inline"`
Organizations []model.Organization `json:"organizations"`
}
func Organization(logger mlogger.Logger, organization *model.Organization, accessToken *TokenData) http.HandlerFunc {
return Organizations(logger, []model.Organization{*organization}, accessToken)
}
func Organizations(logger mlogger.Logger, organizations []model.Organization, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, organizationsResponse{
Organizations: organizations,
authResponse: authResponse{AccessToken: *accessToken},
})
}
type organizationPublicResponse struct {
Organizations []model.OrganizationBase `json:"organizations"`
}
func OrganizationPublic(logger mlogger.Logger, organization *model.OrganizationBase) http.HandlerFunc {
return response.Ok(logger, organizationPublicResponse{
Organizations: []model.OrganizationBase{*organization},
})
}
@@ -1,45 +0,0 @@
package sresponse
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
)
type permissionsDescription struct {
Roles []model.RoleDescription `json:"roles"`
Policies []model.PolicyDescription `json:"policies"`
}
type permissionsData struct {
Roles []model.Role `json:"roles"`
Policies []model.RolePolicy `json:"policies"`
Permissions []model.Permission `json:"permissions"`
}
type permissionsResponse struct {
authResponse `json:",inline"`
Descriptions permissionsDescription `json:"descriptions"`
Permissions permissionsData `json:"permissions"`
}
func Permissions(logger mlogger.Logger,
rolesDescs []model.RoleDescription, policiesDescs []model.PolicyDescription,
roles []model.Role, policies []model.RolePolicy, permissions []model.Permission,
accessToken *TokenData,
) http.HandlerFunc {
return response.Ok(logger, permissionsResponse{
Descriptions: permissionsDescription{
Roles: rolesDescs,
Policies: policiesDescs,
},
Permissions: permissionsData{
Roles: roles,
Policies: policies,
Permissions: permissions,
},
authResponse: authResponse{AccessToken: *accessToken},
})
}
@@ -1,9 +0,0 @@
package ws
import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
)
type (
Config = middleware.WebSocketConfig
)
@@ -1,12 +0,0 @@
package ws
import (
"context"
"golang.org/x/net/websocket"
)
type (
ResponseHandler func(messageType string, conn *websocket.Conn)
HandlerFunc func(ctx context.Context, msg Message) ResponseHandler
)
@@ -1,9 +0,0 @@
package ws
import "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
type Message struct {
response.BaseResponse
ID string `json:"id"`
MessageType string `json:"messageType"`
}
@@ -68,11 +68,6 @@ func (c TokenConfig) Validate() error {
return nil
}
type WebSocketConfig struct {
EndpointEnv string `yaml:"endpoint_env"`
Timeout int `yaml:"timeout"`
}
type PasswordChecks struct {
Digit bool `yaml:"digit"`
Upper bool `yaml:"upper"`
@@ -92,7 +87,6 @@ type Config struct {
APIProtocolEnv string `yaml:"api_protocol_env"`
Signature SignatureConfig `yaml:"signature"`
CORS CORSSettings `yaml:"CORS"`
WebSocket WebSocketConfig `yaml:"websocket"`
Token TokenConfig `yaml:"token"`
Password PasswordConfig `yaml:"password"`
LoginRateLimits *LoginRateLimitsConfig `yaml:"login_rate_limits"`
@@ -1,25 +0,0 @@
package storage
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
type Driver string
const (
LocalFS Driver = "local_fs"
AWSS3 Driver = "aws_s3"
)
type Config = model.DriverConfig[Driver]
type LocalFSConfig struct {
RootPath string `mapstructure:"root_path" yaml:"root_path"`
}
type AWSS3Config struct {
AccessKeyIDEnv string `mapstructure:"access_key_id_env" yaml:"access_key_id_env"`
SecretAccessKeyEnv string `mapstructure:"secret_access_key_env" yaml:"secret_access_key_env"`
RegionEnv string `mapstructure:"region_env" yaml:"region_env"`
BucketNameEnv string `mapstructure:"bucket_name_env" yaml:"bucket_name_env"`
EndpointEnv string `mapstructure:"endpoint_env" yaml:"endpoint_env"`
UsePathStyle bool `mapstructure:"use_path_style" yaml:"use_path_style"`
}
@@ -6,7 +6,6 @@ import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
httptransport "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http"
amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/domainprovider"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
@@ -58,10 +57,6 @@ func (a *APIImp) Register() api.Register {
return a.mw
}
func (a *APIImp) Permissions() auth.Provider {
return a.db.Permissions()
}
func (a *APIImp) RuntimeComponent(name string) any {
if a == nil || a.components == nil {
return nil
@@ -79,11 +79,9 @@ func (a *APIImp) installRuntimeBasics(
if a.mw, err = httptransport.CreateMiddleware(
logger,
db,
a.db.Permissions().Enforcer(),
apiRouter,
a.config.Mw,
a.config.AuthStorage,
a.runtime.EnableWebSocket,
debug,
); err != nil {
a.logger.Error("Failed to create middleware", zap.Error(err))
@@ -0,0 +1,63 @@
package runtime_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
sharedruntime "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/runtime"
"git.sendico.io/tech/sxchnge/bff/internal/profile"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
func TestProfileStartsOnEmptyDatabase(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
endpoint, err := url.Parse(uri)
require.NoError(t, err)
query := endpoint.Query()
query.Set("directConnection", "true")
endpoint.RawQuery = query.Encode()
uri = endpoint.String()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: model.SettingsT{"uri": uri, "database": "sxchnge_runtime"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
data, err := os.ReadFile("../../../../config.example.yml")
require.NoError(t, err)
var example struct {
API api.Config `yaml:"api"`
}
require.NoError(t, yaml.Unmarshal(data, &example))
t.Setenv("SXCHNGE_DOMAIN", "localhost")
t.Setenv("SXCHNGE_API_PROTOCOL", "http")
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
root, routes := chi.NewRouter(), chi.NewRouter()
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
root.Mount("/", routes)
result := httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil))
require.Equal(t, http.StatusOK, result.Code)
// Runtime auth routes are installed even with zero customers and no seed.
result = httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
require.Equal(t, http.StatusBadRequest, result.Code)
}
@@ -9,14 +9,11 @@ import (
authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage"
edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
wsh "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
httprouter "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router"
mr "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/metrics"
ws "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/websocket"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
@@ -34,7 +31,6 @@ type Middleware struct {
router *chi.Mux
apiEndpoint string
metrics mr.Metrics
wshandler ws.Router
epdispatcher *httprouter.Dispatcher
}
@@ -50,14 +46,6 @@ func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint stri
mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler)
}
func (mw *Middleware) WSHandler(messageType string, handler wsh.HandlerFunc) {
if mw.wshandler == nil {
mw.logger.Info("WebSocket handler registration skipped because websocket router is disabled", zap.String("message_type", messageType))
return
}
mw.wshandler.InstallHandler(messageType, handler)
}
func (mw *Middleware) Finish() {
mw.metrics.Finish()
}
@@ -116,11 +104,9 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool {
func CreateMiddleware(
logger mlogger.Logger,
db db.Factory,
enforcer auth.Enforcer,
apiRouter *chi.Mux,
config *middleware.Config,
authStorage *edgeapi.AuthStorageConfig,
installWebSocket bool,
debug bool,
) (*Middleware, error) {
if config == nil {
@@ -161,12 +147,8 @@ func CreateMiddleware(
stores.Verification,
stores.RefreshTokens,
stores.LoginAttempts,
enforcer,
config,
loginRateLimits,
)
if installWebSocket {
p.wshandler = ws.NewRouter(p.logger, p.router, &config.WebSocket, p.apiEndpoint)
}
return p, nil
}
@@ -0,0 +1,74 @@
package authorized
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"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/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type customerDB struct {
account.DB
customer model.Account
requested bson.ObjectID
}
func (d *customerDB) Get(_ context.Context, id bson.ObjectID, result *model.Account) error {
d.requested = id
if id != d.customer.ID {
return merrors.NoData("customer not found")
}
*result = d.customer
return nil
}
func (d *sessionDB) GetClient(context.Context, string) (*model.Client, error) {
return &model.Client{ClientID: "web"}, nil
}
func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
for _, status := range []model.AccountStatus{model.AccountActive, model.AccountBlocked, model.AccountPendingVerification} {
t.Run(string(status), func(t *testing.T) {
accounts := &customerDB{}
accounts.customer.ID = bson.NewObjectID()
accounts.customer.Status = status
routes := chi.NewRouter()
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")}
router := NewRouter(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &model.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
called := false
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
called = true
require.Equal(t, accounts.customer.ID, c.ID)
return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
})
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, nil)
_, token, err := ja.Encode(emodel.Account2ClaimsForSession(&accounts.customer, 1, model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}))
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/me?accountRef="+bson.NewObjectID().Hex(), nil)
req.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
routes.ServeHTTP(recorder, req)
require.Equal(t, accounts.customer.ID, accounts.requested)
if status == model.AccountActive {
require.Equal(t, http.StatusNoContent, recorder.Code)
require.True(t, called)
} else {
require.Equal(t, http.StatusUnauthorized, recorder.Code)
require.False(t, called)
}
})
}
}
@@ -128,6 +128,10 @@ func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint strin
}
return response.Internal(ar.logger, ar.service, err)
}
if !a.IsActive() || a.IsArchived() {
return response.Unauthorized(ar.logger, ar.service, "customer is inactive")
}
accessToken, err := ar.imp.CreateAccessTokenForSession(&a, t.SessionIdentifier)
if err != nil {
ar.logger.Warn("Failed to generate access token", zap.Error(err))
@@ -148,6 +152,9 @@ func (ar *AuthorizedRouter) PendingAccountHandler(service mservice.Type, endpoin
}
return response.Internal(ar.logger, ar.service, err)
}
if a.Status == model.AccountBlocked || a.IsArchived() {
return response.Unauthorized(ar.logger, ar.service, "customer is inactive")
}
return handler(r, &a, t)
}
ar.tokenHandler(service, endpoint, method, hndlr)
@@ -10,7 +10,6 @@ import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
@@ -28,7 +27,7 @@ type AuthorizedRouter struct {
service mservice.Type
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
router.Use(jwtauth.Verifier(ja))
router.Use(jwtauth.Authenticator(ja))
@@ -13,7 +13,6 @@ import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
@@ -44,7 +43,6 @@ func NewDispatcher(
vdb verification.DB,
rtdb refreshtokens.DB,
attempts ratelimits.Store,
enforcer auth.Enforcer,
config *middleware.Config,
loginRateLimits middleware.LoginRateLimits,
) *Dispatcher {
@@ -59,7 +57,7 @@ func NewDispatcher(
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits)
})
router.Group(func(r chi.Router) {
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, enforcer, &config.Token, &signature)
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
})
return d
@@ -46,7 +46,7 @@ func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.L
return nil, true, response.Unauthorized(pr.logger, pr.service, "invalid credentials")
}
if !account.IsActive() {
if !account.IsActive() || account.IsArchived() {
return nil, false, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
}
@@ -87,10 +87,6 @@ func (db *testAccountDB) GetByEmail(ctx context.Context, email string) (*model.A
return db.result, db.err
}
func (db *testAccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) {
return nil, merrors.NotImplemented("stub")
}
func (db *testAccountDB) Create(ctx context.Context, object *model.Account) error {
return merrors.NotImplemented("stub")
}
@@ -135,3 +135,17 @@ func TestNativeRefreshRejectsStaleTokenWithoutCookieMutation(t *testing.T) {
require.Contains(t, response.Body.String(), `"error":"unauthorized"`)
require.Empty(t, response.Header().Get("Set-Cookie"))
}
func TestRefreshRejectsInactiveCustomerAndClientMismatch(t *testing.T) {
for _, status := range []model.AccountStatus{model.AccountBlocked, model.AccountPendingVerification} {
t.Run(string(status), func(t *testing.T) {
router, _ := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour))
router.db.(*testAccountDB).result.Status = status
require.Equal(t, http.StatusUnauthorized, executeBrowserRefresh(t, router).Code)
})
}
router, sessions := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour))
other := testActiveAccount(t)
sessions.client.AccountRef = &other.ID
require.Equal(t, http.StatusUnauthorized, executeBrowserRefresh(t, router).Code)
}
@@ -88,6 +88,13 @@ func (pr *PublicRouter) validateRefreshToken(ctx context.Context, r *http.Reques
return nil, nil, nil, err
}
if !account.IsActive() || account.IsArchived() {
return nil, nil, nil, merrors.Unauthorized("customer is inactive")
}
if client.AccountRef != nil && *client.AccountRef != account.ID {
return nil, nil, nil, merrors.Unauthorized("client account mismatch")
}
accessToken, err := pr.imp.CreateAccessTokenForSession(&account, rt.SessionIdentifier)
if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err))
@@ -1,68 +0,0 @@
package websocket
import (
"context"
"fmt"
"net/http"
"os"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
"golang.org/x/net/websocket"
)
type DispatcherImpl struct {
logger mlogger.Logger
handlers map[string]ws.HandlerFunc
timeout int
}
func (d *DispatcherImpl) InstallHandler(messageType string, handler ws.HandlerFunc) {
d.handlers[messageType] = handler
d.logger.Info("Handler installed", zap.String("message_type", messageType))
}
func (d *DispatcherImpl) dispatchMessage(ctx context.Context, conn *websocket.Conn) {
var msg ws.Message
err := websocket.JSON.Receive(conn, &msg)
if err != nil {
d.logger.Warn("Failed to read websocket message", zap.Error(err))
return
}
if handler, exists := d.handlers[msg.MessageType]; exists {
responseHandler := handler(ctx, msg)
responseHandler(msg.MessageType, conn)
} else {
d.logger.Warn("Unknown websocket message type", zap.String("message_type", msg.MessageType), zap.Any("message", &msg))
}
}
func (d *DispatcherImpl) handle(w http.ResponseWriter, r *http.Request) {
//nolint:contextcheck // websocket.Handler callback signature does not carry request context.
websocket.Handler(func(conn *websocket.Conn) {
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(d.timeout)*time.Second)
defer cancel()
d.dispatchMessage(ctx, conn)
}).ServeHTTP(w, r)
}
func NewDispatcher(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) *DispatcherImpl {
d := &DispatcherImpl{
logger: logger.Named("websocket"),
handlers: make(map[string]ws.HandlerFunc),
timeout: config.Timeout,
}
d.logger.Debug("Installing websocket middleware...")
router.Group(func(r chi.Router) {
ep := fmt.Sprintf("%s%s", apiEndpoint, os.Getenv(config.EndpointEnv))
d.logger.Info("Installing websockets handler", zap.String("endpoint", ep))
r.Get(ep, d.handle)
})
return d
}
@@ -1,15 +0,0 @@
package websocket
import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"github.com/go-chi/chi/v5"
)
type Router interface {
InstallHandler(messageType string, handler ws.HandlerFunc)
}
func NewRouter(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) Router {
return NewDispatcher(logger, router, config, apiEndpoint)
}
@@ -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)
}