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
@@ -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