Remove platform tenancy from SXCHNGE customer BFF (#34)
This commit is contained in:
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user