From 981e84a4b402b720e10e4d5790d816caf6e0dc58 Mon Sep 17 00:00:00 2001 From: Stephan D Date: Sat, 12 Sep 2026 14:00:14 +0200 Subject: [PATCH] Remove platform tenancy from SXCHNGE customer BFF (#34) --- README.md | 1 + backend/AGENTS.md | 6 +- backend/PROVENANCE.json | 1180 +---------------- backend/PROVENANCE.md | 102 +- backend/README.md | 15 +- backend/config.example.yml | 2 - .../accountservice/internal/customer_test.go | 130 ++ .../internal/join_organization_test.go | 119 -- .../accountservice/internal/service.go | 334 +---- .../internal/service_deletion_test.go | 156 --- .../product/interface/accountservice/types.go | 52 +- backend/edge/shared/interface/api/api.go | 2 - backend/edge/shared/interface/api/config.go | 8 +- backend/edge/shared/interface/api/register.go | 2 - backend/edge/shared/interface/api/runtime.go | 11 +- .../interface/api/srequest/changepolicies.go | 12 - .../interface/api/srequest/changerole.go | 8 - .../shared/interface/api/sresponse/account.go | 40 +- .../shared/interface/api/sresponse/login.go | 2 +- .../interface/api/sresponse/login_pending.go | 2 +- .../interface/api/sresponse/login_test.go | 12 + .../interface/api/sresponse/organization.go | 35 - .../interface/api/sresponse/permissions.go | 45 - .../edge/shared/interface/api/ws/config.go | 9 - .../edge/shared/interface/api/ws/handler.go | 12 - .../edge/shared/interface/api/ws/message.go | 9 - .../shared/interface/middleware/middleware.go | 6 - .../edge/shared/interface/storage/config.go | 25 - backend/edge/shared/internal/runtime/api.go | 5 - .../edge/shared/internal/runtime/bootstrap.go | 2 - .../shared/internal/runtime/bootstrap_test.go | 63 + .../internal/transport/http/middleware.go | 18 - .../http/router/authorized/customer_test.go | 74 ++ .../http/router/authorized/handler.go | 7 + .../http/router/authorized/router.go | 3 +- .../transport/http/router/dispatcher.go | 4 +- .../transport/http/router/public/login.go | 2 +- .../router/public/login_test_support_test.go | 4 - .../http/router/public/refresh_test.go | 14 + .../transport/http/router/public/validate.go | 7 + .../transport/http/websocket/dispimp.go | 68 - .../transport/http/websocket/router.go | 15 - .../edge/shared/storage/mongo/dependencies.go | 18 +- .../shared/storage/organization/archived.go | 32 - .../shared/storage/organization/cascade.go | 23 - .../shared/storage/organization/create.go | 19 - .../edge/shared/storage/organization/db.go | 113 -- .../edge/shared/storage/organization/get.go | 12 - .../shared/storage/organization/index_test.go | 54 - .../edge/shared/storage/organization/list.go | 16 - .../shared/storage/organization/member.go | 49 - .../storage/organization/member_test.go | 153 --- .../memory_repository_queries_test.go | 378 ------ .../organization/memory_repository_test.go | 149 --- .../storage/organization/organization.go | 19 - .../edge/shared/storage/organization/owned.go | 14 - .../shared/storage/organization/provider.go | 14 - .../storage/organization/provisioning.go | 166 --- .../storage/organization/provisioning_test.go | 180 --- .../storage/organization/setarchived_test.go | 51 - .../storage/organization/test_helpers_test.go | 58 - .../edge/shared/storage/refreshtokens/crud.go | 2 +- backend/edge/shared/storage/s3key/key.go | 35 - backend/edge/shared/storage/s3key/key_test.go | 42 - .../memory_token_repository_test.go | 8 - backend/go.mod | 5 - backend/go.sum | 8 - .../internal/architecture/boundary_test.go | 2 +- .../internal/architecture/customer_test.go | 48 + backend/internal/profile/runtime.go | 3 +- backend/pkg/api/http/response/response.go | 3 - .../pkg/api/http/response/response_test.go | 15 - backend/pkg/auth/anyobject/anyobject.go | 3 - backend/pkg/auth/archivable.go | 35 - backend/pkg/auth/archivableimp.go | 107 -- backend/pkg/auth/authorizer.go | 87 -- backend/pkg/auth/authorizer_test.go | 147 -- backend/pkg/auth/config.go | 12 - backend/pkg/auth/db.go | 38 - backend/pkg/auth/dbab.go | 51 - backend/pkg/auth/dbimp.go | 319 ----- backend/pkg/auth/dbimpab.go | 394 ------ backend/pkg/auth/dbimpab_test.go | 80 -- backend/pkg/auth/dbimpscoped.go | 142 -- backend/pkg/auth/dbimpscoped_test.go | 848 ------------ backend/pkg/auth/dbimpscopedcrud.go | 262 ---- backend/pkg/auth/dbimpscopedpatch.go | 129 -- backend/pkg/auth/enforcer.go | 32 - backend/pkg/auth/factory.go | 58 - backend/pkg/auth/helper.go | 61 - backend/pkg/auth/indexable.go | 29 - backend/pkg/auth/indexableimp.go | 182 --- .../pkg/auth/internal/casbin/config/config.go | 127 -- backend/pkg/auth/internal/casbin/enforcer.go | 206 --- backend/pkg/auth/internal/casbin/factory.go | 34 - backend/pkg/auth/internal/casbin/logger.go | 61 - backend/pkg/auth/internal/casbin/manager.go | 61 - .../internal/casbin/mongoadapter/adapter.go | 357 ----- .../casbin/mongoadapter/adapter_test.go | 68 - .../pkg/auth/internal/casbin/permissions.go | 167 --- backend/pkg/auth/internal/casbin/role.go | 209 --- .../casbin/serialization/internal/policy.go | 81 -- .../casbin/serialization/internal/role.go | 57 - .../internal/casbin/serialization/policy.go | 12 - .../internal/casbin/serialization/role.go | 12 - .../casbin/serialization/serializer.go | 10 - .../pkg/auth/internal/native/db/policies.go | 151 --- backend/pkg/auth/internal/native/db/roles.go | 99 -- .../pkg/auth/internal/native/dbpolicies.go | 27 - backend/pkg/auth/internal/native/dbroles.go | 24 - backend/pkg/auth/internal/native/enforcer.go | 282 ---- .../pkg/auth/internal/native/enforcer_test.go | 746 ----------- backend/pkg/auth/internal/native/manager.go | 57 - .../internal/native/nstructures/policies.go | 17 - .../auth/internal/native/nstructures/role.go | 15 - .../auth/internal/native/object_scope_test.go | 175 --- .../pkg/auth/internal/native/permission.go | 101 -- backend/pkg/auth/internal/native/role.go | 142 -- backend/pkg/auth/management/permission.go | 27 - backend/pkg/auth/management/role.go | 41 - backend/pkg/auth/manager.go | 15 - backend/pkg/auth/patchguard.go | 30 - backend/pkg/auth/provider.go | 14 - backend/pkg/auth/scoped.go | 71 - backend/pkg/auth/scoped_test.go | 130 -- backend/pkg/auth/scopeddb.go | 129 -- backend/pkg/auth/scopeddb_test.go | 256 ---- backend/pkg/db/account/account.go | 2 - backend/pkg/db/config.go | 90 -- backend/pkg/db/factory.go | 23 - .../pkg/db/internal/mongo/accountdb/user.go | 8 - backend/pkg/db/internal/mongo/db.go | 130 +- .../db/internal/mongo/invitationdb/accept.go | 12 - .../internal/mongo/invitationdb/archived.go | 49 - .../db/internal/mongo/invitationdb/cascade.go | 24 - .../pkg/db/internal/mongo/invitationdb/db.go | 48 - .../mongo/invitationdb/db_integration_test.go | 108 -- .../db/internal/mongo/invitationdb/db_test.go | 81 -- .../db/internal/mongo/invitationdb/decline.go | 12 - .../internal/mongo/invitationdb/getpublic.go | 121 -- .../db/internal/mongo/invitationdb/indexes.go | 30 - .../db/internal/mongo/invitationdb/list.go | 28 - .../mongo/invitationdb/updatestatus.go | 26 - .../pkg/db/internal/mongo/policiesdb/all.go | 20 - .../db/internal/mongo/policiesdb/builtin.go | 13 - .../pkg/db/internal/mongo/policiesdb/db.go | 21 - .../db/internal/mongo/policiesdb/db_test.go | 355 ----- .../db/internal/mongo/policiesdb/policies.go | 18 - .../mongo/repositoryimp/repository.go | 52 - .../repository_comprehensive_test.go | 66 - backend/pkg/db/internal/mongo/rolesdb/db.go | 21 - backend/pkg/db/internal/mongo/rolesdb/list.go | 15 - .../pkg/db/internal/mongo/rolesdb/roles.go | 15 - .../mongo/transactionimp/transaction.go | 4 + backend/pkg/db/invitation/invitation.go | 19 - backend/pkg/db/permissions_config_test.go | 129 -- backend/pkg/db/policy/policy.go | 17 - backend/pkg/db/repository/abfilter.go | 78 -- backend/pkg/db/repository/builders.go | 16 - .../pkg/db/repository/filter_factory_test.go | 93 -- backend/pkg/db/repository/repository.go | 3 - backend/pkg/db/role/role.go | 15 - backend/pkg/db/storable/id.go | 8 +- backend/pkg/db/template/template.go | 5 - backend/pkg/db/template/tseries.go | 14 - backend/pkg/db/tseries/point/interface.go | 14 - backend/pkg/decimal/money.go | 129 -- backend/pkg/decimal/rational.go | 161 --- backend/pkg/decimal/rounding.go | 29 - backend/pkg/merrors/accounting.go | 25 - backend/pkg/merrors/aml.go | 20 - backend/pkg/merrors/aml_test.go | 21 - backend/pkg/merrors/chain_execution.go | 21 - backend/pkg/merrors/chain_execution_test.go | 17 - backend/pkg/merrors/documents.go | 44 - backend/pkg/merrors/documents_test.go | 37 - backend/pkg/merrors/errors.go | 6 - backend/pkg/merrors/ledger.go | 65 - backend/pkg/merrors/ledger_test.go | 48 - backend/pkg/merrors/limits.go | 19 - backend/pkg/merrors/organization.go | 66 - backend/pkg/merrors/paymaster_control.go | 9 - backend/pkg/merrors/payment_execution.go | 47 - backend/pkg/merrors/payment_execution_test.go | 26 - backend/pkg/merrors/quotation.go | 32 - backend/pkg/merrors/quotation_test.go | 44 - backend/pkg/merrors/reason.go | 88 +- backend/pkg/merrors/routes.go | 127 -- backend/pkg/merrors/routes_test.go | 52 - backend/pkg/merrors/signup_email_policy.go | 78 -- .../pkg/merrors/signup_email_policy_test.go | 52 - backend/pkg/merrors/treasury.go | 15 - backend/pkg/model/account.go | 47 +- backend/pkg/model/account_metadata.go | 45 - .../pkg/model/account_role/account_role.go | 57 - backend/pkg/model/account_test.go | 51 - backend/pkg/model/auth.go | 84 -- backend/pkg/model/card.go | 169 --- backend/pkg/model/card_test.go | 68 - backend/pkg/model/chains.go | 97 -- backend/pkg/model/chains_test.go | 33 - backend/pkg/model/chainwalletroute.go | 29 - backend/pkg/model/cryptoaddress.go | 127 -- backend/pkg/model/cryptoaddress_test.go | 84 -- backend/pkg/model/ctoken.go | 47 - backend/pkg/model/ctoken_test.go | 30 - backend/pkg/model/currency.go | 77 -- backend/pkg/model/currency_test.go | 41 - backend/pkg/model/iban.go | 26 - backend/pkg/model/indexable.go | 12 - backend/pkg/model/invitation.go | 72 - backend/pkg/model/invitation_status.go | 18 - backend/pkg/model/invitation_test.go | 62 - backend/pkg/model/invitation_validation.go | 23 - backend/pkg/model/ledger.go | 132 -- backend/pkg/model/object.go | 20 - backend/pkg/model/organization.go | 111 -- backend/pkg/model/ownerbound.go | 21 - backend/pkg/model/ownerkind.go | 24 - backend/pkg/model/payment.go | 276 ---- backend/pkg/model/payment_ledger.go | 77 -- backend/pkg/model/payment_method_json_test.go | 122 -- backend/pkg/model/permission.go | 33 - backend/pkg/model/rba.go | 52 - backend/pkg/model/recipient.go | 128 -- backend/pkg/model/recipient_json_test.go | 62 - backend/pkg/model/recipient_wire_test.go | 64 - backend/pkg/model/scopedstorablemodel.go | 19 - backend/pkg/model/signup_provisioning.go | 150 --- backend/pkg/model/wallet.go | 25 - backend/pkg/model/walletprovisioning.go | 105 -- backend/pkg/moneybase/fx_rounding.go | 50 - backend/pkg/moneybase/fx_rounding_test.go | 29 - backend/pkg/moneybase/scale.go | 12 - backend/pkg/mservice/services.go | 107 +- backend/pkg/mservice/services_test.go | 40 - backend/pkg/mutil/db/auth/accountbound.go | 89 -- backend/pkg/mutil/db/auth/protected.go | 58 - backend/pkg/mutil/mzap/object.go | 13 - backend/pkg/person/name.go | 38 - backend/pkg/person/name_test.go | 14 - backend/scripts/seed-policies.js | 88 -- docs/architecture/bff.md | 21 +- docs/implementation.md | 82 +- 244 files changed, 651 insertions(+), 17130 deletions(-) create mode 100644 backend/edge/product/interface/accountservice/internal/customer_test.go delete mode 100644 backend/edge/product/interface/accountservice/internal/join_organization_test.go delete mode 100644 backend/edge/product/interface/accountservice/internal/service_deletion_test.go delete mode 100644 backend/edge/shared/interface/api/srequest/changepolicies.go delete mode 100644 backend/edge/shared/interface/api/srequest/changerole.go delete mode 100644 backend/edge/shared/interface/api/sresponse/organization.go delete mode 100644 backend/edge/shared/interface/api/sresponse/permissions.go delete mode 100644 backend/edge/shared/interface/api/ws/config.go delete mode 100644 backend/edge/shared/interface/api/ws/handler.go delete mode 100644 backend/edge/shared/interface/api/ws/message.go delete mode 100644 backend/edge/shared/interface/storage/config.go create mode 100644 backend/edge/shared/internal/runtime/bootstrap_test.go create mode 100644 backend/edge/shared/internal/transport/http/router/authorized/customer_test.go delete mode 100644 backend/edge/shared/internal/transport/http/websocket/dispimp.go delete mode 100644 backend/edge/shared/internal/transport/http/websocket/router.go delete mode 100644 backend/edge/shared/storage/organization/archived.go delete mode 100644 backend/edge/shared/storage/organization/cascade.go delete mode 100644 backend/edge/shared/storage/organization/create.go delete mode 100644 backend/edge/shared/storage/organization/db.go delete mode 100644 backend/edge/shared/storage/organization/get.go delete mode 100644 backend/edge/shared/storage/organization/index_test.go delete mode 100644 backend/edge/shared/storage/organization/list.go delete mode 100644 backend/edge/shared/storage/organization/member.go delete mode 100644 backend/edge/shared/storage/organization/member_test.go delete mode 100644 backend/edge/shared/storage/organization/memory_repository_queries_test.go delete mode 100644 backend/edge/shared/storage/organization/memory_repository_test.go delete mode 100644 backend/edge/shared/storage/organization/organization.go delete mode 100644 backend/edge/shared/storage/organization/owned.go delete mode 100644 backend/edge/shared/storage/organization/provider.go delete mode 100644 backend/edge/shared/storage/organization/provisioning.go delete mode 100644 backend/edge/shared/storage/organization/provisioning_test.go delete mode 100644 backend/edge/shared/storage/organization/setarchived_test.go delete mode 100644 backend/edge/shared/storage/organization/test_helpers_test.go delete mode 100644 backend/edge/shared/storage/s3key/key.go delete mode 100644 backend/edge/shared/storage/s3key/key_test.go create mode 100644 backend/internal/architecture/customer_test.go delete mode 100644 backend/pkg/auth/anyobject/anyobject.go delete mode 100644 backend/pkg/auth/archivable.go delete mode 100644 backend/pkg/auth/archivableimp.go delete mode 100644 backend/pkg/auth/authorizer.go delete mode 100644 backend/pkg/auth/authorizer_test.go delete mode 100644 backend/pkg/auth/config.go delete mode 100644 backend/pkg/auth/db.go delete mode 100644 backend/pkg/auth/dbab.go delete mode 100644 backend/pkg/auth/dbimp.go delete mode 100644 backend/pkg/auth/dbimpab.go delete mode 100644 backend/pkg/auth/dbimpab_test.go delete mode 100644 backend/pkg/auth/dbimpscoped.go delete mode 100644 backend/pkg/auth/dbimpscoped_test.go delete mode 100644 backend/pkg/auth/dbimpscopedcrud.go delete mode 100644 backend/pkg/auth/dbimpscopedpatch.go delete mode 100644 backend/pkg/auth/enforcer.go delete mode 100644 backend/pkg/auth/factory.go delete mode 100644 backend/pkg/auth/helper.go delete mode 100644 backend/pkg/auth/indexable.go delete mode 100644 backend/pkg/auth/indexableimp.go delete mode 100644 backend/pkg/auth/internal/casbin/config/config.go delete mode 100644 backend/pkg/auth/internal/casbin/enforcer.go delete mode 100644 backend/pkg/auth/internal/casbin/factory.go delete mode 100644 backend/pkg/auth/internal/casbin/logger.go delete mode 100644 backend/pkg/auth/internal/casbin/manager.go delete mode 100644 backend/pkg/auth/internal/casbin/mongoadapter/adapter.go delete mode 100644 backend/pkg/auth/internal/casbin/mongoadapter/adapter_test.go delete mode 100644 backend/pkg/auth/internal/casbin/permissions.go delete mode 100644 backend/pkg/auth/internal/casbin/role.go delete mode 100644 backend/pkg/auth/internal/casbin/serialization/internal/policy.go delete mode 100644 backend/pkg/auth/internal/casbin/serialization/internal/role.go delete mode 100644 backend/pkg/auth/internal/casbin/serialization/policy.go delete mode 100644 backend/pkg/auth/internal/casbin/serialization/role.go delete mode 100644 backend/pkg/auth/internal/casbin/serialization/serializer.go delete mode 100644 backend/pkg/auth/internal/native/db/policies.go delete mode 100644 backend/pkg/auth/internal/native/db/roles.go delete mode 100644 backend/pkg/auth/internal/native/dbpolicies.go delete mode 100644 backend/pkg/auth/internal/native/dbroles.go delete mode 100644 backend/pkg/auth/internal/native/enforcer.go delete mode 100644 backend/pkg/auth/internal/native/enforcer_test.go delete mode 100644 backend/pkg/auth/internal/native/manager.go delete mode 100644 backend/pkg/auth/internal/native/nstructures/policies.go delete mode 100644 backend/pkg/auth/internal/native/nstructures/role.go delete mode 100644 backend/pkg/auth/internal/native/object_scope_test.go delete mode 100644 backend/pkg/auth/internal/native/permission.go delete mode 100644 backend/pkg/auth/internal/native/role.go delete mode 100644 backend/pkg/auth/management/permission.go delete mode 100644 backend/pkg/auth/management/role.go delete mode 100644 backend/pkg/auth/manager.go delete mode 100644 backend/pkg/auth/patchguard.go delete mode 100644 backend/pkg/auth/provider.go delete mode 100644 backend/pkg/auth/scoped.go delete mode 100644 backend/pkg/auth/scoped_test.go delete mode 100644 backend/pkg/auth/scopeddb.go delete mode 100644 backend/pkg/auth/scopeddb_test.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/accept.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/archived.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/cascade.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/db.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/db_integration_test.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/db_test.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/decline.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/getpublic.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/indexes.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/list.go delete mode 100644 backend/pkg/db/internal/mongo/invitationdb/updatestatus.go delete mode 100644 backend/pkg/db/internal/mongo/policiesdb/all.go delete mode 100644 backend/pkg/db/internal/mongo/policiesdb/builtin.go delete mode 100644 backend/pkg/db/internal/mongo/policiesdb/db.go delete mode 100644 backend/pkg/db/internal/mongo/policiesdb/db_test.go delete mode 100644 backend/pkg/db/internal/mongo/policiesdb/policies.go delete mode 100644 backend/pkg/db/internal/mongo/rolesdb/db.go delete mode 100644 backend/pkg/db/internal/mongo/rolesdb/list.go delete mode 100644 backend/pkg/db/internal/mongo/rolesdb/roles.go delete mode 100644 backend/pkg/db/invitation/invitation.go delete mode 100644 backend/pkg/db/permissions_config_test.go delete mode 100644 backend/pkg/db/policy/policy.go delete mode 100644 backend/pkg/db/repository/abfilter.go delete mode 100644 backend/pkg/db/repository/filter_factory_test.go delete mode 100644 backend/pkg/db/role/role.go delete mode 100644 backend/pkg/db/template/tseries.go delete mode 100644 backend/pkg/db/tseries/point/interface.go delete mode 100644 backend/pkg/decimal/money.go delete mode 100644 backend/pkg/decimal/rational.go delete mode 100644 backend/pkg/decimal/rounding.go delete mode 100644 backend/pkg/merrors/accounting.go delete mode 100644 backend/pkg/merrors/aml.go delete mode 100644 backend/pkg/merrors/aml_test.go delete mode 100644 backend/pkg/merrors/chain_execution.go delete mode 100644 backend/pkg/merrors/chain_execution_test.go delete mode 100644 backend/pkg/merrors/documents.go delete mode 100644 backend/pkg/merrors/documents_test.go delete mode 100644 backend/pkg/merrors/ledger.go delete mode 100644 backend/pkg/merrors/ledger_test.go delete mode 100644 backend/pkg/merrors/limits.go delete mode 100644 backend/pkg/merrors/organization.go delete mode 100644 backend/pkg/merrors/paymaster_control.go delete mode 100644 backend/pkg/merrors/payment_execution.go delete mode 100644 backend/pkg/merrors/payment_execution_test.go delete mode 100644 backend/pkg/merrors/quotation.go delete mode 100644 backend/pkg/merrors/quotation_test.go delete mode 100644 backend/pkg/merrors/routes.go delete mode 100644 backend/pkg/merrors/routes_test.go delete mode 100644 backend/pkg/merrors/signup_email_policy.go delete mode 100644 backend/pkg/merrors/signup_email_policy_test.go delete mode 100644 backend/pkg/merrors/treasury.go delete mode 100644 backend/pkg/model/account_metadata.go delete mode 100644 backend/pkg/model/account_role/account_role.go delete mode 100644 backend/pkg/model/account_test.go delete mode 100644 backend/pkg/model/auth.go delete mode 100644 backend/pkg/model/card.go delete mode 100644 backend/pkg/model/card_test.go delete mode 100644 backend/pkg/model/chains.go delete mode 100644 backend/pkg/model/chains_test.go delete mode 100644 backend/pkg/model/chainwalletroute.go delete mode 100644 backend/pkg/model/cryptoaddress.go delete mode 100644 backend/pkg/model/cryptoaddress_test.go delete mode 100644 backend/pkg/model/ctoken.go delete mode 100644 backend/pkg/model/ctoken_test.go delete mode 100644 backend/pkg/model/currency.go delete mode 100644 backend/pkg/model/currency_test.go delete mode 100644 backend/pkg/model/iban.go delete mode 100644 backend/pkg/model/indexable.go delete mode 100644 backend/pkg/model/invitation.go delete mode 100644 backend/pkg/model/invitation_status.go delete mode 100644 backend/pkg/model/invitation_test.go delete mode 100644 backend/pkg/model/invitation_validation.go delete mode 100644 backend/pkg/model/ledger.go delete mode 100644 backend/pkg/model/object.go delete mode 100644 backend/pkg/model/organization.go delete mode 100644 backend/pkg/model/ownerbound.go delete mode 100644 backend/pkg/model/ownerkind.go delete mode 100644 backend/pkg/model/payment.go delete mode 100644 backend/pkg/model/payment_ledger.go delete mode 100644 backend/pkg/model/payment_method_json_test.go delete mode 100644 backend/pkg/model/permission.go delete mode 100644 backend/pkg/model/rba.go delete mode 100644 backend/pkg/model/recipient.go delete mode 100644 backend/pkg/model/recipient_json_test.go delete mode 100644 backend/pkg/model/recipient_wire_test.go delete mode 100644 backend/pkg/model/scopedstorablemodel.go delete mode 100644 backend/pkg/model/signup_provisioning.go delete mode 100644 backend/pkg/model/wallet.go delete mode 100644 backend/pkg/model/walletprovisioning.go delete mode 100644 backend/pkg/moneybase/fx_rounding.go delete mode 100644 backend/pkg/moneybase/fx_rounding_test.go delete mode 100644 backend/pkg/moneybase/scale.go delete mode 100644 backend/pkg/mservice/services_test.go delete mode 100644 backend/pkg/mutil/db/auth/accountbound.go delete mode 100644 backend/pkg/mutil/db/auth/protected.go delete mode 100644 backend/pkg/person/name.go delete mode 100644 backend/pkg/person/name_test.go delete mode 100644 backend/scripts/seed-policies.js diff --git a/README.md b/README.md index 82629d9..de8f711 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,7 @@ usability, accessibility and journey measurement under the repository's UX rules The integration is: **consumer app → sxchnge BFF → Sendico public HTTP API**. SXCHNGE is one organization in Sendico; its customers are owned locally. +Local accounts have no organization tenancy, membership, roles or policy engine. - sxchnge will own consumer registration/sessions, onboarding and exchange UX, and mappings from local customers to organization-scoped platform resources. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index b17e35b..748d279 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -86,7 +86,11 @@ skill to perform this completion workflow. ## SXCHNGE provider boundary SXCHNGE owns auth, sessions and customer management. SXCHNGE is one organization -inside Sendico. Copied BFF components are locally owned and may diverge. +inside Sendico. This organization reference belongs only in the public provider +adapter configuration. Local customers have no organizations, tenant IDs, +membership, roles or policy engine. Do not copy platform scaffolding to satisfy +unused dependencies; remove those dependencies from the selected components. +Copied BFF components are locally owned and may diverge. Use only Sendico's public HTTP API for platform operations. Never import sibling Sendico modules or access its database, internal gRPC APIs, discovery or message bus. Keep the pinned public wire contract separate from local DTOs and models. diff --git a/backend/PROVENANCE.json b/backend/PROVENANCE.json index 5823010..c79f6f4 100644 --- a/backend/PROVENANCE.json +++ b/backend/PROVENANCE.json @@ -2,71 +2,17 @@ "repository": "S_I/sendico", "revision": "8bb20dcd622dd8eb92d289b6b34411e7eccab083", "files": [ - { - "source": "api/edge/shared/storage/s3key/key.go", - "target": "edge/shared/storage/s3key/key.go", - "blob": "e3cca16304d2c4cb9f256f510d5a1a30a21e22a6", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/s3key/key_test.go", - "target": "edge/shared/storage/s3key/key_test.go", - "blob": "e016ff8b05c0975c72015d952e408dba3ba85f6f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/accounting.go", - "target": "pkg/merrors/accounting.go", - "blob": "e5f34a8c17af718e16c624ee72abf45c913b250c", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/aml.go", - "target": "pkg/merrors/aml.go", - "blob": "c544dfcad1c701fbbfb89afa2e35235cbec9ad39", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/aml_test.go", - "target": "pkg/merrors/aml_test.go", - "blob": "bf03151338f835e5275ca03a26531cd0a8186325", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/chain_execution.go", - "target": "pkg/merrors/chain_execution.go", - "blob": "6dd96e073549f15f91ed25ea3191103ddc645097", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/chain_execution_test.go", - "target": "pkg/merrors/chain_execution_test.go", - "blob": "85831a603568cca18e0db4e0ffb8359d043f0838", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/merrors/classified.go", "target": "pkg/merrors/classified.go", "blob": "569049273d437caa7d7d62e8f804f2d8b5ba632f", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/merrors/documents.go", - "target": "pkg/merrors/documents.go", - "blob": "f94d13e56de3e57fd6faaf975d7f44acda984fce", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/documents_test.go", - "target": "pkg/merrors/documents_test.go", - "blob": "6b048c6066bf23c2821e960d6dc494269c92a4a0", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/merrors/errors.go", "target": "pkg/merrors/errors.go", "blob": "dceb39670721ed0b4bc59e8da9a96a0428c87684", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/merrors/errors_test.go", @@ -80,89 +26,11 @@ "blob": "954402a713c11f113377a08cdb27ef87006e40c3", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/merrors/ledger.go", - "target": "pkg/merrors/ledger.go", - "blob": "bfaa6f9bd8683d5830ad24f9353047ae9764b0e9", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/ledger_test.go", - "target": "pkg/merrors/ledger_test.go", - "blob": "05287d741d2d97230be202e91736952c54e16161", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/limits.go", - "target": "pkg/merrors/limits.go", - "blob": "8e16a8fc4e13c34cdc0712bd4ead4c7e405522aa", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/organization.go", - "target": "pkg/merrors/organization.go", - "blob": "591ada734f2a3d9b4ed590de577cb432ac2a0cbf", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/paymaster_control.go", - "target": "pkg/merrors/paymaster_control.go", - "blob": "db12becdc40206d4cbc344a628776f26c703d7e0", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/payment_execution.go", - "target": "pkg/merrors/payment_execution.go", - "blob": "86b0b6aa3395fa392c2c952775881da8cc8ad013", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/payment_execution_test.go", - "target": "pkg/merrors/payment_execution_test.go", - "blob": "82c5ae93c047eb0d2902e990e53b2ded983e5e0b", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/quotation.go", - "target": "pkg/merrors/quotation.go", - "blob": "b65c15845a6e0c51513a06af57ae4ea18a26b260", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/quotation_test.go", - "target": "pkg/merrors/quotation_test.go", - "blob": "584af1ef24af82fa63f4c41ebb02489edbce309d", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/merrors/reason.go", "target": "pkg/merrors/reason.go", "blob": "9205bac7ece87eaf56e410ff96ec68a0f0f69e51", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/routes.go", - "target": "pkg/merrors/routes.go", - "blob": "546018c398d8417975c0986184daec643e1acbf3", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/routes_test.go", - "target": "pkg/merrors/routes_test.go", - "blob": "40f2fbdf82fc4d701835243ffc844ffd233afdc0", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/signup_email_policy.go", - "target": "pkg/merrors/signup_email_policy.go", - "blob": "7fdc705e8d71641b95c6fef42dedbc471515d8bf", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/merrors/signup_email_policy_test.go", - "target": "pkg/merrors/signup_email_policy_test.go", - "blob": "40b44ba9833e8e870b2c185b6535e89cb107c34c", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/merrors/throttle.go", @@ -176,12 +44,6 @@ "blob": "10014a308444208cc67fbefc61c0bb815697dafe", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/merrors/treasury.go", - "target": "pkg/merrors/treasury.go", - "blob": "d4c4d5347cb775012a5a1232e3b3bf50c319da1b", - "adapted_beyond_namespace": false - }, { "source": "api/edge/shared/appversion/version.go", "target": "edge/shared/appversion/version.go", @@ -276,31 +138,13 @@ "source": "api/pkg/mservice/services.go", "target": "pkg/mservice/services.go", "blob": "d02e6f5b5af4ba7dac1fdd1cf3f6331ecd1800a1", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/mservice/services_test.go", - "target": "pkg/mservice/services_test.go", - "blob": "546cbf5ad65032e0cd594a98cd0c4e08cf99c3ec", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/model/account.go", "target": "pkg/model/account.go", "blob": "552c87f19dedb4927b3758d7ca86f091be8e2f34", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/account_metadata.go", - "target": "pkg/model/account_metadata.go", - "blob": "5f0e82c1abf347df42db7410bfb91b40a1eef7f0", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/account_test.go", - "target": "pkg/model/account_test.go", - "blob": "0aff70c89c5a52a92f943d6e3f8da513bf89d4ba", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/model/archivable.go", @@ -308,84 +152,12 @@ "blob": "9b51c661f5c09825a6ea8dfc46aed09f94166654", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/model/auth.go", - "target": "pkg/model/auth.go", - "blob": "2c6bda159fbf0ee5fb64e8de8faf59bb3ceee166", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/card.go", - "target": "pkg/model/card.go", - "blob": "426c22e21bace53eef91b6ddcd82365944f7c902", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/card_test.go", - "target": "pkg/model/card_test.go", - "blob": "2ca8c0a88376dc19ca2372ea372cfc255e0b3c9d", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/chains.go", - "target": "pkg/model/chains.go", - "blob": "b0e8b27da0f2c551fb4861fe704d1f262d3cdf2d", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/chains_test.go", - "target": "pkg/model/chains_test.go", - "blob": "84c4a9b8f7edd95146c83e463ce2cb34932daf5b", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/chainwalletroute.go", - "target": "pkg/model/chainwalletroute.go", - "blob": "d5f16f43ede5b0750fdd84f1c447dc6fc36009a7", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/model/client.go", "target": "pkg/model/client.go", "blob": "d61c63a55985b8b3bf05bb417f1079ab98fbf495", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/model/cryptoaddress.go", - "target": "pkg/model/cryptoaddress.go", - "blob": "73a63eef658f91e722a0c8849414a2936eb7da84", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/cryptoaddress_test.go", - "target": "pkg/model/cryptoaddress_test.go", - "blob": "71601d1782e1d9a16b35639b9f979126b23d0a0d", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/ctoken.go", - "target": "pkg/model/ctoken.go", - "blob": "0b9f239d63d83ce16203e2c76d24546aa2082019", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/ctoken_test.go", - "target": "pkg/model/ctoken_test.go", - "blob": "51f9066c8e532613c75c7d7c7fa71fd61afcbeb9", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/currency.go", - "target": "pkg/model/currency.go", - "blob": "8938cecae14171a0b0c1756d279fb6d648868cbb", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/currency_test.go", - "target": "pkg/model/currency_test.go", - "blob": "c490497b254f00d90cd32d9e685d541f6839a9bb", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/model/describable.go", "target": "pkg/model/describable.go", @@ -398,144 +170,18 @@ "blob": "8806c3b3c30f17033a8b1a0228b445c437f7b50d", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/model/iban.go", - "target": "pkg/model/iban.go", - "blob": "6a2550b382fa369765838cfe01f04f8c8635a3d3", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/indexable.go", - "target": "pkg/model/indexable.go", - "blob": "7cba1fb5e1b457dfeabae5da7972ae21adcf7dba", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/invitation.go", - "target": "pkg/model/invitation.go", - "blob": "e300325868e4bbf8662352f3c1652e5870e3c429", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/invitation_status.go", - "target": "pkg/model/invitation_status.go", - "blob": "b5799c21c0d5a0801eb3c2dbdb44dc83537af13e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/invitation_test.go", - "target": "pkg/model/invitation_test.go", - "blob": "fd80f2b7094cd6578e6ead214fff22264a8bc6e3", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/invitation_validation.go", - "target": "pkg/model/invitation_validation.go", - "blob": "e7b594d602c95c942c207dbedcacdb616a7c69b4", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/ledger.go", - "target": "pkg/model/ledger.go", - "blob": "7738efe09c48393ee363f38c3ba7f02bd8f56c1c", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/object.go", - "target": "pkg/model/object.go", - "blob": "32b43c8549e342bad8304a44b01de45932c9e805", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/organization.go", - "target": "pkg/model/organization.go", - "blob": "514fd23e2ce101f89dab7f78a24eb6a27a63611f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/ownerbound.go", - "target": "pkg/model/ownerbound.go", - "blob": "34fdc46f738cd9f89c2588cb66cce4fc4bc4ce4f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/ownerkind.go", - "target": "pkg/model/ownerkind.go", - "blob": "47313d9ec4cf7b1937dafddc598c6b4c71a9073f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/payment.go", - "target": "pkg/model/payment.go", - "blob": "05acab59443febad981d02e258e9fd1b481c12e7", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/payment_ledger.go", - "target": "pkg/model/payment_ledger.go", - "blob": "232af9035327944b723321d304a6cd8ffd9ea20c", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/payment_method_json_test.go", - "target": "pkg/model/payment_method_json_test.go", - "blob": "828d23294a2d24fc9fa69bfc48e18e9bd438109e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/permission.go", - "target": "pkg/model/permission.go", - "blob": "2b80f1bb4fec3b0e2e9956ce57f9812cadedde82", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/rba.go", - "target": "pkg/model/rba.go", - "blob": "fca3808c1ae663dd15a824424f9c73405518e250", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/recipient.go", - "target": "pkg/model/recipient.go", - "blob": "99157a525e0fa511f38af31d9c93842422224cf6", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/recipient_json_test.go", - "target": "pkg/model/recipient_json_test.go", - "blob": "563ec39a91b23c12a1a72a25f7e01b9b78ca0b00", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/recipient_wire_test.go", - "target": "pkg/model/recipient_wire_test.go", - "blob": "bbcd1355d222d47b4b68e317633a13660e278c66", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/model/refresh.go", "target": "pkg/model/refresh.go", "blob": "43e956be1babedf7857f7697dd3478eb9d05b006", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/model/scopedstorablemodel.go", - "target": "pkg/model/scopedstorablemodel.go", - "blob": "c96b6dcec5324ce9b1e723c3a212a01d0eb366ff", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/model/sessionid.go", "target": "pkg/model/sessionid.go", "blob": "251eea9f0bb56d67d89d32851e004313d17aae52", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/model/signup_provisioning.go", - "target": "pkg/model/signup_provisioning.go", - "blob": "6d0c3e0ba4f0f51a31308f8f180f77562bfa850b", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/model/userdata.go", "target": "pkg/model/userdata.go", @@ -554,23 +200,11 @@ "blob": "0143c3f99f3131f778fba713c33f796fb4fb9b6e", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/model/wallet.go", - "target": "pkg/model/wallet.go", - "blob": "b8ab22548c62c21ddc70eb5839a6d2ec159160a6", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/walletprovisioning.go", - "target": "pkg/model/walletprovisioning.go", - "blob": "dcd5f369090822d038da7c9ccdde96c3314d5a75", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/db/storable/id.go", "target": "pkg/db/storable/id.go", "blob": "58d06374158e2e606aa98fc21860a5077b26b934", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/storable/ref.go", @@ -584,60 +218,6 @@ "blob": "e048582bbc669013eaf02ea57076072a3d4d16e6", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/person/name.go", - "target": "pkg/person/name.go", - "blob": "862fbd765dbc5f8b0f21b3c5800b0c33bb94cfa9", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/person/name_test.go", - "target": "pkg/person/name_test.go", - "blob": "828b4f7a30312620c5d77641edb1771dfe29334c", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/model/account_role/account_role.go", - "target": "pkg/model/account_role/account_role.go", - "blob": "2a6e35e9ef99a90ecf1f74ccadcc049faf41b19e", - "adapted_beyond_namespace": true - }, - { - "source": "api/pkg/moneybase/fx_rounding.go", - "target": "pkg/moneybase/fx_rounding.go", - "blob": "cf24e19179f7f5a6fb8382ee92064a5294105e29", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/moneybase/fx_rounding_test.go", - "target": "pkg/moneybase/fx_rounding_test.go", - "blob": "424051f72310b48bf77f7848482bc03291f3c0d9", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/moneybase/scale.go", - "target": "pkg/moneybase/scale.go", - "blob": "60c218d246fb64f22e5a13cfaf0af336d80285a1", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/decimal/money.go", - "target": "pkg/decimal/money.go", - "blob": "4d781719fa10f743ebe9b2c22afaae6843ada245", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/decimal/rational.go", - "target": "pkg/decimal/rational.go", - "blob": "ebbc45d72f58227702343815fbb7b6a6d50f8510", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/decimal/rounding.go", - "target": "pkg/decimal/rounding.go", - "blob": "65b345d20df1d961a5c37eb4a12b6c060d4dfbcd", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/mutil/mzap/account.go", "target": "pkg/mutil/mzap/account.go", @@ -654,7 +234,7 @@ "source": "api/pkg/mutil/mzap/object.go", "target": "pkg/mutil/mzap/object.go", "blob": "663574556a14cd592fcaa3be615bed3644f7dea1", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/mutil/mask/mask.go", @@ -672,13 +252,13 @@ "source": "api/edge/product/interface/accountservice/types.go", "target": "edge/product/interface/accountservice/types.go", "blob": "848519b9000cda122be8fa652544b33ed2f7fd52", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/config.go", "target": "pkg/db/config.go", "blob": "86d46f9f0c78e76e782f134222028fe91d955f53", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/connection.go", @@ -690,13 +270,7 @@ "source": "api/pkg/db/factory.go", "target": "pkg/db/factory.go", "blob": "b9ea3bb4da4fae375d9fabe7c816c7a6b65847bf", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/permissions_config_test.go", - "target": "pkg/db/permissions_config_test.go", - "blob": "00b0dfafa2a1951052e8a41c45a154cdaa8a9b99", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/transaction/context.go", @@ -722,12 +296,6 @@ "blob": "859977f653db2e0dac676cd12f161acc2d91b72b", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/db/role/role.go", - "target": "pkg/db/role/role.go", - "blob": "30a4e4e1f82ff2601cce8f592ee10b33a1e11804", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/db/template/interface.go", "target": "pkg/db/template/interface.go", @@ -738,19 +306,7 @@ "source": "api/pkg/db/template/template.go", "target": "pkg/db/template/template.go", "blob": "abe24d42447e1759aa373a5ecadd06ccb879d672", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/template/tseries.go", - "target": "pkg/db/template/tseries.go", - "blob": "6a1504731ad18ae2cc4b3487dc9fec4acecea8cd", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/tseries/point/interface.go", - "target": "pkg/db/tseries/point/interface.go", - "blob": "efb9483928a38f8b5599254222d88ccb3c594282", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/repository/builder/accumulator.go", @@ -830,17 +386,11 @@ "blob": "45b011c84f129e508231c314b4935c0f33bf08ad", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/db/repository/abfilter.go", - "target": "pkg/db/repository/abfilter.go", - "blob": "8e09216ecdfb07aeaae364ba1e6976a3fe48e686", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/db/repository/builders.go", "target": "pkg/db/repository/builders.go", "blob": "ba4190e467397693c5dfce8c991bfbbd2fcf7cd5", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/repository/cursor.go", @@ -848,17 +398,11 @@ "blob": "bfcbb351acc317539655053a5ff7861d1c987fdf", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/db/repository/filter_factory_test.go", - "target": "pkg/db/repository/filter_factory_test.go", - "blob": "9ba32e80407fd44319c08784cc626ea5ff81eb31", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/db/repository/repository.go", "target": "pkg/db/repository/repository.go", "blob": "14c4c10705cc641fe788dd0b10d9e1344c699599", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/repository/index/index.go", @@ -900,13 +444,13 @@ "source": "api/pkg/db/internal/mongo/repositoryimp/repository.go", "target": "pkg/db/internal/mongo/repositoryimp/repository.go", "blob": "28d3683fc0b9ad9ce2ba8dc04eebad0247c04465", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go", "target": "pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go", "blob": "1395cc67768367047dcee5b39ac60725f58e546c", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/internal/mongo/repositoryimp/repository_filter.go", @@ -1028,258 +572,6 @@ "blob": "10de4fa645cc76a6e6fdb369177d63bf5f723974", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/db/policy/policy.go", - "target": "pkg/db/policy/policy.go", - "blob": "30b0eeaaa8cae80408acc81c0d0ff1e499b56766", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/invitation/invitation.go", - "target": "pkg/db/invitation/invitation.go", - "blob": "3097375dd85fdd1a88fa752f07c81e35ddeea3bd", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/archivable.go", - "target": "pkg/auth/archivable.go", - "blob": "e770edc4751021fd49dcd9c80a5f1c15a3cf9324", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/archivableimp.go", - "target": "pkg/auth/archivableimp.go", - "blob": "256da6fe5b7916761f19c586971169f6aed375f0", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/authorizer.go", - "target": "pkg/auth/authorizer.go", - "blob": "3594b5aaa3986f89dfe3d38037b12cf7dfa0f2bb", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/authorizer_test.go", - "target": "pkg/auth/authorizer_test.go", - "blob": "66caca0a2e0dfdec3b3bc352e25cb9ba6461bff5", - "adapted_beyond_namespace": true - }, - { - "source": "api/pkg/auth/config.go", - "target": "pkg/auth/config.go", - "blob": "4ec922ec8517f5b9a28a2837416943d23d89500d", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/db.go", - "target": "pkg/auth/db.go", - "blob": "9ccd3fd3da0f52a30796fa0032c209c434f8efd8", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbab.go", - "target": "pkg/auth/dbab.go", - "blob": "b8fa72ab976d768c3195495db52e9a6b0357b772", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbimp.go", - "target": "pkg/auth/dbimp.go", - "blob": "83cb6cc2b864882e58a2e39d55b60e06cf406dce", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbimpab.go", - "target": "pkg/auth/dbimpab.go", - "blob": "612db1719bcc37f8e55d6fd75e9ddffd08121748", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbimpab_test.go", - "target": "pkg/auth/dbimpab_test.go", - "blob": "008b09a1cb83267c3d1df2738daaf51e542ea97e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbimpscoped.go", - "target": "pkg/auth/dbimpscoped.go", - "blob": "43886943b4e5f56029f43d17e5a52c39897213f3", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbimpscoped_test.go", - "target": "pkg/auth/dbimpscoped_test.go", - "blob": "c87042f5020fe466d721442a9b42c910ba6263f6", - "adapted_beyond_namespace": true - }, - { - "source": "api/pkg/auth/dbimpscopedcrud.go", - "target": "pkg/auth/dbimpscopedcrud.go", - "blob": "ce4e9bddac3108697064b03ab8387be3374c697f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/dbimpscopedpatch.go", - "target": "pkg/auth/dbimpscopedpatch.go", - "blob": "239cf5b6e6c848fd975ade685900d5bde0140e91", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/enforcer.go", - "target": "pkg/auth/enforcer.go", - "blob": "487c3b22396fe8589a09f9e7e069d1da095ea0d8", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/factory.go", - "target": "pkg/auth/factory.go", - "blob": "3e7fb12d677a80ac11a42dbb023905ae3ea072f1", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/helper.go", - "target": "pkg/auth/helper.go", - "blob": "dbdfabb90bf5dd3dea991ca2ff00faa06a7333ba", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/indexable.go", - "target": "pkg/auth/indexable.go", - "blob": "4ba65ec9dddc6d0ccb15a1a8ce8ff8e56cc4d66d", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/indexableimp.go", - "target": "pkg/auth/indexableimp.go", - "blob": "eb47986d9c8623933b365b6396144ca3d54b112e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/manager.go", - "target": "pkg/auth/manager.go", - "blob": "0184a198b9bc6a6522851aed5ca742b365c873dc", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/patchguard.go", - "target": "pkg/auth/patchguard.go", - "blob": "5762afa0ed126d4b446f476bb054e4db548008c3", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/provider.go", - "target": "pkg/auth/provider.go", - "blob": "21c3bb5e1d7cb9174d245bbe52097ec15d810343", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/scoped.go", - "target": "pkg/auth/scoped.go", - "blob": "83fe6d0adfaadb4e14e73ec16a76503f3030b6f4", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/scoped_test.go", - "target": "pkg/auth/scoped_test.go", - "blob": "d6b11145b87fbddb987d3df7360a9beebb54d901", - "adapted_beyond_namespace": true - }, - { - "source": "api/pkg/auth/scopeddb.go", - "target": "pkg/auth/scopeddb.go", - "blob": "ffbd1ea3cc91deabba2a36d9c7a0fe6a1f8aae8c", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/scopeddb_test.go", - "target": "pkg/auth/scopeddb_test.go", - "blob": "70e58db28ba4cd1ab1aa17b81d54f2c317613b20", - "adapted_beyond_namespace": true - }, - { - "source": "api/pkg/auth/management/permission.go", - "target": "pkg/auth/management/permission.go", - "blob": "35ea4729da67a39797772f0604a70ded9ea48b5c", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/management/role.go", - "target": "pkg/auth/management/role.go", - "blob": "ea3b902effbbc2aa2c2381453c1d833807645821", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/dbpolicies.go", - "target": "pkg/auth/internal/native/dbpolicies.go", - "blob": "35958c0c109df2bd8d8ab716ca5a84ebcb1ddee5", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/dbroles.go", - "target": "pkg/auth/internal/native/dbroles.go", - "blob": "72b98ab1e2874980a5e169c5a489f79a66818639", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/enforcer.go", - "target": "pkg/auth/internal/native/enforcer.go", - "blob": "b1382b5448c4c5202b17c059f955a1399f567ab4", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/enforcer_test.go", - "target": "pkg/auth/internal/native/enforcer_test.go", - "blob": "d31e40531d1877459a6e3337339c65055e698407", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/manager.go", - "target": "pkg/auth/internal/native/manager.go", - "blob": "58b021c774a72b68d078800986fb54afae5b7c2e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/object_scope_test.go", - "target": "pkg/auth/internal/native/object_scope_test.go", - "blob": "740f09f825ebfc8d9ea1b640e5226f570d248815", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/permission.go", - "target": "pkg/auth/internal/native/permission.go", - "blob": "4d2f04b620a5654fe544953ec0ac856ddf766866", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/role.go", - "target": "pkg/auth/internal/native/role.go", - "blob": "6c155b540c7e5d6617faf1a38373a78f591a5c5f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/nstructures/policies.go", - "target": "pkg/auth/internal/native/nstructures/policies.go", - "blob": "a6791ab0d9e27ffb4dccb8e60793fd63513fcf3e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/nstructures/role.go", - "target": "pkg/auth/internal/native/nstructures/role.go", - "blob": "af3c6c1b86eea99b99247ca97b0bb77c2904851e", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/db/policies.go", - "target": "pkg/auth/internal/native/db/policies.go", - "blob": "51f51ec24da544737c8b5078538efa3e0c0eee40", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/native/db/roles.go", - "target": "pkg/auth/internal/native/db/roles.go", - "blob": "b57868046c36e82849b568926445e68bb5770e57", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/mutil/db/archive.go", "target": "pkg/mutil/db/archive.go", @@ -1298,96 +590,6 @@ "blob": "d29855c92fe4df1abf5e342d5f025c4e6e7434ab", "adapted_beyond_namespace": false }, - { - "source": "api/pkg/auth/internal/casbin/enforcer.go", - "target": "pkg/auth/internal/casbin/enforcer.go", - "blob": "b5614b07360e37e580ad5539e8abface9eb0c0ee", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/factory.go", - "target": "pkg/auth/internal/casbin/factory.go", - "blob": "8a1a2da2965c5796d02da5a31e7baa384fd324f0", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/logger.go", - "target": "pkg/auth/internal/casbin/logger.go", - "blob": "1a172bdf25514f970e4a3f187d942c193ca73b55", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/manager.go", - "target": "pkg/auth/internal/casbin/manager.go", - "blob": "1499471a58548d3147e385e3e28b16b3e199234b", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/permissions.go", - "target": "pkg/auth/internal/casbin/permissions.go", - "blob": "554e702620b99ae2fc90bc67058e0a404703e450", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/role.go", - "target": "pkg/auth/internal/casbin/role.go", - "blob": "359f4ff2b746a90b51dab9aa7972f3b0e805e757", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/serialization/policy.go", - "target": "pkg/auth/internal/casbin/serialization/policy.go", - "blob": "0e1fa29b7307a83ea4eef7888cd375f63de7eebc", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/serialization/role.go", - "target": "pkg/auth/internal/casbin/serialization/role.go", - "blob": "59ea1dc241d99dce612ff9150c54b5530527fc36", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/serialization/serializer.go", - "target": "pkg/auth/internal/casbin/serialization/serializer.go", - "blob": "36d11db558d4ea903f400b0754cfc453cfc2fa7d", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/serialization/internal/policy.go", - "target": "pkg/auth/internal/casbin/serialization/internal/policy.go", - "blob": "e119f97631a87bce2230bb45cc3b5f7c15fee762", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/serialization/internal/role.go", - "target": "pkg/auth/internal/casbin/serialization/internal/role.go", - "blob": "796914fdbcdf789c416a06deb9c259ae6d06fa56", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/anyobject/anyobject.go", - "target": "pkg/auth/anyobject/anyobject.go", - "blob": "124da0fed3bc57ed35deae1382ef6364f686b1ca", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/mongoadapter/adapter.go", - "target": "pkg/auth/internal/casbin/mongoadapter/adapter.go", - "blob": "0d64409a9a8cbf9470ad917dc805c7717fd5c483", - "adapted_beyond_namespace": true - }, - { - "source": "api/pkg/auth/internal/casbin/mongoadapter/adapter_test.go", - "target": "pkg/auth/internal/casbin/mongoadapter/adapter_test.go", - "blob": "36ea81dd413ce6c84370b0f65cce32e9f76062a5", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/auth/internal/casbin/config/config.go", - "target": "pkg/auth/internal/casbin/config/config.go", - "blob": "ce4b097622d26c0f6c4a43e526276c0d61bda153", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/db/internal/mongo/db.go", "target": "pkg/db/internal/mongo/db.go", @@ -1416,133 +618,7 @@ "source": "api/pkg/db/internal/mongo/transactionimp/transaction.go", "target": "pkg/db/internal/mongo/transactionimp/transaction.go", "blob": "266feed66a033d86f8c75785718d424ac10f8db8", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/rolesdb/db.go", - "target": "pkg/db/internal/mongo/rolesdb/db.go", - "blob": "2f5f3848c31add6ff42c6d32c3ce6ec50501c164", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/rolesdb/list.go", - "target": "pkg/db/internal/mongo/rolesdb/list.go", - "blob": "54a993dad72cea6ea3d1b9e81fe017db83e16801", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/rolesdb/roles.go", - "target": "pkg/db/internal/mongo/rolesdb/roles.go", - "blob": "4d070ce586a21251312749bbe5f48337dc9c014f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/policiesdb/all.go", - "target": "pkg/db/internal/mongo/policiesdb/all.go", - "blob": "d944be2159240a7df7055162adb71364024bf18f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/policiesdb/builtin.go", - "target": "pkg/db/internal/mongo/policiesdb/builtin.go", - "blob": "758d423bf3b4ecd330143c6ee18557aaf984877f", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/policiesdb/db.go", - "target": "pkg/db/internal/mongo/policiesdb/db.go", - "blob": "b4c0e9733c80fced9c3403a61d11ca6c8d7f36a6", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/policiesdb/db_test.go", - "target": "pkg/db/internal/mongo/policiesdb/db_test.go", - "blob": "5089eec6afd4fa32fbe4e4c7e66bcede8d54d6b2", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/policiesdb/policies.go", - "target": "pkg/db/internal/mongo/policiesdb/policies.go", - "blob": "b802cb5d99749e06945e592103808d66e0d9f5fe", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/accept.go", - "target": "pkg/db/internal/mongo/invitationdb/accept.go", - "blob": "8b22999c234e9019c21b4c70512907af2701d747", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/archived.go", - "target": "pkg/db/internal/mongo/invitationdb/archived.go", - "blob": "c899d1aed20cdcfa4421d8ec8093cd267aefe681", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/cascade.go", - "target": "pkg/db/internal/mongo/invitationdb/cascade.go", - "blob": "6ec580bf26da2d725ef544c52e8619277cde90ea", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/db.go", - "target": "pkg/db/internal/mongo/invitationdb/db.go", - "blob": "7f1f4f1735880bba21dd4b6ce35184ec2a6463fe", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/db_integration_test.go", - "target": "pkg/db/internal/mongo/invitationdb/db_integration_test.go", - "blob": "05482982cd2b2f7e66ef145f8791689dc451d1b0", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/db_test.go", - "target": "pkg/db/internal/mongo/invitationdb/db_test.go", - "blob": "c0c2d973796d2873af3366dbeaa0d1cd023b8dcd", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/decline.go", - "target": "pkg/db/internal/mongo/invitationdb/decline.go", - "blob": "0cd0b9896f3bbdc6dbf16cd4500e2d810cfe2381", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/getpublic.go", - "target": "pkg/db/internal/mongo/invitationdb/getpublic.go", - "blob": "d072f157dfa6d6dc70d5e5192058283d1b88c3b9", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/indexes.go", - "target": "pkg/db/internal/mongo/invitationdb/indexes.go", - "blob": "014fcfa60822306d86c05b7439caf5fb50fb69be", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/list.go", - "target": "pkg/db/internal/mongo/invitationdb/list.go", - "blob": "4eb61b8ac8258d10cbcc5a0c26cecd3ed677f3e9", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/db/internal/mongo/invitationdb/updatestatus.go", - "target": "pkg/db/internal/mongo/invitationdb/updatestatus.go", - "blob": "a55ba4600ca41695987a99d74a87ae19e0df97d6", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/mutil/db/auth/accountbound.go", - "target": "pkg/mutil/db/auth/accountbound.go", - "blob": "097eb5a688970b81059a36299562afdb6df44e95", - "adapted_beyond_namespace": false - }, - { - "source": "api/pkg/mutil/db/auth/protected.go", - "target": "pkg/mutil/db/auth/protected.go", - "blob": "d91a8a9a391dd0a8159c4f90fa7e8c27453f9279", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/internal/mongo/accountdb/db.go", @@ -1554,13 +630,13 @@ "source": "api/pkg/db/internal/mongo/accountdb/user.go", "target": "pkg/db/internal/mongo/accountdb/user.go", "blob": "22d68e5e07e5ac002c661e07409e3f21ba5c475c", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/db/account/account.go", "target": "pkg/db/account/account.go", "blob": "22f6191c47730d7e1373e96c8aa691940525fcf3", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/middleware/cors_test.go", @@ -1598,23 +674,11 @@ "blob": "1854a6abeadc2a9ce014e79f124c8cb213e17a65", "adapted_beyond_namespace": false }, - { - "source": "api/edge/product/interface/accountservice/internal/join_organization_test.go", - "target": "edge/product/interface/accountservice/internal/join_organization_test.go", - "blob": "1a06c563c9cb0d9d056ed7b4450e34cf199c0639", - "adapted_beyond_namespace": false - }, { "source": "api/edge/product/interface/accountservice/internal/service.go", "target": "edge/product/interface/accountservice/internal/service.go", "blob": "e0a26dd3f3d740ad5103e98a56bfa68f1065b1b6", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/product/interface/accountservice/internal/service_deletion_test.go", - "target": "edge/product/interface/accountservice/internal/service_deletion_test.go", - "blob": "0100b9aa1c727b213e4d6e20dc2c7ee874c6be46", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/product/interface/accountservice/internal/service_test.go", @@ -1716,7 +780,7 @@ "source": "api/edge/shared/storage/verification/memory_token_repository_test.go", "target": "edge/shared/storage/verification/memory_token_repository_test.go", "blob": "3a24298b02e90323416f1b47a1012d36b7cd26bc", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/verification/request.go", @@ -1766,119 +830,11 @@ "blob": "6d5d118198d62f545f0ef10216466c4cbec9b16d", "adapted_beyond_namespace": false }, - { - "source": "api/edge/shared/storage/organization/archived.go", - "target": "edge/shared/storage/organization/archived.go", - "blob": "f0a947a6bec2f360ccf7162b5e0d7aab58747499", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/cascade.go", - "target": "edge/shared/storage/organization/cascade.go", - "blob": "f735105520c2bc379c887445dda74516a8d4f29f", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/create.go", - "target": "edge/shared/storage/organization/create.go", - "blob": "9cf661ded9a9e8187471ac85682fb2c5d5800815", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/db.go", - "target": "edge/shared/storage/organization/db.go", - "blob": "841d914c1741c3a71e189e32ad1dfe2ae8f999eb", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/get.go", - "target": "edge/shared/storage/organization/get.go", - "blob": "54df2ef59f9ffddc0f2917d1d748b1bd608a3e80", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/index_test.go", - "target": "edge/shared/storage/organization/index_test.go", - "blob": "4b42ae41dfc351dba8140657077dd7ca7cbb5394", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/list.go", - "target": "edge/shared/storage/organization/list.go", - "blob": "d50ab508ab749ab55d49cb974a363d3a47148e74", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/member.go", - "target": "edge/shared/storage/organization/member.go", - "blob": "073b5f461d9e8caf1a32c0c677f18b33f399b8fa", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/member_test.go", - "target": "edge/shared/storage/organization/member_test.go", - "blob": "4d82611cdbe96285c67c65e49199f569515d4d51", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/memory_repository_queries_test.go", - "target": "edge/shared/storage/organization/memory_repository_queries_test.go", - "blob": "a05138478d0be4e3f4caca500266f5ca3b34861d", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/memory_repository_test.go", - "target": "edge/shared/storage/organization/memory_repository_test.go", - "blob": "3c2578380f193c61cb2647cb7a76d77205bbd3ce", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/organization.go", - "target": "edge/shared/storage/organization/organization.go", - "blob": "5fd085d8dfa08e388cead3fe6d50d87ace2beb83", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/owned.go", - "target": "edge/shared/storage/organization/owned.go", - "blob": "1d342fb4006fe44924f2a3017c19b10233f23495", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/provider.go", - "target": "edge/shared/storage/organization/provider.go", - "blob": "03e1dbfdeb2714241bea537b8156539a649aef2a", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/provisioning.go", - "target": "edge/shared/storage/organization/provisioning.go", - "blob": "c76533cd227c9f64b96eb46c92f4e4bc3c5728b6", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/provisioning_test.go", - "target": "edge/shared/storage/organization/provisioning_test.go", - "blob": "ff2dc3a4650da4059785e8abd2ecc43be5d0dc6e", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/setarchived_test.go", - "target": "edge/shared/storage/organization/setarchived_test.go", - "blob": "20933949c004cba73c2b3f4eadf46cfa5cc1458c", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/storage/organization/test_helpers_test.go", - "target": "edge/shared/storage/organization/test_helpers_test.go", - "blob": "d32f33069a11f97bf5d1d782157fb05fc119177d", - "adapted_beyond_namespace": false - }, { "source": "api/edge/shared/storage/mongo/dependencies.go", "target": "edge/shared/storage/mongo/dependencies.go", "blob": "4aa6a89fb359a305ddc74d6e4e9bdcdb50cadb49", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/server/server.go", @@ -1914,7 +870,7 @@ "source": "api/edge/shared/interface/api/config.go", "target": "edge/shared/interface/api/config.go", "blob": "1ea1e746f6bd9d28494e9b26d9f97bbdda1f000b", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/register.go", @@ -1952,35 +908,17 @@ "blob": "76f0b7a6e861e1519e7ee018564e5bfadf4be4f6", "adapted_beyond_namespace": false }, - { - "source": "api/edge/shared/interface/api/ws/config.go", - "target": "edge/shared/interface/api/ws/config.go", - "blob": "3f8bafa8d31b4c3542e0e9fc5a7f23ab36eecf9b", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/interface/api/ws/handler.go", - "target": "edge/shared/interface/api/ws/handler.go", - "blob": "34979bb556fd75d2b83e999bd6c62dbdbb49958e", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/interface/api/ws/message.go", - "target": "edge/shared/interface/api/ws/message.go", - "blob": "66544fcdce615e704fa3b51c27b17ec970a34bf9", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/api/http/response/response.go", "target": "pkg/api/http/response/response.go", "blob": "8904d881b718a9ec20d3c4644717118760d8d27e", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/api/http/response/response_test.go", "target": "pkg/api/http/response/response_test.go", "blob": "592cbefaeec2bac5bef8d10223e156b6da150a98", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/pkg/api/http/response/result.go", @@ -1992,7 +930,7 @@ "source": "api/edge/shared/interface/api/sresponse/account.go", "target": "edge/shared/interface/api/sresponse/account.go", "blob": "0369f802304ab0b19a57349939ac8a73e924322a", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/authresp.go", @@ -2010,31 +948,19 @@ "source": "api/edge/shared/interface/api/sresponse/login.go", "target": "edge/shared/interface/api/sresponse/login.go", "blob": "962b9ca0852da034c70b9d4174f497b887267ac8", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/login_pending.go", "target": "edge/shared/interface/api/sresponse/login_pending.go", "blob": "5bcd839cb379c44c4a95d897af4a055b6b9375fc", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/login_test.go", "target": "edge/shared/interface/api/sresponse/login_test.go", "blob": "419995b9a91ebe8db544c7224be6e945de0c5e55", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/interface/api/sresponse/organization.go", - "target": "edge/shared/interface/api/sresponse/organization.go", - "blob": "1d0fa6782ba1f9ccde8e2e86fd48683a28aa6b84", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/interface/api/sresponse/permissions.go", - "target": "edge/shared/interface/api/sresponse/permissions.go", - "blob": "5dced1126eeb6c98936ac25bb74b49268afd1ee9", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/sresponse/response.go", @@ -2090,12 +1016,6 @@ "blob": "d376b2879803f69acd3d52348d259f5ac3629b1b", "adapted_beyond_namespace": false }, - { - "source": "api/edge/shared/interface/storage/config.go", - "target": "edge/shared/interface/storage/config.go", - "blob": "8d4369b4a69dbc7a146c27ca1b261f1220ad526d", - "adapted_beyond_namespace": false - }, { "source": "api/pkg/domainprovider/domain_provider.go", "target": "pkg/domainprovider/domain_provider.go", @@ -2174,18 +1094,6 @@ "blob": "7359d959b4c29ef95312fb49056351d408fc9957", "adapted_beyond_namespace": false }, - { - "source": "api/edge/shared/internal/transport/http/websocket/dispimp.go", - "target": "edge/shared/internal/transport/http/websocket/dispimp.go", - "blob": "1ea2001be84217020e61a8fa827e3fe5cd62ab35", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/internal/transport/http/websocket/router.go", - "target": "edge/shared/internal/transport/http/websocket/router.go", - "blob": "49cbc75d4c298e5504943123bb9dee48dc169a16", - "adapted_beyond_namespace": false - }, { "source": "api/edge/shared/internal/transport/http/router/metrics/handler.go", "target": "edge/shared/internal/transport/http/router/metrics/handler.go", @@ -2202,7 +1110,7 @@ "source": "api/edge/shared/internal/transport/http/router/dispatcher.go", "target": "edge/shared/internal/transport/http/router/dispatcher.go", "blob": "f173a53bab40b61dead720e92fe1cd27f3e5b5a5", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/router.go", @@ -2220,7 +1128,7 @@ "source": "api/edge/shared/storage/refreshtokens/crud.go", "target": "edge/shared/storage/refreshtokens/crud.go", "blob": "5f8044ca8c7cd30d107f2272042e4b7b1cfea291", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/storage/refreshtokens/crud_test.go", @@ -2292,7 +1200,7 @@ "source": "api/edge/shared/internal/transport/http/router/public/login.go", "target": "edge/shared/internal/transport/http/router/public/login.go", "blob": "92666648e8df7c93fc7ac28a4d0e413f0a370499", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/login_rate_limit.go", @@ -2316,7 +1224,7 @@ "source": "api/edge/shared/internal/transport/http/router/public/login_test_support_test.go", "target": "edge/shared/internal/transport/http/router/public/login_test_support_test.go", "blob": "7578548953e01a7cf33e265e4202e6c862ad6bb6", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/logout.go", @@ -2340,7 +1248,7 @@ "source": "api/edge/shared/internal/transport/http/router/public/refresh_test.go", "target": "edge/shared/internal/transport/http/router/public/refresh_test.go", "blob": "4f7b9f98a1705414fd1e312629d8ea498c80da64", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/public/respond.go", @@ -2364,19 +1272,7 @@ "source": "api/edge/shared/internal/transport/http/router/public/validate.go", "target": "edge/shared/internal/transport/http/router/public/validate.go", "blob": "5a5ea587e1a62eb194e68996e841d3a20cf97fce", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/interface/api/srequest/changepolicies.go", - "target": "edge/shared/interface/api/srequest/changepolicies.go", - "blob": "2c6154e801e645134a1aba4b8d742792cba89ac6", - "adapted_beyond_namespace": false - }, - { - "source": "api/edge/shared/interface/api/srequest/changerole.go", - "target": "edge/shared/interface/api/srequest/changerole.go", - "blob": "ec2b3517c840a05f5a17f45db19d21dbf8c5222f", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/interface/api/srequest/login.go", @@ -2472,13 +1368,13 @@ "source": "api/edge/shared/internal/transport/http/router/authorized/handler.go", "target": "edge/shared/internal/transport/http/router/authorized/handler.go", "blob": "bb2fcde2e850dd687e78cad5f4dc95f9b9417ac6", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/authorized/router.go", "target": "edge/shared/internal/transport/http/router/authorized/router.go", "blob": "4347dd32db99ae4e46ded03bdff2e64a3a049515", - "adapted_beyond_namespace": false + "adapted_beyond_namespace": true }, { "source": "api/edge/shared/internal/transport/http/router/authorized/session_test.go", @@ -3060,12 +1956,6 @@ "target": "cmd/bff/main.go", "blob": "4c9f6dfd4436bf75fac15690decdac0efaa5b9c2", "adapted_beyond_namespace": true - }, - { - "source": "scripts/mongosh/product-permissions-seed.js", - "target": "scripts/seed-policies.js", - "blob": "52597d928f0154ba9eb1cfc6975d6c44cac104a9", - "adaptation": "SXCHNGE database; retain organization/account/role policy definitions and upsert helpers; omit unrelated policies, backfills and pruning." } ], "excluded": [ diff --git a/backend/PROVENANCE.md b/backend/PROVENANCE.md index 8863d29..5a8e592 100644 --- a/backend/PROVENANCE.md +++ b/backend/PROVENANCE.md @@ -1,64 +1,62 @@ # Copied Sendico source -Source repository: `S_I/sendico` (`git.sendico.io`). Revision: +Source repository: `S_I/sendico` (`git.sendico.io`), revision `8bb20dcd622dd8eb92d289b6b34411e7eccab083`. -[PROVENANCE.json](PROVENANCE.json) records each copied file, original path and -Git blob, plus whether a Go file changed beyond import namespace and formatting. -It contains 416 Go source/test files, 112 public OpenAPI files and one adapted policy seed. Sources were -read from the pinned commit, not Sendico's dirty working tree. These are owned -SXCHNGE copies, not submodules or automatic synchronization links. +[PROVENANCE.json](PROVENANCE.json) records each retained file, its original path +and Git blob, and whether a Go file changed beyond namespace replacement and +formatting. It currently covers 232 Go source/test files and 112 public OpenAPI +files. Sources came from the pinned commit, not the dirty Sendico checkout. +These are SXCHNGE-owned copies that may diverge independently. -## What was retained +## Retained implementation -- Shared BFF Chi runtime, process/version/logger bootstrap, HTTP middleware, - auth transport and response conventions. -- Password/account service, local Mongo account/role/policy repositories, - transactions and local organization membership. -- Verification issuance, expiry, attempt limits, consumption and idempotency; - refresh-session rotation/revocation and login rate-limit storage. -- Canonical models, `merrors`, structured logging, health and retained tests. -- The public OpenAPI 4.2.37 entry point and recursive local schema references, - copied into `contracts/sendico` without schema changes (trailing blank lines - normalized in three YAML files). +- Chi HTTP runtime, process/version bootstrap, structured logging and health. +- Customer password validation/hashing, account repository and account service. +- Customer login, pending verification, session validation, refresh rotation, + revocation, browser cookies/CSRF and login rate limiting. +- Verification issuance, expiry, attempts, consumption and idempotency. +- Local Mongo connection, transactions and the repository/query implementation + required by account, session and verification storage, with retained tests. +- Public OpenAPI 4.2.37 and its recursive schema references, isolated under + `contracts/sendico`. These wire files are unchanged except for trailing blank + line normalization in three YAML files; they are not local domain models. -## Deliberate adaptations +## Adaptations and exclusions -- Import roots now belong to `git.sendico.io/tech/sxchnge/bff`. Versions are seeded - from committed Sendico manifests and tidied for the selected dependency set. -- Product's composition root selects local account services instead of platform - services/provisioning. Its shared HTTP runtime remains the implementation. -- Removed internal discovery and broker configuration, registration, startup and - teardown. An instance ID uses the existing UUID dependency without discovery. -- Excluded internal gRPC/protobuf clients/converters and unused model files. - Local account-role values remain; private protobuf conversion helpers do not. -- Program identity and the refresh-cookie name use SXCHNGE. -- The policy seed retains the existing upsert helpers and three local - organization/account/role definitions, with an SXCHNGE database default. - Platform policies, automatic role backfills and pruning are excluded. -- Startup configuration now rejects missing database/middleware/HTTP settings - and invalid HTTP timeouts. Runtime-construction failure closes the database. - Listen/start failures preserve the cause and produce a nonzero CLI exit. -- Copied Casbin adapter errors and test sentinel errors use canonical `merrors` - instead of `errors.New`. Removed raw database settings from error logs. -- The recipient-migration-only test was excluded together with its migration: - neither that platform feature nor historical Sendico data migration is part of - this bootstrap. Tests for every retained runtime/component remain included. +Imports belong to `git.sendico.io/tech/sxchnge/bff`. The composition root selects +local customer services. Discovery, message-bus registration, private gRPC and +platform factories are absent. Process identity and the refresh cookie use SXCHNGE. +Startup validates required settings and timeouts, closes failed connections and +preserves process failures. Errors use `merrors`; logs exclude raw DB settings. -No financial engine, internal service client, gateway, platform organization -provisioning, deployment configuration, credential or generated binary was copied. -The local account service's organization is an SXCHNGE authorization tenant; -it does not create Sendico organizations per customer. +The earlier extraction retained too much. Local organizations, membership, +roles, Casbin, policies, invitations, policy seeding and their repositories and +models are removed. There is no replacement tenant or permissive enforcer. +Unneeded financial models/errors, ledger and wallet provisioning metadata, +platform service names, S3 configuration and WebSocket scaffolding are removed. +Tests specific to removed features are removed with those implementations. -## Local additions +The copied account service now creates a local customer from a raw password, +hashes it and commits the customer and activation token atomically. It uses the +same configured collections as login. Deletion revokes that customer's sessions +and deletes its record atomically; it has no sole-member rule. The copied Mongo +transaction helper joins an active outer transaction so verification does not +commit separately from customer creation. Generic repositories no longer expose +organization- or permission-bound operations. Customer responses use explicit DTO fields and a mapper; they have no anonymous +organization-account flag or embedded database record. Refresh and protected +handlers reject inactive customers; refresh checks account-bound application +clients. -`internal/profile` composes the copied account service. Architecture checks enforce -project errors, no private provider imports/transports and self-contained public -contract references. Configuration tests cover the executable example and missing -settings. Guidelines and the backend-review skill are restored from Sendico with -SXCHNGE paths and the public-only integration boundary. +## Local additions and limits -The source includes complete local auth building blocks, not a fully wired -SXCHNGE product API. Remaining endpoint, email, local tenant/client provisioning -and public Sendico integration work is explicit in the -[architecture plan](../docs/architecture/bff.md). +Architecture tests reject private provider imports, policy-engine dependencies +and local tenancy declarations. Fresh Mongo replica-set tests exercise the actual +customer lifecycle, isolation, duplicate login, rollback, deletion/session +revocation and runtime startup without a seed. Existing retained auth/session and +verification tests remain in place. + +This is a bootstrap. Signup/profile HTTP wiring, verification delivery/completion +and the public Sendico HTTP adapter remain explicit work in the +[architecture plan](../docs/architecture/bff.md). The sole Sendico organization +reference belongs to that adapter's configuration, never to local customers. diff --git a/backend/README.md b/backend/README.md index e442c9a..0c4373f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,7 +1,7 @@ # SXCHNGE BFF A locally owned copy of Sendico's reusable BFF runtime, authentication/session -stack and account service. See [provenance](PROVENANCE.md) and the +stack and customer account service. See [provenance](PROVENANCE.md) and the [architecture and public API plan](../docs/architecture/bff.md). SXCHNGE owns its customers and authentication. SXCHNGE itself is one organization @@ -19,21 +19,16 @@ Copy `config.example.yml` to an untracked `config.yml`. Set: - `SXCHNGE_API_PROTOCOL`: `http` locally, `https` in deployment. - `SXCHNGE_API_ENDPOINT`: `/api/v1`. -Seed the local policy descriptions before first startup (requires `mongosh`): - -```sh -mongosh "$SXCHNGE_MONGO_URI" --file backend/scripts/seed-policies.js -``` - -The seed is repeatable and creates only the three local built-in policy -descriptions required by the copied runtime. It grants no role permissions. +The runtime starts against an empty SXCHNGE database and creates its indexes. +No organization, role, policy or seed is required. Run `make run-backend` from the repository root. The copied CLI accepts `-config.file`, `-version` and `-debug`. Configure exact browser origins in YAML; secure refresh cookies require HTTPS for browser session testing. The runtime mounts `/api/v1/health` and the copied account login, API login, refresh, rotation and logout handlers. Customer/client records must exist in the -local database. The account service is initialized locally; signup/profile and +local database. The account service creates customers and activation tokens +atomically, and deletes customers with session revocation. It is initialized locally; signup/profile and verification-delivery/completion routes are the next wiring work. This is a bootstrap, not a finished authentication deployment. No Sendico calls are made. diff --git a/backend/config.example.yml b/backend/config.example.yml index 0417f80..0773abf 100644 --- a/backend/config.example.yml +++ b/backend/config.example.yml @@ -8,8 +8,6 @@ database: settings: uri_env: SXCHNGE_MONGO_URI database: sxchnge - enforcer: - driver: native api: middleware: domain_env: SXCHNGE_DOMAIN diff --git a/backend/edge/product/interface/accountservice/internal/customer_test.go b/backend/edge/product/interface/accountservice/internal/customer_test.go new file mode 100644 index 0000000..b6f0387 --- /dev/null +++ b/backend/edge/product/interface/accountservice/internal/customer_test.go @@ -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 +} diff --git a/backend/edge/product/interface/accountservice/internal/join_organization_test.go b/backend/edge/product/interface/accountservice/internal/join_organization_test.go deleted file mode 100644 index 70728e0..0000000 --- a/backend/edge/product/interface/accountservice/internal/join_organization_test.go +++ /dev/null @@ -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) - } -} diff --git a/backend/edge/product/interface/accountservice/internal/service.go b/backend/edge/product/interface/accountservice/internal/service.go index 588bf49..ef82c0e 100644 --- a/backend/edge/product/interface/accountservice/internal/service.go +++ b/backend/edge/product/interface/accountservice/internal/service.go @@ -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 } diff --git a/backend/edge/product/interface/accountservice/internal/service_deletion_test.go b/backend/edge/product/interface/accountservice/internal/service_deletion_test.go deleted file mode 100644 index 57aec6f..0000000 --- a/backend/edge/product/interface/accountservice/internal/service_deletion_test.go +++ /dev/null @@ -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 -} diff --git a/backend/edge/product/interface/accountservice/types.go b/backend/edge/product/interface/accountservice/types.go index 146a45c..fa2807f 100644 --- a/backend/edge/product/interface/accountservice/types.go +++ b/backend/edge/product/interface/accountservice/types.go @@ -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) } diff --git a/backend/edge/shared/interface/api/api.go b/backend/edge/shared/interface/api/api.go index f68cf6d..480518d 100644 --- a/backend/edge/shared/interface/api/api.go +++ b/backend/edge/shared/interface/api/api.go @@ -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 } diff --git a/backend/edge/shared/interface/api/config.go b/backend/edge/shared/interface/api/config.go index 9b1c500..ce81552 100644 --- a/backend/edge/shared/interface/api/config.go +++ b/backend/edge/shared/interface/api/config.go @@ -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 { diff --git a/backend/edge/shared/interface/api/register.go b/backend/edge/shared/interface/api/register.go index a412717..d20e924 100644 --- a/backend/edge/shared/interface/api/register.go +++ b/backend/edge/shared/interface/api/register.go @@ -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) } diff --git a/backend/edge/shared/interface/api/runtime.go b/backend/edge/shared/interface/api/runtime.go index a75aa5c..439b4fb 100644 --- a/backend/edge/shared/interface/api/runtime.go +++ b/backend/edge/shared/interface/api/runtime.go @@ -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 { diff --git a/backend/edge/shared/interface/api/srequest/changepolicies.go b/backend/edge/shared/interface/api/srequest/changepolicies.go deleted file mode 100644 index 7b12476..0000000 --- a/backend/edge/shared/interface/api/srequest/changepolicies.go +++ /dev/null @@ -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"` -} diff --git a/backend/edge/shared/interface/api/srequest/changerole.go b/backend/edge/shared/interface/api/srequest/changerole.go deleted file mode 100644 index ec2b351..0000000 --- a/backend/edge/shared/interface/api/srequest/changerole.go +++ /dev/null @@ -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"` -} diff --git a/backend/edge/shared/interface/api/sresponse/account.go b/backend/edge/shared/interface/api/sresponse/account.go index 1e7ee59..ee2c821 100644 --- a/backend/edge/shared/interface/api/sresponse/account.go +++ b/backend/edge/shared/interface/api/sresponse/account.go @@ -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}, }, ) diff --git a/backend/edge/shared/interface/api/sresponse/login.go b/backend/edge/shared/interface/api/sresponse/login.go index ae2f7da..c987f10 100644 --- a/backend/edge/shared/interface/api/sresponse/login.go +++ b/backend/edge/shared/interface/api/sresponse/login.go @@ -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, diff --git a/backend/edge/shared/interface/api/sresponse/login_pending.go b/backend/edge/shared/interface/api/sresponse/login_pending.go index 8e054ad..d0333d0 100644 --- a/backend/edge/shared/interface/api/sresponse/login_pending.go +++ b/backend/edge/shared/interface/api/sresponse/login_pending.go @@ -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, diff --git a/backend/edge/shared/interface/api/sresponse/login_test.go b/backend/edge/shared/interface/api/sresponse/login_test.go index d0e4f84..9866324 100644 --- a/backend/edge/shared/interface/api/sresponse/login_test.go +++ b/backend/edge/shared/interface/api/sresponse/login_test.go @@ -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) + } +} diff --git a/backend/edge/shared/interface/api/sresponse/organization.go b/backend/edge/shared/interface/api/sresponse/organization.go deleted file mode 100644 index e632a31..0000000 --- a/backend/edge/shared/interface/api/sresponse/organization.go +++ /dev/null @@ -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}, - }) -} diff --git a/backend/edge/shared/interface/api/sresponse/permissions.go b/backend/edge/shared/interface/api/sresponse/permissions.go deleted file mode 100644 index f1ca7f2..0000000 --- a/backend/edge/shared/interface/api/sresponse/permissions.go +++ /dev/null @@ -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}, - }) -} diff --git a/backend/edge/shared/interface/api/ws/config.go b/backend/edge/shared/interface/api/ws/config.go deleted file mode 100644 index b37a5a7..0000000 --- a/backend/edge/shared/interface/api/ws/config.go +++ /dev/null @@ -1,9 +0,0 @@ -package ws - -import ( - "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware" -) - -type ( - Config = middleware.WebSocketConfig -) diff --git a/backend/edge/shared/interface/api/ws/handler.go b/backend/edge/shared/interface/api/ws/handler.go deleted file mode 100644 index 34979bb..0000000 --- a/backend/edge/shared/interface/api/ws/handler.go +++ /dev/null @@ -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 -) diff --git a/backend/edge/shared/interface/api/ws/message.go b/backend/edge/shared/interface/api/ws/message.go deleted file mode 100644 index e4bb5d6..0000000 --- a/backend/edge/shared/interface/api/ws/message.go +++ /dev/null @@ -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"` -} diff --git a/backend/edge/shared/interface/middleware/middleware.go b/backend/edge/shared/interface/middleware/middleware.go index 7a3ebb8..86fded4 100644 --- a/backend/edge/shared/interface/middleware/middleware.go +++ b/backend/edge/shared/interface/middleware/middleware.go @@ -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"` diff --git a/backend/edge/shared/interface/storage/config.go b/backend/edge/shared/interface/storage/config.go deleted file mode 100644 index 5024e6a..0000000 --- a/backend/edge/shared/interface/storage/config.go +++ /dev/null @@ -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"` -} diff --git a/backend/edge/shared/internal/runtime/api.go b/backend/edge/shared/internal/runtime/api.go index 165c605..e20747b 100644 --- a/backend/edge/shared/internal/runtime/api.go +++ b/backend/edge/shared/internal/runtime/api.go @@ -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 diff --git a/backend/edge/shared/internal/runtime/bootstrap.go b/backend/edge/shared/internal/runtime/bootstrap.go index f4fb56e..bdc136b 100644 --- a/backend/edge/shared/internal/runtime/bootstrap.go +++ b/backend/edge/shared/internal/runtime/bootstrap.go @@ -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)) diff --git a/backend/edge/shared/internal/runtime/bootstrap_test.go b/backend/edge/shared/internal/runtime/bootstrap_test.go new file mode 100644 index 0000000..91b76f9 --- /dev/null +++ b/backend/edge/shared/internal/runtime/bootstrap_test.go @@ -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) +} diff --git a/backend/edge/shared/internal/transport/http/middleware.go b/backend/edge/shared/internal/transport/http/middleware.go index ea519cc..6e8a502 100644 --- a/backend/edge/shared/internal/transport/http/middleware.go +++ b/backend/edge/shared/internal/transport/http/middleware.go @@ -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 } diff --git a/backend/edge/shared/internal/transport/http/router/authorized/customer_test.go b/backend/edge/shared/internal/transport/http/router/authorized/customer_test.go new file mode 100644 index 0000000..26c25bb --- /dev/null +++ b/backend/edge/shared/internal/transport/http/router/authorized/customer_test.go @@ -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) + } + }) + } +} diff --git a/backend/edge/shared/internal/transport/http/router/authorized/handler.go b/backend/edge/shared/internal/transport/http/router/authorized/handler.go index fdb5fb7..0abafd0 100644 --- a/backend/edge/shared/internal/transport/http/router/authorized/handler.go +++ b/backend/edge/shared/internal/transport/http/router/authorized/handler.go @@ -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) diff --git a/backend/edge/shared/internal/transport/http/router/authorized/router.go b/backend/edge/shared/internal/transport/http/router/authorized/router.go index 1abc55a..a7af5b4 100644 --- a/backend/edge/shared/internal/transport/http/router/authorized/router.go +++ b/backend/edge/shared/internal/transport/http/router/authorized/router.go @@ -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)) diff --git a/backend/edge/shared/internal/transport/http/router/dispatcher.go b/backend/edge/shared/internal/transport/http/router/dispatcher.go index 6c6c7cd..733109b 100644 --- a/backend/edge/shared/internal/transport/http/router/dispatcher.go +++ b/backend/edge/shared/internal/transport/http/router/dispatcher.go @@ -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 diff --git a/backend/edge/shared/internal/transport/http/router/public/login.go b/backend/edge/shared/internal/transport/http/router/public/login.go index 79636f5..f6b3c7f 100644 --- a/backend/edge/shared/internal/transport/http/router/public/login.go +++ b/backend/edge/shared/internal/transport/http/router/public/login.go @@ -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") } diff --git a/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go b/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go index 2a60f86..df513c2 100644 --- a/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go +++ b/backend/edge/shared/internal/transport/http/router/public/login_test_support_test.go @@ -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") } diff --git a/backend/edge/shared/internal/transport/http/router/public/refresh_test.go b/backend/edge/shared/internal/transport/http/router/public/refresh_test.go index 54acf0c..2a8dabf 100644 --- a/backend/edge/shared/internal/transport/http/router/public/refresh_test.go +++ b/backend/edge/shared/internal/transport/http/router/public/refresh_test.go @@ -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) +} diff --git a/backend/edge/shared/internal/transport/http/router/public/validate.go b/backend/edge/shared/internal/transport/http/router/public/validate.go index bd7b514..0d4b93e 100644 --- a/backend/edge/shared/internal/transport/http/router/public/validate.go +++ b/backend/edge/shared/internal/transport/http/router/public/validate.go @@ -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)) diff --git a/backend/edge/shared/internal/transport/http/websocket/dispimp.go b/backend/edge/shared/internal/transport/http/websocket/dispimp.go deleted file mode 100644 index 7b9cac3..0000000 --- a/backend/edge/shared/internal/transport/http/websocket/dispimp.go +++ /dev/null @@ -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 -} diff --git a/backend/edge/shared/internal/transport/http/websocket/router.go b/backend/edge/shared/internal/transport/http/websocket/router.go deleted file mode 100644 index 600c41e..0000000 --- a/backend/edge/shared/internal/transport/http/websocket/router.go +++ /dev/null @@ -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) -} diff --git a/backend/edge/shared/storage/mongo/dependencies.go b/backend/edge/shared/storage/mongo/dependencies.go index 84105e8..0b22592 100644 --- a/backend/edge/shared/storage/mongo/dependencies.go +++ b/backend/edge/shared/storage/mongo/dependencies.go @@ -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 } diff --git a/backend/edge/shared/storage/organization/archived.go b/backend/edge/shared/storage/organization/archived.go deleted file mode 100644 index 68c2ca3..0000000 --- a/backend/edge/shared/storage/organization/archived.go +++ /dev/null @@ -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 -} diff --git a/backend/edge/shared/storage/organization/cascade.go b/backend/edge/shared/storage/organization/cascade.go deleted file mode 100644 index b2f11d4..0000000 --- a/backend/edge/shared/storage/organization/cascade.go +++ /dev/null @@ -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 -} diff --git a/backend/edge/shared/storage/organization/create.go b/backend/edge/shared/storage/organization/create.go deleted file mode 100644 index 15a2928..0000000 --- a/backend/edge/shared/storage/organization/create.go +++ /dev/null @@ -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) -} diff --git a/backend/edge/shared/storage/organization/db.go b/backend/edge/shared/storage/organization/db.go deleted file mode 100644 index 2bf8f4b..0000000 --- a/backend/edge/shared/storage/organization/db.go +++ /dev/null @@ -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)) -} diff --git a/backend/edge/shared/storage/organization/get.go b/backend/edge/shared/storage/organization/get.go deleted file mode 100644 index c6a001f..0000000 --- a/backend/edge/shared/storage/organization/get.go +++ /dev/null @@ -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) -} diff --git a/backend/edge/shared/storage/organization/index_test.go b/backend/edge/shared/storage/organization/index_test.go deleted file mode 100644 index 786e2d0..0000000 --- a/backend/edge/shared/storage/organization/index_test.go +++ /dev/null @@ -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 -} diff --git a/backend/edge/shared/storage/organization/list.go b/backend/edge/shared/storage/organization/list.go deleted file mode 100644 index a4a677c..0000000 --- a/backend/edge/shared/storage/organization/list.go +++ /dev/null @@ -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) -} diff --git a/backend/edge/shared/storage/organization/member.go b/backend/edge/shared/storage/organization/member.go deleted file mode 100644 index 8c43150..0000000 --- a/backend/edge/shared/storage/organization/member.go +++ /dev/null @@ -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 -} diff --git a/backend/edge/shared/storage/organization/member_test.go b/backend/edge/shared/storage/organization/member_test.go deleted file mode 100644 index a32daf2..0000000 --- a/backend/edge/shared/storage/organization/member_test.go +++ /dev/null @@ -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)) -} diff --git a/backend/edge/shared/storage/organization/memory_repository_queries_test.go b/backend/edge/shared/storage/organization/memory_repository_queries_test.go deleted file mode 100644 index 2d5c300..0000000 --- a/backend/edge/shared/storage/organization/memory_repository_queries_test.go +++ /dev/null @@ -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 -} diff --git a/backend/edge/shared/storage/organization/memory_repository_test.go b/backend/edge/shared/storage/organization/memory_repository_test.go deleted file mode 100644 index f7576c3..0000000 --- a/backend/edge/shared/storage/organization/memory_repository_test.go +++ /dev/null @@ -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 -} diff --git a/backend/edge/shared/storage/organization/organization.go b/backend/edge/shared/storage/organization/organization.go deleted file mode 100644 index 401121f..0000000 --- a/backend/edge/shared/storage/organization/organization.go +++ /dev/null @@ -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) -} diff --git a/backend/edge/shared/storage/organization/owned.go b/backend/edge/shared/storage/organization/owned.go deleted file mode 100644 index 01a36a6..0000000 --- a/backend/edge/shared/storage/organization/owned.go +++ /dev/null @@ -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) -} diff --git a/backend/edge/shared/storage/organization/provider.go b/backend/edge/shared/storage/organization/provider.go deleted file mode 100644 index 25eaf99..0000000 --- a/backend/edge/shared/storage/organization/provider.go +++ /dev/null @@ -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) -} diff --git a/backend/edge/shared/storage/organization/provisioning.go b/backend/edge/shared/storage/organization/provisioning.go deleted file mode 100644 index ac02647..0000000 --- a/backend/edge/shared/storage/organization/provisioning.go +++ /dev/null @@ -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 -} diff --git a/backend/edge/shared/storage/organization/provisioning_test.go b/backend/edge/shared/storage/organization/provisioning_test.go deleted file mode 100644 index e1adc6d..0000000 --- a/backend/edge/shared/storage/organization/provisioning_test.go +++ /dev/null @@ -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) - } -} diff --git a/backend/edge/shared/storage/organization/setarchived_test.go b/backend/edge/shared/storage/organization/setarchived_test.go deleted file mode 100644 index dc4e9b4..0000000 --- a/backend/edge/shared/storage/organization/setarchived_test.go +++ /dev/null @@ -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)) -} diff --git a/backend/edge/shared/storage/organization/test_helpers_test.go b/backend/edge/shared/storage/organization/test_helpers_test.go deleted file mode 100644 index 2d256e0..0000000 --- a/backend/edge/shared/storage/organization/test_helpers_test.go +++ /dev/null @@ -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 -} diff --git a/backend/edge/shared/storage/refreshtokens/crud.go b/backend/edge/shared/storage/refreshtokens/crud.go index 86ce4f4..cef1040 100644 --- a/backend/edge/shared/storage/refreshtokens/crud.go +++ b/backend/edge/shared/storage/refreshtokens/crud.go @@ -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 diff --git a/backend/edge/shared/storage/s3key/key.go b/backend/edge/shared/storage/s3key/key.go deleted file mode 100644 index cfd9fb7..0000000 --- a/backend/edge/shared/storage/s3key/key.go +++ /dev/null @@ -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 -} diff --git a/backend/edge/shared/storage/s3key/key_test.go b/backend/edge/shared/storage/s3key/key_test.go deleted file mode 100644 index e016ff8..0000000 --- a/backend/edge/shared/storage/s3key/key_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/backend/edge/shared/storage/verification/memory_token_repository_test.go b/backend/edge/shared/storage/verification/memory_token_repository_test.go index a935b0b..a5afafe 100644 --- a/backend/edge/shared/storage/verification/memory_token_repository_test.go +++ b/backend/edge/shared/storage/verification/memory_token_repository_test.go @@ -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) } diff --git a/backend/go.mod b/backend/go.mod index e909a1a..faf3ea6 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,7 +3,6 @@ module git.sendico.io/tech/sxchnge/bff go 1.27.0 require ( - github.com/casbin/casbin/v3 v3.11.0 github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/cors v1.2.2 github.com/go-chi/jwtauth/v5 v5.4.0 @@ -18,7 +17,6 @@ require ( go.mongodb.org/mongo-driver/v2 v2.9.0 go.uber.org/zap v1.28.0 golang.org/x/crypto v0.56.0 - golang.org/x/net v0.58.0 gopkg.in/yaml.v3 v3.0.1 moul.io/chizap v1.0.3 ) @@ -28,8 +26,6 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect - github.com/casbin/govaluate v1.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -76,7 +72,6 @@ require ( github.com/segmentio/asm v1.2.0 // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/stretchr/objx v0.5.3 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 246ab68..21fb41b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -9,12 +9,6 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= -github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/casbin/casbin/v3 v3.11.0 h1:xVlnpCGs0c2Hp2WuUF36o8iZgEsXUne9/j1qJjOgJPA= -github.com/casbin/casbin/v3 v3.11.0/go.mod h1:5rJbQr2e6AuuDDNxnPc5lQlC9nIgg6nS1zYwKXhpHC8= -github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= -github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -232,8 +226,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/backend/internal/architecture/boundary_test.go b/backend/internal/architecture/boundary_test.go index 280c91d..6e552f0 100644 --- a/backend/internal/architecture/boundary_test.go +++ b/backend/internal/architecture/boundary_test.go @@ -28,7 +28,7 @@ func TestNoSendicoRuntimeOrPrivateTransportImports(t *testing.T) { if err != nil { return err } - for _, blocked := range []string{"github.com/tech/sendico/", "google.golang.org/grpc", "github.com/nats-io/", "/pkg/discovery", "/pkg/proto/"} { + for _, blocked := range []string{"github.com/tech/sendico/", "google.golang.org/grpc", "github.com/nats-io/", "/pkg/discovery", "/pkg/proto/", "github.com/casbin/", "/pkg/auth", "/storage/organization", "/mongo/policiesdb", "/mongo/rolesdb", "/mongo/invitationdb"} { if strings.Contains(name, blocked) { t.Errorf("%s imports private provider transport or shared runtime: %s", path, name) } diff --git a/backend/internal/architecture/customer_test.go b/backend/internal/architecture/customer_test.go new file mode 100644 index 0000000..594627c --- /dev/null +++ b/backend/internal/architecture/customer_test.go @@ -0,0 +1,48 @@ +package architecture + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +func TestLocalCustomerModelHasNoPlatformTenancy(t *testing.T) { + // Provider organization fields are allowed in the isolated public wire snapshot, + // never in the local customer model, stores or service contracts. + forbidden := map[string]bool{"organization": true, "organizationref": true, "orgref": true, "orgdb": true, "role": true, "roles": true, "roledescription": true, "rolemanager": true, "policy": true, "policies": true, "policydescription": true, "policydb": true, "permissionbound": true, "signup_provisioning": true} + err := filepath.WalkDir("../..", func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + return err + } + ast.Inspect(parsed, func(node ast.Node) bool { + var names []*ast.Ident + switch n := node.(type) { + case *ast.TypeSpec: + names = []*ast.Ident{n.Name} + case *ast.Field: + names = n.Names + } + for _, name := range names { + if forbidden[strings.ToLower(name.Name)] { + t.Errorf("%s reintroduces local platform tenancy: %s", path, name.Name) + } + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/profile/runtime.go b/backend/internal/profile/runtime.go index d6bddf5..119749f 100644 --- a/backend/internal/profile/runtime.go +++ b/backend/internal/profile/runtime.go @@ -13,8 +13,7 @@ func Runtime() api.RuntimeConfig { } func bootstrap(a api.API) (api.RuntimeComponents, error) { - permissions := a.Permissions() - accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), permissions.Enforcer(), permissions.Manager().Role(), &a.Config().Mw.Password) + accounts, err := accountservice.NewAccountService(a.Logger(), a.DBFactory(), &a.Config().Mw.Password, a.Config().AuthStorage) if err != nil { return nil, err } diff --git a/backend/pkg/api/http/response/response.go b/backend/pkg/api/http/response/response.go index 71681e5..01a331b 100644 --- a/backend/pkg/api/http/response/response.go +++ b/backend/pkg/api/http/response/response.go @@ -120,9 +120,6 @@ func Auto(logger mlogger.Logger, source mservice.Type, err error) http.HandlerFu if errors.Is(err, merrors.ErrAccessDenied) { return AccessDenied(logger, source, errMessage(err)) } - if errors.Is(err, merrors.ErrDocumentsReportOwned) { - return Error(logger, source, http.StatusConflict, "documents_report_owned", errMessage(err)) - } if errors.Is(err, merrors.ErrDataConflict) { return DataConflict(logger, source, errMessage(err)) } diff --git a/backend/pkg/api/http/response/response_test.go b/backend/pkg/api/http/response/response_test.go index b65c101..b71e996 100644 --- a/backend/pkg/api/http/response/response_test.go +++ b/backend/pkg/api/http/response/response_test.go @@ -24,21 +24,6 @@ func TestAutoMapsServiceUnavailable(t *testing.T) { require.True(t, strings.Contains(recorder.Body.String(), "service_unavailable")) } -func TestAutoMapsDocumentsReportOwned(t *testing.T) { - req := httptest.NewRequestWithContext(context.Background(), http.MethodPut, "/reports", nil) - recorder := httptest.NewRecorder() - - Auto(zap.NewNop(), mservice.Type("documents_report"), merrors.DocumentsReportOwned("report has an active payout")).ServeHTTP(recorder, req) - - var payload struct { - Data ErrorResponse `json:"data"` - } - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload)) - require.Equal(t, http.StatusConflict, recorder.Code) - require.Equal(t, "documents_report_owned", payload.Data.Error) - require.Equal(t, "report has an active payout", payload.Data.Details) -} - func TestErrorWithDetailsPreservesStructuredPayload(t *testing.T) { req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/actions", nil) recorder := httptest.NewRecorder() diff --git a/backend/pkg/auth/anyobject/anyobject.go b/backend/pkg/auth/anyobject/anyobject.go deleted file mode 100644 index 124da0f..0000000 --- a/backend/pkg/auth/anyobject/anyobject.go +++ /dev/null @@ -1,3 +0,0 @@ -package anyobject - -const ID = "*" diff --git a/backend/pkg/auth/archivable.go b/backend/pkg/auth/archivable.go deleted file mode 100644 index 4eebcf1..0000000 --- a/backend/pkg/auth/archivable.go +++ /dev/null @@ -1,35 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// ArchivableDB implements archive operations with permission checking -type ArchivableDB[T model.PermissionBoundStorable] interface { - // SetArchived sets the archived status of an entity with permission checking - SetArchived(ctx context.Context, accountRef, objectRef bson.ObjectID, archived bool) error - // IsArchived checks if an entity is archived with permission checking - IsArchived(ctx context.Context, accountRef, objectRef bson.ObjectID) (bool, error) - - // Archive archives an entity with permission checking (sets archived to true) - Archive(ctx context.Context, accountRef, objectRef bson.ObjectID) error - - // Unarchive unarchives an entity with permission checking (sets archived to false) - Unarchive(ctx context.Context, accountRef, objectRef bson.ObjectID) error -} - -// NewArchivableDB creates a new auth.ArchivableDB instance -func NewArchivableDB[T model.PermissionBoundStorable]( - dbImp *template.DBImp[T], - logger mlogger.Logger, - enforcer Enforcer, - createEmpty func() T, - getArchivable func(T) model.Archivable, -) ArchivableDB[T] { - return newArchivableDBImp(dbImp, logger, enforcer, createEmpty, getArchivable) -} diff --git a/backend/pkg/auth/archivableimp.go b/backend/pkg/auth/archivableimp.go deleted file mode 100644 index b07be6a..0000000 --- a/backend/pkg/auth/archivableimp.go +++ /dev/null @@ -1,107 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "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/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// ArchivableDB implements archive operations with permission checking -type ArchivableDBImp[T model.PermissionBoundStorable] struct { - dbImp *template.DBImp[T] - logger mlogger.Logger - enforcer Enforcer - createEmpty func() T - getArchivable func(T) model.Archivable -} - -// NewArchivableDB creates a new auth.ArchivableDB instance -func newArchivableDBImp[T model.PermissionBoundStorable]( - dbImp *template.DBImp[T], - logger mlogger.Logger, - enforcer Enforcer, - createEmpty func() T, - getArchivable func(T) model.Archivable, -) ArchivableDB[T] { - return &ArchivableDBImp[T]{ - dbImp: dbImp, - logger: logger.Named("archivable"), - enforcer: enforcer, - createEmpty: createEmpty, - getArchivable: getArchivable, - } -} - -// SetArchived sets the archived status of an entity with permission checking -func (db *ArchivableDBImp[T]) SetArchived(ctx context.Context, accountRef, objectRef bson.ObjectID, archived bool) error { - // Check permissions using enforceObject helper - if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionUpdate, accountRef, objectRef); err != nil { - db.logger.Warn("Failed to enforce object permission", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) - return err - } - - // Get the object to check current archived status - obj := db.createEmpty() - if err := db.dbImp.Get(ctx, objectRef, obj); err != nil { - db.logger.Warn("Failed to get object for setting archived status", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) - return err - } - - // Extract archivable from the object - archivable := db.getArchivable(obj) - currentArchived := archivable.IsArchived() - if currentArchived == archived { - db.logger.Debug("No change needed - same archived status", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) - return nil // No change needed - } - - // Set the archived status - patch := repository.Patch().Set(repository.IsArchivedField(), archived) - if err := db.dbImp.Patch(ctx, objectRef, patch); err != nil { - db.logger.Warn("Failed to set archived status on object", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) - return err - } - - db.logger.Debug("Successfully set archived status on object", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Bool("archived", archived)) - return nil -} - -// IsArchived checks if an entity is archived with permission checking -func (db *ArchivableDBImp[T]) IsArchived(ctx context.Context, accountRef, objectRef bson.ObjectID) (bool, error) { - // // Check permissions using single Enforce - if err := enforceObjectByRef(ctx, db.dbImp, db.enforcer, model.ActionRead, accountRef, objectRef); err != nil { - db.logger.Debug("Permission denied for checking archived status", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionRead))) - return false, merrors.AccessDenied("read", "object", objectRef) - } - obj := db.createEmpty() - if err := db.dbImp.Get(ctx, objectRef, obj); err != nil { - db.logger.Warn("Failed to get object for checking archived status", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return false, err - } - archivable := db.getArchivable(obj) - return archivable.IsArchived(), nil -} - -// Archive archives an entity with permission checking (sets archived to true) -func (db *ArchivableDBImp[T]) Archive(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - return db.SetArchived(ctx, accountRef, objectRef, true) -} - -// Unarchive unarchives an entity with permission checking (sets archived to false) -func (db *ArchivableDBImp[T]) Unarchive(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - return db.SetArchived(ctx, accountRef, objectRef, false) -} diff --git a/backend/pkg/auth/authorizer.go b/backend/pkg/auth/authorizer.go deleted file mode 100644 index 0f3a295..0000000 --- a/backend/pkg/auth/authorizer.go +++ /dev/null @@ -1,87 +0,0 @@ -package auth - -import ( - "context" - "errors" - - "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/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -type MembershipChecker interface { - IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error) -} - -type ScopedAuthorizer struct { - // Logger receives Debug/Warn messages from the enforcement path. - Logger mlogger.Logger - Membership MembershipChecker - Enforcer Enforcer -} - -// NewScopedAuthorizer constructs a ScopedAuthorizer and validates required dependencies. -// Logger defaults to zap.NewNop() if nil. -func NewScopedAuthorizer(membership MembershipChecker, enforcer Enforcer, logger mlogger.Logger) *ScopedAuthorizer { - if membership == nil { - panic("auth.NewScopedAuthorizer: MembershipChecker must not be nil") - } - if enforcer == nil { - panic("auth.NewScopedAuthorizer: Enforcer must not be nil") - } - if logger == nil { - logger = zap.NewNop() - } - return &ScopedAuthorizer{ - Logger: logger, - Membership: membership, - Enforcer: enforcer, - } -} - -func (a *ScopedAuthorizer) Authorize(ctx context.Context, p ScopedAccessParams) error { - a.Logger.Debug("scoped access check started", - mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef), - mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action))) - - // Personal resources are guarded by ownership alone — membership is not consulted. - // Checking membership first would lock the owner out after org membership is revoked. - if p.OwnerKind == model.OwnerKindAccount { - err := enforceOwnershipAccess(ctx, a.Enforcer, p) - if err != nil && errors.Is(err, merrors.ErrAccessDenied) { - a.Logger.Debug("access denied: not the owner", - mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef), - zap.String("action", string(p.Action))) - } - return err - } - - ok, err := a.Membership.IsMember(ctx, p.OrgRef, p.CallerRef) - if err != nil { - a.Logger.Warn("membership check failed", - zap.Error(err), mzap.AccRef(p.CallerRef), mzap.OrgRef(p.OrgRef)) - return err - } - if !ok { - a.Logger.Debug("access denied: caller is not a member of the organization", - mzap.AccRef(p.CallerRef), mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action))) - return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef) - } - - err = enforceOwnershipAccess(ctx, a.Enforcer, p) - if err != nil { - if errors.Is(err, merrors.ErrAccessDenied) { - a.Logger.Debug("access denied", - mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef), - mzap.OrgRef(p.OrgRef), zap.String("action", string(p.Action))) - } else { - a.Logger.Warn("enforcer returned error during scoped access check", - zap.Error(err), mzap.AccRef(p.CallerRef), mzap.ObjRef("object_ref", p.ObjectRef), - zap.String("action", string(p.Action))) - } - } - return err -} diff --git a/backend/pkg/auth/authorizer_test.go b/backend/pkg/auth/authorizer_test.go deleted file mode 100644 index bdbaad5..0000000 --- a/backend/pkg/auth/authorizer_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package auth - -import ( - "context" - "errors" - "testing" - - "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" - "go.uber.org/zap" -) - -type fakeMembershipChecker struct { - isMember bool - err error -} - -func (f *fakeMembershipChecker) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) { - return f.isMember, f.err -} - -func TestScopedAuthorizer_Authorize(t *testing.T) { - caller := bson.NewObjectID() - other := bson.NewObjectID() - org := bson.NewObjectID() - object := bson.NewObjectID() - policy := bson.NewObjectID() - - base := func(kind model.OwnerKind, owner bson.ObjectID) ScopedAccessParams { - return ScopedAccessParams{ - Service: mservice.Recipients, - PolicyRef: policy, - CallerRef: caller, - OrgRef: org, - ObjectRef: object, - OwnerKind: kind, - OwnerRef: owner, - Action: model.ActionRead, - } - } - - t.Run("membership checker error propagates for org-owned resource and is not masked as denied", func(t *testing.T) { - boom := merrors.Define("checker down") - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{err: boom}, - Enforcer: &fakeEnforcer{}, - } - err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)) - if !errors.Is(err, boom) { - t.Fatalf("expected underlying error, got %v", err) - } - if errors.Is(err, merrors.ErrAccessDenied) { - t.Fatal("checker error must not be reported as access denied") - } - }) - - t.Run("non-member owning account-owned resource is allowed", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: false}, - Enforcer: &fakeEnforcer{}, - } - if err := a.Authorize(context.Background(), base(model.OwnerKindAccount, caller)); err != nil { - t.Fatalf("expected allow for personal resource owner regardless of membership, got %v", err) - } - }) - - t.Run("non-member is denied for org-owned resource", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: false}, - Enforcer: &fakeEnforcer{allow: true}, - } - err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-member on org resource, got %v", err) - } - }) - - t.Run("member owning account-owned resource is allowed", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: true}, - Enforcer: &fakeEnforcer{}, - } - if err := a.Authorize(context.Background(), base(model.OwnerKindAccount, caller)); err != nil { - t.Fatalf("expected allow, got %v", err) - } - }) - - t.Run("member accessing another account's resource is denied", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: true}, - Enforcer: &fakeEnforcer{}, - } - err := a.Authorize(context.Background(), base(model.OwnerKindAccount, other)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - }) - - t.Run("member with org-owned resource allowed when enforcer approves", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: true}, - Enforcer: &fakeEnforcer{allow: true}, - } - if err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)); err != nil { - t.Fatalf("expected allow, got %v", err) - } - }) - - t.Run("member with org-owned resource denied when enforcer refuses", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: true}, - Enforcer: &fakeEnforcer{allow: false}, - } - err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - }) - - t.Run("enforcer error propagates and is not masked as denied", func(t *testing.T) { - boom := merrors.Define("policy backend down") - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: true}, - Enforcer: &fakeEnforcer{err: boom}, - } - err := a.Authorize(context.Background(), base(model.OwnerKindOrganization, org)) - if !errors.Is(err, boom) { - t.Fatalf("expected underlying error, got %v", err) - } - if errors.Is(err, merrors.ErrAccessDenied) { - t.Fatal("enforcer error must not be reported as access denied") - } - }) - - t.Run("unknown owner kind is denied", func(t *testing.T) { - a := &ScopedAuthorizer{Logger: zap.NewNop(), - Membership: &fakeMembershipChecker{isMember: true}, - Enforcer: &fakeEnforcer{allow: true}, - } - err := a.Authorize(context.Background(), base(model.OwnerKind("garbage"), caller)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for unknown owner kind, got %v", err) - } - }) -} diff --git a/backend/pkg/auth/config.go b/backend/pkg/auth/config.go deleted file mode 100644 index 0891512..0000000 --- a/backend/pkg/auth/config.go +++ /dev/null @@ -1,12 +0,0 @@ -package auth - -import "git.sendico.io/tech/sxchnge/bff/pkg/model" - -type EnforcerType string - -const ( - Casbin EnforcerType = "casbin" - Native EnforcerType = "native" -) - -type Config = model.DriverConfig[EnforcerType] diff --git a/backend/pkg/auth/db.go b/backend/pkg/auth/db.go deleted file mode 100644 index f43841c..0000000 --- a/backend/pkg/auth/db.go +++ /dev/null @@ -1,38 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -type ProtectedDB[T model.PermissionBoundStorable] interface { - Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error - InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error - Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error - Update(ctx context.Context, accountRef bson.ObjectID, object T) error - Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error - DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error - Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error - PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) - Unprotected() template.DB[T] - ListIDs(ctx context.Context, action model.Action, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) -} - -func CreateDB[T model.PermissionBoundStorable]( - ctx context.Context, - l mlogger.Logger, - pdb policy.DB, - enforcer Enforcer, - collection mservice.Type, - db *mongo.Database, -) (ProtectedDB[T], error) { - return CreateDBImp[T](ctx, l, pdb, enforcer, collection, db) -} diff --git a/backend/pkg/auth/dbab.go b/backend/pkg/auth/dbab.go deleted file mode 100644 index 2cbf2f5..0000000 --- a/backend/pkg/auth/dbab.go +++ /dev/null @@ -1,51 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type AccountBoundDB[T model.AccountBoundStorable] interface { - Create(ctx context.Context, accountRef bson.ObjectID, object T) error - Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error - Update(ctx context.Context, accountRef bson.ObjectID, object T) error - Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error - Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error - DeleteMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query) error - FindOne(ctx context.Context, accountRef bson.ObjectID, query builder.Query, result T) error - ListIDs(ctx context.Context, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) - ListAccountBound(ctx context.Context, accountRef, organizationRef bson.ObjectID, query builder.Query) ([]model.AccountBoundStorable, error) -} - -func CreateAccountBound[T model.AccountBoundStorable]( - ctx context.Context, - logger mlogger.Logger, - pdb policy.DB, - enforcer Enforcer, - collection mservice.Type, - db *mongo.Database, -) (AccountBoundDB[T], error) { - logger = logger.Named("account_bound") - var policy model.PolicyDescription - if err := pdb.GetBuiltInPolicy(ctx, mservice.Organizations, &policy); err != nil { - logger.Warn("Failed to fetch organization policy description", zap.Error(err)) - return nil, err - } - res := &AccountBoundDBImp[T]{ - Logger: logger, - DBImp: template.Create[T](logger, collection, db), - Enforcer: enforcer, - PermissionRef: policy.ID, - Collection: collection, - } - return res, nil -} diff --git a/backend/pkg/auth/dbimp.go b/backend/pkg/auth/dbimp.go deleted file mode 100644 index 19876e3..0000000 --- a/backend/pkg/auth/dbimp.go +++ /dev/null @@ -1,319 +0,0 @@ -package auth - -import ( - "context" - "errors" - "fmt" - - "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/db/template" - "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" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type ProtectedDBImp[T model.PermissionBoundStorable] struct { - DBImp *template.DBImp[T] - Enforcer Enforcer - PermissionRef bson.ObjectID - Collection mservice.Type -} - -func (db *ProtectedDBImp[T]) enforce(ctx context.Context, action model.Action, object model.PermissionBoundStorable, accountRef, objectRef bson.ObjectID) error { - res, err := db.Enforcer.Enforce(ctx, object.GetPermissionRef(), accountRef, object.GetOrganizationRef(), objectRef, action) - if err != nil { - db.DBImp.Logger.Warn("Failed to enforce permission", - zap.Error(err), mzap.PermissionRef(object.GetPermissionRef()), - mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return err - } - if !res { - db.DBImp.Logger.Debug("Access denied", mzap.PermissionRef(object.GetPermissionRef()), - mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return merrors.AccessDenied(db.Collection, string(action), objectRef) - } - return nil -} - -func (db *ProtectedDBImp[T]) Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error { - db.DBImp.Logger.Debug("Attempting to create object", mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("collection", db.Collection)) - - if object.GetPermissionRef() == bson.NilObjectID { - object.SetPermissionRef(db.PermissionRef) - } - object.SetOrganizationRef(organizationRef) - - if err := db.enforce(ctx, model.ActionCreate, object, accountRef, bson.NilObjectID); err != nil { - return err - } - - if err := db.DBImp.Create(ctx, object); err != nil { - db.DBImp.Logger.Warn("Failed to create object", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("collection", db.Collection)) - return err - } - - db.DBImp.Logger.Debug("Successfully created object", mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("collection", db.Collection)) - return nil -} - -func (db *ProtectedDBImp[T]) InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error { - if len(objects) == 0 { - return nil - } - - db.DBImp.Logger.Debug("Attempting to insert many objects", mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("collection", db.Collection), - zap.Int("count", len(objects))) - - // Set permission and organization refs for all objects and enforce permissions - for _, object := range objects { - if object.GetPermissionRef() == bson.NilObjectID { - object.SetPermissionRef(db.PermissionRef) - } - object.SetOrganizationRef(organizationRef) - - if err := db.enforce(ctx, model.ActionCreate, object, accountRef, bson.NilObjectID); err != nil { - return err - } - } - - if err := db.DBImp.InsertMany(ctx, objects); err != nil { - db.DBImp.Logger.Warn("Failed to insert many objects", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("collection", db.Collection), - zap.Int("count", len(objects))) - return err - } - - db.DBImp.Logger.Debug("Successfully inserted many objects", mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("collection", db.Collection), - zap.Int("count", len(objects))) - return nil -} - -func (db *ProtectedDBImp[T]) enforceObject(ctx context.Context, action model.Action, accountRef, objectRef bson.ObjectID) error { - l, err := db.ListIDs(ctx, action, accountRef, repository.IDFilter(objectRef)) - if err != nil { - db.DBImp.Logger.Warn("Error occured while checking access rights", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return err - } - if len(l) == 0 { - db.DBImp.Logger.Debug("Access denied", zap.String("action", string(action)), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return merrors.AccessDenied(db.Collection, string(action), objectRef) - } - return nil -} - -func (db *ProtectedDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error { - db.DBImp.Logger.Debug("Attempting to get object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if err := db.enforceObject(ctx, model.ActionRead, accountRef, objectRef); err != nil { - return err - } - - if err := db.DBImp.Get(ctx, objectRef, result); err != nil { - db.DBImp.Logger.Warn("Failed to get object", zap.Error(err), mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection)) - return err - } - - db.DBImp.Logger.Debug("Successfully retrieved object", - mzap.AccRef(accountRef), mzap.OrgRef(result.GetOrganizationRef()), - mzap.StorableRef(result), mzap.PermissionRef(result.GetPermissionRef())) - return nil -} - -func (db *ProtectedDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error { - db.DBImp.Logger.Debug("Attempting to update object", mzap.AccRef(accountRef), mzap.StorableRef(object)) - - if err := db.enforceObject(ctx, model.ActionUpdate, accountRef, *object.GetID()); err != nil { - return err - } - - if err := db.DBImp.Update(ctx, object); err != nil { - db.DBImp.Logger.Warn("Failed to update object", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object)) - return err - } - - db.DBImp.Logger.Debug("Successfully updated object", - mzap.AccRef(accountRef), mzap.OrgRef(object.GetOrganizationRef()), - mzap.StorableRef(object), mzap.PermissionRef(object.GetPermissionRef())) - return nil -} - -func (db *ProtectedDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - db.DBImp.Logger.Debug("Attempting to delete object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if err := db.enforceObject(ctx, model.ActionDelete, accountRef, objectRef); err != nil { - return err - } - - if err := db.DBImp.Delete(ctx, objectRef); err != nil { - db.DBImp.Logger.Warn("Failed to delete object", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - - db.DBImp.Logger.Debug("Successfully deleted object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return nil -} - -func (db *ProtectedDBImp[T]) ListIDs( - ctx context.Context, - action model.Action, - accountRef bson.ObjectID, - query builder.Query, -) ([]bson.ObjectID, error) { - db.DBImp.Logger.Debug("Attempting to list object IDs", - mzap.AccRef(accountRef), zap.String("collection", db.Collection), zap.Any("filter", query.BuildQuery())) - - // 1. Fetch all candidate IDs from the underlying DB - allIDs, err := db.DBImp.ListPermissionBound(ctx, query) - if err != nil { - db.DBImp.Logger.Warn("Failed to list object IDs", zap.Error(err), mzap.AccRef(accountRef), - zap.String("collection", db.Collection), zap.String("action", string(action))) - return nil, err - } - if len(allIDs) == 0 { - db.DBImp.Logger.Debug("No objects found matching filter", mzap.AccRef(accountRef), - zap.String("collection", db.Collection), zap.Any("filter", query.BuildQuery())) - return []bson.ObjectID{}, merrors.NoData(fmt.Sprintf("no %s found", db.Collection)) - } - - // 2. Check read permission for each ID - var allowedIDs []bson.ObjectID - for _, desc := range allIDs { - enforceErr := db.enforce(ctx, action, desc, accountRef, *desc.GetID()) - if enforceErr == nil { - allowedIDs = append(allowedIDs, *desc.GetID()) - } else if !errors.Is(enforceErr, merrors.ErrAccessDenied) { - // If the error is something other than AccessDenied, we want to fail - db.DBImp.Logger.Warn("Error while enforcing read permission", zap.Error(enforceErr), - mzap.PermissionRef(desc.GetPermissionRef()), zap.String("action", string(action)), - mzap.AccRef(accountRef), mzap.OrgRef(desc.GetOrganizationRef()), - mzap.ObjRef("object_ref", *desc.GetID()), zap.String("collection", db.Collection), - ) - return nil, enforceErr - } - // If AccessDenied, we simply skip that ID. - } - - db.DBImp.Logger.Debug("Successfully enforced read permission on IDs", zap.Int("fetched_count", len(allIDs)), - zap.Int("allowed_count", len(allowedIDs)), mzap.AccRef(accountRef), - zap.String("collection", db.Collection), zap.String("action", string(action))) - - // 3. Return only the IDs that passed permission checks - return allowedIDs, nil -} - -func (db *ProtectedDBImp[T]) Unprotected() template.DB[T] { - return db.DBImp -} - -func (db *ProtectedDBImp[T]) DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - if err := db.enforceObject(ctx, model.ActionDelete, accountRef, objectRef); err != nil { - return err - } - if err := db.DBImp.DeleteCascade(ctx, objectRef); err != nil { - db.DBImp.Logger.Warn("Failed to delete dependent object", zap.Error(err)) - return err - } - return nil -} - -func CreateDBImp[T model.PermissionBoundStorable]( - ctx context.Context, - l mlogger.Logger, - pdb policy.DB, - enforcer Enforcer, - collection mservice.Type, - db *mongo.Database, -) (*ProtectedDBImp[T], error) { - logger := l.Named("protected") - var policy model.PolicyDescription - if err := pdb.GetBuiltInPolicy(ctx, collection, &policy); err != nil { - logger.Warn("Failed to fetch policy description", zap.Error(err), zap.String("resource_type", collection)) - return nil, err - } - p := &ProtectedDBImp[T]{ - DBImp: template.Create[T](logger, collection, db), - PermissionRef: policy.ID, - Collection: collection, - Enforcer: enforcer, - } - if err := p.DBImp.Repository.CreateIndex(&ri.Definition{ - Keys: []ri.Key{{Field: storable.OrganizationRefField, Sort: ri.Asc}}, - }); err != nil { - logger.Warn("Failed to create index", zap.Error(err), zap.String("resource_type", collection)) - return nil, err - } - - return p, nil -} - -func (db *ProtectedDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error { - db.DBImp.Logger.Debug("Attempting to patch object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if err := db.enforceObject(ctx, model.ActionUpdate, accountRef, objectRef); err != nil { - return err - } - - if err := db.DBImp.Repository.Patch(ctx, objectRef, patch); err != nil { - db.DBImp.Logger.Warn("Failed to patch object", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - - db.DBImp.Logger.Debug("Successfully patched object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return nil -} - -func (db *ProtectedDBImp[T]) PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) { - db.DBImp.Logger.Debug("Attempting to patch many objects", - mzap.AccRef(accountRef), zap.Any("filter", query.BuildQuery())) - - ids, err := db.ListIDs(ctx, model.ActionUpdate, accountRef, query) - if err != nil { - return 0, err - } - if len(ids) == 0 { - return 0, nil - } - - values := make([]any, len(ids)) - for i, id := range ids { - values[i] = id - } - idFilter := repository.Query().In(repository.IDField(), values...) - finalQuery := query.And(idFilter) - - modified, err := db.DBImp.Repository.PatchMany(ctx, finalQuery, patch) - if err != nil { - db.DBImp.Logger.Warn("Failed to patch many objects", zap.Error(err), - mzap.AccRef(accountRef)) - return 0, err - } - - db.DBImp.Logger.Debug("Successfully patched many objects", - mzap.AccRef(accountRef), zap.Int("modified_count", modified)) - return modified, nil -} diff --git a/backend/pkg/auth/dbimpab.go b/backend/pkg/auth/dbimpab.go deleted file mode 100644 index 0276ec6..0000000 --- a/backend/pkg/auth/dbimpab.go +++ /dev/null @@ -1,394 +0,0 @@ -package auth - -import ( - "context" - "errors" - - "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" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "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" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type AccountBoundDBImp[T model.AccountBoundStorable] struct { - Logger mlogger.Logger - DBImp *template.DBImp[T] - Enforcer Enforcer - PermissionRef bson.ObjectID - Collection mservice.Type -} - -func (db *AccountBoundDBImp[T]) enforce(ctx context.Context, action model.Action, object model.AccountBoundStorable, accountRef bson.ObjectID) error { - // FIRST: Check if the object's AccountRef equals the calling accountRef - if so, ALLOW - objectAccountRef := object.GetAccountRef() - if objectAccountRef != nil && *objectAccountRef == accountRef { - db.Logger.Debug("Access granted - object belongs to calling account", - mzap.ObjRef("object_account_ref", *objectAccountRef), - mzap.ObjRef("calling_account_ref", accountRef), - zap.String("action", string(action))) - return nil - } - - // SECOND: If not owned by calling account, check organization-level permissions - organizationRef := object.GetOrganizationRef() - res, err := db.Enforcer.Enforce(ctx, db.PermissionRef, accountRef, organizationRef, organizationRef, action) - if err != nil { - db.Logger.Warn("Failed to enforce permission", - zap.Error(err), mzap.PermissionRef(db.PermissionRef), - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.String("action", string(action))) - return err - } - if !res { - db.Logger.Debug("Access denied", mzap.PermissionRef(db.PermissionRef), - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.String("action", string(action))) - return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID) - } - return nil -} - -func (db *AccountBoundDBImp[T]) enforceInterface(ctx context.Context, action model.Action, object model.AccountBoundStorable, accountRef bson.ObjectID) error { - return db.enforce(ctx, action, object, accountRef) -} - -func (db *AccountBoundDBImp[T]) Create(ctx context.Context, accountRef bson.ObjectID, object T) error { - orgRef := object.GetOrganizationRef() - db.Logger.Debug("Attempting to create object", mzap.AccRef(accountRef), - mzap.OrgRef(orgRef), zap.String("collection", db.Collection)) - - // Check organization update permission for create operations - if err := db.enforce(ctx, model.ActionUpdate, object, accountRef); err != nil { - return err - } - - if err := db.DBImp.Create(ctx, object); err != nil { - db.Logger.Warn("Failed to create object", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(orgRef), zap.String("collection", db.Collection)) - return err - } - - db.Logger.Debug("Successfully created object", mzap.AccRef(accountRef), - mzap.OrgRef(orgRef), zap.String("collection", db.Collection)) - return nil -} - -func (db *AccountBoundDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error { - db.Logger.Debug("Attempting to get object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - // First get the object to check its organization - if err := db.DBImp.Get(ctx, objectRef, result); err != nil { - db.Logger.Warn("Failed to get object", zap.Error(err), mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection)) - return err - } - - // Check organization read permission - if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil { - return err - } - - db.Logger.Debug("Successfully retrieved object", mzap.AccRef(accountRef), - mzap.OrgRef(result.GetOrganizationRef()), zap.String("collection", db.Collection)) - return nil -} - -func (db *AccountBoundDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error { - db.Logger.Debug("Attempting to update object", mzap.AccRef(accountRef), mzap.StorableRef(object)) - - // Check organization update permission - if err := db.enforce(ctx, model.ActionUpdate, object, accountRef); err != nil { - return err - } - - if err := db.DBImp.Update(ctx, object); err != nil { - db.Logger.Warn("Failed to update object", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object)) - return err - } - - db.Logger.Debug("Successfully updated object", mzap.AccRef(accountRef), - mzap.OrgRef(object.GetOrganizationRef()), mzap.StorableRef(object)) - return nil -} - -func (db *AccountBoundDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error { - db.Logger.Debug("Attempting to patch object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - // First get the object to check its organization - objs, err := db.DBImp.Repository.ListAccountBound(ctx, repository.IDFilter(objectRef)) - if err != nil { - db.Logger.Warn("Failed to get object for permission check when deleting", zap.Error(err), mzap.ObjRef("object_ref", objectRef)) - return err - } - if len(objs) == 0 { - db.Logger.Debug("Permission denied for deletion", mzap.ObjRef("object_ref", objectRef), mzap.AccRef(accountRef)) - return merrors.AccessDenied(db.Collection, string(model.ActionDelete), objectRef) - } - - // Check organization update permission - if err := db.enforce(ctx, model.ActionUpdate, objs[0], accountRef); err != nil { - return err - } - - if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil { - db.Logger.Warn("Failed to patch object", zap.Error(err), mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection)) - return err - } - - db.Logger.Debug("Successfully patched object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return nil -} - -func (db *AccountBoundDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - db.Logger.Debug("Attempting to delete object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - // First get the object to check its organization - objs, err := db.DBImp.Repository.ListAccountBound(ctx, repository.IDFilter(objectRef)) - if err != nil { - db.Logger.Warn("Failed to get object for permission check when deleting", zap.Error(err), mzap.ObjRef("object_ref", objectRef)) - return err - } - if len(objs) == 0 { - db.Logger.Debug("Permission denied for deletion", mzap.ObjRef("object_ref", objectRef), mzap.AccRef(accountRef)) - return merrors.AccessDenied(db.Collection, string(model.ActionDelete), objectRef) - } - // Check organization update permission for delete operations - if err := db.enforce(ctx, model.ActionUpdate, objs[0], accountRef); err != nil { - return err - } - - if err := db.DBImp.Delete(ctx, objectRef); err != nil { - db.Logger.Warn("Failed to delete object", zap.Error(err), mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("collection", db.Collection)) - return err - } - - db.Logger.Debug("Successfully deleted object", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return nil -} - -func (db *AccountBoundDBImp[T]) DeleteMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query) error { - db.Logger.Debug("Attempting to delete many objects", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - - // Get all candidate objects for batch permission checking - allObjects, err := db.DBImp.Repository.ListPermissionBound(ctx, query) - if err != nil { - db.Logger.Warn("Failed to list objects for delete many", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - // Use batch enforcement for efficiency - allowedResults, err := db.Enforcer.EnforceBatch(ctx, allObjects, accountRef, model.ActionUpdate) - if err != nil { - db.Logger.Warn("Failed to enforce batch permissions for delete many", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - // Build query for objects that passed permission check - var allowedIDs []bson.ObjectID - for _, obj := range allObjects { - if allowedResults[*obj.GetID()] { - allowedIDs = append(allowedIDs, *obj.GetID()) - } - } - - if len(allowedIDs) == 0 { - db.Logger.Debug("No objects allowed for deletion", mzap.AccRef(accountRef)) - return nil - } - - // Delete only the allowed objects - allowedQuery := query.And(repository.Query().In(repository.IDField(), allowedIDs)) - if err := db.DBImp.DeleteMany(ctx, allowedQuery); err != nil { - db.Logger.Warn("Failed to delete many objects", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - db.Logger.Debug("Successfully deleted many objects", mzap.AccRef(accountRef), zap.Int("count", len(allowedIDs))) - return nil -} - -func (db *AccountBoundDBImp[T]) FindOne(ctx context.Context, accountRef bson.ObjectID, query builder.Query, result T) error { - db.Logger.Debug("Attempting to find one object", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - - // For FindOne, we need to check read permission after finding the object - if err := db.DBImp.FindOne(ctx, query, result); err != nil { - db.Logger.Warn("Failed to find one object", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - // Check organization read permission for the found object - if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil { - return err - } - - db.Logger.Debug("Successfully found one object", mzap.AccRef(accountRef), - mzap.OrgRef(result.GetOrganizationRef())) - return nil -} - -func (db *AccountBoundDBImp[T]) ListIDs(ctx context.Context, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) { - db.Logger.Debug("Attempting to list object IDs", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - - // Get all candidate objects for batch permission checking - allObjects, err := db.DBImp.Repository.ListPermissionBound(ctx, query) - if err != nil { - db.Logger.Warn("Failed to list objects for ID filtering", zap.Error(err), mzap.AccRef(accountRef)) - return nil, err - } - - // Use batch enforcement for efficiency - allowedResults, err := db.Enforcer.EnforceBatch(ctx, allObjects, accountRef, model.ActionRead) - if err != nil { - db.Logger.Warn("Failed to enforce batch permissions for ID listing", zap.Error(err), mzap.AccRef(accountRef)) - return nil, err - } - - // Filter to only allowed object IDs - var allowedIDs []bson.ObjectID - for _, obj := range allObjects { - if allowedResults[*obj.GetID()] { - allowedIDs = append(allowedIDs, *obj.GetID()) - } - } - - db.Logger.Debug("Successfully filtered object IDs", zap.Int("total_count", len(allObjects)), - zap.Int("allowed_count", len(allowedIDs)), mzap.AccRef(accountRef)) - return allowedIDs, nil -} - -func (db *AccountBoundDBImp[T]) ListAccountBound(ctx context.Context, accountRef, organizationRef bson.ObjectID, query builder.Query) ([]model.AccountBoundStorable, error) { - db.Logger.Debug("Attempting to list account bound objects", mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - - // Build query to find objects where accountRef matches OR is null/absent - accountQuery := repository.WithOrg(accountRef, organizationRef) - - // Combine with the provided query - finalQuery := query.And(accountQuery) - - // Get all candidate objects - allObjects, err := db.DBImp.Repository.ListAccountBound(ctx, finalQuery) - if err != nil { - db.Logger.Warn("Failed to list account bound objects", zap.Error(err), mzap.AccRef(accountRef)) - return nil, err - } - - // Filter objects based on read permissions (AccountBoundStorable doesn't have permission info, so we check organization level) - var allowedObjects []model.AccountBoundStorable - for _, obj := range allObjects { - if err := db.enforceInterface(ctx, model.ActionRead, obj, accountRef); err == nil { - allowedObjects = append(allowedObjects, obj) - } else if !errors.Is(err, merrors.ErrAccessDenied) { - // If the error is something other than AccessDenied, we want to fail - db.Logger.Warn("Error while enforcing read permission", zap.Error(err), mzap.ObjRef("object_ref", *obj.GetID())) - return nil, err - } - // If AccessDenied, we simply skip that object - } - - db.Logger.Debug("Successfully filtered account bound objects", zap.Int("total_count", len(allObjects)), - zap.Int("allowed_count", len(allowedObjects)), mzap.AccRef(accountRef)) - return allowedObjects, nil -} - -func (db *AccountBoundDBImp[T]) GetByAccountRef(ctx context.Context, accountRef bson.ObjectID, result T) error { - db.Logger.Debug("Attempting to get object by account ref", mzap.AccRef(accountRef)) - - // Build query to find objects where accountRef matches OR is null/absent - query := repository.WithoutOrg(accountRef) - - if err := db.DBImp.FindOne(ctx, query, result); err != nil { - db.Logger.Warn("Failed to get object by account ref", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - // Check organization read permission for the found object - if err := db.enforce(ctx, model.ActionRead, result, accountRef); err != nil { - return err - } - - db.Logger.Debug("Successfully retrieved object by account ref", mzap.AccRef(accountRef), - mzap.OrgRef(result.GetOrganizationRef())) - return nil -} - -func (db *AccountBoundDBImp[T]) DeleteByAccountRef(ctx context.Context, accountRef bson.ObjectID) error { - db.Logger.Debug("Attempting to delete objects by account ref", mzap.AccRef(accountRef)) - - // Build query to find objects where accountRef matches OR is null/absent - query := repository.WithoutOrg(accountRef) - - // Get all candidate objects for individual permission checking - allObjects, err := db.DBImp.Repository.ListAccountBound(ctx, query) - if err != nil { - db.Logger.Warn("Failed to list objects for delete by account ref", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - // Check permissions for each object individually (AccountBoundStorable doesn't have permission info) - var allowedIDs []bson.ObjectID - for _, obj := range allObjects { - if err := db.enforceInterface(ctx, model.ActionUpdate, obj, accountRef); err == nil { - allowedIDs = append(allowedIDs, *obj.GetID()) - } else if !errors.Is(err, merrors.ErrAccessDenied) { - // If the error is something other than AccessDenied, we want to fail - db.Logger.Warn("Error while enforcing update permission", zap.Error(err), mzap.ObjRef("object_ref", *obj.GetID())) - return err - } - // If AccessDenied, we simply skip that object - } - - if len(allowedIDs) == 0 { - db.Logger.Debug("No objects allowed for deletion by account ref", mzap.AccRef(accountRef)) - return nil - } - - // Delete only the allowed objects - allowedQuery := query.And(repository.Query().In(repository.IDField(), allowedIDs)) - if err := db.DBImp.DeleteMany(ctx, allowedQuery); err != nil { - db.Logger.Warn("Failed to delete objects by account ref", zap.Error(err), mzap.AccRef(accountRef)) - return err - } - - db.Logger.Debug("Successfully deleted objects by account ref", mzap.AccRef(accountRef), zap.Int("count", len(allowedIDs))) - return nil -} - -func (db *AccountBoundDBImp[T]) DeleteCascade(ctx context.Context, objectRef bson.ObjectID) error { - return db.DBImp.DeleteCascade(ctx, objectRef) -} - -// CreateAccountBoundImp creates a concrete AccountBoundDBImp instance for internal use -func CreateAccountBoundImp[T model.AccountBoundStorable]( - ctx context.Context, - logger mlogger.Logger, - pdb policy.DB, - enforcer Enforcer, - collection mservice.Type, - db *mongo.Database, -) (*AccountBoundDBImp[T], error) { - logger = logger.Named("account_bound") - var policy model.PolicyDescription - if err := pdb.GetBuiltInPolicy(ctx, mservice.Organizations, &policy); err != nil { - logger.Warn("Failed to fetch organization policy description", zap.Error(err)) - return nil, err - } - res := &AccountBoundDBImp[T]{ - Logger: logger, - DBImp: template.Create[T](logger, collection, db), - Enforcer: enforcer, - PermissionRef: policy.ID, - Collection: collection, - } - return res, nil -} diff --git a/backend/pkg/auth/dbimpab_test.go b/backend/pkg/auth/dbimpab_test.go deleted file mode 100644 index 529bd33..0000000 --- a/backend/pkg/auth/dbimpab_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package auth - -import ( - "errors" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/stretchr/testify/assert" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// TestAccountBoundDBImp_Enforce tests the enforce method -func TestAccountBoundDBImp_Enforce(t *testing.T) { - logger := zap.NewNop() - db := &AccountBoundDBImp[model.AccountBoundStorable]{ - Logger: logger, - PermissionRef: bson.NewObjectID(), - Collection: "test_collection", - } - - t.Run("EnforceMethodExists", func(t *testing.T) { - // Test that the enforce method exists and can be called - // This is a basic test to ensure the method signature is correct - assert.NotNil(t, db.enforce) - }) - - t.Run("PermissionRefSet", func(t *testing.T) { - // Test that PermissionRef is properly set - assert.NotEqual(t, bson.NilObjectID, db.PermissionRef) - }) - - t.Run("CollectionSet", func(t *testing.T) { - // Test that Collection is properly set - assert.Equal(t, "test_collection", db.Collection) - }) -} - -// TestAccountBoundDBImp_InterfaceCompliance tests that the struct implements required interfaces -func TestAccountBoundDBImp_InterfaceCompliance(t *testing.T) { - logger := zap.NewNop() - db := &AccountBoundDBImp[model.AccountBoundStorable]{ - Logger: logger, - PermissionRef: bson.NewObjectID(), - Collection: "test_collection", - } - - t.Run("StructInitialization", func(t *testing.T) { - // Test that the struct can be initialized - assert.NotNil(t, db) - assert.NotNil(t, db.Logger) - assert.NotEqual(t, bson.NilObjectID, db.PermissionRef) - assert.NotEmpty(t, db.Collection) - }) - - t.Run("LoggerInitialization", func(t *testing.T) { - // Test that logger is properly initialized - assert.NotNil(t, db.Logger) - }) -} - -// TestAccountBoundDBImp_ErrorHandling tests error handling patterns -func TestAccountBoundDBImp_ErrorHandling(t *testing.T) { - t.Run("AccessDeniedError", func(t *testing.T) { - // Test that AccessDenied error is properly created - err := merrors.AccessDenied("test_collection", "read", bson.NilObjectID) - assert.Error(t, err) - assert.True(t, errors.Is(err, merrors.ErrAccessDenied)) - }) - - t.Run("ErrorTypeChecking", func(t *testing.T) { - // Test error type checking - accessDeniedErr := merrors.AccessDenied("test", "read", bson.NilObjectID) - otherErr := merrors.Define("other error") - - assert.True(t, errors.Is(accessDeniedErr, merrors.ErrAccessDenied)) - assert.False(t, errors.Is(otherErr, merrors.ErrAccessDenied)) - }) -} diff --git a/backend/pkg/auth/dbimpscoped.go b/backend/pkg/auth/dbimpscoped.go deleted file mode 100644 index 525c4b9..0000000 --- a/backend/pkg/auth/dbimpscoped.go +++ /dev/null @@ -1,142 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// ownerKindField and ownerRefField are the canonical BSON paths produced by -// model.Owner embedded with bson:"owner". Resources managed by ScopedDBImp -// must use these same tags for BuildScopedQuery to produce correct filters. -const ( - ownerKindField = "owner.kind" - ownerRefField = "owner.ref" -) - -// ScopedDBImp provides generic CRUD, patch, and bulk operations for resources -// that follow the personal-vs-org dual-ownership model (ownerKind + ownerRef). -// -// Unlike ProtectedDBImp, it never uses permissionRef as a routing sentinel. -// Every read/mutate operation calls LoadAndEnforce, which reads ownerKind and -// ownerRef from the authoritative database record before making the access -// decision: -// - personal (ownerKind=account): caller must be the owner (no policy consulted) -// - org (ownerKind=organization): caller must pass the org policy check -// -// This eliminates the permissionRef==NilObjectID pattern that would otherwise -// be required for personal-owned resources under ProtectedDBImp. -type ScopedDBImp[T model.ScopedStorable] struct { - DBImp *template.DBImp[T] - scoped *ScopedDB[T] - Enforcer Enforcer - OrgPolicyRef bson.ObjectID - Collection mservice.Type -} - -// NewScopedDBImp wires a ScopedDBImp. Panics on nil required dependencies to -// surface misconfiguration at startup, matching the contract of NewScopedDB -// and CreateDBImp. -func NewScopedDBImp[T model.ScopedStorable]( - dbImp *template.DBImp[T], - enforcer Enforcer, - membership MembershipChecker, - orgPolicyRef bson.ObjectID, - collection mservice.Type, - newFn func() T, - logger mlogger.Logger, -) *ScopedDBImp[T] { - if dbImp == nil { - panic("auth.NewScopedDBImp: DBImp must not be nil") - } - if enforcer == nil { - panic("auth.NewScopedDBImp: Enforcer must not be nil") - } - if membership == nil { - panic("auth.NewScopedDBImp: MembershipChecker must not be nil") - } - if newFn == nil { - panic("auth.NewScopedDBImp: New must not be nil") - } - if logger == nil { - logger = zap.NewNop() - } - return &ScopedDBImp[T]{ - DBImp: dbImp, - Enforcer: enforcer, - OrgPolicyRef: orgPolicyRef, - Collection: collection, - scoped: NewScopedDB( - dbImp, - NewScopedAuthorizer(membership, enforcer, logger.Named("scoped")), - orgPolicyRef, - collection, - newFn, - logger.Named("scoped"), - ), - } -} - -func (db *ScopedDBImp[T]) loadAndEnforce(ctx context.Context, accountRef, objectRef bson.ObjectID, action model.Action) (T, error) { - return db.scoped.LoadAndEnforce(ctx, accountRef, objectRef, action) -} - -// Unprotected returns the raw DBImp for operations that bypass scoped auth. -// Callers are responsible for their own authorization. -func (db *ScopedDBImp[T]) Unprotected() template.DB[T] { - return db.DBImp -} - -// BuildScopedQuery returns the ownership-aware OR-query for use in List -// implementations. It mirrors the access model used by all other ScopedDBImp -// operations: membership is checked first, then org policy read access. -// -// The returned query always includes personal resources owned by accountRef, -// regardless of the organization they were created in — personal resources -// follow their owner across every org the account is a member of. Org-owned -// resources are bounded to organizationRef and added only when the caller is -// a member of the org AND the org policy grants read access. -// -// Callers must compose the returned query with resource-specific filters -// (additional field conditions, cursor pagination, etc.). -// -// Resources must embed model.Owner with bson:"owner" so the generated field -// paths (owner.kind, owner.ref) match the stored documents. -func (db *ScopedDBImp[T]) BuildScopedQuery(ctx context.Context, accountRef, organizationRef bson.ObjectID) (builder.Query, error) { - personalQ := repository.Query(). - Filter(repository.Field(ownerKindField), model.OwnerKindAccount). - Filter(repository.Field(ownerRefField), accountRef) - - conditions := []builder.Query{personalQ} - - isMember, err := db.scoped.Authorizer.Membership.IsMember(ctx, organizationRef, accountRef) - if err != nil { - // Membership backend failure degrades org access but must not prevent - // the caller from seeing their own personal resources. - db.scoped.Logger.Warn("membership check failed, returning personal-only results", - zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - } else if isMember { - hasOrgAccess, enforceErr := db.Enforcer.Enforce( - ctx, db.OrgPolicyRef, accountRef, organizationRef, bson.NilObjectID, model.ActionRead, - ) - if enforceErr != nil { - return nil, enforceErr - } - if hasOrgAccess { - conditions = append(conditions, repository.Query().And( - repository.OrgFilter(organizationRef), - repository.Filter(ownerKindField, model.OwnerKindOrganization), - )) - } - } - - return repository.Query().Or(conditions...), nil -} diff --git a/backend/pkg/auth/dbimpscoped_test.go b/backend/pkg/auth/dbimpscoped_test.go deleted file mode 100644 index 2a218bb..0000000 --- a/backend/pkg/auth/dbimpscoped_test.go +++ /dev/null @@ -1,848 +0,0 @@ -package auth - -import ( - "context" - "errors" - "fmt" - "testing" - - "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/db/template" - "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" - "go.uber.org/zap" -) - -// testScopedObj is a minimal model.ScopedStorable used exclusively by -// ScopedDBImp tests. -type testScopedObj struct { - id bson.ObjectID - orgRef bson.ObjectID - ownerKind model.OwnerKind - ownerRef bson.ObjectID - permissionRef bson.ObjectID -} - -func (o *testScopedObj) GetID() *bson.ObjectID { return &o.id } -func (o *testScopedObj) SetID(id bson.ObjectID) { o.id = id } -func (o *testScopedObj) Update() {} -func (o *testScopedObj) Collection() string { return "test_scoped" } -func (o *testScopedObj) GetOrganizationRef() bson.ObjectID { return o.orgRef } -func (o *testScopedObj) SetOrganizationRef(ref bson.ObjectID) { o.orgRef = ref } -func (o *testScopedObj) GetOwnerKind() model.OwnerKind { return o.ownerKind } -func (o *testScopedObj) GetOwnerRef() bson.ObjectID { return o.ownerRef } -func (o *testScopedObj) SetOwnerKind(k model.OwnerKind) { o.ownerKind = k } -func (o *testScopedObj) SetOwnerRef(ref bson.ObjectID) { o.ownerRef = ref } -func (o *testScopedObj) GetPermissionRef() bson.ObjectID { return o.permissionRef } -func (o *testScopedObj) SetPermissionRef(ref bson.ObjectID) { o.permissionRef = ref } - -var _ model.ScopedStorable = (*testScopedObj)(nil) - -// fakeScopedRepository is a minimal in-memory repository.Repository that -// supports Insert, InsertMany, Get, Update, Patch, Delete, and ListIDs. -// All other methods return merrors.NotImplemented. -type fakeScopedRepository struct { - objects map[bson.ObjectID]*testScopedObj - insertErr error - getErr error - updateErr error - patchErr error - deleteErr error - listIDsErr error - patchedIDs []bson.ObjectID - // patchManyIDs is the set of IDs that PatchMany will record as affected. - // Tests using PatchMany must set this to the expected authorized IDs so the - // fake can simulate per-ID tracking without parsing the bulk filter. - patchManyIDs []bson.ObjectID -} - -func newFakeScopedRepository() *fakeScopedRepository { - return &fakeScopedRepository{objects: make(map[bson.ObjectID]*testScopedObj)} -} - -func (f *fakeScopedRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error { - if f.insertErr != nil { - return f.insertErr - } - o := obj.(*testScopedObj) - if o.id == bson.NilObjectID { - o.id = bson.NewObjectID() - } - clone := *o - f.objects[o.id] = &clone - return nil -} - -func (f *fakeScopedRepository) InsertMany(_ context.Context, objects []storable.Storable) error { - if f.insertErr != nil { - return f.insertErr - } - for _, obj := range objects { - o := obj.(*testScopedObj) - if o.id == bson.NilObjectID { - o.id = bson.NewObjectID() - } - clone := *o - f.objects[o.id] = &clone - } - return nil -} - -func (f *fakeScopedRepository) Get(_ context.Context, id bson.ObjectID, result storable.Storable) error { - if f.getErr != nil { - return f.getErr - } - obj, ok := f.objects[id] - if !ok { - return merrors.NoData(fmt.Sprintf("test_scoped %s not found", id.Hex())) - } - *result.(*testScopedObj) = *obj - return nil -} - -func (f *fakeScopedRepository) Update(_ context.Context, obj storable.Storable) error { - if f.updateErr != nil { - return f.updateErr - } - o := obj.(*testScopedObj) - clone := *o - f.objects[*o.GetID()] = &clone - return nil -} - -func (f *fakeScopedRepository) Patch(_ context.Context, id bson.ObjectID, _ builder.Patch) error { - if f.patchErr != nil { - return f.patchErr - } - f.patchedIDs = append(f.patchedIDs, id) - return nil -} - -func (f *fakeScopedRepository) Delete(_ context.Context, id bson.ObjectID) error { - if f.deleteErr != nil { - return f.deleteErr - } - delete(f.objects, id) - return nil -} - -func (f *fakeScopedRepository) ListIDs(_ context.Context, _ builder.Query) ([]bson.ObjectID, error) { - if f.listIDsErr != nil { - return nil, f.listIDsErr - } - ids := make([]bson.ObjectID, 0, len(f.objects)) - for id := range f.objects { - ids = append(ids, id) - } - return ids, nil -} - -// Unimplemented repository.Repository stubs. -func (f *fakeScopedRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error { - return merrors.NotImplemented("aggregate") -} -func (f *fakeScopedRepository) FindOneByFilter(context.Context, builder.Query, storable.Storable) error { - return merrors.NotImplemented("FindOneByFilter") -} -func (f *fakeScopedRepository) FindFirstByFilter(context.Context, builder.Query, storable.Storable) error { - return merrors.NotImplemented("FindFirstByFilter") -} -func (f *fakeScopedRepository) FindManyByFilter(context.Context, builder.Query, rd.DecodingFunc) error { - return merrors.NotImplemented("FindManyByFilter") -} -func (f *fakeScopedRepository) FindAllByFilter(context.Context, builder.Query, any) error { - return merrors.NotImplemented("FindAllByFilter") -} -func (f *fakeScopedRepository) CountByFilter(context.Context, builder.Query) (int64, error) { - return 0, merrors.NotImplemented("CountByFilter") -} -func (f *fakeScopedRepository) ReplaceOneByFilter(context.Context, builder.Query, storable.Storable) (bool, error) { - return false, merrors.NotImplemented("ReplaceOneByFilter") -} -func (f *fakeScopedRepository) Upsert(context.Context, storable.Storable) error { - return merrors.NotImplemented("Upsert") -} -func (f *fakeScopedRepository) PatchOneByFilter(context.Context, builder.Query, builder.Patch, storable.Storable) (bool, error) { - return false, merrors.NotImplemented("PatchOneByFilter") -} -func (f *fakeScopedRepository) PatchMany(_ context.Context, _ builder.Query, _ builder.Patch) (int, error) { - f.patchedIDs = append(f.patchedIDs, f.patchManyIDs...) - return len(f.patchManyIDs), nil -} -func (f *fakeScopedRepository) DeleteMany(context.Context, builder.Query) error { - return merrors.NotImplemented("DeleteMany") -} -func (f *fakeScopedRepository) CreateIndex(*ri.Definition) error { return nil } -func (f *fakeScopedRepository) DropIndex(context.Context, string) error { - return merrors.NotImplemented("DropIndex") -} -func (f *fakeScopedRepository) ListIndexDefinitions(context.Context) ([]*ri.Definition, error) { - return nil, merrors.NotImplemented("ListIndexDefinitions") -} -func (f *fakeScopedRepository) ListPermissionBound(context.Context, builder.Query) ([]model.PermissionBoundStorable, error) { - return nil, merrors.NotImplemented("ListPermissionBound") -} -func (f *fakeScopedRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) { - return nil, merrors.NotImplemented("ListAccountBound") -} -func (f *fakeScopedRepository) WithTransaction(_ context.Context, cb transaction.Callback) (any, error) { - return cb(context.Background()) -} -func (f *fakeScopedRepository) Collection() string { return "test_scoped" } - -// newTestScopedDBImp wires a ScopedDBImp backed by the given in-memory repo, -// membership, and enforcer. The orgPolicyRef is returned so callers can verify -// it was applied correctly. -func newTestScopedDBImp(repo *fakeScopedRepository, membership *fakeMembership, enforcer Enforcer) (*ScopedDBImp[*testScopedObj], bson.ObjectID) { - orgPolicyRef := bson.NewObjectID() - dbImp := &template.DBImp[*testScopedObj]{ - Logger: zap.NewNop(), - Repository: repo, - } - dbImp.SetDeleter(func(_ context.Context, id bson.ObjectID) error { - return repo.Delete(context.Background(), id) - }) - imp := NewScopedDBImp[*testScopedObj]( - dbImp, - enforcer, - membership, - orgPolicyRef, - mservice.Type("test_scoped"), - func() *testScopedObj { return &testScopedObj{} }, - zap.NewNop(), - ) - return imp, orgPolicyRef -} - -// ── Create ──────────────────────────────────────────────────────────────────── - -func TestScopedDBImp_Create_Personal_SkipsPolicy(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - obj := &testScopedObj{ownerKind: model.OwnerKindAccount} - if err := db.Create(context.Background(), caller, orgRef, obj); err != nil { - t.Fatalf("expected success, got %v", err) - } - if enf.called { - t.Fatal("personal create must not consult the enforcer") - } - if obj.GetOwnerRef() != caller { - t.Fatalf("expected ownerRef=%s, got %s", caller.Hex(), obj.GetOwnerRef().Hex()) - } - if obj.GetOrganizationRef() != orgRef { - t.Fatal("organizationRef must be set on create") - } -} - -func TestScopedDBImp_Create_Personal_DefaultsToAccountWhenKindMissing(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - obj := &testScopedObj{} // ownerKind is zero value (invalid) - if err := db.Create(context.Background(), caller, orgRef, obj); err != nil { - t.Fatalf("expected success with defaulted ownerKind, got %v", err) - } - if obj.GetOwnerKind() != model.OwnerKindAccount { - t.Fatalf("expected ownerKind=account, got %s", obj.GetOwnerKind()) - } - if enf.called { - t.Fatal("must not consult enforcer when defaulting to personal") - } -} - -func TestScopedDBImp_Create_Personal_DeniedWhenCallerIsNotMember(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - membership := &fakeMembership{member: false} - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, membership, enf) - - obj := &testScopedObj{ownerKind: model.OwnerKindAccount} - err := db.Create(context.Background(), caller, orgRef, obj) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-member personal create, got %v", err) - } - if !membership.called { - t.Fatal("personal create must check organization membership before insert") - } - if enf.called { - t.Fatal("personal create must not consult policy when caller is not an organization member") - } - if len(repo.objects) != 0 { - t.Fatal("object must not be inserted when membership denies") - } -} - -func TestScopedDBImp_Create_Org_CallsPolicyAndSetsFields(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{allow: true} - db, orgPolicyRef := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - obj := &testScopedObj{ownerKind: model.OwnerKindOrganization} - if err := db.Create(context.Background(), caller, orgRef, obj); err != nil { - t.Fatalf("expected success, got %v", err) - } - if !enf.called { - t.Fatal("org create must consult the enforcer") - } - if enf.gotAction != model.ActionCreate { - t.Fatalf("expected ActionCreate, got %s", enf.gotAction) - } - if obj.GetOwnerRef() != orgRef { - t.Fatalf("expected ownerRef=orgRef for org object, got %s", obj.GetOwnerRef().Hex()) - } - if obj.GetPermissionRef() != orgPolicyRef { - t.Fatal("permissionRef must be set to OrgPolicyRef for org objects") - } -} - -func TestScopedDBImp_Create_Org_DeniedByPolicy(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{allow: false} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - obj := &testScopedObj{ownerKind: model.OwnerKindOrganization} - err := db.Create(context.Background(), caller, orgRef, obj) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - if len(repo.objects) != 0 { - t.Fatal("object must not be inserted when policy denies") - } -} - -func TestScopedDBImp_Create_Org_DeniedWhenCallerIsNotMember(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - membership := &fakeMembership{member: false} - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, membership, enf) - - obj := &testScopedObj{ownerKind: model.OwnerKindOrganization} - err := db.Create(context.Background(), caller, orgRef, obj) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-member org create, got %v", err) - } - if !membership.called { - t.Fatal("org create must check organization membership before policy") - } - if enf.called { - t.Fatal("org create must not consult policy when caller is not an organization member") - } - if len(repo.objects) != 0 { - t.Fatal("object must not be inserted when membership denies") - } -} - -func TestScopedDBImp_Create_Org_MembershipErrorFailsClosed(t *testing.T) { - boom := merrors.Define("membership backend down") - repo := newFakeScopedRepository() - membership := &fakeMembership{err: boom} - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, membership, enf) - - obj := &testScopedObj{ownerKind: model.OwnerKindOrganization} - err := db.Create(context.Background(), bson.NewObjectID(), bson.NewObjectID(), obj) - if !errors.Is(err, boom) { - t.Fatalf("expected membership backend error propagated, got %v", err) - } - if enf.called { - t.Fatal("org create must fail closed and not consult policy on membership error") - } - if len(repo.objects) != 0 { - t.Fatal("object must not be inserted when the membership check errors") - } -} - -// ── InsertMany ──────────────────────────────────────────────────────────────── - -func TestScopedDBImp_InsertMany_Empty_IsNoOp(t *testing.T) { - repo := newFakeScopedRepository() - enf := &fakeEnforcer{} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - if err := db.InsertMany(context.Background(), bson.NewObjectID(), bson.NewObjectID(), nil); err != nil { - t.Fatalf("empty InsertMany must succeed, got %v", err) - } - if enf.called { - t.Fatal("empty InsertMany must not call enforcer") - } -} - -func TestScopedDBImp_InsertMany_AllPersonal_SkipsPolicy(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - objects := []*testScopedObj{ - {ownerKind: model.OwnerKindAccount}, - {ownerKind: model.OwnerKindAccount}, - } - if err := db.InsertMany(context.Background(), caller, orgRef, objects); err != nil { - t.Fatalf("expected success, got %v", err) - } - if enf.called { - t.Fatal("all-personal InsertMany must not consult the enforcer") - } - if len(repo.objects) != 2 { - t.Fatalf("expected 2 inserted objects, got %d", len(repo.objects)) - } -} - -func TestScopedDBImp_InsertMany_AllPersonalDeniedWhenCallerIsNotMember(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - membership := &fakeMembership{member: false} - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, membership, enf) - - objects := []*testScopedObj{ - {ownerKind: model.OwnerKindAccount}, - {ownerKind: model.OwnerKindAccount}, - } - err := db.InsertMany(context.Background(), caller, orgRef, objects) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-member personal InsertMany, got %v", err) - } - if !membership.called { - t.Fatal("personal InsertMany must check organization membership before insert") - } - if enf.called { - t.Fatal("personal InsertMany must not consult policy when caller is not an organization member") - } - if len(repo.objects) != 0 { - t.Fatal("objects must not be inserted when membership denies") - } -} - -func TestScopedDBImp_InsertMany_MixedBatch_OrgPolicyCheckedOnce(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - objects := []*testScopedObj{ - {ownerKind: model.OwnerKindAccount}, - {ownerKind: model.OwnerKindOrganization}, - {ownerKind: model.OwnerKindOrganization}, - } - if err := db.InsertMany(context.Background(), caller, orgRef, objects); err != nil { - t.Fatalf("expected success, got %v", err) - } - if !enf.called { - t.Fatal("enforcer must be called for mixed batch with org objects") - } - // Enforcer is called only once regardless of how many org objects are in the batch. - callCount := 0 - if enf.called { - callCount = 1 - } - if callCount != 1 { - t.Fatal("org policy must be checked exactly once for the entire batch") - } -} - -func TestScopedDBImp_InsertMany_OrgDenied_NoObjectsInsertedOrMutated(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - enf := &fakeEnforcer{allow: false} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - personal := &testScopedObj{} - org := &testScopedObj{ - orgRef: bson.NewObjectID(), - ownerKind: model.OwnerKindOrganization, - ownerRef: bson.NewObjectID(), - permissionRef: bson.NewObjectID(), - } - originalPersonal := *personal - originalOrg := *org - objects := []*testScopedObj{personal, org} - err := db.InsertMany(context.Background(), caller, orgRef, objects) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - if len(repo.objects) != 0 { - t.Fatal("objects must not be inserted when policy denies") - } - if *personal != originalPersonal { - t.Fatalf("personal object was mutated on denied InsertMany: got %+v want %+v", *personal, originalPersonal) - } - if *org != originalOrg { - t.Fatalf("org object was mutated on denied InsertMany: got %+v want %+v", *org, originalOrg) - } -} - -func TestScopedDBImp_InsertMany_OrgDeniedWhenCallerIsNotMember(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - repo := newFakeScopedRepository() - membership := &fakeMembership{member: false} - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, membership, enf) - - objects := []*testScopedObj{{ownerKind: model.OwnerKindOrganization}} - err := db.InsertMany(context.Background(), caller, orgRef, objects) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-member org InsertMany, got %v", err) - } - if !membership.called { - t.Fatal("org InsertMany must check organization membership before policy") - } - if enf.called { - t.Fatal("org InsertMany must not consult policy when caller is not an organization member") - } - if len(repo.objects) != 0 { - t.Fatal("objects must not be inserted when membership denies") - } -} - -func TestScopedDBImp_InsertMany_OrgMembershipErrorFailsClosed(t *testing.T) { - boom := merrors.Define("membership backend down") - repo := newFakeScopedRepository() - membership := &fakeMembership{err: boom} - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(repo, membership, enf) - - objects := []*testScopedObj{{ownerKind: model.OwnerKindOrganization}} - err := db.InsertMany(context.Background(), bson.NewObjectID(), bson.NewObjectID(), objects) - if !errors.Is(err, boom) { - t.Fatalf("expected membership backend error propagated, got %v", err) - } - if enf.called { - t.Fatal("org InsertMany must fail closed and not consult policy on membership error") - } - if len(repo.objects) != 0 { - t.Fatal("objects must not be inserted when the membership check errors") - } -} - -// ── Update ──────────────────────────────────────────────────────────────────── - -func TestScopedDBImp_Update_RestoresOwnershipFields(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - objectRef := bson.NewObjectID() - orgPolicyRef := bson.NewObjectID() - - // Seed the repo with a personal object - repo := newFakeScopedRepository() - repo.objects[objectRef] = &testScopedObj{ - id: objectRef, - orgRef: orgRef, - ownerKind: model.OwnerKindAccount, - ownerRef: caller, - permissionRef: orgPolicyRef, - } - - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) - - // Submit an update that tries to change ownership - updated := &testScopedObj{ - id: objectRef, - ownerKind: model.OwnerKindOrganization, // attempt to escalate - ownerRef: bson.NewObjectID(), // attempt to hijack owner - } - if err := db.Update(context.Background(), caller, updated); err != nil { - t.Fatalf("expected success, got %v", err) - } - // After update the ownership fields must match the DB, not the submitted payload - if updated.GetOwnerKind() != model.OwnerKindAccount { - t.Fatalf("ownerKind must not change: got %s", updated.GetOwnerKind()) - } - if updated.GetOwnerRef() != caller { - t.Fatalf("ownerRef must not change: got %s", updated.GetOwnerRef().Hex()) - } - if updated.GetOrganizationRef() != orgRef { - t.Fatal("organizationRef must not change") - } - if updated.GetPermissionRef() != orgPolicyRef { - t.Fatal("permissionRef must not change") - } -} - -func TestScopedDBImp_Update_DeniedForNonOwner(t *testing.T) { - owner := bson.NewObjectID() - attacker := bson.NewObjectID() - objectRef := bson.NewObjectID() - orgRef := bson.NewObjectID() - - repo := newFakeScopedRepository() - repo.objects[objectRef] = &testScopedObj{ - id: objectRef, - orgRef: orgRef, - ownerKind: model.OwnerKindAccount, - ownerRef: owner, - } - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) - - err := db.Update(context.Background(), attacker, &testScopedObj{id: objectRef}) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-owner, got %v", err) - } -} - -// ── ListIDs ─────────────────────────────────────────────────────────────────── - -func TestScopedDBImp_ListIDs_FiltersUnauthorized(t *testing.T) { - caller := bson.NewObjectID() - other := bson.NewObjectID() - orgRef := bson.NewObjectID() - - id1 := bson.NewObjectID() - id2 := bson.NewObjectID() - id3 := bson.NewObjectID() - - repo := newFakeScopedRepository() - repo.objects = map[bson.ObjectID]*testScopedObj{ - id1: {id: id1, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller}, - id2: {id: id2, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: other}, // unauthorized - id3: {id: id3, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller}, - } - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) - - ids, err := db.ListIDs(context.Background(), model.ActionRead, caller, nil) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - if len(ids) != 2 { - t.Fatalf("expected 2 authorized IDs, got %d", len(ids)) - } - for _, id := range ids { - if id == id2 { - t.Fatal("unauthorized ID must not be returned") - } - } -} - -func TestScopedDBImp_ListIDs_PropagatesNonAuthError(t *testing.T) { - boom := merrors.Define("enforcer backend down") - objectRef := bson.NewObjectID() - orgRef := bson.NewObjectID() - - repo := newFakeScopedRepository() - repo.objects[objectRef] = &testScopedObj{ - id: objectRef, - orgRef: orgRef, - ownerKind: model.OwnerKindOrganization, - ownerRef: orgRef, - } - enf := &fakeEnforcer{err: boom} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - _, err := db.ListIDs(context.Background(), model.ActionRead, bson.NewObjectID(), nil) - if !errors.Is(err, boom) { - t.Fatalf("expected underlying error propagated, got %v", err) - } -} - -func TestScopedDBImp_ListIDs_RawRepoErrorPropagated(t *testing.T) { - boom := merrors.Define("mongo down") - repo := newFakeScopedRepository() - repo.listIDsErr = boom - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) - - _, err := db.ListIDs(context.Background(), model.ActionRead, bson.NewObjectID(), nil) - if !errors.Is(err, boom) { - t.Fatalf("expected raw list error propagated, got %v", err) - } -} - -// ── PatchMany ───────────────────────────────────────────────────────────────── - -func TestScopedDBImp_PatchMany_PatchesOnlyAuthorizedIDs(t *testing.T) { - caller := bson.NewObjectID() - other := bson.NewObjectID() - orgRef := bson.NewObjectID() - - id1 := bson.NewObjectID() - id2 := bson.NewObjectID() - - repo := newFakeScopedRepository() - repo.objects = map[bson.ObjectID]*testScopedObj{ - id1: {id: id1, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: caller}, - id2: {id: id2, orgRef: orgRef, ownerKind: model.OwnerKindAccount, ownerRef: other}, - } - // patchManyIDs declares which IDs the bulk DB call will report as affected. - // Only id1 is authorized (ownerRef==caller), so the IN filter contains only id1. - repo.patchManyIDs = []bson.ObjectID{id1} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, &fakeEnforcer{}) - - count, err := db.PatchMany(context.Background(), caller, nil, nil) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - if count != 1 { - t.Fatalf("expected 1 patched object, got %d", count) - } - if len(repo.patchedIDs) != 1 || repo.patchedIDs[0] != id1 { - t.Fatal("only the authorized ID must be patched") - } -} - -// ── BuildScopedQuery ────────────────────────────────────────────────────────── - -// bsonHasPair reports whether the key/value pair occurs anywhere within the -// bson.D/bson.A tree produced by builder.Query.BuildQuery(). -func bsonHasPair(node any, key string, value any) bool { - switch n := node.(type) { - case bson.D: - for _, e := range n { - if e.Key == key && e.Value == value { - return true - } - if bsonHasPair(e.Value, key, value) { - return true - } - } - case bson.A: - for _, item := range n { - if bsonHasPair(item, key, value) { - return true - } - } - } - return false -} - -// bsonHasKey reports whether the key occurs anywhere within the tree. -func bsonHasKey(node any, key string) bool { - switch n := node.(type) { - case bson.D: - for _, e := range n { - if e.Key == key { - return true - } - if bsonHasKey(e.Value, key) { - return true - } - } - case bson.A: - for _, item := range n { - if bsonHasKey(item, key) { - return true - } - } - } - return false -} - -func TestScopedDBImp_BuildScopedQuery_WithOrgAccess_PersonalBranchIsCrossOrg(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{member: true}, enf) - - q, err := db.BuildScopedQuery(context.Background(), caller, orgRef) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - if !enf.called { - t.Fatal("enforcer must be called to check org access") - } - - built := q.BuildQuery() - if len(built) != 1 || built[0].Key != "$or" { - t.Fatalf("expected a single top-level $or, got %v", built) - } - branches, ok := built[0].Value.(bson.A) - if !ok || len(branches) != 2 { - t.Fatalf("expected 2 $or branches, got %v", built[0].Value) - } - - personal := branches[0] - if !bsonHasPair(personal, ownerKindField, model.OwnerKindAccount) || - !bsonHasPair(personal, ownerRefField, caller) { - t.Fatalf("personal branch must match owner kind/ref, got %v", personal) - } - if bsonHasKey(personal, storable.OrganizationRefField) { - t.Fatalf("personal branch must not be org-bounded, got %v", personal) - } - - org := branches[1] - if !bsonHasPair(org, storable.OrganizationRefField, orgRef) || - !bsonHasPair(org, ownerKindField, model.OwnerKindOrganization) { - t.Fatalf("org branch must be org-bounded and org-owned, got %v", org) - } -} - -func TestScopedDBImp_BuildScopedQuery_WithoutOrgAccess_PersonalOnly(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - enf := &fakeEnforcer{allow: false} - db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{member: true}, enf) - - q, err := db.BuildScopedQuery(context.Background(), caller, orgRef) - if err != nil { - t.Fatalf("expected success, got %v", err) - } - - built := q.BuildQuery() - if bsonHasKey(built, storable.OrganizationRefField) { - t.Fatalf("query must be personal-only without org access, got %v", built) - } - if !bsonHasPair(built, ownerKindField, model.OwnerKindAccount) || - !bsonHasPair(built, ownerRefField, caller) { - t.Fatalf("query must match personal owner kind/ref, got %v", built) - } -} - -func TestScopedDBImp_BuildScopedQuery_MembershipError_PersonalOnly(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - enf := &fakeEnforcer{allow: true} - db, _ := newTestScopedDBImp(newFakeScopedRepository(), &fakeMembership{err: merrors.Define("membership down")}, enf) - - q, err := db.BuildScopedQuery(context.Background(), caller, orgRef) - if err != nil { - t.Fatalf("membership failure must degrade to personal-only, got %v", err) - } - if enf.called { - t.Fatal("enforcer must not be consulted when the membership check fails") - } - - built := q.BuildQuery() - if bsonHasKey(built, storable.OrganizationRefField) { - t.Fatalf("query must be personal-only on membership failure, got %v", built) - } - if !bsonHasPair(built, ownerKindField, model.OwnerKindAccount) || - !bsonHasPair(built, ownerRefField, caller) { - t.Fatalf("query must match personal owner kind/ref, got %v", built) - } -} - -func TestScopedDBImp_BuildScopedQuery_EnforcerError_Propagated(t *testing.T) { - boom := merrors.Define("enforcer down") - repo := newFakeScopedRepository() - enf := &fakeEnforcer{err: boom} - db, _ := newTestScopedDBImp(repo, &fakeMembership{member: true}, enf) - - _, err := db.BuildScopedQuery(context.Background(), bson.NewObjectID(), bson.NewObjectID()) - if !errors.Is(err, boom) { - t.Fatalf("expected enforcer error propagated, got %v", err) - } -} diff --git a/backend/pkg/auth/dbimpscopedcrud.go b/backend/pkg/auth/dbimpscopedcrud.go deleted file mode 100644 index c64afac..0000000 --- a/backend/pkg/auth/dbimpscopedcrud.go +++ /dev/null @@ -1,262 +0,0 @@ -package auth - -import ( - "context" - - "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" -) - -// Create inserts a new resource. If ownerKind is absent or invalid it defaults -// to account. -// -// Personal path (ownerKind=account): requires the caller to be a member of -// organizationRef (so a non-member cannot plant personal objects in a foreign -// org namespace), then sets ownerRef=accountRef and inserts. The org policy is -// not consulted — after creation, access is governed by ownership alone. -// -// Org path (ownerKind=organization): checks org membership and the org policy -// (via authorizeOrgMutation) first, then stamps ownerRef/permissionRef and -// inserts. Ownership fields are only mutated after the access check so a denied -// caller's object is unchanged. -func (db *ScopedDBImp[T]) Create(ctx context.Context, accountRef, organizationRef bson.ObjectID, object T) error { - db.DBImp.Logger.Debug("attempting to create scoped object", - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.String("collection", db.Collection)) - - if !object.GetOwnerKind().IsValid() { - object.SetOwnerKind(model.OwnerKindAccount) - } - - switch object.GetOwnerKind() { - case model.OwnerKindAccount: - if err := db.authorizeOrgMembership(ctx, accountRef, organizationRef, model.ActionCreate); err != nil { - return err - } - object.SetOwnerRef(accountRef) - object.SetOrganizationRef(organizationRef) - if err := db.DBImp.Create(ctx, object); err != nil { - db.DBImp.Logger.Warn("failed to create personal scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return err - } - return nil - - case model.OwnerKindOrganization: - if err := db.authorizeOrgMutation(ctx, accountRef, organizationRef, model.ActionCreate); err != nil { - return err - } - object.SetOwnerRef(organizationRef) - object.SetOrganizationRef(organizationRef) - object.SetPermissionRef(db.OrgPolicyRef) - if err := db.DBImp.Create(ctx, object); err != nil { - db.DBImp.Logger.Warn("failed to create org scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return err - } - return nil - - default: - db.DBImp.Logger.Warn("unsupported ownerKind on create", - zap.String("ownerKind", string(object.GetOwnerKind())), - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return merrors.InvalidArgument("unsupported ownerKind", string(object.GetOwnerKind())) - } -} - -// InsertMany bulk-inserts resources. Ownership fields are normalized per -// object (defaulting to account when ownerKind is absent). Org membership, and -// any required org policy check, run before any object is mutated. -func (db *ScopedDBImp[T]) InsertMany(ctx context.Context, accountRef, organizationRef bson.ObjectID, objects []T) error { - if len(objects) == 0 { - return nil - } - - db.DBImp.Logger.Debug("attempting to insert many scoped objects", - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.Int("count", len(objects)), zap.String("collection", db.Collection)) - - if err := db.authorizeOrgMembership(ctx, accountRef, organizationRef, model.ActionCreate); err != nil { - return err - } - - ownerKinds := make([]model.OwnerKind, len(objects)) - var hasOrgObjects bool - for i, obj := range objects { - ownerKind := obj.GetOwnerKind() - if !ownerKind.IsValid() { - ownerKind = model.OwnerKindAccount - } - switch ownerKind { - case model.OwnerKindAccount: - case model.OwnerKindOrganization: - hasOrgObjects = true - default: - return merrors.InvalidArgument("unsupported ownerKind in InsertMany", string(ownerKind)) - } - ownerKinds[i] = ownerKind - } - - if hasOrgObjects { - if err := db.authorizeOrgPolicy(ctx, accountRef, organizationRef, model.ActionCreate); err != nil { - return err - } - } - - for i, obj := range objects { - switch ownerKinds[i] { - case model.OwnerKindAccount: - obj.SetOwnerKind(model.OwnerKindAccount) - obj.SetOwnerRef(accountRef) - obj.SetOrganizationRef(organizationRef) - case model.OwnerKindOrganization: - obj.SetOwnerKind(model.OwnerKindOrganization) - obj.SetOwnerRef(organizationRef) - obj.SetOrganizationRef(organizationRef) - obj.SetPermissionRef(db.OrgPolicyRef) - default: - return merrors.InvalidArgument("unsupported ownerKind in InsertMany", string(ownerKinds[i])) - } - } - - if err := db.DBImp.InsertMany(ctx, objects); err != nil { - db.DBImp.Logger.Warn("failed to insert many scoped objects", - zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.Int("count", len(objects))) - return err - } - return nil -} - -// authorizeOrgMembership requires the caller to be a member of the -// organization. It is the create-time gate shared by the personal and org -// paths: since no record exists yet to read ownership from, membership in the -// target org is the minimum proof that the caller may write into its -// namespace. It fails closed — a membership backend error denies the mutation. -func (db *ScopedDBImp[T]) authorizeOrgMembership(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error { - isMember, err := db.scoped.Authorizer.Membership.IsMember(ctx, organizationRef, accountRef) - if err != nil { - db.DBImp.Logger.Warn("membership check failed during create-time mutation", - zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.String("collection", db.Collection)) - return err - } - if !isMember { - db.DBImp.Logger.Debug("access denied: caller is not a member of the organization", - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.String("collection", db.Collection), zap.String("action", string(action))) - return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID) - } - - return nil -} - -// authorizeOrgPolicy checks that the org policy allows the caller to perform -// the action. It assumes membership has already been verified — callers must -// go through authorizeOrgMembership (or authorizeOrgMutation) first. -func (db *ScopedDBImp[T]) authorizeOrgPolicy(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error { - ok, err := db.Enforcer.Enforce(ctx, db.OrgPolicyRef, accountRef, organizationRef, bson.NilObjectID, action) - if err != nil { - return err - } - if !ok { - db.DBImp.Logger.Debug("access denied: org mutation policy", - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef), - zap.String("collection", db.Collection), zap.String("action", string(action))) - return merrors.AccessDenied(db.Collection, string(action), bson.NilObjectID) - } - return nil -} - -// authorizeOrgMutation gates a create-time org mutation: the caller must be a -// member of the organization (authorizeOrgMembership) AND the org policy must -// allow the action (authorizeOrgPolicy). Because no record exists yet to read -// ownerKind from, it cannot go through loadAndEnforce; this mirrors the -// membership-first check ScopedAuthorizer applies to read/update/delete of -// org-owned resources. -func (db *ScopedDBImp[T]) authorizeOrgMutation(ctx context.Context, accountRef, organizationRef bson.ObjectID, action model.Action) error { - isMemberErr := db.authorizeOrgMembership(ctx, accountRef, organizationRef, action) - if isMemberErr != nil { - return isMemberErr - } - if isPolicyErr := db.authorizeOrgPolicy(ctx, accountRef, organizationRef, action); isPolicyErr != nil { - return isPolicyErr - } - return nil -} - -// Get fetches a resource after verifying ActionRead via LoadAndEnforce. -func (db *ScopedDBImp[T]) Get(ctx context.Context, accountRef, objectRef bson.ObjectID, result T) error { - db.DBImp.Logger.Debug("attempting to get scoped object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionRead); err != nil { - return err - } - if err := db.DBImp.Get(ctx, objectRef, result); err != nil { - db.DBImp.Logger.Warn("failed to get scoped object after auth", - zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - return nil -} - -// Update replaces a resource after verifying ActionUpdate. Immutable ownership -// fields (ownerKind, ownerRef, orgRef, permissionRef) are always restored from -// the database record so callers cannot change resource ownership via the payload. -func (db *ScopedDBImp[T]) Update(ctx context.Context, accountRef bson.ObjectID, object T) error { - db.DBImp.Logger.Debug("attempting to update scoped object", - mzap.AccRef(accountRef), mzap.StorableRef(object)) - - existing, err := db.loadAndEnforce(ctx, accountRef, *object.GetID(), model.ActionUpdate) - if err != nil { - return err - } - object.SetOwnerKind(existing.GetOwnerKind()) - object.SetOwnerRef(existing.GetOwnerRef()) - object.SetOrganizationRef(existing.GetOrganizationRef()) - object.SetPermissionRef(existing.GetPermissionRef()) - - if err := db.DBImp.Update(ctx, object); err != nil { - db.DBImp.Logger.Warn("failed to update scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.StorableRef(object)) - return err - } - return nil -} - -// Delete removes a resource after verifying ActionDelete. -func (db *ScopedDBImp[T]) Delete(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - db.DBImp.Logger.Debug("attempting to delete scoped object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionDelete); err != nil { - return err - } - if err := db.DBImp.Delete(ctx, objectRef); err != nil { - db.DBImp.Logger.Warn("failed to delete scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - return nil -} - -// DeleteCascadeAuth removes a resource (with cascade via the registered deleter) -// after verifying ActionDelete. -func (db *ScopedDBImp[T]) DeleteCascadeAuth(ctx context.Context, accountRef, objectRef bson.ObjectID) error { - db.DBImp.Logger.Debug("attempting cascade-delete scoped object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionDelete); err != nil { - return err - } - if err := db.DBImp.DeleteCascade(ctx, objectRef); err != nil { - db.DBImp.Logger.Warn("failed to cascade-delete scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - return nil -} diff --git a/backend/pkg/auth/dbimpscopedpatch.go b/backend/pkg/auth/dbimpscopedpatch.go deleted file mode 100644 index ec9a50c..0000000 --- a/backend/pkg/auth/dbimpscopedpatch.go +++ /dev/null @@ -1,129 +0,0 @@ -package auth - -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" -) - -// Patch applies a partial update after verifying ActionUpdate. -func (db *ScopedDBImp[T]) Patch(ctx context.Context, accountRef, objectRef bson.ObjectID, patch builder.Patch) error { - db.DBImp.Logger.Debug("attempting to patch scoped object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - - if err := denyImmutablePatch(patch); err != nil { - return err - } - if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionUpdate); err != nil { - return err - } - if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil { - db.DBImp.Logger.Warn("failed to patch scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - return nil -} - -// SetArchived patches the isArchived field after verifying ActionUpdate. -// -// organizationRef is accepted for interface compatibility but not used for -// enforcement — ownership is read from the DB via LoadAndEnforce. -// cascade is accepted but not applied; resources that require cascading archived -// status must implement cascade logic in a dedicated wrapper. -func (db *ScopedDBImp[T]) SetArchived(ctx context.Context, accountRef, _ bson.ObjectID, objectRef bson.ObjectID, isArchived, _ bool) error { - db.DBImp.Logger.Debug("attempting to change archive status of scoped object", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), - zap.Bool("archived", isArchived)) - - if _, err := db.loadAndEnforce(ctx, accountRef, objectRef, model.ActionUpdate); err != nil { - return err - } - patch := repository.Patch().Set(repository.IsArchivedField(), isArchived) - if err := db.DBImp.Patch(ctx, objectRef, patch); err != nil { - db.DBImp.Logger.Warn("failed to change archive status of scoped object", - zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - return nil -} - -// ListIDs returns the authorized subset of IDs matching query for the given -// action. Each candidate is checked via LoadAndEnforce; unauthorized IDs are -// silently skipped. Non-access-denied errors abort the call. -func (db *ScopedDBImp[T]) ListIDs(ctx context.Context, action model.Action, accountRef bson.ObjectID, query builder.Query) ([]bson.ObjectID, error) { - db.DBImp.Logger.Debug("attempting to list scoped object IDs", - mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - - rawIDs, err := db.DBImp.ListIDs(ctx, query) - if err != nil { - db.DBImp.Logger.Warn("failed to list raw object IDs", - zap.Error(err), mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - return nil, err - } - - allowed := make([]bson.ObjectID, 0, len(rawIDs)) - for _, id := range rawIDs { - if _, authErr := db.loadAndEnforce(ctx, accountRef, id, action); authErr == nil { - allowed = append(allowed, id) - } else if !errors.Is(authErr, merrors.ErrAccessDenied) { - db.DBImp.Logger.Warn("enforcer error during ListIDs", - zap.Error(authErr), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", id)) - return nil, authErr - } - // ErrAccessDenied: silently skip - } - - db.DBImp.Logger.Debug("completed ListIDs", - mzap.AccRef(accountRef), zap.Int("total", len(rawIDs)), zap.Int("allowed", len(allowed))) - return allowed, nil -} - -// PatchMany applies patch to all resources in query that accountRef is -// authorized to update. Authorization is resolved via ListIDs; the write is a -// single bulk operation using originalQuery AND _id IN (authorizedIDs) so -// records that leave the original query between the read and write are not -// inadvertently mutated. -func (db *ScopedDBImp[T]) PatchMany(ctx context.Context, accountRef bson.ObjectID, query builder.Query, patch builder.Patch) (int, error) { - db.DBImp.Logger.Debug("attempting to patch many scoped objects", - mzap.AccRef(accountRef), zap.String("collection", db.Collection)) - - if err := denyImmutablePatch(patch); err != nil { - return 0, err - } - - ids, err := db.ListIDs(ctx, model.ActionUpdate, accountRef, query) - if err != nil { - return 0, err - } - if len(ids) == 0 { - return 0, nil - } - - values := make([]any, len(ids)) - for i, id := range ids { - values[i] = id - } - idFilter := repository.Query().In(repository.IDField(), values...) - finalQuery := idFilter - if query != nil { - finalQuery = query.And(idFilter) - } - modified, err := db.DBImp.PatchMany(ctx, finalQuery, patch) - if err != nil { - db.DBImp.Logger.Warn("failed to patch many scoped objects", - zap.Error(err), mzap.AccRef(accountRef)) - return 0, err - } - - db.DBImp.Logger.Debug("patched many scoped objects", - mzap.AccRef(accountRef), zap.Int("count", modified)) - return modified, nil -} diff --git a/backend/pkg/auth/enforcer.go b/backend/pkg/auth/enforcer.go deleted file mode 100644 index a3815a8..0000000 --- a/backend/pkg/auth/enforcer.go +++ /dev/null @@ -1,32 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type Enforcer interface { - // Enforce checks if accountRef can do `action` on objectRef in an org (domainRef). - Enforce( - ctx context.Context, - permissionRef, accountRef, orgRef, objectRef bson.ObjectID, - action model.Action, - ) (bool, error) - - // Enforce batch of objects - EnforceBatch( - ctx context.Context, - objectRefs []model.PermissionBoundStorable, - accountRef bson.ObjectID, - action model.Action, - ) (map[bson.ObjectID]bool, error) - - // GetRoles returns the user's roles in a given org domain, plus any partial scopes if relevant. - GetRoles(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, error) - - // GetPermissions returns all effective permissions (with effect, object scoping) for a user in org domain. - // Merges from all roles the user holds, plus any denies/exceptions. - GetPermissions(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, []model.Permission, error) -} diff --git a/backend/pkg/auth/factory.go b/backend/pkg/auth/factory.go deleted file mode 100644 index 7b100c2..0000000 --- a/backend/pkg/auth/factory.go +++ /dev/null @@ -1,58 +0,0 @@ -package auth - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -func CreateAuth( - logger mlogger.Logger, - client *mongo.Client, - db *mongo.Database, - pdb policy.DB, - rdb role.DB, - config *Config, -) (Enforcer, Manager, error) { - lg := logger.Named("auth") - if config == nil || config.Driver == "" { - lg.Warn("Permissions enforcer config is missing, defaulting to native enforcer") - config = &Config{ - Driver: Native, - } - } - lg.Debug("Creating enforcer...", zap.String("driver", string(config.Driver))) - l := lg.Named(string(config.Driver)) - if config.Driver == Casbin { - enforcer, err := casbin.NewEnforcer(l, client, config.Settings) - if err != nil { - lg.Warn("Failed to create enforcer", zap.Error(err)) - return nil, nil, err - } - manager, err := casbin.NewManager(l, pdb, rdb, enforcer, config.Settings) - if err != nil { - lg.Warn("Failed to create managment interface", zap.Error(err)) - return nil, nil, err - } - return enforcer, manager, nil - } - if config.Driver == Native { - enforcer, err := native.NewEnforcer(l, db) - if err != nil { - lg.Warn("Failed to create enforcer", zap.Error(err)) - return nil, nil, err - } - manager, err := native.NewManager(l, pdb, rdb, enforcer) - if err != nil { - lg.Warn("Failed to create managment interface", zap.Error(err)) - return nil, nil, err - } - return enforcer, manager, nil - } - return nil, nil, merrors.InvalidArgument("Unknown enforcer type: "+string(config.Driver), "config.driver") -} diff --git a/backend/pkg/auth/helper.go b/backend/pkg/auth/helper.go deleted file mode 100644 index e82c171..0000000 --- a/backend/pkg/auth/helper.go +++ /dev/null @@ -1,61 +0,0 @@ -package auth - -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/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/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func enforceObject[T model.PermissionBoundStorable](ctx context.Context, db *template.DBImp[T], enforcer Enforcer, action model.Action, accountRef bson.ObjectID, query builder.Query) error { - l, err := db.ListPermissionBound(ctx, query) - if err != nil { - db.Logger.Warn("Error occured while checking access rights", zap.Error(err), - mzap.AccRef(accountRef), zap.String("action", string(action))) - return err - } - if len(l) == 0 { - db.Logger.Debug("Access denied", mzap.AccRef(accountRef), zap.String("action", string(action))) - return merrors.AccessDenied(db.Repository.Collection(), string(action), bson.NilObjectID) - } - for _, item := range l { - db.Logger.Debug("Object found", mzap.ObjRef("object_ref", *item.GetID()), - mzap.OrgRef(item.GetOrganizationRef()), - mzap.PermissionRef(item.GetPermissionRef()), - zap.String("collection", item.Collection())) - } - res, err := enforcer.EnforceBatch(ctx, l, accountRef, action) - if err != nil { - db.Logger.Warn("Failed to enforce permission", zap.Error(err), - mzap.AccRef(accountRef), zap.String("action", string(action))) - } - for objectRef, hasPermission := range res { - if !hasPermission { - db.Logger.Info("Permission denied for object during reordering", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionUpdate))) - return merrors.AccessDenied(db.Repository.Collection(), string(action), objectRef) - } - } - return nil -} - -func enforceObjectByRef[T model.PermissionBoundStorable](ctx context.Context, db *template.DBImp[T], enforcer Enforcer, action model.Action, accountRef, objectRef bson.ObjectID) error { - err := enforceObject(ctx, db, enforcer, action, accountRef, repository.IDFilter(objectRef)) - if err != nil { - if errors.Is(err, merrors.ErrAccessDenied) { - db.Logger.Debug("Access denied", mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return merrors.AccessDenied(db.Repository.Collection(), string(action), objectRef) - } else { - db.Logger.Warn("Error occurred while checking permissions", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - } - } - return err -} diff --git a/backend/pkg/auth/indexable.go b/backend/pkg/auth/indexable.go deleted file mode 100644 index dd205f2..0000000 --- a/backend/pkg/auth/indexable.go +++ /dev/null @@ -1,29 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// IndexableDB implements reordering with permission checking -type IndexableDB[T storable.Storable] interface { - // Reorder implements reordering with permission checking using EnforceBatch - Reorder(ctx context.Context, accountRef, objectRef bson.ObjectID, newIndex int, filter builder.Query) error -} - -// NewIndexableDB creates a new auth.IndexableDB instance -func NewIndexableDB[T storable.Storable]( - repo repository.Repository, - logger mlogger.Logger, - enforcer Enforcer, - createEmpty func() T, - getIndexable func(T) *model.Indexable, -) IndexableDB[T] { - return newIndexableDBImp(repo, logger, enforcer, createEmpty, getIndexable) -} diff --git a/backend/pkg/auth/indexableimp.go b/backend/pkg/auth/indexableimp.go deleted file mode 100644 index cb2b4e6..0000000 --- a/backend/pkg/auth/indexableimp.go +++ /dev/null @@ -1,182 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/db/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/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// IndexableDB implements reordering with permission checking -type indexableDBImp[T storable.Storable] struct { - repo repository.Repository - logger mlogger.Logger - enforcer Enforcer - createEmpty func() T - getIndexable func(T) *model.Indexable -} - -// NewIndexableDB creates a new auth.IndexableDB instance -func newIndexableDBImp[T storable.Storable]( - repo repository.Repository, - logger mlogger.Logger, - enforcer Enforcer, - createEmpty func() T, - getIndexable func(T) *model.Indexable, -) IndexableDB[T] { - return &indexableDBImp[T]{ - repo: repo, - logger: logger.Named("indexable"), - enforcer: enforcer, - createEmpty: createEmpty, - getIndexable: getIndexable, - } -} - -// Reorder implements reordering with permission checking using EnforceBatch -func (db *indexableDBImp[T]) Reorder(ctx context.Context, accountRef, objectRef bson.ObjectID, newIndex int, filter builder.Query) error { - // Get current object to find its index - obj := db.createEmpty() - if err := db.repo.Get(ctx, objectRef, obj); err != nil { - db.logger.Warn("Failed to get object for reordering", zap.Error(err), zap.Int("new_index", newIndex), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - - // Extract index from the object - indexable := db.getIndexable(obj) - currentIndex := indexable.Index - if currentIndex == newIndex { - db.logger.Debug("No reordering needed - same index", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) - return nil // No change needed - } - - // Determine which objects will be affected by the reordering - var affectedObjects []model.PermissionBoundStorable - - if currentIndex < newIndex { - // Moving down: items between currentIndex+1 and newIndex will be shifted up by -1 - reorderFilter := filter. - And(repository.IndexOpFilter(currentIndex+1, builder.Gte)). - And(repository.IndexOpFilter(newIndex, builder.Lte)) - - // Get all affected objects using ListPermissionBound - objects, err := db.repo.ListPermissionBound(ctx, reorderFilter) - if err != nil { - db.logger.Warn("Failed to get affected objects for reordering (moving down)", - zap.Error(err), mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), - zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) - return err - } - affectedObjects = append(affectedObjects, objects...) - db.logger.Debug("Found affected objects for moving down", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(objects))) - } else { - // Moving up: items between newIndex and currentIndex-1 will be shifted down by +1 - reorderFilter := filter. - And(repository.IndexOpFilter(newIndex, builder.Gte)). - And(repository.IndexOpFilter(currentIndex-1, builder.Lte)) - - // Get all affected objects using ListPermissionBound - objects, err := db.repo.ListPermissionBound(ctx, reorderFilter) - if err != nil { - db.logger.Warn("Failed to get affected objects for reordering (moving up)", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), - zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) - return err - } - affectedObjects = append(affectedObjects, objects...) - db.logger.Debug("Found affected objects for moving up", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(objects))) - } - - // Add the target object to the list of objects that need permission checking - targetObjects, err := db.repo.ListPermissionBound(ctx, repository.IDFilter(objectRef)) - if err != nil { - db.logger.Warn("Failed to get target object for permission checking", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef)) - return err - } - if len(targetObjects) > 0 { - affectedObjects = append(affectedObjects, targetObjects[0]) - } - - // Check permissions for all affected objects using EnforceBatch - db.logger.Debug("Checking permissions for reordering", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(affectedObjects)), - zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) - - permissions, err := db.enforcer.EnforceBatch(ctx, affectedObjects, accountRef, model.ActionUpdate) - if err != nil { - db.logger.Warn("Failed to check permissions for reordering", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("affected_count", len(affectedObjects))) - return merrors.Internal("failed to check permissions for reordering") - } - - // Verify all objects have update permission - for resObjectRef, hasPermission := range permissions { - if !hasPermission { - db.logger.Info("Permission denied for object during reordering", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(model.ActionUpdate))) - return merrors.AccessDenied(db.repo.Collection(), string(model.ActionUpdate), resObjectRef) - } - } - - db.logger.Debug("All permissions granted, proceeding with reordering", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("permission_count", len(permissions))) - - // All permissions checked, proceed with reordering - if currentIndex < newIndex { - // Moving down: shift items between currentIndex+1 and newIndex up by -1 - patch := repository.Patch().Inc(repository.IndexField(), -1) - reorderFilter := filter. - And(repository.IndexOpFilter(currentIndex+1, builder.Gte)). - And(repository.IndexOpFilter(newIndex, builder.Lte)) - - updatedCount, err := db.repo.PatchMany(ctx, reorderFilter, patch) - if err != nil { - db.logger.Warn("Failed to shift objects during reordering (moving down)", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), - zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex), zap.Int("updated_count", updatedCount)) - return err - } - db.logger.Debug("Successfully shifted objects (moving down)", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("updated_count", updatedCount)) - } else { - // Moving up: shift items between newIndex and currentIndex-1 down by +1 - patch := repository.Patch().Inc(repository.IndexField(), 1) - reorderFilter := filter. - And(repository.IndexOpFilter(newIndex, builder.Gte)). - And(repository.IndexOpFilter(currentIndex-1, builder.Lte)) - - updatedCount, err := db.repo.PatchMany(ctx, reorderFilter, patch) - if err != nil { - db.logger.Warn("Failed to shift objects during reordering (moving up)", zap.Error(err), - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), - zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex), zap.Int("updated_count", updatedCount)) - return err - } - db.logger.Debug("Successfully shifted objects (moving up)", mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("updated_count", updatedCount)) - } - - // Update the target object to new index - if err := db.repo.Patch(ctx, objectRef, repository.Patch().Set(repository.IndexField(), newIndex)); err != nil { - db.logger.Warn("Failed to update target object index", zap.Error(err), mzap.AccRef(accountRef), - mzap.ObjRef("object_ref", objectRef), zap.Int("current_index", currentIndex), zap.Int("new_index", newIndex)) - return err - } - - db.logger.Debug("Successfully reordered object with permission checking", - mzap.AccRef(accountRef), mzap.ObjRef("object_ref", objectRef), zap.Int("old_index", currentIndex), - zap.Int("new_index", newIndex), zap.Int("affected_count", len(affectedObjects))) - return nil -} diff --git a/backend/pkg/auth/internal/casbin/config/config.go b/backend/pkg/auth/internal/casbin/config/config.go deleted file mode 100644 index 924ced4..0000000 --- a/backend/pkg/auth/internal/casbin/config/config.go +++ /dev/null @@ -1,127 +0,0 @@ -package casbin - -import ( - "os" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/mongoadapter" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "go.uber.org/zap" -) - -type AdapterConfig struct { - DatabaseName *string `mapstructure:"database_name"` - DatabaseNameEnv *string `mapstructure:"database_name_env"` - CollectionName *string `mapstructure:"collection_name"` - CollectionNameEnv *string `mapstructure:"collection_name_env"` - TimeoutSeconds *int `mapstructure:"timeout_seconds"` - TimeoutSecondsEnv *string `mapstructure:"timeout_seconds_env"` - IsFiltered *bool `mapstructure:"is_filtered"` - IsFilteredEnv *string `mapstructure:"is_filtered_env"` -} - -type Config struct { - ModelPath *string `mapstructure:"model_path"` - ModelPathEnv *string `mapstructure:"model_path_env"` - Adapter *AdapterConfig `mapstructure:"adapter"` -} - -type EnforcerConfig struct { - ModelPath string - Adapter *mongoadapter.Config -} - -func getEnvValue(logger mlogger.Logger, varName, envVarName string, value, envValue *string) string { - if value != nil && envValue != nil { - logger.Warn("Both variable and environment variable are set, using environment variable value", - zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.String("value", *value), zap.String("env_value", os.Getenv(*envValue))) - } - - if envValue != nil { - return os.Getenv(*envValue) - } - - if value != nil { - return *value - } - - return "" -} - -func getEnvIntValue(logger mlogger.Logger, varName, envVarName string, value *int, envValue *string) int { - if value != nil && envValue != nil { - logger.Warn("Both variable and environment variable are set, using environment variable value", - zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.Int("value", *value), zap.String("env_value", os.Getenv(*envValue))) - } - - if envValue != nil { - envStr := os.Getenv(*envValue) - if envStr != "" { - if parsed, err := time.ParseDuration(envStr + "s"); err == nil { - return int(parsed.Seconds()) - } - logger.Warn("Invalid environment variable value for timeout", zap.String("environment_variable", envVarName), zap.String("value", envStr)) - } - } - - if value != nil { - return *value - } - - return 30 // Default timeout in seconds -} - -func getEnvBoolValue(logger mlogger.Logger, varName, envVarName string, value *bool, envValue *string) bool { - if value != nil && envValue != nil { - logger.Warn("Both variable and environment variable are set, using environment variable value", - zap.String("variable", varName), zap.String("environment_variable", envVarName), zap.Bool("value", *value), zap.String("env_value", os.Getenv(*envValue))) - } - - if envValue != nil { - envStr := os.Getenv(*envValue) - switch envStr { - case "true", "1": - return true - case "false", "0": - return false - } - logger.Warn("Invalid environment variable value for boolean", zap.String("environment_variable", envVarName), zap.String("value", envStr)) - } - - if value != nil { - return *value - } - - return false // Default for boolean -} - -func PrepareConfig(logger mlogger.Logger, config *Config) (*EnforcerConfig, error) { - if config == nil { - return nil, merrors.Internal("No configuration provided") - } - - adapter := &mongoadapter.Config{ - DatabaseName: getEnvValue(logger, "database_name", "database_name_env", config.Adapter.DatabaseName, config.Adapter.DatabaseNameEnv), - CollectionName: getEnvValue(logger, "collection_name", "collection_name_env", config.Adapter.CollectionName, config.Adapter.CollectionNameEnv), - Timeout: time.Duration(getEnvIntValue(logger, "timeout_seconds", "timeout_seconds_env", config.Adapter.TimeoutSeconds, config.Adapter.TimeoutSecondsEnv)) * time.Second, - IsFiltered: getEnvBoolValue(logger, "is_filtered", "is_filtered_env", config.Adapter.IsFiltered, config.Adapter.IsFilteredEnv), - } - - if len(adapter.DatabaseName) == 0 { - logger.Error("Database name is not set") - return nil, merrors.InvalidArgument("database name must be provided", "adapter.databaseName") - } - - path := getEnvValue(logger, "model_path", "model_path_env", config.ModelPath, config.ModelPathEnv) - - logger.Info("Configuration prepared", - zap.String("model_path", path), - zap.String("database_name", adapter.DatabaseName), - zap.String("collection_name", adapter.CollectionName), - zap.Duration("timeout", adapter.Timeout), - zap.Bool("is_filtered", adapter.IsFiltered), - ) - - return &EnforcerConfig{ModelPath: path, Adapter: adapter}, nil -} diff --git a/backend/pkg/auth/internal/casbin/enforcer.go b/backend/pkg/auth/internal/casbin/enforcer.go deleted file mode 100644 index 5b654d3..0000000 --- a/backend/pkg/auth/internal/casbin/enforcer.go +++ /dev/null @@ -1,206 +0,0 @@ -// casbin_enforcer.go -package casbin - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject" - cc "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/config" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization" - "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/mutil/mzap" - "github.com/casbin/casbin/v3" - "github.com/go-viper/mapstructure/v2" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -// CasbinEnforcer implements the Enforcer interface using Casbin. -type CasbinEnforcer struct { - logger mlogger.Logger - enforcer *casbin.Enforcer - roleSerializer serialization.Role - permissionSerializer serialization.Policy -} - -// NewCasbinEnforcer initializes a new CasbinEnforcer with a MongoDB adapter, logger, and PolicySerializer. -// The 'domain' parameter is no longer stored internally, as the interface requires passing a domainRef per method call. -func NewEnforcer( - logger mlogger.Logger, - client *mongo.Client, - settings model.SettingsT, -) (*CasbinEnforcer, error) { - var config cc.Config - if err := mapstructure.Decode(settings, &config); err != nil { - logger.Warn("Failed to decode Casbin configuration", zap.Error(err), zap.Any("settings", settings)) - return nil, merrors.Internal("failed to decode Casbin configuration") - } - - // Create a Casbin adapter + enforcer from your config and client. - l := logger.Named("enforcer") - e, err := createAdapter(l, &config, client) - if err != nil { - logger.Warn("Failed to create Casbin enforcer", zap.Error(err)) - return nil, merrors.Internal("failed to create Casbin enforcer") - } - - logger.Info("Casbin enforcer created") - return &CasbinEnforcer{ - logger: l, - enforcer: e, - permissionSerializer: serialization.NewPolicySerializer(), - roleSerializer: serialization.NewRoleSerializer(), - }, nil -} - -// Enforce checks if a user has the specified action permission on an object within a domain. -func (c *CasbinEnforcer) Enforce( - _ context.Context, - permissionRef, accountRef, organizationRef, objectRef bson.ObjectID, - action model.Action, -) (bool, error) { - // Convert ObjectIDs to strings for Casbin - account := accountRef.Hex() - organization := organizationRef.Hex() - permission := permissionRef.Hex() - object := anyobject.ID - if objectRef != bson.NilObjectID { - object = objectRef.Hex() - } - act := string(action) - - c.logger.Debug("Enforcing policy", - zap.String("account", account), zap.String("organization", organization), - zap.String("permission", permission), zap.String("object", object), - zap.String("action", act)) - - // Perform the enforcement - result, err := c.enforcer.Enforce(account, organization, permission, object, act) - if err != nil { - c.logger.Warn("Failed to enforce policy", zap.Error(err), - zap.String("account", account), zap.String("organization", organization), - zap.String("permission", permission), zap.String("object", object), - zap.String("action", act)) - return false, err - } - - c.logger.Debug("Policy enforcement result", zap.Bool("result", result)) - return result, nil -} - -// EnforceBatch checks a user’s permission for multiple objects at once. -// It returns a map from objectRef -> boolean indicating whether access is granted. -func (c *CasbinEnforcer) EnforceBatch( - ctx context.Context, - objectRefs []model.PermissionBoundStorable, - accountRef bson.ObjectID, - action model.Action, -) (map[bson.ObjectID]bool, error) { - results := make(map[bson.ObjectID]bool, len(objectRefs)) - for _, desc := range objectRefs { - ok, err := c.Enforce(ctx, desc.GetPermissionRef(), accountRef, desc.GetOrganizationRef(), *desc.GetID(), action) - if err != nil { - c.logger.Warn("Failed to enforce", zap.Error(err), mzap.PermissionRef(desc.GetPermissionRef()), - mzap.AccRef(accountRef), mzap.OrgRef(desc.GetOrganizationRef()), - mzap.StorableRef(desc), zap.String("action", string(action))) - return nil, err - } - results[*desc.GetID()] = ok - } - - return results, nil -} - -// GetRoles retrieves all roles assigned to the user within the domain. -func (c *CasbinEnforcer) GetRoles(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, error) { - sub := accountRef.Hex() - dom := orgRef.Hex() - - c.logger.Debug("Fetching roles for user", zap.String("subject", sub), zap.String("domain", dom)) - - // Get all roles for the user in the domain - sroles, err := c.enforcer.GetFilteredGroupingPolicy(0, sub, "", dom) - if err != nil { - c.logger.Warn("Failed to get roles from policies", zap.Error(err), - zap.String("account_ref", sub), zap.String("organization_ref", dom), - ) - return nil, merrors.Internal("failed to fetch roles from policies") - } - - roles := make([]model.Role, 0, len(sroles)) - for _, srole := range sroles { - role, err := c.roleSerializer.Deserialize(srole) - if err != nil { - c.logger.Warn("Failed to deserialize role", zap.Error(err)) - return nil, err - } - roles = append(roles, *role) - } - - c.logger.Debug("Roles fetched successfully", zap.Int("count", len(roles))) - return roles, nil -} - -// GetPermissions retrieves all effective policies for the user within the domain. -func (c *CasbinEnforcer) GetPermissions(ctx context.Context, accountRef, orgRef bson.ObjectID) ([]model.Role, []model.Permission, error) { - c.logger.Debug("Fetching policies for user", mzap.AccRef(accountRef), mzap.OrgRef(orgRef)) - - // Step 1: Retrieve all roles assigned to the user within the domain - roles, err := c.GetRoles(ctx, accountRef, orgRef) - if err != nil { - c.logger.Warn("Failed to get roles", zap.Error(err)) - return nil, nil, err - } - - // Map to hold unique policies - permissionsMap := make(map[string]*model.Permission) - for _, role := range roles { - // Step 2a: Retrieve all policies associated with the role within the domain - policies, err := c.enforcer.GetFilteredPolicy(0, role.DescriptionRef.Hex()) - if err != nil { - c.logger.Warn("Failed to get policies for role", zap.Error(err), mzap.ObjRef("role_ref", role.DescriptionRef)) - continue - } - - // Step 2b: Process each policy to extract Permission, Action, and Effect - for _, policy := range policies { - - if len(policy) < 5 { - c.logger.Warn("Incomplete policy encountered", zap.Strings("policy", policy)) - continue // Ensure the policy line has enough fields - } - - // Deserialize the policy using - deserializedPolicy, err := c.permissionSerializer.Deserialize(policy) - if err != nil { - c.logger.Warn("Failed to deserialize policy", zap.Error(err), zap.Strings("policy", policy)) - continue - } - - // Construct a unique key combining Permission ID and Action to prevent duplicates - policyKey := deserializedPolicy.DescriptionRef.Hex() + ":" + string(deserializedPolicy.Effect.Action) - if _, exists := permissionsMap[policyKey]; exists { - continue // Policy-action pair already accounted for - } - - // Add the Policy to the map - permissionsMap[policyKey] = &model.Permission{ - RolePolicy: *deserializedPolicy, - AccountRef: accountRef, - } - c.logger.Debug("Policy added to policyMap", zap.Any("policy_key", policyKey)) - } - } - - // Convert the map to a slice - permissions := make([]model.Permission, 0, len(permissionsMap)) - for _, permission := range permissionsMap { - permissions = append(permissions, *permission) - } - - c.logger.Debug("Permissions fetched successfully", zap.Int("count", len(permissions))) - return roles, permissions, nil -} diff --git a/backend/pkg/auth/internal/casbin/factory.go b/backend/pkg/auth/internal/casbin/factory.go deleted file mode 100644 index 4377e22..0000000 --- a/backend/pkg/auth/internal/casbin/factory.go +++ /dev/null @@ -1,34 +0,0 @@ -package casbin - -import ( - cc "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/config" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/mongoadapter" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "github.com/casbin/casbin/v3" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -func createAdapter(logger mlogger.Logger, config *cc.Config, client *mongo.Client) (*casbin.Enforcer, error) { - dbc, err := cc.PrepareConfig(logger, config) - if err != nil { - logger.Warn("Failed to prepare database configuration", zap.Error(err)) - return nil, err - } - - adapter, err := mongoadapter.New(client, dbc.Adapter) - if err != nil { - logger.Warn("Failed to create DB adapter", zap.Error(err)) - return nil, err - } - - e, err := casbin.NewEnforcer(dbc.ModelPath, adapter, NewCasbinLogger(logger)) - if err != nil { - logger.Warn("Failed to create permissions enforcer", zap.Error(err)) - return nil, err - } - e.EnableAutoSave(true) - - // No need to manually load policy. Casbin does it for us - return e, nil -} diff --git a/backend/pkg/auth/internal/casbin/logger.go b/backend/pkg/auth/internal/casbin/logger.go deleted file mode 100644 index 70df728..0000000 --- a/backend/pkg/auth/internal/casbin/logger.go +++ /dev/null @@ -1,61 +0,0 @@ -package casbin - -import ( - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "go.uber.org/zap" -) - -// CasbinZapLogger wraps a zap.Logger to implement Casbin's Logger interface. -type CasbinZapLogger struct { - logger mlogger.Logger -} - -// NewCasbinLogger constructs a new CasbinZapLogger. -func NewCasbinLogger(logger mlogger.Logger) *CasbinZapLogger { - return &CasbinZapLogger{ - logger: logger.Named("driver"), - } -} - -// EnableLog enables or disables logging. -func (l *CasbinZapLogger) EnableLog(_ bool) { - // ignore -} - -// IsEnabled returns whether logging is currently enabled. -func (l *CasbinZapLogger) IsEnabled() bool { - return true -} - -// LogModel is called by Casbin when loading model settings (you can customize if you want). -func (l *CasbinZapLogger) LogModel(m [][]string) { - l.logger.Info("Model loaded", zap.Any("model", m)) -} - -func (l *CasbinZapLogger) LogPolicy(m map[string][][]string) { - l.logger.Info("Policy loaded", zap.Int("entries", len(m))) -} - -func (l *CasbinZapLogger) LogError(err error, msg ...string) { - // If no custom message was passed, log a generic one - if len(msg) == 0 { - l.logger.Warn("Error occurred", zap.Error(err)) - return - } - - // Otherwise, join any provided messages and include them - l.logger.Warn(strings.Join(msg, " "), zap.Error(err)) -} - -// LogEnforce is called by Casbin to log each Enforce() call if logging is enabled. -func (l *CasbinZapLogger) LogEnforce(matcher string, request []any, result bool, explains [][]string) { - l.logger.Debug("Enforcing policy...", zap.String("matcher", matcher), zap.Any("request", request), - zap.Bool("result", result), zap.Any("explains", explains)) -} - -// LogRole is called by Casbin when role manager adds or deletes a role. -func (l *CasbinZapLogger) LogRole(roles []string) { - l.logger.Debug("Changing roles...", zap.Strings("roles", roles)) -} diff --git a/backend/pkg/auth/internal/casbin/manager.go b/backend/pkg/auth/internal/casbin/manager.go deleted file mode 100644 index d15926c..0000000 --- a/backend/pkg/auth/internal/casbin/manager.go +++ /dev/null @@ -1,61 +0,0 @@ -// package casbin - -package casbin - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.uber.org/zap" -) - -// CasbinManager implements the auth.Manager interface by aggregating Role and Permission managers. -type CasbinManager struct { - logger mlogger.Logger - roleManager management.Role - permManager management.Permission -} - -// NewManager creates a new CasbinManager with specified domains and role-domain mappings. -func NewManager( - l mlogger.Logger, - pdb policy.DB, - rdb role.DB, - enforcer *CasbinEnforcer, - settings model.SettingsT, -) (*CasbinManager, error) { - logger := l.Named("manager") - - var pdesc model.PolicyDescription - if err := pdb.GetBuiltInPolicy(context.Background(), "roles", &pdesc); err != nil { - logger.Warn("Failed to fetch roles permission reference", zap.Error(err)) - return nil, err - } - - return &CasbinManager{ - logger: logger, - roleManager: NewRoleManager(logger, enforcer, pdesc.ID, rdb), - permManager: NewPermissionManager(logger, enforcer), - }, nil -} - -// Permission returns the Permission manager. -func (m *CasbinManager) Permission() management.Permission { - return m.permManager -} - -// Role returns the Role manager. -func (m *CasbinManager) Role() management.Role { - return m.roleManager -} - -// TransactionalGrants reports that casbin grants live in the enforcer's memory -// until Save rewrites the whole policy store: they neither respect a Mongo -// transaction context nor survive concurrent Save calls from other instances. -func (m *CasbinManager) TransactionalGrants() bool { - return false -} diff --git a/backend/pkg/auth/internal/casbin/mongoadapter/adapter.go b/backend/pkg/auth/internal/casbin/mongoadapter/adapter.go deleted file mode 100644 index 4427fa4..0000000 --- a/backend/pkg/auth/internal/casbin/mongoadapter/adapter.go +++ /dev/null @@ -1,357 +0,0 @@ -package mongoadapter - -import ( - "context" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "time" - - "github.com/casbin/casbin/v3/model" - "github.com/casbin/casbin/v3/persist" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -const ( - defaultDatabaseName = "casbin" - defaultCollectionName = "casbin_rule" - defaultTimeout = 30 * time.Second -) - -var ( - _ persist.BatchAdapter = (*Adapter)(nil) - _ persist.FilteredAdapter = (*Adapter)(nil) - _ persist.UpdatableAdapter = (*Adapter)(nil) -) - -type Config struct { - DatabaseName string - CollectionName string - Timeout time.Duration - IsFiltered bool -} - -type Rule struct { - PType string - V0 string - V1 string - V2 string - V3 string - V4 string - V5 string -} - -type Adapter struct { - collection *mongo.Collection - timeout time.Duration - filtered bool -} - -func New(client *mongo.Client, config *Config) (*Adapter, error) { - if config == nil { - config = &Config{} - } - - databaseName := config.DatabaseName - if databaseName == "" { - databaseName = defaultDatabaseName - } - collectionName := config.CollectionName - if collectionName == "" { - collectionName = defaultCollectionName - } - timeout := config.Timeout - if timeout == 0 { - timeout = defaultTimeout - } - - adapter := &Adapter{ - collection: client.Database(databaseName).Collection(collectionName), - timeout: timeout, - filtered: config.IsFiltered, - } - if err := adapter.prepareIndexes(); err != nil { - return nil, err - } - return adapter, nil -} - -func (a *Adapter) prepareIndexes() error { - keys := bson.D{ - {Key: "ptype", Value: 1}, - {Key: "v0", Value: 1}, - {Key: "v1", Value: 1}, - {Key: "v2", Value: 1}, - {Key: "v3", Value: 1}, - {Key: "v4", Value: 1}, - {Key: "v5", Value: 1}, - } - - _, err := a.collection.Indexes().CreateOne( - context.Background(), - mongo.IndexModel{ - Keys: keys, - Options: options.Index().SetUnique(true), - }, - ) - return err -} - -func (a *Adapter) withTimeout() (context.Context, context.CancelFunc) { - return context.WithTimeout(context.Background(), a.timeout) -} - -func loadPolicyLine(line Rule, m model.Model) error { - policy := line.policyWithType() - for len(policy) > 0 && policy[len(policy)-1] == "" { - policy = policy[:len(policy)-1] - } - if len(policy) == 0 { - return nil - } - return persist.LoadPolicyArray(policy, m) -} - -func (r Rule) policyWithType() []string { - return []string{r.PType, r.V0, r.V1, r.V2, r.V3, r.V4, r.V5} -} - -func (r Rule) policyValues() []string { - values := []string{r.V0, r.V1, r.V2, r.V3, r.V4, r.V5} - for len(values) > 0 && values[len(values)-1] == "" { - values = values[:len(values)-1] - } - return values -} - -func ruleFromPolicy(ptype string, policy []string) Rule { - rule := Rule{PType: ptype} - if len(policy) > 0 { - rule.V0 = policy[0] - } - if len(policy) > 1 { - rule.V1 = policy[1] - } - if len(policy) > 2 { - rule.V2 = policy[2] - } - if len(policy) > 3 { - rule.V3 = policy[3] - } - if len(policy) > 4 { - rule.V4 = policy[4] - } - if len(policy) > 5 { - rule.V5 = policy[5] - } - return rule -} - -func rulesFromPolicies(ptype string, policies [][]string) []Rule { - rules := make([]Rule, 0, len(policies)) - for _, policy := range policies { - rules = append(rules, ruleFromPolicy(ptype, policy)) - } - return rules -} - -func rulesAsDocuments(rules []Rule) []any { - docs := make([]any, 0, len(rules)) - for _, rule := range rules { - docs = append(docs, rule) - } - return docs -} - -func selectorForPolicy(ptype string, fieldIndex int, fieldValues ...string) bson.M { - selector := bson.M{"ptype": ptype} - fields := []string{"v0", "v1", "v2", "v3", "v4", "v5"} - for i, fieldValue := range fieldValues { - if fieldValue == "" { - continue - } - field := fieldIndex + i - if field < 0 || field >= len(fields) { - continue - } - selector[fields[field]] = fieldValue - } - return selector -} - -func (a *Adapter) LoadPolicy(m model.Model) error { - return a.LoadFilteredPolicy(m, nil) -} - -func (a *Adapter) LoadFilteredPolicy(m model.Model, filter any) error { - if filter == nil { - a.filtered = false - filter = bson.D{} - } else { - a.filtered = true - } - - ctx, cancel := a.withTimeout() - defer cancel() - - cursor, err := a.collection.Find(ctx, filter) - if err != nil { - return err - } - defer func() { - _ = cursor.Close(ctx) - }() - - for cursor.Next(ctx) { - var line Rule - if err := cursor.Decode(&line); err != nil { - return err - } - if err := loadPolicyLine(line, m); err != nil { - return err - } - } - return cursor.Err() -} - -func (a *Adapter) IsFiltered() bool { - return a.filtered -} - -func (a *Adapter) SavePolicy(m model.Model) error { - if a.filtered { - return merrors.Define("cannot save a filtered policy") - } - - ctx, cancel := a.withTimeout() - defer cancel() - - if _, err := a.collection.DeleteMany(ctx, bson.D{}); err != nil { - return err - } - - var docs []any - for ptype, ast := range m["p"] { - docs = append(docs, rulesAsDocuments(rulesFromPolicies(ptype, ast.Policy))...) - } - for ptype, ast := range m["g"] { - docs = append(docs, rulesAsDocuments(rulesFromPolicies(ptype, ast.Policy))...) - } - if len(docs) == 0 { - return nil - } - - _, err := a.collection.InsertMany(ctx, docs) - return err -} - -func (a *Adapter) AddPolicy(_ string, ptype string, policy []string) error { - ctx, cancel := a.withTimeout() - defer cancel() - - _, err := a.collection.InsertOne(ctx, ruleFromPolicy(ptype, policy)) - return err -} - -func (a *Adapter) AddPolicies(_ string, ptype string, policies [][]string) error { - if len(policies) == 0 { - return nil - } - - ctx, cancel := a.withTimeout() - defer cancel() - - _, err := a.collection.InsertMany(ctx, rulesAsDocuments(rulesFromPolicies(ptype, policies))) - return err -} - -func (a *Adapter) RemovePolicy(_ string, ptype string, policy []string) error { - ctx, cancel := a.withTimeout() - defer cancel() - - _, err := a.collection.DeleteOne(ctx, ruleFromPolicy(ptype, policy)) - return err -} - -func (a *Adapter) RemovePolicies(_ string, ptype string, policies [][]string) error { - ctx, cancel := a.withTimeout() - defer cancel() - - for _, rule := range rulesFromPolicies(ptype, policies) { - if _, err := a.collection.DeleteOne(ctx, rule); err != nil { - return err - } - } - return nil -} - -func (a *Adapter) RemoveFilteredPolicy(_ string, ptype string, fieldIndex int, fieldValues ...string) error { - ctx, cancel := a.withTimeout() - defer cancel() - - _, err := a.collection.DeleteMany(ctx, selectorForPolicy(ptype, fieldIndex, fieldValues...)) - return err -} - -func (a *Adapter) UpdatePolicy(_ string, ptype string, oldPolicy, newPolicy []string) error { - ctx, cancel := a.withTimeout() - defer cancel() - - _, err := a.collection.ReplaceOne(ctx, ruleFromPolicy(ptype, oldPolicy), ruleFromPolicy(ptype, newPolicy)) - return err -} - -func (a *Adapter) UpdatePolicies(_ string, ptype string, oldPolicies, newPolicies [][]string) error { - if len(oldPolicies) != len(newPolicies) { - return merrors.Define("old and new policy counts differ") - } - - ctx, cancel := a.withTimeout() - defer cancel() - - for i := range oldPolicies { - if _, err := a.collection.ReplaceOne(ctx, ruleFromPolicy(ptype, oldPolicies[i]), ruleFromPolicy(ptype, newPolicies[i])); err != nil { - return err - } - } - return nil -} - -func (a *Adapter) UpdateFilteredPolicies(_ string, ptype string, newPolicies [][]string, fieldIndex int, fieldValues ...string) ([][]string, error) { - ctx, cancel := a.withTimeout() - defer cancel() - - selector := selectorForPolicy(ptype, fieldIndex, fieldValues...) - cursor, err := a.collection.Find(ctx, selector) - if err != nil { - return nil, err - } - - var oldPolicies [][]string - for cursor.Next(ctx) { - var line Rule - if err := cursor.Decode(&line); err != nil { - _ = cursor.Close(ctx) - return nil, err - } - oldPolicies = append(oldPolicies, line.policyValues()) - } - if err := cursor.Close(ctx); err != nil { - return nil, err - } - if err := cursor.Err(); err != nil { - return nil, err - } - - if _, err := a.collection.DeleteMany(ctx, selector); err != nil { - return nil, err - } - if len(newPolicies) == 0 { - return oldPolicies, nil - } - - if _, err := a.collection.InsertMany(ctx, rulesAsDocuments(rulesFromPolicies(ptype, newPolicies))); err != nil { - return nil, err - } - return oldPolicies, nil -} diff --git a/backend/pkg/auth/internal/casbin/mongoadapter/adapter_test.go b/backend/pkg/auth/internal/casbin/mongoadapter/adapter_test.go deleted file mode 100644 index 36ea81d..0000000 --- a/backend/pkg/auth/internal/casbin/mongoadapter/adapter_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package mongoadapter - -import ( - "reflect" - "testing" - - "github.com/casbin/casbin/v3/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestRuleFromPolicyTrimsOnlyTrailingEmptyValues(t *testing.T) { - rule := ruleFromPolicy("p", []string{"role", "org", "", "object", ""}) - - if got, want := rule.PType, "p"; got != want { - t.Fatalf("PType = %q, want %q", got, want) - } - if got, want := rule.policyValues(), []string{"role", "org", "", "object"}; !reflect.DeepEqual(got, want) { - t.Fatalf("policyValues() = %#v, want %#v", got, want) - } -} - -func TestSelectorForPolicySkipsEmptyAndOutOfRangeFields(t *testing.T) { - got := selectorForPolicy("p", 1, "role", "", "object", "read", "ignored", "ignored") - want := bson.M{ - "ptype": "p", - "v1": "role", - "v3": "object", - "v4": "read", - "v5": "ignored", - } - - if !reflect.DeepEqual(got, want) { - t.Fatalf("selectorForPolicy() = %#v, want %#v", got, want) - } -} - -func TestLoadPolicyLineAddsPolicyToModel(t *testing.T) { - m, err := model.NewModelFromString(` -[request_definition] -r = sub, obj, act - -[policy_definition] -p = sub, obj, act - -[policy_effect] -e = some(where (p.eft == allow)) - -[matchers] -m = r.sub == p.sub && r.obj == p.obj && r.act == p.act -`) - if err != nil { - t.Fatalf("NewModelFromString() error = %v", err) - } - - err = loadPolicyLine(Rule{PType: "p", V0: "alice", V1: "data1", V2: "read"}, m) - if err != nil { - t.Fatalf("loadPolicyLine() error = %v", err) - } - - got, err := m.GetPolicy("p", "p") - if err != nil { - t.Fatalf("GetPolicy() error = %v", err) - } - want := [][]string{{"alice", "data1", "read"}} - if !reflect.DeepEqual(got, want) { - t.Fatalf("GetPolicy() = %#v, want %#v", got, want) - } -} diff --git a/backend/pkg/auth/internal/casbin/permissions.go b/backend/pkg/auth/internal/casbin/permissions.go deleted file mode 100644 index a36f0a8..0000000 --- a/backend/pkg/auth/internal/casbin/permissions.go +++ /dev/null @@ -1,167 +0,0 @@ -package casbin - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization" - "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/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// CasbinPermissionManager manages permissions using Casbin. -type CasbinPermissionManager struct { - logger mlogger.Logger // Logger for logging operations - enforcer *CasbinEnforcer // Casbin enforcer for managing policies - serializer serialization.Policy // Serializer for converting policies to/from Casbin -} - -// GrantToRole adds a permission to a role in Casbin. -func (m *CasbinPermissionManager) GrantToRole(ctx context.Context, policy *model.RolePolicy) error { - objRef := anyobject.ID - if (policy.ObjectRef != nil) && (*policy.ObjectRef != bson.NilObjectID) { - objRef = policy.ObjectRef.Hex() - } - - m.logger.Debug("Granting permission to role", - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - zap.String("object_ref", objRef), - zap.String("action", string(policy.Effect.Action)), - zap.String("effect", string(policy.Effect.Effect)), - ) - - // Serialize permission - serializedPolicy, err := m.serializer.Serialize(policy) - if err != nil { - m.logger.Error("Failed to serialize permission while granting permission", zap.Error(err), - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - mzap.OrgRef(policy.OrganizationRef), - ) - return err - } - - // Add policy to Casbin - added, err := m.enforcer.enforcer.AddPolicy(serializedPolicy...) - if err != nil { - m.logger.Error("Failed to add policy to Casbin", zap.Error(err)) - return err - } - if added { - m.logger.Info("Policy added to Casbin", - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - zap.String("object_ref", objRef), - ) - } else { - m.logger.Warn("Policy already exists in Casbin", - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - zap.String("object_ref", objRef), - ) - } - - return nil -} - -// RevokeFromRole removes a permission from a role in Casbin. -func (m *CasbinPermissionManager) RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error { - objRef := anyobject.ID - if policy.ObjectRef != nil { - objRef = policy.ObjectRef.Hex() - } - m.logger.Debug("Revoking permission from role", - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - zap.String("object_ref", objRef), - zap.String("action", string(policy.Effect.Action)), - zap.String("effect", string(policy.Effect.Effect)), - ) - - // Serialize policy - serializedPolicy, err := m.serializer.Serialize(policy) - if err != nil { - m.logger.Error("Failed to serialize policy while revoking permission from role", - zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.ObjRef("policy_ref", policy.DescriptionRef)) - return err - } - - // Remove policy from Casbin - removed, err := m.enforcer.enforcer.RemovePolicy(serializedPolicy...) - if err != nil { - m.logger.Error("Failed to remove policy from Casbin", zap.Error(err)) - return err - } - if removed { - m.logger.Info("Policy removed from Casbin", - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - zap.String("object_ref", objRef), - ) - } else { - m.logger.Warn("Policy does not exist in Casbin", - mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), - zap.String("object_ref", objRef), - ) - } - - return nil -} - -// GetPolicies retrieves all policies for a specific role. -func (m *CasbinPermissionManager) GetPolicies( - ctx context.Context, - roleRef bson.ObjectID, -) ([]model.RolePolicy, error) { - m.logger.Debug("Fetching policies for role", mzap.ObjRef("role_ref", roleRef)) - - // Retrieve Casbin policies for the role - policies, err := m.enforcer.enforcer.GetFilteredPolicy(0, roleRef.Hex()) - if err != nil { - m.logger.Warn("Failed to get policies", zap.Error(err), mzap.ObjRef("role_ref", roleRef)) - return nil, err - } - if len(policies) == 0 { - m.logger.Info("No policies found for role", mzap.ObjRef("role_ref", roleRef)) - return nil, merrors.NoData("no policies") - } - - // Deserialize policies - var result []model.RolePolicy - for _, policy := range policies { - permission, err := m.serializer.Deserialize(policy) - if err != nil { - m.logger.Warn("Failed to deserialize policy", zap.Error(err), zap.String("policy", policy[0])) - continue - } - result = append(result, *permission) - } - - m.logger.Debug("Policies fetched successfully", mzap.ObjRef("role_ref", roleRef), zap.Int("count", len(result))) - return result, nil -} - -// Save persists changes to the Casbin policy store. -func (m *CasbinPermissionManager) Save() error { - if err := m.enforcer.enforcer.SavePolicy(); err != nil { - m.logger.Error("Failed to save policies in Casbin", zap.Error(err)) - return err - } - m.logger.Info("Policies successfully saved in Casbin") - return nil -} - -func NewPermissionManager(logger mlogger.Logger, enforcer *CasbinEnforcer) *CasbinPermissionManager { - return &CasbinPermissionManager{ - logger: logger.Named("permission"), - enforcer: enforcer, - serializer: serialization.NewPolicySerializer(), - } -} diff --git a/backend/pkg/auth/internal/casbin/role.go b/backend/pkg/auth/internal/casbin/role.go deleted file mode 100644 index 69f3919..0000000 --- a/backend/pkg/auth/internal/casbin/role.go +++ /dev/null @@ -1,209 +0,0 @@ -package casbin - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" - "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/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// RoleManager manages roles using Casbin. -type RoleManager struct { - logger mlogger.Logger - enforcer *CasbinEnforcer - rdb role.DB - rolePermissionRef bson.ObjectID -} - -// NewRoleManager creates a new RoleManager. -func NewRoleManager(logger mlogger.Logger, enforcer *CasbinEnforcer, rolePermissionRef bson.ObjectID, rdb role.DB) *RoleManager { - return &RoleManager{ - logger: logger.Named("role"), - enforcer: enforcer, - rdb: rdb, - rolePermissionRef: rolePermissionRef, - } -} - -// validateObjectIDs ensures that all provided ObjectIDs are non-zero. -func (rm *RoleManager) validateObjectIDs(ids ...bson.ObjectID) error { - for _, id := range ids { - if id.IsZero() { - return merrors.InvalidArgument("Object references cannot be zero", "objectRef") - } - } - return nil -} - -// removePolicies removes policies based on the provided filter and logs the results. -func (rm *RoleManager) removePolicies(policyType, role string, roleRef bson.ObjectID) error { - filterIndex := 1 - if policyType == "permission" { - filterIndex = 0 - } - policies, err := rm.enforcer.enforcer.GetFilteredPolicy(filterIndex, role) - if err != nil { - rm.logger.Warn("Failed to fetch "+policyType+" policies", zap.Error(err), mzap.ObjRef("role_ref", roleRef)) - return err - } - - for _, policy := range policies { - args := make([]any, len(policy)) - for i, v := range policy { - args[i] = v - } - var removed bool - var removeErr error - if policyType == "grouping" { - removed, removeErr = rm.enforcer.enforcer.RemoveGroupingPolicy(args...) - } else { - removed, removeErr = rm.enforcer.enforcer.RemovePolicy(args...) - } - - if removeErr != nil { - rm.logger.Warn("Failed to remove "+policyType+" policy for role", zap.Error(removeErr), mzap.ObjRef("role_ref", roleRef), zap.Strings("policy", policy)) - return removeErr - } - if removed { - rm.logger.Info("Removed "+policyType+" policy for role", mzap.ObjRef("role_ref", roleRef), zap.Strings("policy", policy)) - } - } - return nil -} - -// fetchRolesFromPolicies retrieves and converts policies to roles. -func (rm *RoleManager) fetchRolesFromPolicies(policies [][]string, orgRef bson.ObjectID) []model.RoleDescription { - roles := make([]model.RoleDescription, 0, len(policies)) - for _, policy := range policies { - if len(policy) < 2 { - continue - } - - roleID, err := bson.ObjectIDFromHex(policy[1]) - if err != nil { - rm.logger.Warn("Invalid role ID", zap.String("roleID", policy[1])) - continue - } - roles = append(roles, model.RoleDescription{Base: storable.Base{ID: roleID}, OrganizationRef: orgRef}) - } - return roles -} - -// Create creates a new role in an organization. -func (rm *RoleManager) Create(ctx context.Context, orgRef bson.ObjectID, description *model.Describable) (*model.RoleDescription, error) { - if err := rm.validateObjectIDs(orgRef); err != nil { - return nil, err - } - - role := &model.RoleDescription{ - Describable: *description, - OrganizationRef: orgRef, - } - if err := rm.rdb.Create(ctx, role); err != nil { - rm.logger.Warn("Failed to create role", zap.Error(err), mzap.ObjRef("organiztion_ref", orgRef)) - return nil, err - } - - rm.logger.Info("Role created successfully", mzap.StorableRef(role), mzap.OrgRef(orgRef)) - return role, nil -} - -// Assign assigns a role to a user in the given organization. -func (rm *RoleManager) Assign(ctx context.Context, role *model.Role) error { - if err := rm.validateObjectIDs(role.DescriptionRef, role.AccountRef, role.OrganizationRef); err != nil { - return err - } - - sub := role.AccountRef.Hex() - roleID := role.DescriptionRef.Hex() - domain := role.OrganizationRef.Hex() - - added, err := rm.enforcer.enforcer.AddGroupingPolicy(sub, roleID, domain) - return rm.logPolicyResult("assign", added, err, role.DescriptionRef, role.AccountRef, role.OrganizationRef) -} - -// Delete removes a role entirely and cleans up associated Casbin policies. -func (rm *RoleManager) Delete(ctx context.Context, roleRef bson.ObjectID) error { - if err := rm.validateObjectIDs(roleRef); err != nil { - rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef)) - return err - } - - if err := rm.rdb.Delete(ctx, roleRef); err != nil { - rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef)) - return err - } - - role := roleRef.Hex() - - // Remove grouping policies - if err := rm.removePolicies("grouping", role, roleRef); err != nil { - return err - } - - // Remove permission policies - if err := rm.removePolicies("permission", role, roleRef); err != nil { - return err - } - - // // Save changes - // if err := rm.enforcer.enforcer.SavePolicy(); err != nil { - // rm.logger.Warn("Failed to save Casbin policies after role deletion", - // zap.Error(err), - // mzap.ObjRef("role_ref", roleRef), - // ) - // return err - // } - - rm.logger.Info("Role deleted successfully along with associated policies", mzap.ObjRef("role_ref", roleRef)) - return nil -} - -// Revoke removes a role from a user. -func (rm *RoleManager) Revoke(ctx context.Context, roleRef, accountRef, orgRef bson.ObjectID) error { - if err := rm.validateObjectIDs(roleRef, accountRef, orgRef); err != nil { - return err - } - - sub := accountRef.Hex() - role := roleRef.Hex() - domain := orgRef.Hex() - - removed, err := rm.enforcer.enforcer.RemoveGroupingPolicy(sub, role, domain) - return rm.logPolicyResult("revoke", removed, err, roleRef, accountRef, orgRef) -} - -// logPolicyResult logs results for Assign and Revoke. -func (rm *RoleManager) logPolicyResult(action string, result bool, err error, roleRef, accountRef, orgRef bson.ObjectID) error { - if err != nil { - rm.logger.Warn("Failed to "+action+" role", zap.Error(err), mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(orgRef)) - return err - } - msg := "Role " + action + "ed successfully" - if !result { - msg = "Role already " + action + "ed" - } - rm.logger.Info(msg, mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(orgRef)) - return nil -} - -// List retrieves all roles in an organization or all roles if orgRef is zero. -func (rm *RoleManager) List(ctx context.Context, orgRef bson.ObjectID) ([]model.RoleDescription, error) { - domain := orgRef.Hex() - groupingPolicies, err := rm.enforcer.enforcer.GetFilteredGroupingPolicy(2, domain) - if err != nil { - rm.logger.Warn("Failed to fetch grouping policies", zap.Error(err), mzap.OrgRef(orgRef)) - return nil, err - } - - roles := rm.fetchRolesFromPolicies(groupingPolicies, orgRef) - - rm.logger.Info("Retrieved roles for organization", mzap.OrgRef(orgRef), zap.Int("count", len(roles))) - return roles, nil -} diff --git a/backend/pkg/auth/internal/casbin/serialization/internal/policy.go b/backend/pkg/auth/internal/casbin/serialization/internal/policy.go deleted file mode 100644 index a1096eb..0000000 --- a/backend/pkg/auth/internal/casbin/serialization/internal/policy.go +++ /dev/null @@ -1,81 +0,0 @@ -package serializationimp - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/auth/anyobject" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// PolicySerializer implements CasbinSerializer for Permission. -type PolicySerializer struct{} - -// Serialize converts a Permission object into a Casbin policy. -func (s *PolicySerializer) Serialize(entity *model.RolePolicy) ([]any, error) { - if entity.RoleDescriptionRef.IsZero() || - entity.OrganizationRef.IsZero() || - entity.DescriptionRef.IsZero() || // Ensure permissionRef is valid - entity.Effect.Action == "" || // Ensure action is not empty - entity.Effect.Effect == "" { // Ensure effect (eft) is not empty - return nil, merrors.InvalidArgument("permission contains invalid object references or missing fields") - } - - objectRef := anyobject.ID - if entity.ObjectRef != nil { - objectRef = entity.ObjectRef.Hex() - } - - return []any{ - entity.RoleDescriptionRef.Hex(), // Maps to p.roleRef - entity.OrganizationRef.Hex(), // Maps to p.organizationRef - entity.DescriptionRef.Hex(), // Maps to p.permissionRef - objectRef, // Maps to p.objectRef (wildcard if empty) - string(entity.Effect.Action), // Maps to p.action - string(entity.Effect.Effect), // Maps to p.eft - }, nil -} - -// Deserialize converts a Casbin policy into a Permission object. -func (s *PolicySerializer) Deserialize(policy []string) (*model.RolePolicy, error) { - if len(policy) != 6 { // Ensure policy has the correct number of fields - return nil, merrors.Internal("invalid policy format") - } - - roleRef, err := bson.ObjectIDFromHex(policy[0]) - if err != nil { - return nil, merrors.InvalidArgument("invalid roleRef in policy") - } - - organizationRef, err := bson.ObjectIDFromHex(policy[1]) - if err != nil { - return nil, merrors.InvalidArgument("invalid organizationRef in policy") - } - - permissionRef, err := bson.ObjectIDFromHex(policy[2]) - if err != nil { - return nil, merrors.InvalidArgument("invalid permissionRef in policy") - } - - // Handle wildcard for ObjectRef - var objectRef *bson.ObjectID - if policy[3] != anyobject.ID { - ref, err := bson.ObjectIDFromHex(policy[3]) - if err != nil { - return nil, merrors.InvalidArgument("invalid objectRef in policy") - } - objectRef = &ref - } - - return &model.RolePolicy{ - RoleDescriptionRef: roleRef, - Policy: model.Policy{ - OrganizationRef: organizationRef, - DescriptionRef: permissionRef, - ObjectRef: objectRef, - Effect: model.ActionEffect{ - Action: model.Action(policy[4]), - Effect: model.Effect(policy[5]), - }, - }, - }, nil -} diff --git a/backend/pkg/auth/internal/casbin/serialization/internal/role.go b/backend/pkg/auth/internal/casbin/serialization/internal/role.go deleted file mode 100644 index c829235..0000000 --- a/backend/pkg/auth/internal/casbin/serialization/internal/role.go +++ /dev/null @@ -1,57 +0,0 @@ -package serializationimp - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// RoleSerializer implements CasbinSerializer for Role. -type RoleSerializer struct{} - -// Serialize converts a Role object into a Casbin grouping policy. -func (s *RoleSerializer) Serialize(entity *model.Role) ([]any, error) { - // Validate required fields - if entity.AccountRef.IsZero() || entity.DescriptionRef.IsZero() || entity.OrganizationRef.IsZero() { - return nil, merrors.InvalidArgument("role contains invalid object references") - } - - return []any{ - entity.AccountRef.Hex(), // Maps to g(_, _, _) accountRef - entity.DescriptionRef.Hex(), // Maps to g(_, _, _) roleRef - entity.OrganizationRef.Hex(), // Maps to g(_, _, _) organizationRef - }, nil -} - -// Deserialize converts a Casbin grouping policy into a Role object. -func (s *RoleSerializer) Deserialize(policy []string) (*model.Role, error) { - // Ensure the policy has exactly 3 fields - if len(policy) != 3 { - return nil, merrors.Internal("invalid grouping policy format") - } - - // Parse accountRef - accountRef, err := bson.ObjectIDFromHex(policy[0]) - if err != nil { - return nil, merrors.InvalidArgument("invalid accountRef in grouping policy") - } - - // Parse roleDescriptionRef (roleRef) - roleDescriptionRef, err := bson.ObjectIDFromHex(policy[1]) - if err != nil { - return nil, merrors.InvalidArgument("invalid roleRef in grouping policy") - } - - // Parse organizationRef - organizationRef, err := bson.ObjectIDFromHex(policy[2]) - if err != nil { - return nil, merrors.InvalidArgument("invalid organizationRef in grouping policy") - } - - // Return the constructed Role object - return &model.Role{ - AccountRef: accountRef, - DescriptionRef: roleDescriptionRef, - OrganizationRef: organizationRef, - }, nil -} diff --git a/backend/pkg/auth/internal/casbin/serialization/policy.go b/backend/pkg/auth/internal/casbin/serialization/policy.go deleted file mode 100644 index a6ce963..0000000 --- a/backend/pkg/auth/internal/casbin/serialization/policy.go +++ /dev/null @@ -1,12 +0,0 @@ -package serialization - -import ( - serializationimp "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization/internal" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -type Policy = CasbinSerializer[model.RolePolicy] - -func NewPolicySerializer() Policy { - return &serializationimp.PolicySerializer{} -} diff --git a/backend/pkg/auth/internal/casbin/serialization/role.go b/backend/pkg/auth/internal/casbin/serialization/role.go deleted file mode 100644 index 4b04288..0000000 --- a/backend/pkg/auth/internal/casbin/serialization/role.go +++ /dev/null @@ -1,12 +0,0 @@ -package serialization - -import ( - serializationimp "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/casbin/serialization/internal" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -type Role = CasbinSerializer[model.Role] - -func NewRoleSerializer() Role { - return &serializationimp.RoleSerializer{} -} diff --git a/backend/pkg/auth/internal/casbin/serialization/serializer.go b/backend/pkg/auth/internal/casbin/serialization/serializer.go deleted file mode 100644 index 36d11db..0000000 --- a/backend/pkg/auth/internal/casbin/serialization/serializer.go +++ /dev/null @@ -1,10 +0,0 @@ -package serialization - -// CasbinSerializer defines methods for serializing and deserializing any Casbin-compatible entity. -type CasbinSerializer[T any] interface { - // Serialize converts an entity (Role or Permission) into a Casbin policy. - Serialize(entity *T) ([]any, error) - - // Deserialize converts a Casbin policy into an entity (Role or Permission). - Deserialize(policy []string) (*T, error) -} diff --git a/backend/pkg/auth/internal/native/db/policies.go b/backend/pkg/auth/internal/native/db/policies.go deleted file mode 100644 index 3d9b16a..0000000 --- a/backend/pkg/auth/internal/native/db/policies.go +++ /dev/null @@ -1,151 +0,0 @@ -package db - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type PermissionsDBImp struct { - template.DBImp[*nstructures.PolicyAssignment] -} - -func (db *PermissionsDBImp) Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) { - return mutil.GetObjects[nstructures.PolicyAssignment]( - ctx, - db.Logger, - repository.Query().And( - repository.Filter("policy.organizationRef", object.GetOrganizationRef()), - repository.Filter("policy.descriptionRef", object.GetPermissionRef()), - repository.Filter("policy.effect.action", action), - repository.Query().Or( - repository.Filter("policy.objectRef", *object.GetID()), - repository.Filter("policy.objectRef", nil), - ), - ), - nil, - db.Repository, - ) -} - -func (db *PermissionsDBImp) PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) { - return mutil.GetObjects[nstructures.PolicyAssignment]( - ctx, - db.Logger, - repository.Query().And( - repository.Filter("roleRef", roleRef), - repository.Filter("policy.descriptionRef", permissionRef), - repository.Filter("policy.effect.action", action), - ), - nil, - db.Repository, - ) -} - -func (db *PermissionsDBImp) Remove(ctx context.Context, policy *model.RolePolicy) error { - objRefFilter := repository.Query().Or( - repository.Filter("policy.objectRef", nil), - repository.Filter("policy.objectRef", bson.NilObjectID), - ) - if policy.ObjectRef != nil { - objRefFilter = repository.Filter("policy.objectRef", *policy.ObjectRef) - } - return db.Repository.DeleteMany( - ctx, - repository.Query().And( - repository.Filter("roleRef", policy.RoleDescriptionRef), - repository.Filter("policy.organizationRef", policy.OrganizationRef), - repository.Filter("policy.descriptionRef", policy.DescriptionRef), - objRefFilter, - repository.Filter("policy.effect.action", policy.Effect.Action), - repository.Filter("policy.effect.effect", policy.Effect.Effect), - ), - ) -} - -func (db *PermissionsDBImp) PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) { - return mutil.GetObjects[nstructures.PolicyAssignment]( - ctx, - db.Logger, - repository.Filter("roleRef", roleRef), - nil, - db.Repository, - ) -} - -func (db *PermissionsDBImp) PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) { - if len(roleRefs) == 0 { - db.Logger.Debug("Empty role references list provided, returning empty resposnse") - return []nstructures.PolicyAssignment{}, nil - } - return mutil.GetObjects[nstructures.PolicyAssignment]( - ctx, - db.Logger, - repository.Query().And( - repository.Query().In(repository.Field("roleRef"), roleRefs), - repository.Filter("policy.effect.action", action), - ), - nil, - db.Repository, - ) -} - -func NewPoliciesDB(logger mlogger.Logger, db *mongo.Database) (*PermissionsDBImp, error) { - p := &PermissionsDBImp{ - DBImp: *template.Create[*nstructures.PolicyAssignment](logger, mservice.PolicyAssignements, db), - } - - // faster - // harder - // index - policiesQueryIndex := &ri.Definition{ - Keys: []ri.Key{ - {Field: "policy.organizationRef", Sort: ri.Asc}, - {Field: "policy.descriptionRef", Sort: ri.Asc}, - {Field: "policy.effect.action", Sort: ri.Asc}, - {Field: "policy.objectRef", Sort: ri.Asc}, - }, - } - if err := p.Repository.CreateIndex(policiesQueryIndex); err != nil { - p.Logger.Warn("Failed to prepare policies query index", zap.Error(err)) - return nil, err - } - - roleBasedQueriesIndex := &ri.Definition{ - Keys: []ri.Key{ - {Field: "roleRef", Sort: ri.Asc}, - {Field: "policy.effect.action", Sort: ri.Asc}, - }, - } - if err := p.Repository.CreateIndex(roleBasedQueriesIndex); err != nil { - p.Logger.Warn("Failed to prepare role based query index", zap.Error(err)) - return nil, err - } - - uniquePolicyConstaint := &ri.Definition{ - Keys: []ri.Key{ - {Field: "policy.organizationRef", Sort: ri.Asc}, - {Field: "roleRef", Sort: ri.Asc}, - {Field: "policy.descriptionRef", Sort: ri.Asc}, - {Field: "policy.effect.action", Sort: ri.Asc}, - {Field: "policy.objectRef", Sort: ri.Asc}, - }, - Unique: true, - } - if err := p.Repository.CreateIndex(uniquePolicyConstaint); err != nil { - p.Logger.Warn("Failed to unique policy assignment index", zap.Error(err)) - return nil, err - } - - return p, nil -} diff --git a/backend/pkg/auth/internal/native/db/roles.go b/backend/pkg/auth/internal/native/db/roles.go deleted file mode 100644 index 1152a2a..0000000 --- a/backend/pkg/auth/internal/native/db/roles.go +++ /dev/null @@ -1,99 +0,0 @@ -package db - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type RolesDBImp struct { - template.DBImp[*nstructures.RoleAssignment] -} - -func (db *RolesDBImp) Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { - return mutil.GetObjects[nstructures.RoleAssignment]( - ctx, - db.Logger, - repository.Query().And( - repository.Filter("role.accountRef", accountRef), - repository.Filter("role.organizationRef", organizationRef), - ), - nil, - db.Repository, - ) -} - -func (db *RolesDBImp) RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { - return mutil.GetObjects[nstructures.RoleAssignment]( - ctx, - db.Logger, - repository.Query().And( - repository.Filter("role.organizationRef", organizationRef), - ), - nil, - db.Repository, - ) -} - -func (db *RolesDBImp) DeleteRole(ctx context.Context, roleRef bson.ObjectID) error { - return db.DeleteMany( - ctx, - repository.Query().And( - repository.Filter("role.descriptionRef", roleRef), - ), - ) -} - -func (db *RolesDBImp) RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error { - return db.DeleteMany( - ctx, - repository.Query().And( - repository.Filter("role.accountRef", accountRef), - repository.Filter("role.organizationRef", organizationRef), - repository.Filter("role.descriptionRef", roleRef), - ), - ) -} - -func NewRolesDB(logger mlogger.Logger, db *mongo.Database) (*RolesDBImp, error) { - p := &RolesDBImp{ - DBImp: *template.Create[*nstructures.RoleAssignment](logger, "role_assignments", db), - } - - if err := p.Repository.CreateIndex(&ri.Definition{ - Keys: []ri.Key{{Field: "role.organizationRef", Sort: ri.Asc}}, - }); err != nil { - p.Logger.Warn("Failed to prepare venue index", zap.Error(err)) - return nil, err - } - - if err := p.Repository.CreateIndex(&ri.Definition{ - Keys: []ri.Key{{Field: "role.descriptionRef", Sort: ri.Asc}}, - }); err != nil { - p.Logger.Warn("Failed to prepare role description index", zap.Error(err)) - return nil, err - } - - uniqueRoleConstaint := &ri.Definition{ - Keys: []ri.Key{ - {Field: "role.organizationRef", Sort: ri.Asc}, - {Field: "role.accountRef", Sort: ri.Asc}, - {Field: "role.descriptionRef", Sort: ri.Asc}, - }, - Unique: true, - } - if err := p.Repository.CreateIndex(uniqueRoleConstaint); err != nil { - p.Logger.Warn("Failed to prepare role assignment index", zap.Error(err)) - return nil, err - } - - return p, nil -} diff --git a/backend/pkg/auth/internal/native/dbpolicies.go b/backend/pkg/auth/internal/native/dbpolicies.go deleted file mode 100644 index bd4b3af..0000000 --- a/backend/pkg/auth/internal/native/dbpolicies.go +++ /dev/null @@ -1,27 +0,0 @@ -package native - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/db" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -type PoliciesDB interface { - template.DB[*nstructures.PolicyAssignment] - // plenty of interfaces for performance reasons - Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) - PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) - PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) - PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) - Remove(ctx context.Context, policy *model.RolePolicy) error -} - -func NewPoliciesDBDB(logger mlogger.Logger, conn *mongo.Database) (PoliciesDB, error) { - return db.NewPoliciesDB(logger, conn) -} diff --git a/backend/pkg/auth/internal/native/dbroles.go b/backend/pkg/auth/internal/native/dbroles.go deleted file mode 100644 index a97ff9e..0000000 --- a/backend/pkg/auth/internal/native/dbroles.go +++ /dev/null @@ -1,24 +0,0 @@ -package native - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/db" - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -type RolesDB interface { - template.DB[*nstructures.RoleAssignment] - Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) - RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) - RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error - DeleteRole(ctx context.Context, roleRef bson.ObjectID) error -} - -func NewRolesDB(logger mlogger.Logger, conn *mongo.Database) (RolesDB, error) { - return db.NewRolesDB(logger, conn) -} diff --git a/backend/pkg/auth/internal/native/enforcer.go b/backend/pkg/auth/internal/native/enforcer.go deleted file mode 100644 index eaa728a..0000000 --- a/backend/pkg/auth/internal/native/enforcer.go +++ /dev/null @@ -1,282 +0,0 @@ -package native - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "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/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -type Enforcer struct { - logger mlogger.Logger - pdb PoliciesDB - rdb RolesDB -} - -func NewEnforcer( - logger mlogger.Logger, - db *mongo.Database, -) (*Enforcer, error) { - e := &Enforcer{logger: logger.Named("enforcer")} - - var err error - if e.pdb, err = NewPoliciesDBDB(e.logger, db); err != nil { - e.logger.Warn("Failed to create permission assignments database", zap.Error(err)) - return nil, err - } - - if e.rdb, err = NewRolesDB(e.logger, db); err != nil { - e.logger.Warn("Failed to create role assignments database", zap.Error(err)) - return nil, err - } - - logger.Info("Native enforcer created") - return e, nil -} - -// Enforce checks if a user has the specified action permission on an object within a domain. -func (n *Enforcer) Enforce( - ctx context.Context, - permissionRef, accountRef, organizationRef, objectRef bson.ObjectID, - action model.Action, -) (bool, error) { - if organizationRef.IsZero() { - n.logger.Warn("Missing organization context", mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object", objectRef), zap.String("action", string(action))) - return false, merrors.InvalidArgument("organization context missing", "organizationRef") - } - roleAssignments, err := n.rdb.Roles(ctx, accountRef, organizationRef) - if errors.Is(err, merrors.ErrNoData) { - n.logger.Debug("No roles defined for account", mzap.AccRef(accountRef)) - return false, nil - } - if err != nil { - n.logger.Warn("Failed to fetch roles while checking permissions", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object", objectRef), zap.String("action", string(action))) - return false, err - } - if len(roleAssignments) == 0 { - n.logger.Warn("No roles found for account", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return false, merrors.Internal("No roles found for account " + accountRef.Hex()) - } - allowFound := false // Track if any allow is found across roles - - for _, roleAssignment := range roleAssignments { - policies, err := n.pdb.PoliciesForPermissionAction(ctx, roleAssignment.DescriptionRef, permissionRef, action) - if err != nil && !errors.Is(err, merrors.ErrNoData) { - n.logger.Warn("Failed to fetch permissions", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return false, err - } - - for _, permission := range policies { - if !policyAppliesToObject(permission.ObjectRef, objectRef) { - continue // Scoped to a different object; it says nothing about this one. - } - - if permission.Effect.Effect == model.EffectDeny { - n.logger.Debug("Found denying policy", mzap.ObjRef("account", accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - return false, nil // Deny takes precedence immediately - } - - if permission.Effect.Effect == model.EffectAllow { - n.logger.Debug("Allowing policy found", mzap.ObjRef("account", accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - allowFound = true // At least one allow found - } else { - n.logger.Warn("Corrupted policy", mzap.StorableRef(&permission)) - return false, merrors.Internal("Corrupted action effect data for permissions entry " + permission.ID.Hex() + ": " + string(permission.Effect.Effect)) - } - } - } - - // Final decision based on whether any allow was found - if allowFound { - return true, nil // At least one allow and no deny - } - - n.logger.Debug("No allowing policy found", mzap.ObjRef("account", accountRef), - mzap.OrgRef(organizationRef), mzap.PermissionRef(permissionRef), - mzap.ObjRef("object_ref", objectRef), zap.String("action", string(action))) - - return false, nil // No allow found, default deny -} - -// EnforceBatch checks a user’s permission for multiple objects at once. -// It returns a map from objectRef -> boolean indicating whether access is granted. -func (n *Enforcer) EnforceBatch( - ctx context.Context, - objectRefs []model.PermissionBoundStorable, - accountRef bson.ObjectID, - action model.Action, -) (map[bson.ObjectID]bool, error) { - results := make(map[bson.ObjectID]bool, len(objectRefs)) - - // Group objectRefs by organizationRef. - objectsByVenue := make(map[bson.ObjectID][]model.PermissionBoundStorable) - for _, obj := range objectRefs { - organizationRef := obj.GetOrganizationRef() - objectsByVenue[organizationRef] = append(objectsByVenue[organizationRef], obj) - } - - // Process each venue group separately. - for organizationRef, objs := range objectsByVenue { - // 1. Fetch roles once for this account and venue. - roles, err := n.rdb.Roles(ctx, accountRef, organizationRef) - if err != nil { - if errors.Is(err, merrors.ErrNoData) { - n.logger.Debug("No roles defined for account", zap.Error(err), - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - // With no roles, mark all objects in this venue as denied. - for _, obj := range objs { - results[*obj.GetID()] = false - } - // Continue to next venue - continue - } - n.logger.Warn("Failed to fetch roles", zap.Error(err), - mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return nil, err - } - - // 2. Extract role description references - var roleRefs []bson.ObjectID - for _, role := range roles { - roleRefs = append(roleRefs, role.DescriptionRef) - } - - // 3. Fetch all policies for these roles and the given action in one call. - allPolicies, err := n.pdb.PoliciesForRoles(ctx, roleRefs, action) - if err != nil { - n.logger.Warn("Failed to fetch policies", zap.Error(err)) - return nil, err - } - - // 4. Build a lookup map keyed by PermissionRef. - policyMap := make(map[bson.ObjectID][]nstructures.PolicyAssignment) - for _, policy := range allPolicies { - policyMap[policy.DescriptionRef] = append(policyMap[policy.DescriptionRef], policy) - } - - // 5. Evaluate permissions for each object in this venue group. - for _, obj := range objs { - permRef := obj.GetPermissionRef() - allow := false - if policies, ok := policyMap[permRef]; ok { - for _, policy := range policies { - if !policyAppliesToObject(policy.ObjectRef, *obj.GetID()) { - continue // Scoped to a different object. - } - // Deny takes precedence. - if policy.Effect.Effect == model.EffectDeny { - allow = false - break - } - if policy.Effect.Effect == model.EffectAllow { - allow = true - // Continue checking in case a deny exists among policies. - } else { - // should never get here - return nil, merrors.Internal("Corrupted permissions effect in policy assignment '" + policy.GetID().Hex() + "': " + string(policy.Effect.Effect)) - } - } - } - results[*obj.GetID()] = allow - } - } - - return results, nil -} - -// GetRoles retrieves all roles assigned to the user within the domain. -func (n *Enforcer) GetRoles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]model.Role, error) { - n.logger.Debug("Fetching roles for user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - ra, err := n.rdb.Roles(ctx, accountRef, organizationRef) - if errors.Is(err, merrors.ErrNoData) { - n.logger.Debug("No roles assigned to user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return []model.Role{}, nil - } - if err != nil { - n.logger.Warn("Failed to fetch roles", zap.Error(err), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return nil, err - } - - roles := make([]model.Role, len(ra)) - for i, roleAssignement := range ra { - roles[i] = roleAssignement.Role - } - - n.logger.Debug("Fetched roles", zap.Int("roles_count", len(roles))) - return roles, nil -} - -func (n *Enforcer) Reload() error { - n.logger.Info("Policies reloaded") // do nothing actually - return nil -} - -// GetPermissions retrieves all effective policies for the user within the domain. -func (n *Enforcer) GetPermissions(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]model.Role, []model.Permission, error) { - n.logger.Debug("Fetching policies for user", mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - - roles, err := n.GetRoles(ctx, accountRef, organizationRef) - if err != nil { - n.logger.Warn("Failed to get roles", zap.Error(err)) - return nil, nil, err - } - - uniquePermissions := make(map[bson.ObjectID]model.Permission) - for _, role := range roles { - perms, err := n.pdb.PoliciesForRole(ctx, role.DescriptionRef) - if err != nil { - n.logger.Warn("Failed to get policies for role", zap.Error(err), mzap.ObjRef("role_ref", role.DescriptionRef)) - continue - } - n.logger.Debug("Policies fetched for role", mzap.ObjRef("role_ref", role.DescriptionRef), zap.Int("count", len(perms))) - for _, p := range perms { - uniquePermissions[*p.GetID()] = model.Permission{ - RolePolicy: model.RolePolicy{ - Policy: p.Policy, - RoleDescriptionRef: p.RoleRef, - }, - AccountRef: accountRef, - } - } - } - - permissionsSlice := make([]model.Permission, 0, len(uniquePermissions)) - for _, permission := range uniquePermissions { - permissionsSlice = append(permissionsSlice, permission) - } - - n.logger.Debug("Policies fetched successfully", zap.Int("count", len(permissionsSlice))) - return roles, permissionsSlice, nil -} - -// policyAppliesToObject reports whether a policy governs the object being -// checked. A policy without an object reference — nil or the zero value — is a -// wildcard covering every object of its resource type. A policy naming a -// specific object says nothing about any other one, so it is skipped rather -// than counted: otherwise an allow on one object would leak onto its siblings, -// and a deny on one object would block them. -func policyAppliesToObject(policyObjectRef *bson.ObjectID, objectRef bson.ObjectID) bool { - if policyObjectRef == nil || policyObjectRef.IsZero() { - return true - } - return *policyObjectRef == objectRef -} diff --git a/backend/pkg/auth/internal/native/enforcer_test.go b/backend/pkg/auth/internal/native/enforcer_test.go deleted file mode 100644 index 6dc8c2b..0000000 --- a/backend/pkg/auth/internal/native/enforcer_test.go +++ /dev/null @@ -1,746 +0,0 @@ -package native - -import ( - "context" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - factory "git.sendico.io/tech/sxchnge/bff/pkg/mlogger/factory" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// Mock implementations for testing -type MockPoliciesDB struct { - mock.Mock -} - -func (m *MockPoliciesDB) PoliciesForPermissionAction(ctx context.Context, roleRef, permissionRef bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) { - args := m.Called(ctx, roleRef, permissionRef, action) - return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) -} - -func (m *MockPoliciesDB) PoliciesForRole(ctx context.Context, roleRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) { - args := m.Called(ctx, roleRef) - return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) -} - -func (m *MockPoliciesDB) PoliciesForRoles(ctx context.Context, roleRefs []bson.ObjectID, action model.Action) ([]nstructures.PolicyAssignment, error) { - args := m.Called(ctx, roleRefs, action) - return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) -} - -func (m *MockPoliciesDB) Policies(ctx context.Context, object model.PermissionBoundStorable, action model.Action) ([]nstructures.PolicyAssignment, error) { - args := m.Called(ctx, object, action) - return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) -} - -func (m *MockPoliciesDB) Remove(ctx context.Context, policy *model.RolePolicy) error { - args := m.Called(ctx, policy) - return args.Error(0) -} - -// Template DB methods - implement as needed for testing -func (m *MockPoliciesDB) Create(ctx context.Context, assignment *nstructures.PolicyAssignment) error { - args := m.Called(ctx, assignment) - return args.Error(0) -} - -func (m *MockPoliciesDB) Get(ctx context.Context, id bson.ObjectID, assignment *nstructures.PolicyAssignment) error { - args := m.Called(ctx, id, assignment) - return args.Error(0) -} - -func (m *MockPoliciesDB) Update(ctx context.Context, assignment *nstructures.PolicyAssignment) error { - args := m.Called(ctx, assignment) - return args.Error(0) -} - -func (m *MockPoliciesDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error { - args := m.Called(ctx, objectRef, patch) - return args.Error(0) -} - -func (m *MockPoliciesDB) Delete(ctx context.Context, id bson.ObjectID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockPoliciesDB) DeleteMany(ctx context.Context, query builder.Query) error { - args := m.Called(ctx, query) - return args.Error(0) -} - -func (m *MockPoliciesDB) ListPermissionBound(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.PolicyAssignment, error) { - args := m.Called(ctx, accountRef, organizationRef) - return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) -} - -func (m *MockPoliciesDB) ListIDs(ctx context.Context, query interface{}) ([]bson.ObjectID, error) { - args := m.Called(ctx, query) - return args.Get(0).([]bson.ObjectID), args.Error(1) -} - -func (m *MockPoliciesDB) FindOne(ctx context.Context, query builder.Query, assignment *nstructures.PolicyAssignment) error { - args := m.Called(ctx, query, assignment) - return args.Error(0) -} - -func (m *MockPoliciesDB) List(ctx context.Context, query builder.Query) ([]nstructures.PolicyAssignment, error) { - args := m.Called(ctx, query) - return args.Get(0).([]nstructures.PolicyAssignment), args.Error(1) -} - -func (m *MockPoliciesDB) Name() string { - return "mock_policies" -} - -func (m *MockPoliciesDB) DeleteCascade(ctx context.Context, id bson.ObjectID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockPoliciesDB) InsertMany(ctx context.Context, objects []*nstructures.PolicyAssignment) error { - args := m.Called(ctx, objects) - return args.Error(0) -} - -type MockRolesDB struct { - mock.Mock -} - -func (m *MockRolesDB) Roles(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { - args := m.Called(ctx, accountRef, organizationRef) - return args.Get(0).([]nstructures.RoleAssignment), args.Error(1) -} - -func (m *MockRolesDB) RolesForVenue(ctx context.Context, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { - args := m.Called(ctx, organizationRef) - return args.Get(0).([]nstructures.RoleAssignment), args.Error(1) -} - -func (m *MockRolesDB) RemoveRole(ctx context.Context, roleRef, organizationRef, accountRef bson.ObjectID) error { - args := m.Called(ctx, roleRef, organizationRef, accountRef) - return args.Error(0) -} - -func (m *MockRolesDB) DeleteRole(ctx context.Context, roleRef bson.ObjectID) error { - args := m.Called(ctx, roleRef) - return args.Error(0) -} - -// Template DB methods - implement as needed for testing -func (m *MockRolesDB) Create(ctx context.Context, assignment *nstructures.RoleAssignment) error { - args := m.Called(ctx, assignment) - return args.Error(0) -} - -func (m *MockRolesDB) Get(ctx context.Context, id bson.ObjectID, assignment *nstructures.RoleAssignment) error { - args := m.Called(ctx, id, assignment) - return args.Error(0) -} - -func (m *MockRolesDB) Update(ctx context.Context, assignment *nstructures.RoleAssignment) error { - args := m.Called(ctx, assignment) - return args.Error(0) -} - -func (m *MockRolesDB) Patch(ctx context.Context, objectRef bson.ObjectID, patch builder.Patch) error { - args := m.Called(ctx, objectRef, patch) - return args.Error(0) -} - -func (m *MockRolesDB) Delete(ctx context.Context, id bson.ObjectID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockRolesDB) DeleteMany(ctx context.Context, query builder.Query) error { - args := m.Called(ctx, query) - return args.Error(0) -} - -func (m *MockRolesDB) ListPermissionBound(ctx context.Context, accountRef, organizationRef bson.ObjectID) ([]nstructures.RoleAssignment, error) { - args := m.Called(ctx, accountRef, organizationRef) - return args.Get(0).([]nstructures.RoleAssignment), args.Error(1) -} - -func (m *MockRolesDB) ListIDs(ctx context.Context, query interface{}) ([]bson.ObjectID, error) { - args := m.Called(ctx, query) - return args.Get(0).([]bson.ObjectID), args.Error(1) -} - -func (m *MockRolesDB) FindOne(ctx context.Context, query builder.Query, assignment *nstructures.RoleAssignment) error { - args := m.Called(ctx, query, assignment) - return args.Error(0) -} - -func (m *MockRolesDB) List(ctx context.Context, query builder.Query) ([]nstructures.RoleAssignment, error) { - args := m.Called(ctx, query) - return args.Get(0).([]nstructures.RoleAssignment), args.Error(1) -} - -func (m *MockRolesDB) Name() string { - return "mock_roles" -} - -func (m *MockRolesDB) DeleteCascade(ctx context.Context, id bson.ObjectID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockRolesDB) InsertMany(ctx context.Context, objects []*nstructures.RoleAssignment) error { - args := m.Called(ctx, objects) - return args.Error(0) -} - -// Test helper functions -func createTestObjectID() bson.ObjectID { - return bson.NewObjectID() -} - -func createTestRoleAssignment(roleRef, accountRef, organizationRef bson.ObjectID) nstructures.RoleAssignment { - return nstructures.RoleAssignment{ - Role: model.Role{ - AccountRef: accountRef, - DescriptionRef: roleRef, - OrganizationRef: organizationRef, - }, - } -} - -func createTestPolicyAssignment(roleRef bson.ObjectID, effect model.Effect, organizationRef, descriptionRef bson.ObjectID, objectRef *bson.ObjectID) nstructures.PolicyAssignment { - return nstructures.PolicyAssignment{ - Policy: model.Policy{ - OrganizationRef: organizationRef, - DescriptionRef: descriptionRef, - ObjectRef: objectRef, - Effect: model.ActionEffect{ - Action: model.ActionRead, - Effect: effect, - }, - }, - RoleRef: roleRef, - } -} - -func createTestEnforcer(pdb PoliciesDB, rdb RolesDB) *Enforcer { - logger := factory.NewLogger(true) - enforcer := &Enforcer{ - logger: logger.Named("test"), - pdb: pdb, - rdb: rdb, - } - return enforcer -} - -func TestEnforcer_Enforce(t *testing.T) { - ctx := context.Background() - - // Test data - accountRef := createTestObjectID() - organizationRef := createTestObjectID() - permissionRef := createTestObjectID() - objectRef := createTestObjectID() - roleRef := createTestObjectID() - - t.Run("Allow_SingleRole_SinglePolicy", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock policy assignment with ALLOW effect - policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectRef) - mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil) - - // Create enforcer - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.NoError(t, err) - assert.True(t, allowed) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("Deny_SingleRole_SinglePolicy", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock policy assignment with DENY effect - policyAssignment := createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectRef) - mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.NoError(t, err) - assert.False(t, allowed) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("DenyTakesPrecedence_MultipleRoles", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - role1Ref := createTestObjectID() - role2Ref := createTestObjectID() - - // Mock multiple role assignments - roleAssignment1 := createTestRoleAssignment(role1Ref, accountRef, organizationRef) - roleAssignment2 := createTestRoleAssignment(role2Ref, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment1, roleAssignment2}, nil) - - // First role has ALLOW policy - allowPolicy := createTestPolicyAssignment(role1Ref, model.EffectAllow, organizationRef, permissionRef, &objectRef) - mockPDB.On("PoliciesForPermissionAction", ctx, role1Ref, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{allowPolicy}, nil) - - // Second role has DENY policy - should take precedence - denyPolicy := createTestPolicyAssignment(role2Ref, model.EffectDeny, organizationRef, permissionRef, &objectRef) - mockPDB.On("PoliciesForPermissionAction", ctx, role2Ref, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{denyPolicy}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - DENY should take precedence - require.NoError(t, err) - assert.False(t, allowed) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("NoRoles_ReturnsFalse", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock no roles found - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.NoError(t, err) - assert.False(t, allowed) - mockRDB.AssertExpectations(t) - }) - - t.Run("EmptyRoles_ReturnsError", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock empty roles list (not NoData error) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.Error(t, err) - assert.False(t, allowed) - assert.Contains(t, err.Error(), "No roles found for account") - mockRDB.AssertExpectations(t) - }) - - t.Run("DatabaseError_RolesDB", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock database error - dbError := merrors.Define("database connection failed") - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.Error(t, err) - assert.False(t, allowed) - assert.Equal(t, dbError, err) - mockRDB.AssertExpectations(t) - }) - - t.Run("DatabaseError_PoliciesDB", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock database error in policies - dbError := merrors.Define("policies database error") - mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{}, dbError) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.Error(t, err) - assert.False(t, allowed) - assert.Equal(t, dbError, err) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("NoPolicies_ReturnsFalse", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock no policies found - mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{}, merrors.ErrNoData) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.NoError(t, err) - assert.False(t, allowed) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("CorruptedPolicy_ReturnsError", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock corrupted policy with invalid effect - corruptedPolicy := createTestPolicyAssignment(roleRef, "invalid_effect", organizationRef, permissionRef, &objectRef) - mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{corruptedPolicy}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - require.Error(t, err) - assert.False(t, allowed) - assert.Contains(t, err.Error(), "Corrupted action effect data") - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) -} - -// Mock implementation for PermissionBoundStorable -type MockPermissionBoundStorable struct { - id bson.ObjectID - permissionRef bson.ObjectID - organizationRef bson.ObjectID -} - -func (m *MockPermissionBoundStorable) GetID() *bson.ObjectID { - return &m.id -} - -func (m *MockPermissionBoundStorable) GetPermissionRef() bson.ObjectID { - return m.permissionRef -} - -func (m *MockPermissionBoundStorable) GetOrganizationRef() bson.ObjectID { - return m.organizationRef -} - -func (m *MockPermissionBoundStorable) Collection() string { - return "test_objects" -} - -func (m *MockPermissionBoundStorable) SetID(objID bson.ObjectID) { - m.id = objID -} - -func (m *MockPermissionBoundStorable) Update() { - // Do nothing for mock -} - -func (m *MockPermissionBoundStorable) SetPermissionRef(permissionRef bson.ObjectID) { - m.permissionRef = permissionRef -} - -func (m *MockPermissionBoundStorable) SetOrganizationRef(organizationRef bson.ObjectID) { - m.organizationRef = organizationRef -} - -func (m *MockPermissionBoundStorable) IsArchived() bool { - return false // Default to not archived for testing -} - -func (m *MockPermissionBoundStorable) SetArchived(archived bool) { - // No-op for testing -} - -func TestEnforcer_EnforceBatch(t *testing.T) { - ctx := context.Background() - - // Test data - accountRef := createTestObjectID() - organizationRef := createTestObjectID() - permissionRef := createTestObjectID() - roleRef := createTestObjectID() - - // Create test objects - object1 := &MockPermissionBoundStorable{ - id: createTestObjectID(), - permissionRef: permissionRef, - organizationRef: organizationRef, - } - object2 := &MockPermissionBoundStorable{ - id: createTestObjectID(), - permissionRef: permissionRef, - organizationRef: organizationRef, - } - - t.Run("BatchEnforce_MultipleObjects_SameVenue", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock policy assignment with ALLOW effect - policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil) - mockPDB.On("PoliciesForRoles", ctx, []bson.ObjectID{roleRef}, model.ActionRead).Return([]nstructures.PolicyAssignment{policyAssignment}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute batch enforcement - objects := []model.PermissionBoundStorable{object1, object2} - results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead) - - // Verify - require.NoError(t, err) - assert.Len(t, results, 2) - assert.True(t, results[object1.id]) - assert.True(t, results[object2.id]) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("BatchEnforce_NoRoles_AllObjectsDenied", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock no roles found - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute batch enforcement - objects := []model.PermissionBoundStorable{object1, object2} - results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead) - - // Verify - require.NoError(t, err) - assert.Len(t, results, 2) - assert.False(t, results[object1.id]) - assert.False(t, results[object2.id]) - mockRDB.AssertExpectations(t) - }) - - t.Run("BatchEnforce_DatabaseError", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock database error - dbError := merrors.Define("database connection failed") - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute batch enforcement - objects := []model.PermissionBoundStorable{object1, object2} - results, err := enforcer.EnforceBatch(ctx, objects, accountRef, model.ActionRead) - - // Verify - require.Error(t, err) - assert.Nil(t, results) - assert.Equal(t, dbError, err) - mockRDB.AssertExpectations(t) - }) -} - -func TestEnforcer_GetRoles(t *testing.T) { - ctx := context.Background() - - // Test data - accountRef := createTestObjectID() - organizationRef := createTestObjectID() - roleRef := createTestObjectID() - - t.Run("GetRoles_Success", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - roles, err := enforcer.GetRoles(ctx, accountRef, organizationRef) - - // Verify - require.NoError(t, err) - assert.Len(t, roles, 1) - assert.Equal(t, roleRef, roles[0].DescriptionRef) - mockRDB.AssertExpectations(t) - }) - - t.Run("GetRoles_NoRoles", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock no roles found - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, merrors.ErrNoData) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - roles, err := enforcer.GetRoles(ctx, accountRef, organizationRef) - - // Verify - require.NoError(t, err) - assert.Len(t, roles, 0) - mockRDB.AssertExpectations(t) - }) -} - -func TestEnforcer_GetPermissions(t *testing.T) { - ctx := context.Background() - - // Test data - accountRef := createTestObjectID() - organizationRef := createTestObjectID() - roleRef := createTestObjectID() - - t.Run("GetPermissions_Success", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock policy assignment - policyAssignment := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, createTestObjectID(), nil) - mockPDB.On("PoliciesForRole", ctx, roleRef).Return([]nstructures.PolicyAssignment{policyAssignment}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - roles, permissions, err := enforcer.GetPermissions(ctx, accountRef, organizationRef) - - // Verify - require.NoError(t, err) - assert.Len(t, roles, 1) - assert.Len(t, permissions, 1) - assert.Equal(t, accountRef, permissions[0].AccountRef) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) -} - -// Security-focused test scenarios -func TestEnforcer_SecurityScenarios(t *testing.T) { - ctx := context.Background() - - // Test data - accountRef := createTestObjectID() - organizationRef := createTestObjectID() - permissionRef := createTestObjectID() - objectRef := createTestObjectID() - roleRef := createTestObjectID() - - t.Run("Security_DenyAlwaysWins", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock role assignment - roleAssignment := createTestRoleAssignment(roleRef, accountRef, organizationRef) - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{roleAssignment}, nil) - - // Mock multiple policies: both ALLOW and DENY - allowPolicy := createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectRef) - denyPolicy := createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectRef) - mockPDB.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return([]nstructures.PolicyAssignment{allowPolicy, denyPolicy}, nil) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - DENY should always win - require.NoError(t, err) - assert.False(t, allowed) - mockRDB.AssertExpectations(t) - mockPDB.AssertExpectations(t) - }) - - t.Run("Security_InvalidObjectID", func(t *testing.T) { - mockPDB := &MockPoliciesDB{} - mockRDB := &MockRolesDB{} - - // Mock database error for invalid ObjectID - dbError := merrors.Define("invalid ObjectID") - mockRDB.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{}, dbError) - - enforcer := createTestEnforcer(mockPDB, mockRDB) - - // Execute with invalid ObjectID - allowed, err := enforcer.Enforce(ctx, permissionRef, accountRef, organizationRef, objectRef, model.ActionRead) - - // Verify - should fail securely - require.Error(t, err) - assert.False(t, allowed) - mockRDB.AssertExpectations(t) - }) -} - -// Note: This test provides comprehensive coverage of the native enforcer including: -// 1. Basic enforcement logic with deny-takes-precedence -// 2. Batch operations for performance -// 3. Role and permission retrieval -// 4. Security scenarios and edge cases -// 5. Error handling and database failures -// 6. All critical security paths are tested diff --git a/backend/pkg/auth/internal/native/manager.go b/backend/pkg/auth/internal/native/manager.go deleted file mode 100644 index 523ab5d..0000000 --- a/backend/pkg/auth/internal/native/manager.go +++ /dev/null @@ -1,57 +0,0 @@ -package native - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.uber.org/zap" -) - -// NativeManager implements the auth.Manager interface by aggregating Role and Permission managers. -type NativeManager struct { - logger mlogger.Logger - roleManager management.Role - permManager management.Permission -} - -// NewManager creates a new CasbinManager with specified domains and role-domain mappings. -func NewManager( - l mlogger.Logger, - pdb policy.DB, - rdb role.DB, - enforcer *Enforcer, -) (*NativeManager, error) { - logger := l.Named("manager") - - var pdesc model.PolicyDescription - if err := pdb.GetBuiltInPolicy(context.Background(), "roles", &pdesc); err != nil { - logger.Warn("Failed to fetch roles permission reference", zap.Error(err)) - return nil, err - } - - return &NativeManager{ - logger: logger, - roleManager: NewRoleManager(logger, enforcer, pdesc.ID, rdb), - permManager: NewPermissionManager(logger, enforcer), - }, nil -} - -// Permission returns the Permission manager. -func (m *NativeManager) Permission() management.Permission { - return m.permManager -} - -// Role returns the Role manager. -func (m *NativeManager) Role() management.Role { - return m.roleManager -} - -// TransactionalGrants reports that native grants are plain database writes: -// they join the caller's transaction context and roll back with it. -func (m *NativeManager) TransactionalGrants() bool { - return true -} diff --git a/backend/pkg/auth/internal/native/nstructures/policies.go b/backend/pkg/auth/internal/native/nstructures/policies.go deleted file mode 100644 index 43c9aa5..0000000 --- a/backend/pkg/auth/internal/native/nstructures/policies.go +++ /dev/null @@ -1,17 +0,0 @@ -package nstructures - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type PolicyAssignment struct { - storable.Base `bson:",inline" json:",inline"` - model.Policy `bson:"policy" json:"policy"` - RoleRef bson.ObjectID `bson:"roleRef" json:"roleRef"` -} - -func (*PolicyAssignment) Collection() string { - return "permission_assignments" -} diff --git a/backend/pkg/auth/internal/native/nstructures/role.go b/backend/pkg/auth/internal/native/nstructures/role.go deleted file mode 100644 index ee2cc68..0000000 --- a/backend/pkg/auth/internal/native/nstructures/role.go +++ /dev/null @@ -1,15 +0,0 @@ -package nstructures - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/model" -) - -type RoleAssignment struct { - storable.Base `bson:",inline" json:",inline"` - model.Role `bson:"role" json:"role"` -} - -func (*RoleAssignment) Collection() string { - return "role_assignments" -} diff --git a/backend/pkg/auth/internal/native/object_scope_test.go b/backend/pkg/auth/internal/native/object_scope_test.go deleted file mode 100644 index b04048e..0000000 --- a/backend/pkg/auth/internal/native/object_scope_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package native - -import ( - "context" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/stretchr/testify/require" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestEnforcer_EnforceHonorsObjectScope(t *testing.T) { - ctx := context.Background() - accountRef := bson.NewObjectID() - organizationRef := bson.NewObjectID() - permissionRef := bson.NewObjectID() - roleRef := bson.NewObjectID() - objectA := bson.NewObjectID() - objectB := bson.NewObjectID() - wildcard := bson.NilObjectID - - tests := []struct { - name string - policies []nstructures.PolicyAssignment - objectRef bson.ObjectID - wantAccess bool - }{ - { - name: "matching object allow", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA), - }, - objectRef: objectA, - wantAccess: true, - }, - { - name: "different object does not inherit allow", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA), - }, - objectRef: objectB, - wantAccess: false, - }, - { - name: "nil object is wildcard", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil), - }, - objectRef: objectB, - wantAccess: true, - }, - { - name: "zero object is wildcard", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &wildcard), - }, - objectRef: objectB, - wantAccess: true, - }, - { - name: "unrelated object deny does not override wildcard allow", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil), - createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA), - }, - objectRef: objectB, - wantAccess: true, - }, - { - name: "matching object deny overrides wildcard allow", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil), - createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA), - }, - objectRef: objectA, - wantAccess: false, - }, - { - name: "wildcard deny overrides matching object allow", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA), - createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, nil), - }, - objectRef: objectA, - wantAccess: false, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - roles := &MockRolesDB{} - policies := &MockPoliciesDB{} - role := createTestRoleAssignment(roleRef, accountRef, organizationRef) - roles.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{role}, nil) - policies.On("PoliciesForPermissionAction", ctx, roleRef, permissionRef, model.ActionRead).Return(test.policies, nil) - - allowed, err := createTestEnforcer(policies, roles).Enforce( - ctx, - permissionRef, - accountRef, - organizationRef, - test.objectRef, - model.ActionRead, - ) - - require.NoError(t, err) - require.Equal(t, test.wantAccess, allowed) - roles.AssertExpectations(t) - policies.AssertExpectations(t) - }) - } -} - -func TestEnforcer_EnforceBatchHonorsObjectScope(t *testing.T) { - ctx := context.Background() - accountRef := bson.NewObjectID() - organizationRef := bson.NewObjectID() - permissionRef := bson.NewObjectID() - roleRef := bson.NewObjectID() - objectA := &MockPermissionBoundStorable{ - id: bson.NewObjectID(), - permissionRef: permissionRef, - organizationRef: organizationRef, - } - objectB := &MockPermissionBoundStorable{ - id: bson.NewObjectID(), - permissionRef: permissionRef, - organizationRef: organizationRef, - } - - tests := []struct { - name string - policies []nstructures.PolicyAssignment - want map[bson.ObjectID]bool - }{ - { - name: "specific allow applies only to matching object", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, &objectA.id), - }, - want: map[bson.ObjectID]bool{objectA.id: true, objectB.id: false}, - }, - { - name: "specific deny affects only matching object", - policies: []nstructures.PolicyAssignment{ - createTestPolicyAssignment(roleRef, model.EffectAllow, organizationRef, permissionRef, nil), - createTestPolicyAssignment(roleRef, model.EffectDeny, organizationRef, permissionRef, &objectA.id), - }, - want: map[bson.ObjectID]bool{objectA.id: false, objectB.id: true}, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - roles := &MockRolesDB{} - policies := &MockPoliciesDB{} - role := createTestRoleAssignment(roleRef, accountRef, organizationRef) - roles.On("Roles", ctx, accountRef, organizationRef).Return([]nstructures.RoleAssignment{role}, nil) - policies.On("PoliciesForRoles", ctx, []bson.ObjectID{roleRef}, model.ActionRead).Return(test.policies, nil) - - result, err := createTestEnforcer(policies, roles).EnforceBatch( - ctx, - []model.PermissionBoundStorable{objectA, objectB}, - accountRef, - model.ActionRead, - ) - - require.NoError(t, err) - require.Equal(t, test.want, result) - roles.AssertExpectations(t) - policies.AssertExpectations(t) - }) - } -} diff --git a/backend/pkg/auth/internal/native/permission.go b/backend/pkg/auth/internal/native/permission.go deleted file mode 100644 index ed7fa45..0000000 --- a/backend/pkg/auth/internal/native/permission.go +++ /dev/null @@ -1,101 +0,0 @@ -package native - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "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/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// PermissionManager manages permissions using Casbin. -type PermissionManager struct { - logger mlogger.Logger - enforcer *Enforcer -} - -// GrantToRole adds a permission to a role in Casbin. -func (m *PermissionManager) GrantToRole(ctx context.Context, policy *model.RolePolicy) error { - objRef := "any" - if (policy.ObjectRef != nil) && (*policy.ObjectRef != bson.NilObjectID) { - objRef = policy.ObjectRef.Hex() - } - - m.logger.Debug("Granting permission to role", mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef), - zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)), - ) - - assignment := nstructures.PolicyAssignment{ - Policy: policy.Policy, - RoleRef: policy.RoleDescriptionRef, - } - if err := m.enforcer.pdb.Create(ctx, &assignment); err != nil { - m.logger.Warn("Failed to grant policy", zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef), - zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect))) - return err - } - return nil -} - -// RevokeFromRole removes a permission from a role in Casbin. -func (m *PermissionManager) RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error { - objRef := "*" - if policy.ObjectRef != nil { - objRef = policy.ObjectRef.Hex() - } - m.logger.Debug("Revoking permission from role", mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef), - zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect)), - ) - if err := m.enforcer.pdb.Remove(ctx, policy); err != nil { - m.logger.Warn("Failed to revoke policy", zap.Error(err), mzap.ObjRef("role_ref", policy.RoleDescriptionRef), - mzap.PermissionRef(policy.DescriptionRef), zap.String("object_ref", objRef), - zap.String("action", string(policy.Effect.Action)), zap.String("effect", string(policy.Effect.Effect))) - return err - } - - return nil -} - -// GetPolicies retrieves all policies for a specific role. -func (m *PermissionManager) GetPolicies( - ctx context.Context, - roleRef bson.ObjectID, -) ([]model.RolePolicy, error) { - m.logger.Debug("Fetching policies for role", mzap.ObjRef("role_ref", roleRef)) - - assinments, err := m.enforcer.pdb.PoliciesForRole(ctx, roleRef) - if errors.Is(err, merrors.ErrNoData) { - m.logger.Debug("No policies found", mzap.ObjRef("role_ref", roleRef)) - return []model.RolePolicy{}, nil - } - policies := make([]model.RolePolicy, len(assinments)) - for i, assinment := range assinments { - policies[i] = model.RolePolicy{ - Policy: assinment.Policy, - RoleDescriptionRef: assinment.RoleRef, - } - } - m.logger.Debug("Policies fetched successfully", mzap.ObjRef("role_ref", roleRef), zap.Int("count", len(policies))) - return policies, nil -} - -// Save persists changes to the Casbin policy store. -func (m *PermissionManager) Save() error { - m.logger.Info("Policies successfully saved") // do nothing - return nil -} - -func NewPermissionManager(logger mlogger.Logger, enforcer *Enforcer) *PermissionManager { - return &PermissionManager{ - logger: logger.Named("permission"), - enforcer: enforcer, - } -} diff --git a/backend/pkg/auth/internal/native/role.go b/backend/pkg/auth/internal/native/role.go deleted file mode 100644 index d7599aa..0000000 --- a/backend/pkg/auth/internal/native/role.go +++ /dev/null @@ -1,142 +0,0 @@ -package native - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth/internal/native/nstructures" - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" - "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/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// RoleManager manages roles using Casbin. -type RoleManager struct { - logger mlogger.Logger - enforcer *Enforcer - rdb role.DB - rolePermissionRef bson.ObjectID -} - -// NewRoleManager creates a new RoleManager. -func NewRoleManager(logger mlogger.Logger, enforcer *Enforcer, rolePermissionRef bson.ObjectID, rdb role.DB) *RoleManager { - return &RoleManager{ - logger: logger.Named("role"), - enforcer: enforcer, - rdb: rdb, - rolePermissionRef: rolePermissionRef, - } -} - -// validateObjectIDs ensures that all provided ObjectIDs are non-zero. -func (rm *RoleManager) validateObjectIDs(ids ...bson.ObjectID) error { - for _, id := range ids { - if id.IsZero() { - return merrors.InvalidArgument("Object references cannot be zero", "objectRef") - } - } - return nil -} - -// fetchRolesFromPolicies retrieves and converts policies to roles. -func (rm *RoleManager) fetchRolesFromPolicies(roles []nstructures.RoleAssignment, organizationRef bson.ObjectID) []model.RoleDescription { - result := make([]model.RoleDescription, len(roles)) - for i, role := range roles { - result[i] = model.RoleDescription{ - Base: storable.Base{ID: *role.GetID()}, - OrganizationRef: organizationRef, - } - } - return result -} - -// Create creates a new role in an organization. -func (rm *RoleManager) Create(ctx context.Context, organizationRef bson.ObjectID, description *model.Describable) (*model.RoleDescription, error) { - if err := rm.validateObjectIDs(organizationRef); err != nil { - return nil, err - } - - role := &model.RoleDescription{ - OrganizationRef: organizationRef, - Describable: *description, - } - if err := rm.rdb.Create(ctx, role); err != nil { - rm.logger.Warn("Failed to create role", zap.Error(err), mzap.OrgRef(organizationRef)) - return nil, err - } - - rm.logger.Info("Role created successfully", mzap.StorableRef(role), mzap.OrgRef(organizationRef)) - return role, nil -} - -// Assign assigns a role to a user in the given organization. -func (rm *RoleManager) Assign(ctx context.Context, role *model.Role) error { - if err := rm.validateObjectIDs(role.DescriptionRef, role.AccountRef, role.OrganizationRef); err != nil { - return err - } - assogment := nstructures.RoleAssignment{Role: *role} - err := rm.enforcer.rdb.Create(ctx, &assogment) - return rm.logPolicyResult("assign", err == nil, err, role.DescriptionRef, role.AccountRef, role.OrganizationRef) -} - -// Delete removes a role entirely and cleans up associated Casbin policies. -func (rm *RoleManager) Delete(ctx context.Context, roleRef bson.ObjectID) error { - if err := rm.validateObjectIDs(roleRef); err != nil { - rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef)) - return err - } - - if err := rm.rdb.Delete(ctx, roleRef); err != nil { - rm.logger.Warn("Failed to delete role", mzap.ObjRef("role_ref", roleRef)) - return err - } - - if err := rm.enforcer.rdb.DeleteRole(ctx, roleRef); err != nil { - rm.logger.Warn("Failed to remove role", zap.Error(err), mzap.ObjRef("role_ref", roleRef)) - return err - } - - rm.logger.Info("Role deleted successfully along with associated policies", mzap.ObjRef("role_ref", roleRef)) - return nil -} - -// Revoke removes a role from a user. -func (rm *RoleManager) Revoke(ctx context.Context, roleRef, accountRef, organizationRef bson.ObjectID) error { - if err := rm.validateObjectIDs(roleRef, accountRef, organizationRef); err != nil { - return err - } - - err := rm.enforcer.rdb.RemoveRole(ctx, roleRef, organizationRef, accountRef) - return rm.logPolicyResult("revoke", err == nil, err, roleRef, accountRef, organizationRef) -} - -// logPolicyResult logs results for Assign and Revoke. -func (rm *RoleManager) logPolicyResult(action string, result bool, err error, roleRef, accountRef, organizationRef bson.ObjectID) error { - if err != nil { - rm.logger.Warn("Failed to "+action+" role", zap.Error(err), mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return err - } - msg := "Role " + action + "ed successfully" - if !result { - msg = "Role already " + action + "ed" - } - rm.logger.Info(msg, mzap.ObjRef("role_ref", roleRef), mzap.AccRef(accountRef), mzap.OrgRef(organizationRef)) - return nil -} - -// List retrieves all roles in an organization or all roles if organizationRef is zero. -func (rm *RoleManager) List(ctx context.Context, organizationRef bson.ObjectID) ([]model.RoleDescription, error) { - roles4Venues, err := rm.enforcer.rdb.RolesForVenue(ctx, organizationRef) - if err != nil { - rm.logger.Warn("Failed to fetch grouping policies", zap.Error(err), mzap.OrgRef(organizationRef)) - return nil, err - } - - roles := rm.fetchRolesFromPolicies(roles4Venues, organizationRef) - rm.logger.Info("Retrieved roles for organization", mzap.OrgRef(organizationRef), zap.Int("count", len(roles))) - return roles, nil -} diff --git a/backend/pkg/auth/management/permission.go b/backend/pkg/auth/management/permission.go deleted file mode 100644 index 4721d20..0000000 --- a/backend/pkg/auth/management/permission.go +++ /dev/null @@ -1,27 +0,0 @@ -package management - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type Permission interface { - // Grant a permission to a role with an optional object scope and specified effect. - // Use bson.NilObjectID for 'any' objectRef. - GrantToRole(ctx context.Context, policy *model.RolePolicy) error - - // Revoke a permission from a role with an optional object scope and specified effect. - // Use bson.NilObjectID for 'any' objectRef. - RevokeFromRole(ctx context.Context, policy *model.RolePolicy) error - - // Retrieve all policies assigned to a specific role, including scope and effects. - GetPolicies( - ctx context.Context, - roleRef bson.ObjectID, - ) ([]model.RolePolicy, error) - - // Persist any changes made to permissions. - Save() error -} diff --git a/backend/pkg/auth/management/role.go b/backend/pkg/auth/management/role.go deleted file mode 100644 index 8a1709b..0000000 --- a/backend/pkg/auth/management/role.go +++ /dev/null @@ -1,41 +0,0 @@ -package management - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type Role interface { - // Create a new role in an organization (returns the created Role with its ID). - Create( - ctx context.Context, - orgRef bson.ObjectID, - description *model.Describable, - ) (*model.RoleDescription, error) - - // Delete a role entirely. This will cascade and remove all associated - Delete( - ctx context.Context, - roleRef bson.ObjectID, - ) error - - // Assign a role to a user in a specific organization. - Assign( - ctx context.Context, - role *model.Role, - ) error - - // Revoke a role from a user in a specific organization. - Revoke( - ctx context.Context, - roleRef, accountRef, orgRef bson.ObjectID, - ) error - - // List all roles in an organization or globally if orgRef is bson.NilObjectID. - List( - ctx context.Context, - orgRef bson.ObjectID, - ) ([]model.RoleDescription, error) -} diff --git a/backend/pkg/auth/manager.go b/backend/pkg/auth/manager.go deleted file mode 100644 index 7b678a8..0000000 --- a/backend/pkg/auth/manager.go +++ /dev/null @@ -1,15 +0,0 @@ -package auth - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/auth/management" -) - -// Manager provides access to domain-aware Permission and Role managers. -type Manager interface { - // Permission returns a manager that handles permission grants/revokes - // for a specific resource type. (You might add domainRef here if desired.) - Permission() management.Permission - - // Role returns the domain-aware Role manager. - Role() management.Role -} diff --git a/backend/pkg/auth/patchguard.go b/backend/pkg/auth/patchguard.go deleted file mode 100644 index 360e86b..0000000 --- a/backend/pkg/auth/patchguard.go +++ /dev/null @@ -1,30 +0,0 @@ -package auth - -import ( - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// denyImmutablePatch returns ErrInvalidArg if patch touches any field whose -// mutation would silently undermine future access decisions. -func denyImmutablePatch(patch builder.Patch) error { - if patch == nil { - return nil - } - for _, op := range patch.Build() { - fields, ok := op.Value.(bson.D) - if !ok { - continue - } - for _, f := range fields { - if f.Key == "owner" || strings.HasPrefix(f.Key, "owner.") || - f.Key == "organizationRef" || f.Key == "permissionRef" { - return merrors.InvalidArgumentf("patch field %q is immutable", f.Key) - } - } - } - return nil -} diff --git a/backend/pkg/auth/provider.go b/backend/pkg/auth/provider.go deleted file mode 100644 index c775f5c..0000000 --- a/backend/pkg/auth/provider.go +++ /dev/null @@ -1,14 +0,0 @@ -package auth - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" -) - -type Provider interface { - Enforcer() Enforcer - Manager() Manager - GetPolicyDescription(ctx context.Context, resource mservice.Type) (*model.PolicyDescription, error) -} diff --git a/backend/pkg/auth/scoped.go b/backend/pkg/auth/scoped.go deleted file mode 100644 index 043bd48..0000000 --- a/backend/pkg/auth/scoped.go +++ /dev/null @@ -1,71 +0,0 @@ -package auth - -import ( - "context" - - "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" -) - -// ScopedAccessParams bundles everything needed to authorize an action against a -// resource that follows the personal-vs-org ownership model. -// -// It is deliberately a plain struct (not an interface over a model) so it works -// both for locally-stored objects and for objects whose ownership is learned -// from a downstream service response. -type ScopedAccessParams struct { - // Service is the resource type, used only to build the access-denied error. - Service mservice.Type - // PolicyRef is the org policy consulted when the object is org-owned. - PolicyRef bson.ObjectID - // CallerRef is the subject taken from the auth token. Never from the request body. - CallerRef bson.ObjectID - // OrgRef is the organization the object belongs to. For org-owned objects - // caller membership in it is checked before policy enforcement. Personal - // objects are deliberately exempt from the membership check so the owner - // keeps access after leaving the organization; membership for personal - // objects is enforced once, at create time (see ScopedDBImp.Create). - OrgRef bson.ObjectID - // ObjectRef is the concrete object being accessed. Passing it enables - // object-level checks in the Enforcer; use bson.NilObjectID for scope-only. - ObjectRef bson.ObjectID - // OwnerKind / OwnerRef describe the object's actual ownership, read FROM the - // object — not derived from the request. - OwnerKind model.OwnerKind - OwnerRef bson.ObjectID - // Action is the operation being authorized. - Action model.Action -} - -// enforceOwnershipAccess decides access based on ownership and policy only. -// It does NOT check organization membership — the caller must verify membership -// before invoking this. Use ScopedAuthorizer.Authorize for the full -// membership-aware access check. -// -// - personal (OwnerKindAccount): allowed iff OwnerRef == CallerRef. -// - org (OwnerKindOrganization): allowed iff the Enforcer grants Action within OrgRef. -// - anything else: denied by default. -func enforceOwnershipAccess(ctx context.Context, enf Enforcer, p ScopedAccessParams) error { - switch p.OwnerKind { - case model.OwnerKindAccount: - if p.OwnerRef == p.CallerRef { - return nil - } - return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef) - - case model.OwnerKindOrganization: - ok, err := enf.Enforce(ctx, p.PolicyRef, p.CallerRef, p.OrgRef, p.ObjectRef, p.Action) - if err != nil { - return err - } - if !ok { - return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef) - } - return nil - - default: - return merrors.AccessDenied(p.Service, string(p.Action), p.ObjectRef) - } -} diff --git a/backend/pkg/auth/scoped_test.go b/backend/pkg/auth/scoped_test.go deleted file mode 100644 index c9ee79d..0000000 --- a/backend/pkg/auth/scoped_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package auth - -import ( - "context" - "errors" - "testing" - - "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" -) - -// fakeEnforcer records the last call and returns canned results, so the scoped -// access rules can be exercised without a real policy backend. -type fakeEnforcer struct { - allow bool - err error - called bool - - gotPolicyRef bson.ObjectID - gotAccount bson.ObjectID - gotOrg bson.ObjectID - gotObject bson.ObjectID - gotAction model.Action -} - -func (f *fakeEnforcer) Enforce(_ context.Context, permissionRef, accountRef, orgRef, objectRef bson.ObjectID, action model.Action) (bool, error) { - f.called = true - f.gotPolicyRef, f.gotAccount, f.gotOrg, f.gotObject, f.gotAction = permissionRef, accountRef, orgRef, objectRef, action - return f.allow, f.err -} - -func (f *fakeEnforcer) EnforceBatch(context.Context, []model.PermissionBoundStorable, bson.ObjectID, model.Action) (map[bson.ObjectID]bool, error) { - return map[bson.ObjectID]bool{}, nil -} - -func (f *fakeEnforcer) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) { - return nil, nil -} - -func (f *fakeEnforcer) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) { - return nil, nil, nil -} - -func TestEnforceOwnershipAccess(t *testing.T) { - caller := bson.NewObjectID() - other := bson.NewObjectID() - org := bson.NewObjectID() - object := bson.NewObjectID() - policy := bson.NewObjectID() - - base := func(kind model.OwnerKind, owner bson.ObjectID) ScopedAccessParams { - return ScopedAccessParams{ - Service: mservice.Recipients, - PolicyRef: policy, - CallerRef: caller, - OrgRef: org, - ObjectRef: object, - OwnerKind: kind, - OwnerRef: owner, - Action: model.ActionRead, - } - } - - t.Run("personal owner is allowed without policy", func(t *testing.T) { - enf := &fakeEnforcer{} - if err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindAccount, caller)); err != nil { - t.Fatalf("expected allow, got %v", err) - } - if enf.called { - t.Fatal("personal access must not consult the enforcer") - } - }) - - t.Run("personal non-owner is denied without policy", func(t *testing.T) { - enf := &fakeEnforcer{} - err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindAccount, other)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - if enf.called { - t.Fatal("personal access must not consult the enforcer") - } - }) - - t.Run("org owner consults policy and passes object ref", func(t *testing.T) { - enf := &fakeEnforcer{allow: true} - if err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org)); err != nil { - t.Fatalf("expected allow, got %v", err) - } - if !enf.called { - t.Fatal("org access must consult the enforcer") - } - if enf.gotObject != object || enf.gotOrg != org || enf.gotAccount != caller || enf.gotPolicyRef != policy { - t.Fatalf("enforcer received unexpected args: %+v", enf) - } - }) - - t.Run("org owner denied when policy refuses", func(t *testing.T) { - enf := &fakeEnforcer{allow: false} - err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - }) - - t.Run("org policy error is propagated, not masked as denial", func(t *testing.T) { - boom := merrors.Define("backend down") - enf := &fakeEnforcer{err: boom} - err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKindOrganization, org)) - if !errors.Is(err, boom) { - t.Fatalf("expected underlying error, got %v", err) - } - if errors.Is(err, merrors.ErrAccessDenied) { - t.Fatal("internal error must not be reported as access denied") - } - }) - - t.Run("unknown owner kind is denied by default", func(t *testing.T) { - enf := &fakeEnforcer{allow: true} - err := enforceOwnershipAccess(context.Background(), enf, base(model.OwnerKind("garbage"), caller)) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - if enf.called { - t.Fatal("unknown kind must not consult the enforcer") - } - }) -} diff --git a/backend/pkg/auth/scopeddb.go b/backend/pkg/auth/scopeddb.go deleted file mode 100644 index 860c14a..0000000 --- a/backend/pkg/auth/scopeddb.go +++ /dev/null @@ -1,129 +0,0 @@ -package auth - -import ( - "context" - "errors" - - "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" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// ownerLoader is the minimal DB contract that ScopedDB needs: fetch a resource -// by ID and populate the caller-supplied result. *template.DBImp[T] satisfies -// this interface without any adapter code. -type ownerLoader[T any] interface { - Get(ctx context.Context, objectRef bson.ObjectID, result T) error -} - -// ScopedDB[T] provides loadAndEnforce semantics for resources that implement -// model.OwnerBound. It loads the resource from the database (so ownership is -// always read from the authoritative source, never from request fields) and -// then delegates access control to ScopedAuthorizer, which checks membership -// before ownership/policy enforcement. -// -// Use ProtectedDB for resources that carry no ownerKind/ownerRef and rely -// solely on org-level policy checks. -type ScopedDB[T model.OwnerBound] struct { - // DB is the raw repository accessor. *template.DBImp[T] satisfies this. - DB ownerLoader[T] - // Logger receives Debug/Warn messages from the enforcement path. - Logger mlogger.Logger - // Authorizer performs the full membership-aware access check. - Authorizer *ScopedAuthorizer - // OrgPolicyRef is the built-in policy checked when ownerKind==organization. - OrgPolicyRef bson.ObjectID - // Collection identifies the resource type in access-denied errors and logs. - Collection mservice.Type - // New allocates a zero-value T ready to be filled by DB.Get. - New func() T -} - -// NewScopedDB constructs a ScopedDB and validates required dependencies. -// Logger defaults to zap.NewNop() if nil. -func NewScopedDB[T model.OwnerBound]( - db ownerLoader[T], - authorizer *ScopedAuthorizer, - orgPolicyRef bson.ObjectID, - collection mservice.Type, - newFn func() T, - logger mlogger.Logger, -) *ScopedDB[T] { - if db == nil { - panic("auth.NewScopedDB: DB must not be nil") - } - if authorizer == nil { - panic("auth.NewScopedDB: Authorizer must not be nil") - } - if newFn == nil { - panic("auth.NewScopedDB: New must not be nil") - } - if logger == nil { - logger = zap.NewNop() - } - return &ScopedDB[T]{ - DB: db, - Logger: logger, - Authorizer: authorizer, - OrgPolicyRef: orgPolicyRef, - Collection: collection, - New: newFn, - } -} - -// LoadAndEnforce fetches the resource identified by objectRef, reads the -// ownerKind and ownerRef fields from the database record, and enforces access -// via ScopedAuthorizer (membership check → ownership/policy check). It never -// trusts ownership data from the caller. -// -// Logging contract: -// - Debug on access-check start and after ownership is loaded from DB. -// - Debug when access is denied (ErrAccessDenied) — intentional, not an error. -// - Warn when the Enforcer itself returns a non-nil error (unexpected failure). -// - No log on success. -func (s *ScopedDB[T]) LoadAndEnforce(ctx context.Context, callerRef, objectRef bson.ObjectID, action model.Action) (T, error) { - var zero T - - s.Logger.Debug("access check started", - mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef), - zap.String("collection", s.Collection), zap.String("action", string(action))) - - obj := s.New() - if err := s.DB.Get(ctx, objectRef, obj); err != nil { - return zero, err - } - - s.Logger.Debug("ownership loaded from db", - mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef), - zap.String("ownerKind", string(obj.GetOwnerKind()))) - - err := s.Authorizer.Authorize(ctx, ScopedAccessParams{ - Service: s.Collection, - PolicyRef: s.OrgPolicyRef, - CallerRef: callerRef, - OrgRef: obj.GetOrganizationRef(), - ObjectRef: objectRef, - OwnerKind: obj.GetOwnerKind(), - OwnerRef: obj.GetOwnerRef(), - Action: action, - }) - if err == nil { - return obj, nil - } - - if errors.Is(err, merrors.ErrAccessDenied) { - s.Logger.Debug("access denied", - mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef), - zap.String("ownerKind", string(obj.GetOwnerKind())), - zap.String("action", string(action))) - } else { - s.Logger.Warn("enforcer returned error during scoped access check", - zap.Error(err), mzap.AccRef(callerRef), mzap.ObjRef("object_ref", objectRef), - zap.String("action", string(action))) - } - return zero, err -} diff --git a/backend/pkg/auth/scopeddb_test.go b/backend/pkg/auth/scopeddb_test.go deleted file mode 100644 index 240c6d1..0000000 --- a/backend/pkg/auth/scopeddb_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package auth - -import ( - "context" - "errors" - "testing" - - "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" - "go.uber.org/zap" -) - -// testOwned is a minimal model.OwnerBound used exclusively by ScopedDB tests. -type testOwned struct { - id bson.ObjectID - orgRef bson.ObjectID - kind model.OwnerKind - ownerRef bson.ObjectID -} - -func (o *testOwned) GetID() *bson.ObjectID { return &o.id } -func (o *testOwned) SetID(id bson.ObjectID) { o.id = id } -func (o *testOwned) Update() {} -func (o *testOwned) Collection() string { return "test_owned" } -func (o *testOwned) GetOrganizationRef() bson.ObjectID { return o.orgRef } -func (o *testOwned) SetOrganizationRef(ref bson.ObjectID) { o.orgRef = ref } -func (o *testOwned) GetOwnerKind() model.OwnerKind { return o.kind } -func (o *testOwned) GetOwnerRef() bson.ObjectID { return o.ownerRef } - -var _ model.OwnerBound = (*testOwned)(nil) - -// fakeOwnerLoader returns the pre-built object or the configured error. -type fakeOwnerLoader struct { - obj *testOwned - err error -} - -func (f *fakeOwnerLoader) Get(_ context.Context, _ bson.ObjectID, result *testOwned) error { - if f.err != nil { - return f.err - } - *result = *f.obj - return nil -} - -// fakeMembership controls whether IsMember returns true/false/error. -type fakeMembership struct { - member bool - err error - called bool -} - -func (f *fakeMembership) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) { - f.called = true - return f.member, f.err -} - -func newTestScopedDB(loader *fakeOwnerLoader, membership *fakeMembership, enf Enforcer) *ScopedDB[*testOwned] { - authorizer := &ScopedAuthorizer{ - Logger: zap.NewNop(), - Membership: membership, - Enforcer: enf, - } - return &ScopedDB[*testOwned]{ - DB: loader, - Logger: zap.NewNop(), - Authorizer: authorizer, - OrgPolicyRef: bson.NewObjectID(), - Collection: mservice.Type("test_owned"), - New: func() *testOwned { return &testOwned{} }, - } -} - -func TestScopedDB_PersonalOwner_Allowed(t *testing.T) { - caller := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: bson.NewObjectID(), - kind: model.OwnerKindAccount, - ownerRef: caller, // owner == caller - } - enf := &fakeEnforcer{} - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) - - result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if err != nil { - t.Fatalf("expected allow, got %v", err) - } - if result == nil { - t.Fatal("expected non-nil result on success") - } - if enf.called { - t.Fatal("personal access must not consult the enforcer") - } -} - -func TestScopedDB_PersonalOwner_Denied(t *testing.T) { - caller := bson.NewObjectID() - other := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: bson.NewObjectID(), - kind: model.OwnerKindAccount, - ownerRef: other, // owner != caller - } - enf := &fakeEnforcer{} - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) - - _, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - if enf.called { - t.Fatal("personal access must not consult the enforcer") - } -} - -func TestScopedDB_OrgOwner_Allowed(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: orgRef, - kind: model.OwnerKindOrganization, - ownerRef: orgRef, - } - enf := &fakeEnforcer{allow: true} - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) - - result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if err != nil { - t.Fatalf("expected allow, got %v", err) - } - if result == nil { - t.Fatal("expected non-nil result on success") - } - if !enf.called { - t.Fatal("org access must consult the enforcer") - } -} - -func TestScopedDB_OrgOwner_Denied(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: orgRef, - kind: model.OwnerKindOrganization, - ownerRef: orgRef, - } - enf := &fakeEnforcer{allow: false} - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) - - _, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied, got %v", err) - } - if !enf.called { - t.Fatal("org access must consult the enforcer") - } -} - -func TestScopedDB_OrgOwner_EnforcerError_Propagated(t *testing.T) { - boom := merrors.Define("enforcer backend down") - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: orgRef, - kind: model.OwnerKindOrganization, - ownerRef: orgRef, - } - enf := &fakeEnforcer{err: boom} - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, &fakeMembership{member: true}, enf) - - _, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if !errors.Is(err, boom) { - t.Fatalf("expected underlying error propagated, got %v", err) - } - if errors.Is(err, merrors.ErrAccessDenied) { - t.Fatal("enforcer internal error must not be masked as access denied") - } -} - -func TestScopedDB_DBError_Propagated(t *testing.T) { - dbErr := merrors.Define("mongo unavailable") - db := newTestScopedDB(&fakeOwnerLoader{err: dbErr}, &fakeMembership{member: true}, &fakeEnforcer{}) - - _, err := db.LoadAndEnforce(context.Background(), bson.NewObjectID(), bson.NewObjectID(), model.ActionRead) - if !errors.Is(err, dbErr) { - t.Fatalf("expected DB error propagated, got %v", err) - } -} - -// TestScopedDB_PersonalOwner_NonMember_Denied is the main regression case: -// even when ownerRef == callerRef, a non-member of the organization must be -// denied before ownership is checked, so the tenant boundary is enforced. -// TestScopedDB_PersonalOwner_NonMember_Allowed: personal resource, caller is the -// owner but is NOT a member of the org. Membership is not consulted for -// account-owned resources — ownership alone is sufficient. -func TestScopedDB_PersonalOwner_NonMember_Allowed(t *testing.T) { - caller := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: bson.NewObjectID(), - kind: model.OwnerKindAccount, - ownerRef: caller, // owner == caller, membership is irrelevant - } - membership := &fakeMembership{member: false} - enf := &fakeEnforcer{} - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, membership, enf) - - result, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if err != nil { - t.Fatalf("expected allow for personal resource owner regardless of membership, got %v", err) - } - if result == nil { - t.Fatal("expected non-nil result on success") - } - if enf.called { - t.Fatal("enforcer must not be called for account-owned resource") - } -} - -// TestScopedDB_OrgOwner_NonMember_Denied: org-owned object, caller is not a -// member — must be denied before the policy enforcer is consulted. -func TestScopedDB_OrgOwner_NonMember_Denied(t *testing.T) { - caller := bson.NewObjectID() - orgRef := bson.NewObjectID() - objectRef := bson.NewObjectID() - obj := &testOwned{ - id: objectRef, - orgRef: orgRef, - kind: model.OwnerKindOrganization, - ownerRef: orgRef, - } - membership := &fakeMembership{member: false} - enf := &fakeEnforcer{allow: true} // would allow if reached - db := newTestScopedDB(&fakeOwnerLoader{obj: obj}, membership, enf) - - _, err := db.LoadAndEnforce(context.Background(), caller, objectRef, model.ActionRead) - if !errors.Is(err, merrors.ErrAccessDenied) { - t.Fatalf("expected access denied for non-member org-owned object, got %v", err) - } - if enf.called { - t.Fatal("enforcer must not be called when membership check fails") - } -} diff --git a/backend/pkg/db/account/account.go b/backend/pkg/db/account/account.go index 335b1f0..369b04f 100644 --- a/backend/pkg/db/account/account.go +++ b/backend/pkg/db/account/account.go @@ -5,12 +5,10 @@ import ( "git.sendico.io/tech/sxchnge/bff/pkg/db/template" "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 { template.DB[*model.Account] GetByEmail(ctx context.Context, email string) (*model.Account, error) - GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) } diff --git a/backend/pkg/db/config.go b/backend/pkg/db/config.go index 015d0e5..52d626d 100644 --- a/backend/pkg/db/config.go +++ b/backend/pkg/db/config.go @@ -1,13 +1,7 @@ package db import ( - "strings" - - mongoimpl "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/go-viper/mapstructure/v2" - "go.uber.org/zap" ) type DBDriver string @@ -17,87 +11,3 @@ const ( ) type Config = model.DriverConfig[DBDriver] - -// PermissionsDatabaseConfig is the explicit public contract for services that -// read authorization metadata from the central permissions database. -type PermissionsDatabaseConfig Config - -// AsDatabaseConfig converts the permissions DB contract to the existing DB -// config type without duplicating driver parsing or connection behavior. -func (c *PermissionsDatabaseConfig) AsDatabaseConfig() *Config { - if c == nil { - return nil - } - converted := Config(*c) - return &converted -} - -// ResolvedDatabaseName identifies the database selected by a DB config. -type ResolvedDatabaseName struct { - Name string - Source string -} - -// ResolveDatabaseName resolves only the database name portion of a DB config. -// It intentionally mirrors the existing env-over-literal behavior and leaves -// low-level connection validation to the normal DB connection path. -func ResolveDatabaseName(config *Config) ResolvedDatabaseName { - if config == nil { - return ResolvedDatabaseName{} - } - return resolveDatabaseName(config) -} - -// ResolvePermissionsDatabaseName resolves only the database name portion of a -// permissions DB config. -func ResolvePermissionsDatabaseName(config *PermissionsDatabaseConfig) ResolvedDatabaseName { - return ResolveDatabaseName(config.AsDatabaseConfig()) -} - -// ValidateSplitDomainPermissionsDatabase verifies the generic split-domain -// invariant for services that must keep authorization metadata separate from -// domain storage. -func ValidateSplitDomainPermissionsDatabase(service string, domain *Config, permissions *PermissionsDatabaseConfig) (ResolvedDatabaseName, ResolvedDatabaseName, error) { - domainName := ResolveDatabaseName(domain) - permissionsName := ResolvePermissionsDatabaseName(permissions) - - if permissions == nil { - return domainName, permissionsName, merrors.InvalidArgument(service+" permissions_database configuration is required", "permissions_database") - } - if strings.TrimSpace(domainName.Name) == "" { - return domainName, permissionsName, merrors.InvalidArgument(service+" domain database name is required for split-domain validation", "database") - } - if strings.TrimSpace(permissionsName.Name) == "" { - return domainName, permissionsName, merrors.InvalidArgument(service+" permissions database name is required for split-domain validation", "permissions_database") - } - if domainName.Name == permissionsName.Name { - return domainName, permissionsName, merrors.InvalidArgument(service+" permissions_database must resolve to a different database than the domain database", "permissions_database") - } - - return domainName, permissionsName, nil -} - -func resolveDatabaseName(config *Config) ResolvedDatabaseName { - if config == nil || config.Driver != Mongo { - return ResolvedDatabaseName{} - } - var mongoConfig mongoimpl.Config - if err := mapstructure.Decode(config.Settings, &mongoConfig); err != nil { - return ResolvedDatabaseName{} - } - dbSettings := mongoimpl.Config2DBSettings(zap.NewNop(), &mongoConfig) - source := "" - if mongoConfig.DatabaseEnv != nil { - source = "env:" + *mongoConfig.DatabaseEnv - } else if mongoConfig.Database != nil { - source = "settings.database" - } - if source != "" { - return ResolvedDatabaseName{ - Name: dbSettings.Database, - Source: source, - } - } - - return ResolvedDatabaseName{} -} diff --git a/backend/pkg/db/factory.go b/backend/pkg/db/factory.go index a3d42e8..5a49298 100644 --- a/backend/pkg/db/factory.go +++ b/backend/pkg/db/factory.go @@ -1,12 +1,8 @@ package db import ( - "git.sendico.io/tech/sxchnge/bff/pkg/auth" "git.sendico.io/tech/sxchnge/bff/pkg/db/account" mongoimpl "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo" - "git.sendico.io/tech/sxchnge/bff/pkg/db/invitation" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - "git.sendico.io/tech/sxchnge/bff/pkg/db/role" "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" @@ -16,15 +12,9 @@ import ( type Factory interface { NewAccountDB() (account.DB, error) NewAccountDBWithCollection(collection string) (account.DB, error) - NewInvitationsDB() (invitation.DB, error) - - NewRolesDB() (role.DB, error) - NewPoliciesDB() (policy.DB, error) TransactionFactory() transaction.Factory - Permissions() auth.Provider - CloseConnection() } @@ -38,16 +28,3 @@ func NewConnection(logger mlogger.Logger, config *Config) (Factory, error) { } return nil, merrors.InvalidArgument("unknown database driver: "+string(config.Driver), "config.driver") } - -// NewCentralPermissionsConnection opens the explicitly configured central -// permissions database. It intentionally has no fallback to a service domain DB. -func NewCentralPermissionsConnection(logger mlogger.Logger, config *PermissionsDatabaseConfig) (Factory, error) { - if config == nil { - return nil, merrors.InvalidArgument("permissions_database configuration is required", "permissions_database") - } - conn, err := NewConnection(logger, config.AsDatabaseConfig()) - if err != nil { - return nil, merrors.Wrap(err, "permissions_database") - } - return conn, nil -} diff --git a/backend/pkg/db/internal/mongo/accountdb/user.go b/backend/pkg/db/internal/mongo/accountdb/user.go index 434f01f..76f9697 100644 --- a/backend/pkg/db/internal/mongo/accountdb/user.go +++ b/backend/pkg/db/internal/mongo/accountdb/user.go @@ -4,17 +4,9 @@ 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 *AccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) { - filter := repository.Query().Comparison(repository.IDField(), builder.In, refs) - return mutil.GetObjects[model.Account](ctx, db.Logger, filter, nil, db.Repository) -} - func (db *AccountDB) GetByEmail(ctx context.Context, email string) (*model.Account, error) { var account model.Account return &account, db.FindOne(ctx, repository.Filter("login", email), &account) diff --git a/backend/pkg/db/internal/mongo/db.go b/backend/pkg/db/internal/mongo/db.go index b57113a..a536c5a 100644 --- a/backend/pkg/db/internal/mongo/db.go +++ b/backend/pkg/db/internal/mongo/db.go @@ -7,21 +7,12 @@ import ( "os" "strings" - "git.sendico.io/tech/sxchnge/bff/pkg/auth" "git.sendico.io/tech/sxchnge/bff/pkg/db/account" "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/accountdb" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/invitationdb" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/policiesdb" - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/rolesdb" "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/transactionimp" - "git.sendico.io/tech/sxchnge/bff/pkg/db/invitation" - "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/role" "git.sendico.io/tech/sxchnge/bff/pkg/db/transaction" "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/config" "github.com/go-viper/mapstructure/v2" "go.mongodb.org/mongo-driver/v2/mongo" @@ -31,29 +22,28 @@ import ( // Config represents configuration type Config struct { - Port *string `mapstructure:"port"` - PortEnv *string `mapstructure:"port_env"` - User *string `mapstructure:"user"` - UserEnv *string `mapstructure:"user_env"` - PasswordEnv string `mapstructure:"password_env"` - Database *string `mapstructure:"database"` - DatabaseEnv *string `mapstructure:"database_env"` - Host *string `mapstructure:"host"` - HostEnv *string `mapstructure:"host_env"` - Hosts []string `mapstructure:"hosts,omitempty"` - HostsEnvPrefix *string `mapstructure:"hosts_env_prefix,omitempty"` - HostsEnvPrefixEnv *string `mapstructure:"hosts_env_prefix_env,omitempty"` - PortsEnvPrefix *string `mapstructure:"ports_env_prefix,omitempty"` - PortsEnvPrefixEnv *string `mapstructure:"ports_env_prefix_env,omitempty"` - URI *string `mapstructure:"uri,omitempty"` - URIEnv *string `mapstructure:"uri_env,omitempty"` - AuthSource *string `mapstructure:"auth_source,omitempty"` - AuthSourceEnv *string `mapstructure:"auth_source_env,omitempty"` - AuthMechanism *string `mapstructure:"auth_mechanism,omitempty"` - AuthMechanismEnv *string `mapstructure:"auth_mechanism_env,omitempty"` - ReplicaSet *string `mapstructure:"replica_set,omitempty"` - ReplicaSetEnv *string `mapstructure:"replica_set_env,omitempty"` - Enforcer *auth.Config `mapstructure:"enforcer"` + Port *string `mapstructure:"port"` + PortEnv *string `mapstructure:"port_env"` + User *string `mapstructure:"user"` + UserEnv *string `mapstructure:"user_env"` + PasswordEnv string `mapstructure:"password_env"` + Database *string `mapstructure:"database"` + DatabaseEnv *string `mapstructure:"database_env"` + Host *string `mapstructure:"host"` + HostEnv *string `mapstructure:"host_env"` + Hosts []string `mapstructure:"hosts,omitempty"` + HostsEnvPrefix *string `mapstructure:"hosts_env_prefix,omitempty"` + HostsEnvPrefixEnv *string `mapstructure:"hosts_env_prefix_env,omitempty"` + PortsEnvPrefix *string `mapstructure:"ports_env_prefix,omitempty"` + PortsEnvPrefixEnv *string `mapstructure:"ports_env_prefix_env,omitempty"` + URI *string `mapstructure:"uri,omitempty"` + URIEnv *string `mapstructure:"uri_env,omitempty"` + AuthSource *string `mapstructure:"auth_source,omitempty"` + AuthSourceEnv *string `mapstructure:"auth_source_env,omitempty"` + AuthMechanism *string `mapstructure:"auth_mechanism,omitempty"` + AuthMechanismEnv *string `mapstructure:"auth_mechanism_env,omitempty"` + ReplicaSet *string `mapstructure:"replica_set,omitempty"` + ReplicaSetEnv *string `mapstructure:"replica_set_env,omitempty"` } type DBSettings struct { @@ -69,19 +59,6 @@ type DBSettings struct { URI string } -func newProtectedDB[T any]( - db *DB, - create func(ctx context.Context, logger mlogger.Logger, enforcer auth.Enforcer, pdb policy.DB, client *mongo.Database) (T, error), -) (T, error) { - pdb, err := db.NewPoliciesDB() - if err != nil { - db.logger.Warn("Failed to create policies database", zap.Error(err)) - var zero T - return zero, err - } - return create(context.Background(), db.logger, db.Enforcer(), pdb, db.db()) -} - func Config2DBSettings(logger mlogger.Logger, config *Config) *DBSettings { p := new(DBSettings) p.Port = mutil.GetConfigValue(logger, "port", "port_env", config.Port, config.PortEnv) @@ -160,12 +137,9 @@ func ConnectClient(logger mlogger.Logger, settings model.SettingsT) (*mongo.Clie // DB represents the structure of the database type DB struct { - logger mlogger.Logger - config *DBSettings - client *mongo.Client - enforcer auth.Enforcer - manager auth.Manager - pdb policy.DB + logger mlogger.Logger + config *DBSettings + client *mongo.Client } func (db *DB) db() *mongo.Database { @@ -184,18 +158,6 @@ func (db *DB) NewAccountDBWithCollection(collection string) (account.DB, error) return accountdb.CreateWithCollection(db.logger, db.db(), collection) } -func (db *DB) NewInvitationsDB() (invitation.DB, error) { - return newProtectedDB(db, invitationdb.Create) -} - -func (db *DB) NewPoliciesDB() (policy.DB, error) { - return db.pdb, nil -} - -func (db *DB) NewRolesDB() (role.DB, error) { - return rolesdb.Create(db.logger, db.db()) -} - func (db *DB) TransactionFactory() transaction.Factory { return transactionimp.CreateFactory(db.client) } @@ -264,23 +226,6 @@ func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, host return hosts } -func (db *DB) Permissions() auth.Provider { - return db -} - -func (db *DB) Manager() auth.Manager { - return db.manager -} - -func (db *DB) Enforcer() auth.Enforcer { - return db.enforcer -} - -func (db *DB) GetPolicyDescription(ctx context.Context, resource mservice.Type) (*model.PolicyDescription, error) { - var policyDescription model.PolicyDescription - return &policyDescription, db.pdb.FindOne(ctx, repository.Filter("resourceTypes", resource), &policyDescription) -} - func (db *DB) CloseConnection() { if err := db.client.Disconnect(context.Background()); err != nil { db.logger.Warn("Failed to close connection", zap.Error(err)) @@ -290,7 +235,7 @@ func (db *DB) CloseConnection() { // NewConnection creates a new database connection func NewConnection(logger mlogger.Logger, settings model.SettingsT) (*DB, error) { - client, config, dbSettings, err := ConnectClient(logger, settings) + client, _, dbSettings, err := ConnectClient(logger, settings) if err != nil { return nil, err } @@ -301,28 +246,5 @@ func NewConnection(logger mlogger.Logger, settings model.SettingsT) (*DB, error) client: client, } - cleanup := func(ctx context.Context) { - if err := client.Disconnect(ctx); err != nil { - logger.Warn("Failed to close MongoDB connection", zap.Error(err)) - } - } - - rdb, err := db.NewRolesDB() - if err != nil { - db.logger.Warn("Failed to create roles database", zap.Error(err)) - cleanup(context.Background()) - return nil, err - } - if db.pdb, err = policiesdb.Create(db.logger, db.db()); err != nil { - db.logger.Warn("Failed to create policies database", zap.Error(err)) - cleanup(context.Background()) - return nil, err - } - if db.enforcer, db.manager, err = auth.CreateAuth(logger, db.client, db.db(), db.pdb, rdb, config.Enforcer); err != nil { - db.logger.Warn("Failed to create permissions enforcer", zap.Error(err)) - cleanup(context.Background()) - return nil, err - } - return db, nil } diff --git a/backend/pkg/db/internal/mongo/invitationdb/accept.go b/backend/pkg/db/internal/mongo/invitationdb/accept.go deleted file mode 100644 index b493ea9..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/accept.go +++ /dev/null @@ -1,12 +0,0 @@ -package invitationdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *InvitationDB) Accept(ctx context.Context, invitationRef bson.ObjectID) error { - return db.updateStatus(ctx, invitationRef, model.InvitationAccepted) -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/archived.go b/backend/pkg/db/internal/mongo/invitationdb/archived.go deleted file mode 100644 index 0b95b25..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/archived.go +++ /dev/null @@ -1,49 +0,0 @@ -package invitationdb - -import ( - "context" - - "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" -) - -// SetArchived sets the archived status of an invitation -// Invitation supports archiving through PermissionBound embedding ArchivableBase -func (db *InvitationDB) SetArchived(ctx context.Context, accountRef, organizationRef, invitationRef bson.ObjectID, archived, cascade bool) error { - db.DBImp.Logger.Debug("Setting invitation archived status", mzap.ObjRef("invitation_ref", invitationRef), zap.Bool("archived", archived), zap.Bool("cascade", cascade)) - res, err := db.Enforcer.Enforce(ctx, db.PermissionRef, accountRef, organizationRef, invitationRef, model.ActionUpdate) - if err != nil { - db.DBImp.Logger.Warn("Failed to enforce archivation permission", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) - return err - } - if !res { - db.DBImp.Logger.Debug("Permission denied for archivation", mzap.ObjRef("invitation_ref", invitationRef)) - return merrors.AccessDenied(db.Collection, string(model.ActionUpdate), invitationRef) - } - - // Get the invitation first - var invitation model.Invitation - if err := db.Get(ctx, accountRef, invitationRef, &invitation); err != nil { - db.DBImp.Logger.Warn("Error retrieving invitation for archival", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) - return err - } - - // Update the invitation's archived status - invitation.SetArchived(archived) - if err := db.Update(ctx, accountRef, &invitation); err != nil { - db.DBImp.Logger.Warn("Error updating invitation archived status", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) - return err - } - - // Note: Currently no cascade dependencies for invitations - // If cascade is enabled, we could add logic here for any future dependencies - if cascade { - db.DBImp.Logger.Debug("Cascade archiving requested but no dependencies to archive for invitation", mzap.ObjRef("invitation_ref", invitationRef)) - } - - db.DBImp.Logger.Debug("Successfully set invitation archived status", mzap.ObjRef("invitation_ref", invitationRef), zap.Bool("archived", archived)) - return nil -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/cascade.go b/backend/pkg/db/internal/mongo/invitationdb/cascade.go deleted file mode 100644 index 08cce22..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/cascade.go +++ /dev/null @@ -1,24 +0,0 @@ -package invitationdb - -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 invitation -// Invitations don't have cascade dependencies, so this is a simple deletion -func (db *InvitationDB) DeleteCascade(ctx context.Context, accountRef, invitationRef bson.ObjectID) error { - db.DBImp.Logger.Debug("Starting invitation cascade deletion", mzap.ObjRef("invitation_ref", invitationRef)) - - // Delete the invitation itself (no dependencies to cascade delete) - if err := db.Delete(ctx, accountRef, invitationRef); err != nil { - db.DBImp.Logger.Error("Error deleting invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) - return err - } - - db.DBImp.Logger.Debug("Successfully deleted invitation", mzap.ObjRef("invitation_ref", invitationRef)) - return nil -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/db.go b/backend/pkg/db/internal/mongo/invitationdb/db.go deleted file mode 100644 index 3293103..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/db.go +++ /dev/null @@ -1,48 +0,0 @@ -package invitationdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "git.sendico.io/tech/sxchnge/bff/pkg/db/policy" - ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index" - "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 InvitationDB struct { - auth.ProtectedDBImp[*model.Invitation] -} - -func Create( - ctx context.Context, - logger mlogger.Logger, - enforcer auth.Enforcer, - pdb policy.DB, - db *mongo.Database, -) (*InvitationDB, error) { - p, err := auth.CreateDBImp[*model.Invitation](ctx, logger, pdb, enforcer, mservice.Invitations, db) - if err != nil { - return nil, err - } - - if err := p.DBImp.Repository.CreateIndex(pendingInvitationEmailIndexDefinition()); err != nil { - p.DBImp.Logger.Error("Failed to create unique pending invitation email index", zap.Error(err)) - return nil, err - } - - // ttl index - ttl := int32(0) // zero ttl means expiration on date preset when inserting data - if err := p.DBImp.Repository.CreateIndex(&ri.Definition{ - Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}}, - TTL: &ttl, - }); err != nil { - p.DBImp.Logger.Error("Failed to create ttl index in the invitations", zap.Error(err)) - return nil, err - } - - return &InvitationDB{ProtectedDBImp: *p}, nil -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/db_integration_test.go b/backend/pkg/db/internal/mongo/invitationdb/db_integration_test.go deleted file mode 100644 index 6d59c91..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/db_integration_test.go +++ /dev/null @@ -1,108 +0,0 @@ -//go:build integration -// +build integration - -package invitationdb - -import ( - "context" - "testing" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "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" - "github.com/testcontainers/testcontainers-go/modules/mongodb" - "github.com/testcontainers/testcontainers-go/wait" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" -) - -func TestPendingInvitationEmailIndexBehavior(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - mongoContainer, err := mongodb.Run(ctx, - "mongo:latest", - mongodb.WithUsername("root"), - mongodb.WithPassword("password"), - testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)), - ) - require.NoError(t, err) - defer func() { - termCtx, termCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer termCancel() - _ = mongoContainer.Terminate(termCtx) - }() - - mongoURI, err := mongoContainer.ConnectionString(ctx) - require.NoError(t, err) - - client, err := mongo.Connect(options.Client().ApplyURI(mongoURI)) - require.NoError(t, err) - defer func() { _ = client.Disconnect(context.Background()) }() - - database := client.Database("test_invitation_pending_email_index") - defer func() { _ = database.Drop(context.Background()) }() - - repo := repository.CreateMongoRepository(database, "invitations") - require.NoError(t, repo.CreateIndex(pendingInvitationEmailIndexDefinition())) - assertPendingEmailIndexDefinition(t, ctx, database.Collection("invitations")) - - orgRef := bson.NewObjectID() - email := "invitee@example.com" - require.NoError(t, repo.Insert(ctx, testInvitation(orgRef, email, model.InvitationCreated), nil)) - err = repo.Insert(ctx, testInvitation(orgRef, email, model.InvitationSent), nil) - require.ErrorIs(t, err, merrors.ErrDataConflict, "duplicate pending invitation must conflict") - - for _, status := range []model.InvitationStatus{model.InvitationRevoked, model.InvitationDeclined, model.InvitationAccepted} { - statusEmail := string(status) + "@example.com" - require.NoError(t, repo.Insert(ctx, testInvitation(orgRef, statusEmail, status), nil)) - require.NoError(t, repo.Insert(ctx, testInvitation(orgRef, statusEmail, model.InvitationCreated), nil)) - } - - otherOrgRef := bson.NewObjectID() - require.NoError(t, repo.Insert(ctx, testInvitation(otherOrgRef, email, model.InvitationCreated), nil)) -} - -func testInvitation(orgRef bson.ObjectID, email string, status model.InvitationStatus) *model.Invitation { - inv := &model.Invitation{ - OrganizationRef: orgRef, - Status: status, - ExpiresAt: time.Now().UTC().Add(time.Hour), - } - inv.SetID(bson.NewObjectID()) - inv.SetOrganizationRef(orgRef) - inv.Content.Email = email - return inv -} - -func assertPendingEmailIndexDefinition(t *testing.T, ctx context.Context, collection *mongo.Collection) { - t.Helper() - cursor, err := collection.Indexes().List(ctx) - require.NoError(t, err) - defer func() { _ = cursor.Close(ctx) }() - - for cursor.Next(ctx) { - var index bson.M - require.NoError(t, cursor.Decode(&index)) - if index["name"] != uniquePendingInvitationEmailIndexName { - continue - } - - require.Equal(t, true, index["unique"]) - require.Equal(t, bson.D{ - {Key: "organizationRef", Value: int32(1)}, - {Key: "description.email", Value: int32(1)}, - }, index["key"]) - require.Equal(t, bson.D{ - {Key: "status", Value: bson.D{{Key: "$in", Value: bson.A{"created", "sent"}}}}, - {Key: "description.email", Value: bson.D{{Key: "$type", Value: "string"}}}, - }, index["partialFilterExpression"]) - return - } - require.NoError(t, cursor.Err()) - t.Fatalf("index %q was not created", uniquePendingInvitationEmailIndexName) -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/db_test.go b/backend/pkg/db/internal/mongo/invitationdb/db_test.go deleted file mode 100644 index 726b8cb..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/db_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package invitationdb - -import ( - "reflect" - "testing" - - "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/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestPendingInvitationEmailIndexDefinition(t *testing.T) { - def := pendingInvitationEmailIndexDefinition() - - expectedKeys := []ri.Key{ - {Field: "organizationRef", Sort: ri.Asc}, - {Field: "description.email", Sort: ri.Asc}, - } - if !reflect.DeepEqual(def.Keys, expectedKeys) { - t.Fatalf("keys mismatch: got %#v want %#v", def.Keys, expectedKeys) - } - if !def.Unique { - t.Fatal("index must be unique") - } - if def.Name != uniquePendingInvitationEmailIndexName { - t.Fatalf("index name mismatch: got %q want %q", def.Name, uniquePendingInvitationEmailIndexName) - } - - expectedFilter := repository.Query(). - In(repository.Field("status"), "created", "sent"). - Comparison(repository.Field("description.email"), builder.Type, "string"). - BuildQuery() - if !reflect.DeepEqual(def.PartialFilter.BuildQuery(), expectedFilter) { - t.Fatalf("partial filter mismatch: got %#v want %#v", def.PartialFilter.BuildQuery(), expectedFilter) - } -} - -func TestPendingInvitationEmailIndexUsesModelPendingStatuses(t *testing.T) { - def := pendingInvitationEmailIndexDefinition() - filter := def.PartialFilter.BuildQuery() - statuses := statusInValues(t, filter) - - expected := model.PendingInvitationStatusValues() - if !reflect.DeepEqual(statuses, expected) { - t.Fatalf("index statuses drifted from model: got %#v want %#v", statuses, expected) - } -} - -func statusInValues(t *testing.T, filter bson.D) []string { - t.Helper() - for _, item := range filter { - if item.Key != "status" { - continue - } - comparison, ok := item.Value.(bson.M) - if !ok { - t.Fatalf("status filter has unexpected type %T", item.Value) - } - rawValues, ok := comparison["$in"] - if !ok { - t.Fatal("status filter must use $in") - } - values, ok := rawValues.([]any) - if !ok { - t.Fatalf("status $in has unexpected type %T", rawValues) - } - statuses := make([]string, 0, len(values)) - for _, value := range values { - status, ok := value.(string) - if !ok { - t.Fatalf("status value has unexpected type %T", value) - } - statuses = append(statuses, status) - } - return statuses - } - t.Fatal("status filter not found") - return nil -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/decline.go b/backend/pkg/db/internal/mongo/invitationdb/decline.go deleted file mode 100644 index 520bbdf..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/decline.go +++ /dev/null @@ -1,12 +0,0 @@ -package invitationdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *InvitationDB) Decline(ctx context.Context, invitationRef bson.ObjectID) error { - return db.updateStatus(ctx, invitationRef, model.InvitationDeclined) -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/getpublic.go b/backend/pkg/db/internal/mongo/invitationdb/getpublic.go deleted file mode 100644 index 12d91f9..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/getpublic.go +++ /dev/null @@ -1,121 +0,0 @@ -package invitationdb - -import ( - "context" - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.uber.org/zap" -) - -func (db *InvitationDB) GetPublic(ctx context.Context, invitationRef bson.ObjectID) (*model.PublicInvitation, error) { - roleField := repository.Field("role") - orgField := repository.Field("organization") - accField := repository.Field("account") - empField := repository.Field("employee") - regField := repository.Field("registrationAcc") - descEmailField := repository.Field("description").Dot("email") - pipeline := repository.Pipeline(). - // 0) Filter to exactly the invitation(s) you want - Match(repository.IDFilter(invitationRef).And(repository.Filter("status", model.InvitationCreated))). - // 1) Lookup the role document - Lookup( - mservice.Roles, - repository.Field("roleRef"), - repository.IDField(), - roleField, - ). - Unwind(repository.Ref(roleField)). - // 2) Lookup the organization document - Lookup( - mservice.Organizations, - repository.Field("organizationRef"), - repository.IDField(), - orgField, - ). - Unwind(repository.Ref(orgField)). - // 3) Lookup the account document - Lookup( - mservice.Accounts, - repository.Field("inviterRef"), - repository.IDField(), - accField, - ). - Unwind(repository.Ref(accField)). - /* 4) do we already have an account whose login == invitation.description ? */ - Lookup( - mservice.Accounts, - descEmailField, // local field (invitation.description.email) - repository.Field("login"), // foreign field (account.login) - regField, // array: 0-length or ≥1 - ). - // 5) Projection - Project( - repository.SimpleAlias( - empField.Dot("description"), - repository.Ref(accField), - ), - repository.SimpleAlias( - empField.Dot("avatarUrl"), - repository.Ref(accField.Dot("avatarUrl")), - ), - repository.SimpleAlias( - orgField.Dot("description"), - repository.Ref(orgField), - ), - repository.SimpleAlias( - orgField.Dot("logoUrl"), - repository.Ref(orgField.Dot("logoUrl")), - ), - repository.SimpleAlias( - roleField, - repository.Ref(roleField), - ), - repository.SimpleAlias( - repository.Field("invitation"), // ← left-hand side - repository.Ref(repository.Field("description")), // ← right-hand side (“$description”) - ), - repository.SimpleAlias( - repository.Field("storable"), // ← left-hand side - repository.RootRef(), // ← right-hand side (“$description”) - ), - repository.ProjectionExpr( - repository.Field("registrationRequired"), - repository.Eq( - repository.Size(repository.Value(repository.Ref(regField).Build())), - repository.Literal(0), - ), - ), - ) - - var res model.PublicInvitation - haveResult := false - decoder := func(cur *mongo.Cursor) error { - if haveResult { - // should never get here - db.DBImp.Logger.Warn("Unexpected extra invitation", mzap.ObjRef("invitation_ref", invitationRef)) - return merrors.Internal("Unexpected extra invitation found by reference") - } - if e := cur.Decode(&res); e != nil { - db.DBImp.Logger.Warn("Failed to decode entity", zap.Error(e), zap.Any("data", cur.Current.String())) - return e - } - haveResult = true - return nil - } - if err := db.DBImp.Repository.Aggregate(ctx, pipeline, decoder); err != nil { - db.DBImp.Logger.Warn("Failed to execute aggregation pipeline", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef)) - return nil, err - } - if !haveResult { - db.DBImp.Logger.Warn("No results fetched", mzap.ObjRef("invitation_ref", invitationRef)) - return nil, merrors.NoData(fmt.Sprintf("Invitation %s not found", invitationRef.Hex())) - } - return &res, nil -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/indexes.go b/backend/pkg/db/internal/mongo/invitationdb/indexes.go deleted file mode 100644 index df40161..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/indexes.go +++ /dev/null @@ -1,30 +0,0 @@ -package invitationdb - -import ( - "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/model" -) - -const uniquePendingInvitationEmailIndexName = "unique_pending_invitation_email_per_org" - -func pendingInvitationEmailIndexDefinition() *ri.Definition { - statusValues := model.PendingInvitationStatusValues() - statuses := make([]any, 0, len(statusValues)) - for _, status := range statusValues { - statuses = append(statuses, status) - } - - return &ri.Definition{ - Keys: []ri.Key{ - {Field: repository.OrgField().Build(), Sort: ri.Asc}, - {Field: "description.email", Sort: ri.Asc}, - }, - Unique: true, - Name: uniquePendingInvitationEmailIndexName, - PartialFilter: repository.Query(). - In(repository.Field("status"), statuses...). - Comparison(repository.Field("description.email"), builder.Type, "string"), - } -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/list.go b/backend/pkg/db/internal/mongo/invitationdb/list.go deleted file mode 100644 index 8b5cffe..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/list.go +++ /dev/null @@ -1,28 +0,0 @@ -package invitationdb - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mauth "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db/auth" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func (db *InvitationDB) List(ctx context.Context, accountRef, organizationRef, _ bson.ObjectID, cursor *model.ViewCursor) ([]model.Invitation, error) { - res, err := mauth.GetProtectedObjects[model.Invitation]( - ctx, - db.DBImp.Logger, - accountRef, organizationRef, model.ActionRead, - repository.OrgFilter(organizationRef), - cursor, - db.Enforcer, - db.DBImp.Repository, - ) - if errors.Is(err, merrors.ErrNoData) { - return []model.Invitation{}, nil - } - return res, err -} diff --git a/backend/pkg/db/internal/mongo/invitationdb/updatestatus.go b/backend/pkg/db/internal/mongo/invitationdb/updatestatus.go deleted file mode 100644 index 62e5b28..0000000 --- a/backend/pkg/db/internal/mongo/invitationdb/updatestatus.go +++ /dev/null @@ -1,26 +0,0 @@ -package invitationdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "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 *InvitationDB) updateStatus(ctx context.Context, invitationRef bson.ObjectID, newStatus model.InvitationStatus) error { - // db.DBImp.Up - var inv model.Invitation - if err := db.DBImp.FindOne(ctx, repository.IDFilter(invitationRef), &inv); err != nil { - db.DBImp.Logger.Warn("Failed to fetch invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef), zap.String("new_status", string(newStatus))) - return err - } - inv.Status = newStatus - if err := db.DBImp.Update(ctx, &inv); err != nil { - db.DBImp.Logger.Warn("Failed to update invitation", zap.Error(err), mzap.ObjRef("invitation_ref", invitationRef), zap.String("new_status", string(newStatus))) - return err - } - return nil -} diff --git a/backend/pkg/db/internal/mongo/policiesdb/all.go b/backend/pkg/db/internal/mongo/policiesdb/all.go deleted file mode 100644 index c471c39..0000000 --- a/backend/pkg/db/internal/mongo/policiesdb/all.go +++ /dev/null @@ -1,20 +0,0 @@ -package policiesdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "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 *PoliciesDB) All(ctx context.Context, organizationRef bson.ObjectID) ([]model.PolicyDescription, error) { - // all documents - filter := repository.Query().Or( - repository.Filter(storable.OrganizationRefField, nil), - repository.OrgFilter(organizationRef), - ) - return mutil.GetObjects[model.PolicyDescription](ctx, db.Logger, filter, nil, db.Repository) -} diff --git a/backend/pkg/db/internal/mongo/policiesdb/builtin.go b/backend/pkg/db/internal/mongo/policiesdb/builtin.go deleted file mode 100644 index 1c03a5d..0000000 --- a/backend/pkg/db/internal/mongo/policiesdb/builtin.go +++ /dev/null @@ -1,13 +0,0 @@ -package policiesdb - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" -) - -func (db *PoliciesDB) GetBuiltInPolicy(ctx context.Context, resourceType mservice.Type, policy *model.PolicyDescription) error { - return db.FindOne(ctx, repository.Filter("resourceTypes", resourceType), policy) -} diff --git a/backend/pkg/db/internal/mongo/policiesdb/db.go b/backend/pkg/db/internal/mongo/policiesdb/db.go deleted file mode 100644 index 484676f..0000000 --- a/backend/pkg/db/internal/mongo/policiesdb/db.go +++ /dev/null @@ -1,21 +0,0 @@ -package policiesdb - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -type PoliciesDB struct { - template.DBImp[*model.PolicyDescription] -} - -func Create(logger mlogger.Logger, db *mongo.Database) (*PoliciesDB, error) { - p := &PoliciesDB{ - DBImp: *template.Create[*model.PolicyDescription](logger, mservice.Policies, db), - } - - return p, nil -} diff --git a/backend/pkg/db/internal/mongo/policiesdb/db_test.go b/backend/pkg/db/internal/mongo/policiesdb/db_test.go deleted file mode 100644 index 6c4baf8..0000000 --- a/backend/pkg/db/internal/mongo/policiesdb/db_test.go +++ /dev/null @@ -1,355 +0,0 @@ -//go:build integration -// +build integration - -package policiesdb_test - -import ( - "context" - "errors" - "testing" - "time" - - // Your internal packages - "git.sendico.io/tech/sxchnge/bff/pkg/db/internal/mongo/policiesdb" - "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" - - // Model package (contains PolicyDescription + Describable) - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - // Testcontainers - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/modules/mongodb" - "github.com/testcontainers/testcontainers-go/wait" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" - "go.uber.org/zap" -) - -// Helper to terminate container -func terminate(t *testing.T, ctx context.Context, container *mongodb.MongoDBContainer) { - err := container.Terminate(ctx) - require.NoError(t, err, "failed to terminate MongoDB container") -} - -// Helper to disconnect client -func disconnect(t *testing.T, ctx context.Context, client *mongo.Client) { - err := client.Disconnect(context.Background()) - require.NoError(t, err, "failed to disconnect from MongoDB") -} - -// Helper to drop the Policies collection -func cleanupCollection(t *testing.T, ctx context.Context, db *mongo.Database) { - // The actual collection name is typically the value returned by - // (&model.PolicyDescription{}).Collection(), or something similar. - // Make sure it matches what your code uses (often "policies" or "policyDescription"). - err := db.Collection((&model.PolicyDescription{}).Collection()).Drop(ctx) - require.NoError(t, err, "failed to drop collection between sub-tests") -} - -func TestPoliciesDB(t *testing.T) { - // Create context with reasonable timeout - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - // Start MongoDB test container - mongoC, err := mongodb.Run(ctx, - "mongo:latest", - mongodb.WithUsername("root"), - mongodb.WithPassword("password"), - testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), - ) - require.NoError(t, err, "failed to start MongoDB container") - defer terminate(t, ctx, mongoC) - - // Get connection URI - mongoURI, err := mongoC.ConnectionString(ctx) - require.NoError(t, err, "failed to get connection string") - - // Connect client - clientOpts := options.Client().ApplyURI(mongoURI) - client, err := mongo.Connect(ctx, clientOpts) - require.NoError(t, err, "failed to connect to MongoDB") - defer disconnect(t, ctx, client) - - // Create test DB - db := client.Database("testdb") - - // Use a no-op logger (or real logger if you prefer) - logger := zap.NewNop() - - // Create an instance of PoliciesDB - pdb, err := policiesdb.Create(logger, db) - require.NoError(t, err, "unexpected error creating PoliciesDB") - - // --------------------------------------------------------- - // Each sub-test below starts by dropping the collection. - // --------------------------------------------------------- - - t.Run("CreateAndGet", func(t *testing.T) { - cleanupCollection(t, ctx, db) // ensure no leftover data - - desc := "Test policy description" - policy := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "TestPolicy", - Description: &desc, - }, - } - require.NoError(t, pdb.Create(ctx, policy)) - - result := &model.PolicyDescription{} - err := pdb.Get(ctx, policy.ID, result) - require.NoError(t, err) - - assert.Equal(t, policy.ID, result.ID) - assert.Equal(t, "TestPolicy", result.Name) - assert.NotNil(t, result.Description) - assert.Equal(t, "Test policy description", *result.Description) - }) - - t.Run("Get_NotFound", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - // Attempt to get a non-existent ID - nonExistentID := bson.NewObjectID() - result := &model.PolicyDescription{} - err := pdb.Get(ctx, nonExistentID, result) - assert.Error(t, err) - assert.True(t, errors.Is(err, merrors.ErrNoData)) - }) - - t.Run("Update", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - originalDesc := "Original description" - policy := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "OriginalName", - Description: &originalDesc, - }, - } - require.NoError(t, pdb.Create(ctx, policy)) - - newDesc := "Updated description" - policy.Name = "UpdatedName" - policy.Description = &newDesc - - err := pdb.Update(ctx, policy) - require.NoError(t, err) - - updated := &model.PolicyDescription{} - err = pdb.Get(ctx, policy.ID, updated) - require.NoError(t, err) - - assert.Equal(t, "UpdatedName", updated.Name) - assert.NotNil(t, updated.Description) - assert.Equal(t, "Updated description", *updated.Description) - }) - - t.Run("Delete", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - desc := "To be deleted" - policy := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "WillDelete", - Description: &desc, - }, - } - require.NoError(t, pdb.Create(ctx, policy)) - - err := pdb.Delete(ctx, policy.ID) - require.NoError(t, err) - - deleted := &model.PolicyDescription{} - err = pdb.Get(ctx, policy.ID, deleted) - assert.Error(t, err) - assert.True(t, errors.Is(err, merrors.ErrNoData)) - }) - - t.Run("DeleteMany", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - desc1 := "Will be deleted 1" - desc2 := "Will be deleted 2" - pol1 := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "BatchDelete1", - Description: &desc1, - }, - } - pol2 := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "BatchDelete2", - Description: &desc2, - }, - } - require.NoError(t, pdb.Create(ctx, pol1)) - require.NoError(t, pdb.Create(ctx, pol2)) - - q := repository.Query().RegEx(repository.Field("description"), "^Will be deleted", "") - err := pdb.DeleteMany(ctx, q) - require.NoError(t, err) - - res1 := &model.PolicyDescription{} - err1 := pdb.Get(ctx, pol1.ID, res1) - assert.Error(t, err1) - assert.True(t, errors.Is(err1, merrors.ErrNoData)) - - res2 := &model.PolicyDescription{} - err2 := pdb.Get(ctx, pol2.ID, res2) - assert.Error(t, err2) - assert.True(t, errors.Is(err2, merrors.ErrNoData)) - }) - - t.Run("FindOne", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - desc := "Unique find test" - policy := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "FindOneTest", - Description: &desc, - }, - } - require.NoError(t, pdb.Create(ctx, policy)) - - // Match by name == "FindOneTest" - q := repository.Query().Comparison(repository.Field("name"), builder.Eq, "FindOneTest") - - found := &model.PolicyDescription{} - err := pdb.FindOne(ctx, q, found) - require.NoError(t, err) - - assert.Equal(t, policy.ID, found.ID) - assert.Equal(t, "FindOneTest", found.Name) - assert.NotNil(t, found.Description) - assert.Equal(t, "Unique find test", *found.Description) - }) - - t.Run("All", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - // Insert some policies (orgA, orgB, nil org) - orgA := bson.NewObjectID() - orgB := bson.NewObjectID() - - descA := "Org A policy" - policyA := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "PolicyA", - Description: &descA, - }, - OrganizationRef: &orgA, // belongs to orgA - } - descB := "Org B policy" - policyB := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "PolicyB", - Description: &descB, - }, - OrganizationRef: &orgB, // belongs to orgB - } - descNil := "No org policy" - policyNil := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "PolicyNil", - Description: &descNil, - }, - // nil => built-in - } - require.NoError(t, pdb.Create(ctx, policyA)) - require.NoError(t, pdb.Create(ctx, policyB)) - require.NoError(t, pdb.Create(ctx, policyNil)) - - // Suppose the requirement is: "All" returns - // - policies for the requested org - // - plus built-in (nil) ones - resultsA, err := pdb.All(ctx, orgA) - require.NoError(t, err) - require.Len(t, resultsA, 2) // orgA + built-in - - var idsA []bson.ObjectID - for _, r := range resultsA { - idsA = append(idsA, r.ID) - } - assert.Contains(t, idsA, policyA.ID) - assert.Contains(t, idsA, policyNil.ID) - assert.NotContains(t, idsA, policyB.ID) - - resultsB, err := pdb.All(ctx, orgB) - require.NoError(t, err) - require.Len(t, resultsB, 2) // orgB + built-in - - var idsB []bson.ObjectID - for _, r := range resultsB { - idsB = append(idsB, r.ID) - } - assert.Contains(t, idsB, policyB.ID) - assert.Contains(t, idsB, policyNil.ID) - assert.NotContains(t, idsB, policyA.ID) - }) - - t.Run("Policies", func(t *testing.T) { - cleanupCollection(t, ctx, db) - - desc1 := "PolicyOne" - pol1 := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "PolicyOne", - Description: &desc1, - }, - } - desc2 := "PolicyTwo" - pol2 := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "PolicyTwo", - Description: &desc2, - }, - } - desc3 := "PolicyThree" - pol3 := &model.PolicyDescription{ - Describable: model.Describable{ - Name: "PolicyThree", - Description: &desc3, - }, - } - require.NoError(t, pdb.Create(ctx, pol1)) - require.NoError(t, pdb.Create(ctx, pol2)) - require.NoError(t, pdb.Create(ctx, pol3)) - - // 1) Request pol1, pol2 - results12, err := pdb.Policies(ctx, []bson.ObjectID{pol1.ID, pol2.ID}) - require.NoError(t, err) - require.Len(t, results12, 2) - // IDs might be out of order, so we do a set-like check - var set12 []bson.ObjectID - for _, r := range results12 { - set12 = append(set12, r.ID) - } - assert.Contains(t, set12, pol1.ID) - assert.Contains(t, set12, pol2.ID) - - // 2) Request pol1, pol3, plus a random ID - fakeID := bson.NewObjectID() - results13Fake, err := pdb.Policies(ctx, []bson.ObjectID{pol1.ID, pol3.ID, fakeID}) - require.NoError(t, err) - require.Len(t, results13Fake, 2) // pol1 + pol3 only - var set13Fake []bson.ObjectID - for _, r := range results13Fake { - set13Fake = append(set13Fake, r.ID) - } - assert.Contains(t, set13Fake, pol1.ID) - assert.Contains(t, set13Fake, pol3.ID) - - // 3) Request with empty slice => expect no results - resultsEmpty, err := pdb.Policies(ctx, []bson.ObjectID{}) - require.NoError(t, err) - assert.Len(t, resultsEmpty, 0) - }) -} diff --git a/backend/pkg/db/internal/mongo/policiesdb/policies.go b/backend/pkg/db/internal/mongo/policiesdb/policies.go deleted file mode 100644 index abc5893..0000000 --- a/backend/pkg/db/internal/mongo/policiesdb/policies.go +++ /dev/null @@ -1,18 +0,0 @@ -package policiesdb - -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 *PoliciesDB) Policies(ctx context.Context, refs []bson.ObjectID) ([]model.PolicyDescription, error) { - if len(refs) == 0 { - return []model.PolicyDescription{}, nil - } - filter := repository.Query().In(repository.IDField(), refs) - return mutil.GetObjects[model.PolicyDescription](ctx, db.Logger, filter, nil, db.Repository) -} diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository.go b/backend/pkg/db/internal/mongo/repositoryimp/repository.go index 2c6ac60..18d5eec 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/repository.go +++ b/backend/pkg/db/internal/mongo/repositoryimp/repository.go @@ -10,7 +10,6 @@ import ( "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" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" @@ -227,57 +226,6 @@ func (r *MongoRepository) ListIDs(ctx context.Context, query builder.Query) ([]b return ids, nil } -func (r *MongoRepository) ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) { - return listProjectedStorables(ctx, r.collection, query, bson.M{ - storable.IDField: 1, - storable.PermissionRefField: 1, - storable.OrganizationRefField: 1, - }, func(doc *model.PermissionBound) model.PermissionBoundStorable { - return doc - }) -} - -func (r *MongoRepository) ListAccountBound(ctx context.Context, query builder.Query) ([]model.AccountBoundStorable, error) { - return listProjectedStorables(ctx, r.collection, query, bson.M{ - storable.IDField: 1, - model.AccountRefField: 1, - model.OrganizationRefField: 1, - }, func(doc *model.AccountBoundBase) model.AccountBoundStorable { - return doc - }) -} - -func listProjectedStorables[T any, R any]( - ctx context.Context, - collection *mongo.Collection, - query builder.Query, - projection bson.M, - wrap func(*T) R, -) ([]R, error) { - filter := query.BuildQuery() - cursor, err := collection.Find(ctx, filter, options.Find().SetProjection(projection)) - if err != nil { - return nil, err - } - defer func() { - _ = cursor.Close(ctx) - }() - - result := make([]R, 0) - for cursor.Next(ctx) { - doc := new(T) - if err := cursor.Decode(doc); err != nil { - return nil, err - } - result = append(result, wrap(doc)) - } - if err := cursor.Err(); err != nil { - return nil, err - } - - return result, nil -} - func (r *MongoRepository) Delete(ctx context.Context, id bson.ObjectID) error { _, err := r.collection.DeleteOne(ctx, idFilter(id)) return err diff --git a/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go b/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go index e90e6fb..4adf08d 100644 --- a/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go +++ b/backend/pkg/db/internal/mongo/repositoryimp/repository_comprehensive_test.go @@ -446,72 +446,6 @@ func TestMongoRepository_Name(t *testing.T) { }) } -func TestMongoRepository_ListPermissionBound(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - mongoContainer, err := mongodb.Run(ctx, - "mongo:latest", - mongodb.WithUsername("root"), - mongodb.WithPassword("password"), - testcontainers.WithWaitStrategy(wait.ForLog("Waiting for connections")), - ) - require.NoError(t, err, "failed to start MongoDB container") - defer terminate(ctx, t, mongoContainer) - - mongoURI, err := mongoContainer.ConnectionString(ctx) - require.NoError(t, err, "failed to get MongoDB connection string") - - clientOptions := options.Client().ApplyURI(mongoURI) - client, err := mongo.Connect(clientOptions) - require.NoError(t, err, "failed to connect to MongoDB") - defer disconnect(ctx, t, client) - - db := client.Database("testdb") - repository := repositoryimp.NewMongoRepository(db, "testcollection") - - t.Run("ListPermissionBound_WithData", func(t *testing.T) { - // Insert test objects with permission bound data - orgID := bson.NewObjectID() - - // Insert documents directly with permission bound fields - _, err := db.Collection("testcollection").InsertMany(ctx, []interface{}{ - bson.M{ - "_id": bson.NewObjectID(), - "organizationRef": orgID, - "permissionRef": bson.NewObjectID(), - }, - bson.M{ - "_id": bson.NewObjectID(), - "organizationRef": orgID, - "permissionRef": bson.NewObjectID(), - }, - }) - require.NoError(t, err) - - // Query for permission bound objects - query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("organizationRef"), builder.Eq, orgID) - - results, err := repository.ListPermissionBound(ctx, query) - require.NoError(t, err) - assert.Len(t, results, 2) - - for _, result := range results { - assert.Equal(t, orgID, result.GetOrganizationRef()) - assert.NotNil(t, result.GetPermissionRef()) - } - }) - - t.Run("ListPermissionBound_EmptyResult", func(t *testing.T) { - nonExistentOrgID := bson.NewObjectID() - query := builderimp.NewQueryImp().Comparison(builderimp.NewFieldImp("organizationRef"), builder.Eq, nonExistentOrgID) - - results, err := repository.ListPermissionBound(ctx, query) - require.NoError(t, err) - assert.Empty(t, results) - }) -} - func TestMongoRepository_UpdateTimestamp(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() diff --git a/backend/pkg/db/internal/mongo/rolesdb/db.go b/backend/pkg/db/internal/mongo/rolesdb/db.go deleted file mode 100644 index 4ef1f4b..0000000 --- a/backend/pkg/db/internal/mongo/rolesdb/db.go +++ /dev/null @@ -1,21 +0,0 @@ -package rolesdb - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/mongo" -) - -type RolesDB struct { - template.DBImp[*model.RoleDescription] -} - -func Create(logger mlogger.Logger, db *mongo.Database) (*RolesDB, error) { - p := &RolesDB{ - DBImp: *template.Create[*model.RoleDescription](logger, mservice.Roles, db), - } - - return p, nil -} diff --git a/backend/pkg/db/internal/mongo/rolesdb/list.go b/backend/pkg/db/internal/mongo/rolesdb/list.go deleted file mode 100644 index 9f56adb..0000000 --- a/backend/pkg/db/internal/mongo/rolesdb/list.go +++ /dev/null @@ -1,15 +0,0 @@ -package rolesdb - -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 *RolesDB) List(ctx context.Context, organizationRef bson.ObjectID, cursor *model.ViewCursor) ([]model.RoleDescription, error) { - filter := repository.OrgFilter(organizationRef) - return mutil.GetObjects[model.RoleDescription](ctx, db.Logger, filter, cursor, db.Repository) -} diff --git a/backend/pkg/db/internal/mongo/rolesdb/roles.go b/backend/pkg/db/internal/mongo/rolesdb/roles.go deleted file mode 100644 index 89a289f..0000000 --- a/backend/pkg/db/internal/mongo/rolesdb/roles.go +++ /dev/null @@ -1,15 +0,0 @@ -package rolesdb - -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 *RolesDB) Roles(ctx context.Context, refs []bson.ObjectID) ([]model.RoleDescription, error) { - filter := repository.Query().In(repository.IDField(), refs) - return mutil.GetObjects[model.RoleDescription](ctx, db.Logger, filter, nil, db.Repository) -} diff --git a/backend/pkg/db/internal/mongo/transactionimp/transaction.go b/backend/pkg/db/internal/mongo/transactionimp/transaction.go index 4996ee5..73d09f6 100644 --- a/backend/pkg/db/internal/mongo/transactionimp/transaction.go +++ b/backend/pkg/db/internal/mongo/transactionimp/transaction.go @@ -12,6 +12,10 @@ type MongoTransaction struct { } func (mt *MongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) { + // Reuse an outer customer workflow transaction when a token store joins it. + if transaction.ActiveMongoTransaction(ctx) { + return cb(ctx) + } session, err := mt.client.StartSession() if err != nil { return nil, err diff --git a/backend/pkg/db/invitation/invitation.go b/backend/pkg/db/invitation/invitation.go deleted file mode 100644 index 1fbc43b..0000000 --- a/backend/pkg/db/invitation/invitation.go +++ /dev/null @@ -1,19 +0,0 @@ -package invitation - -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" -) - -type DB interface { - auth.ProtectedDB[*model.Invitation] - GetPublic(ctx context.Context, invitationRef bson.ObjectID) (*model.PublicInvitation, error) - Accept(ctx context.Context, invitationRef bson.ObjectID) error - Decline(ctx context.Context, invitationRef bson.ObjectID) error - List(ctx context.Context, accountRef, organizationRef, _ bson.ObjectID, cursor *model.ViewCursor) ([]model.Invitation, error) - DeleteCascade(ctx context.Context, accountRef, statusRef bson.ObjectID) error - SetArchived(ctx context.Context, accountRef, organizationRef, invitationRef bson.ObjectID, archived, cascade bool) error -} diff --git a/backend/pkg/db/permissions_config_test.go b/backend/pkg/db/permissions_config_test.go deleted file mode 100644 index 95140dc..0000000 --- a/backend/pkg/db/permissions_config_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package db - -import ( - "errors" - "strings" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.uber.org/zap" -) - -func TestPermissionsDatabaseConfigAsDatabaseConfig(t *testing.T) { - cfg := &PermissionsDatabaseConfig{ - Driver: Mongo, - Settings: model.SettingsT{ - "database_env": "PERMISSIONS_MONGO_DATABASE", - }, - } - - converted := cfg.AsDatabaseConfig() - if converted == nil { - t.Fatal("expected converted config") - } - if converted.Driver != Mongo { - t.Fatalf("expected driver %q, got %q", Mongo, converted.Driver) - } - if got := converted.Settings["database_env"]; got != "PERMISSIONS_MONGO_DATABASE" { - t.Fatalf("expected database_env to be preserved, got %v", got) - } -} - -func TestNewCentralPermissionsConnectionRequiresConfig(t *testing.T) { - _, err := NewCentralPermissionsConnection(zap.NewNop(), nil) - if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument, got %v", err) - } -} - -func TestNewConnectionRequiresConfig(t *testing.T) { - _, err := NewConnection(zap.NewNop(), nil) - if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument, got %v", err) - } -} - -func TestNewCentralPermissionsConnectionDelegatesToDBConnection(t *testing.T) { - _, err := NewCentralPermissionsConnection(zap.NewNop(), &PermissionsDatabaseConfig{ - Driver: DBDriver("bogus"), - }) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "permissions_database") { - t.Fatalf("expected permissions_database context, got %v", err) - } - if !strings.Contains(err.Error(), "unknown database driver") { - t.Fatalf("expected delegated database driver error, got %v", err) - } -} - -func TestValidateSplitDomainPermissionsDatabase(t *testing.T) { - t.Setenv("DOMAIN_DB", "domain") - t.Setenv("PERMISSIONS_DB", "sendico") - - domain, permissions, err := ValidateSplitDomainPermissionsDatabase( - "test service", - &Config{ - Driver: Mongo, - Settings: model.SettingsT{ - "database_env": "DOMAIN_DB", - }, - }, - &PermissionsDatabaseConfig{ - Driver: Mongo, - Settings: model.SettingsT{ - "database_env": "PERMISSIONS_DB", - }, - }, - ) - if err != nil { - t.Fatalf("expected valid split-domain config, got %v", err) - } - if domain.Name != "domain" || permissions.Name != "sendico" { - t.Fatalf("unexpected resolved names: domain=%+v permissions=%+v", domain, permissions) - } -} - -func TestValidateSplitDomainPermissionsDatabaseRejectsMissingPermissionsConfig(t *testing.T) { - t.Setenv("DOMAIN_DB", "domain") - - _, _, err := ValidateSplitDomainPermissionsDatabase( - "test service", - &Config{ - Driver: Mongo, - Settings: model.SettingsT{ - "database_env": "DOMAIN_DB", - }, - }, - nil, - ) - if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument, got %v", err) - } -} - -func TestValidateSplitDomainPermissionsDatabaseRejectsSameResolvedDatabase(t *testing.T) { - t.Setenv("DOMAIN_DB", "domain") - t.Setenv("PERMISSIONS_DB", "domain") - - _, _, err := ValidateSplitDomainPermissionsDatabase( - "test service", - &Config{ - Driver: Mongo, - Settings: model.SettingsT{ - "database_env": "DOMAIN_DB", - }, - }, - &PermissionsDatabaseConfig{ - Driver: Mongo, - Settings: model.SettingsT{ - "database_env": "PERMISSIONS_DB", - }, - }, - ) - if !errors.Is(err, merrors.ErrInvalidArg) { - t.Fatalf("expected invalid argument, got %v", err) - } -} diff --git a/backend/pkg/db/policy/policy.go b/backend/pkg/db/policy/policy.go deleted file mode 100644 index 095a4a6..0000000 --- a/backend/pkg/db/policy/policy.go +++ /dev/null @@ -1,17 +0,0 @@ -package policy - -import ( - "context" - - "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" -) - -type DB interface { - template.DB[*model.PolicyDescription] - All(ctx context.Context, organizationRef bson.ObjectID) ([]model.PolicyDescription, error) - Policies(ctx context.Context, refs []bson.ObjectID) ([]model.PolicyDescription, error) - GetBuiltInPolicy(ctx context.Context, resourceType mservice.Type, policy *model.PolicyDescription) error -} diff --git a/backend/pkg/db/repository/abfilter.go b/backend/pkg/db/repository/abfilter.go deleted file mode 100644 index 3501db5..0000000 --- a/backend/pkg/db/repository/abfilter.go +++ /dev/null @@ -1,78 +0,0 @@ -package repository - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// AccountBoundFilter provides factory methods for creating account-bound filters -type AccountBoundFilter struct{} - -// NewAccountBoundFilter creates a new AccountBoundFilter instance -func NewAccountBoundFilter() *AccountBoundFilter { - return &AccountBoundFilter{} -} - -// WithoutOrg creates a filter for account-bound objects without organization filter -// This filter finds objects where: -// - accountRef matches the provided accountRef, OR -// - accountRef is nil/null, OR -// - accountRef field doesn't exist -func (f *AccountBoundFilter) WithoutOrg(accountRef bson.ObjectID) builder.Query { - return Query().Or( - AccountFilter(accountRef), - Filter(model.AccountRefField, nil), - Exists(AccountField(), false), - ) -} - -// WithOrg creates a filter for account-bound objects with organization filter -// This filter finds objects where: -// - accountRef matches the provided accountRef, OR -// - accountRef is nil/null, OR -// - accountRef field doesn't exist -// AND combines with organization filter -func (f *AccountBoundFilter) WithOrg(accountRef, organizationRef bson.ObjectID) builder.Query { - return Query().And( - OrgFilter(organizationRef), - f.WithoutOrg(accountRef), - ) -} - -// WithQuery creates a filter for account-bound objects with additional query and organization filter -func (f *AccountBoundFilter) WithQuery(accountRef, organizationRef bson.ObjectID, additionalQuery builder.Query) builder.Query { - accountQuery := f.WithOrg(accountRef, organizationRef) - return additionalQuery.And(accountQuery) -} - -// WithQueryNoOrg creates a filter for account-bound objects with additional query but no org filter -func (f *AccountBoundFilter) WithQueryNoOrg(accountRef bson.ObjectID, additionalQuery builder.Query) builder.Query { - accountQuery := f.WithoutOrg(accountRef) - return additionalQuery.And(accountQuery) -} - -// Global instance for convenience -var DefaultAccountBoundFilter = NewAccountBoundFilter() - -// Convenience functions that use the global factory instance - -// WithOrg is a convenience function that uses the default factory -func WithOrg(accountRef, organizationRef bson.ObjectID) builder.Query { - return DefaultAccountBoundFilter.WithOrg(accountRef, organizationRef) -} - -// WithoutOrg is a convenience function that uses the default factory -func WithoutOrg(accountRef bson.ObjectID) builder.Query { - return DefaultAccountBoundFilter.WithoutOrg(accountRef) -} - -// WithQuery is a convenience function that uses the default factory -func WithQuery(accountRef, organizationRef bson.ObjectID, additionalQuery builder.Query) builder.Query { - return DefaultAccountBoundFilter.WithQuery(accountRef, organizationRef, additionalQuery) -} - -// WithQueryNoOrg is a convenience function that uses the default factory -func WithQueryNoOrg(accountRef bson.ObjectID, additionalQuery builder.Query) builder.Query { - return DefaultAccountBoundFilter.WithQueryNoOrg(accountRef, additionalQuery) -} diff --git a/backend/pkg/db/repository/builders.go b/backend/pkg/db/repository/builders.go index 54543cf..ccf15ec 100644 --- a/backend/pkg/db/repository/builders.go +++ b/backend/pkg/db/repository/builders.go @@ -68,22 +68,6 @@ func IsArchivedFilter(isArchived bool) builder.Query { return Query().Filter(IsArchivedField(), isArchived) } -func OrgField() builder.Field { - return Field(storable.OrganizationRefField) -} - -func OrgFilter(ref bson.ObjectID) builder.Query { - return Query().Filter(OrgField(), ref) -} - -func ProjectField() builder.Field { - return Field("projectRef") -} - -func ProjectFilter(ref bson.ObjectID) builder.Query { - return Query().Filter(ProjectField(), ref) -} - func AccountField() builder.Field { return Field(model.AccountRefField) } diff --git a/backend/pkg/db/repository/filter_factory_test.go b/backend/pkg/db/repository/filter_factory_test.go deleted file mode 100644 index 9ba32e8..0000000 --- a/backend/pkg/db/repository/filter_factory_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package repository - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestAccountBoundFilter_WithOrg(t *testing.T) { - factory := NewAccountBoundFilter() - accountRef := bson.NewObjectID() - orgRef := bson.NewObjectID() - - query := factory.WithOrg(accountRef, orgRef) - - // Test that the query is not nil - assert.NotNil(t, query) -} - -func TestAccountBoundFilter_WithoutOrg(t *testing.T) { - factory := NewAccountBoundFilter() - accountRef := bson.NewObjectID() - - query := factory.WithoutOrg(accountRef) - - // Test that the query is not nil - assert.NotNil(t, query) -} - -func TestAccountBoundFilter_WithQuery(t *testing.T) { - factory := NewAccountBoundFilter() - accountRef := bson.NewObjectID() - orgRef := bson.NewObjectID() - additionalQuery := Query().Filter(Field("status"), "active") - - query := factory.WithQuery(accountRef, orgRef, additionalQuery) - - // Test that the query is not nil - assert.NotNil(t, query) -} - -func TestAccountBoundFilter_WithQueryNoOrg(t *testing.T) { - factory := NewAccountBoundFilter() - accountRef := bson.NewObjectID() - additionalQuery := Query().Filter(Field("status"), "active") - - query := factory.WithQueryNoOrg(accountRef, additionalQuery) - - // Test that the query is not nil - assert.NotNil(t, query) -} - -func TestDefaultAccountBoundFilter(t *testing.T) { - // Test that the default factory is not nil - assert.NotNil(t, DefaultAccountBoundFilter) - - // Test that it's the correct type - assert.IsType(t, &AccountBoundFilter{}, DefaultAccountBoundFilter) -} - -func TestConvenienceFunctions(t *testing.T) { - accountRef := bson.NewObjectID() - orgRef := bson.NewObjectID() - additionalQuery := Query().Filter(Field("status"), "active") - - // Test convenience functions - query1 := WithOrg(accountRef, orgRef) - assert.NotNil(t, query1) - - query2 := WithoutOrg(accountRef) - assert.NotNil(t, query2) - - query3 := WithQuery(accountRef, orgRef, additionalQuery) - assert.NotNil(t, query3) - - query4 := WithQueryNoOrg(accountRef, additionalQuery) - assert.NotNil(t, query4) -} - -func TestFilterFactoryConsistency(t *testing.T) { - factory := NewAccountBoundFilter() - accountRef := bson.NewObjectID() - orgRef := bson.NewObjectID() - - // Test that factory methods and convenience functions produce the same result - query1 := factory.WithOrg(accountRef, orgRef) - query2 := WithOrg(accountRef, orgRef) - - // Both should be valid queries - assert.NotNil(t, query1) - assert.NotNil(t, query2) -} diff --git a/backend/pkg/db/repository/repository.go b/backend/pkg/db/repository/repository.go index e2719b3..00bab1d 100644 --- a/backend/pkg/db/repository/repository.go +++ b/backend/pkg/db/repository/repository.go @@ -9,7 +9,6 @@ import ( 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/db/transaction" - "git.sendico.io/tech/sxchnge/bff/pkg/model" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" ) @@ -50,8 +49,6 @@ type Repository interface { DropIndex(ctx context.Context, name string) error ListIndexDefinitions(ctx context.Context) ([]*ri.Definition, error) ListIDs(ctx context.Context, query builder.Query) ([]bson.ObjectID, error) - ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) - ListAccountBound(ctx context.Context, query builder.Query) ([]model.AccountBoundStorable, error) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) Collection() string } diff --git a/backend/pkg/db/role/role.go b/backend/pkg/db/role/role.go deleted file mode 100644 index 2d5009d..0000000 --- a/backend/pkg/db/role/role.go +++ /dev/null @@ -1,15 +0,0 @@ -package role - -import ( - "context" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/template" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type DB interface { - template.DB[*model.RoleDescription] - Roles(ctx context.Context, refs []bson.ObjectID) ([]model.RoleDescription, error) - List(ctx context.Context, organizationRef bson.ObjectID, cursor *model.ViewCursor) ([]model.RoleDescription, error) -} diff --git a/backend/pkg/db/storable/id.go b/backend/pkg/db/storable/id.go index 58d0637..8259e78 100644 --- a/backend/pkg/db/storable/id.go +++ b/backend/pkg/db/storable/id.go @@ -7,11 +7,9 @@ import ( ) const ( - IDField = "_id" - PermissionRefField = "permissionRef" - OrganizationRefField = "organizationRef" - IsArchivedField = "isArchived" - UpdatedAtField = "updatedAt" + IDField = "_id" + IsArchivedField = "isArchived" + UpdatedAtField = "updatedAt" ) type Base struct { diff --git a/backend/pkg/db/template/template.go b/backend/pkg/db/template/template.go index 5aff65f..e1ad219 100644 --- a/backend/pkg/db/template/template.go +++ b/backend/pkg/db/template/template.go @@ -8,7 +8,6 @@ import ( "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" "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" "go.mongodb.org/mongo-driver/v2/bson" @@ -72,10 +71,6 @@ func (db *DBImp[T]) ListIDs(ctx context.Context, query builder.Query) ([]bson.Ob return db.Repository.ListIDs(ctx, query) } -func (db *DBImp[T]) ListPermissionBound(ctx context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) { - return db.Repository.ListPermissionBound(ctx, query) -} - func (db *DBImp[T]) SetDeleter(deleter CascadeDeleterT) { db.Logger.Debug("Custom cascade deletion method installed") db.cdeleter = deleter diff --git a/backend/pkg/db/template/tseries.go b/backend/pkg/db/template/tseries.go deleted file mode 100644 index 304be17..0000000 --- a/backend/pkg/db/template/tseries.go +++ /dev/null @@ -1,14 +0,0 @@ -package template - -import ( - "context" - - tspoint "git.sendico.io/tech/sxchnge/bff/pkg/db/tseries/point" -) - -type TimeSeries[T tspoint.TimePoint] interface { - // Insert adds a single point into the series. - Insert(ctx context.Context, point T) error - // InsertMany adds multiple points in one bulk operation. - InsertMany(ctx context.Context, points []T) error -} diff --git a/backend/pkg/db/tseries/point/interface.go b/backend/pkg/db/tseries/point/interface.go deleted file mode 100644 index efb9483..0000000 --- a/backend/pkg/db/tseries/point/interface.go +++ /dev/null @@ -1,14 +0,0 @@ -package tspoint - -import ( - "time" -) - -type TimePoint interface { - GetTimeStamp() time.Time -} - -type TimePointWithMeta[T any] interface { - GetTimeStamp() time.Time - GetMeta() T -} diff --git a/backend/pkg/decimal/money.go b/backend/pkg/decimal/money.go deleted file mode 100644 index ee24707..0000000 --- a/backend/pkg/decimal/money.go +++ /dev/null @@ -1,129 +0,0 @@ -package decimal - -import ( - "math/big" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" -) - -// Money represents a monetary amount with currency and precision handling -type Money struct { - Amount *big.Rat - Currency string - Scale uint32 // decimal places for this currency -} - -// NewMoney creates a new Money value from a decimal string -func NewMoney(amount, currency string, scale uint32) (*Money, error) { - rat, err := RatFromString(amount) - if err != nil { - return nil, err - } - return &Money{ - Amount: rat, - Currency: currency, - Scale: scale, - }, nil -} - -// Add adds two money values (must have same currency) -func (m *Money) Add(other *Money) (*Money, error) { - if m.Currency != other.Currency { - return nil, merrors.InvalidArgument("decimal: currency mismatch: " + m.Currency + " != " + other.Currency) - } - return &Money{ - Amount: AddRat(m.Amount, other.Amount), - Currency: m.Currency, - Scale: m.Scale, - }, nil -} - -// Sub subtracts two money values (must have same currency) -func (m *Money) Sub(other *Money) (*Money, error) { - if m.Currency != other.Currency { - return nil, merrors.InvalidArgument("decimal: currency mismatch: " + m.Currency + " != " + other.Currency) - } - return &Money{ - Amount: SubRat(m.Amount, other.Amount), - Currency: m.Currency, - Scale: m.Scale, - }, nil -} - -// Mul multiplies money by a rational number (for fees, percentages, etc.) -func (m *Money) Mul(factor *big.Rat) *Money { - return &Money{ - Amount: MulRat(m.Amount, factor), - Currency: m.Currency, - Scale: m.Scale, - } -} - -// Div divides money by a rational number -func (m *Money) Div(divisor *big.Rat) (*Money, error) { - result, err := DivRat(m.Amount, divisor) - if err != nil { - return nil, err - } - return &Money{ - Amount: result, - Currency: m.Currency, - Scale: m.Scale, - }, nil -} - -// Negate returns the negative of this money value -func (m *Money) Negate() *Money { - return &Money{ - Amount: NegRat(m.Amount), - Currency: m.Currency, - Scale: m.Scale, - } -} - -// Round rounds the money to its scale using the specified rounding mode -func (m *Money) Round(mode RoundingMode) (*Money, error) { - rounded, err := RoundRatToScale(m.Amount, m.Scale, mode) - if err != nil { - return nil, err - } - return &Money{ - Amount: rounded, - Currency: m.Currency, - Scale: m.Scale, - }, nil -} - -// String returns the formatted money value -func (m *Money) String() string { - return FormatRat(m.Amount, m.Scale) + " " + m.Currency -} - -// StringAmount returns just the formatted amount -func (m *Money) StringAmount() string { - return FormatRat(m.Amount, m.Scale) -} - -// IsZero checks if the money amount is zero -func (m *Money) IsZero() bool { - return IsZero(m.Amount) -} - -// IsPositive checks if the money amount is positive -func (m *Money) IsPositive() bool { - return IsPositive(m.Amount) -} - -// IsNegative checks if the money amount is negative -func (m *Money) IsNegative() bool { - return IsNegative(m.Amount) -} - -// Cmp compares two money values (must have same currency) -// Returns -1 if m < other, 0 if m == other, 1 if m > other -func (m *Money) Cmp(other *Money) (int, error) { - if m.Currency != other.Currency { - return 0, merrors.InvalidArgument("decimal: currency mismatch: " + m.Currency + " != " + other.Currency) - } - return CmpRat(m.Amount, other.Amount), nil -} diff --git a/backend/pkg/decimal/rational.go b/backend/pkg/decimal/rational.go deleted file mode 100644 index 81a44f8..0000000 --- a/backend/pkg/decimal/rational.go +++ /dev/null @@ -1,161 +0,0 @@ -package decimal - -import ( - "math/big" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" -) - -// RatFromString parses a decimal string into a big.Rat -// Supports standard decimal notation like "123.45", "-0.001", etc. -func RatFromString(value string) (*big.Rat, error) { - if strings.TrimSpace(value) == "" { - return nil, merrors.InvalidArgument("decimal: empty value") - } - r := new(big.Rat) - if _, ok := r.SetString(value); !ok { - return nil, merrors.InvalidArgument("decimal: invalid decimal value: " + value) - } - return r, nil -} - -// MulRat multiplies two rational numbers -func MulRat(a, b *big.Rat) *big.Rat { - return new(big.Rat).Mul(a, b) -} - -// DivRat divides two rational numbers -func DivRat(a, b *big.Rat) (*big.Rat, error) { - if b.Sign() == 0 { - return nil, merrors.InvalidArgument("decimal: division by zero") - } - return new(big.Rat).Quo(a, b), nil -} - -// AddRat adds two rational numbers -func AddRat(a, b *big.Rat) *big.Rat { - return new(big.Rat).Add(a, b) -} - -// SubRat subtracts two rational numbers (a - b) -func SubRat(a, b *big.Rat) *big.Rat { - return new(big.Rat).Sub(a, b) -} - -// NegRat negates a rational number -func NegRat(a *big.Rat) *big.Rat { - return new(big.Rat).Neg(a) -} - -// RoundRatToScale rounds a rational number to a specific number of decimal places -// using the specified rounding mode -func RoundRatToScale(value *big.Rat, scale uint32, mode RoundingMode) (*big.Rat, error) { - scaleFactor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil) - numerator := new(big.Int).Mul(new(big.Int).Set(value.Num()), scaleFactor) - denominator := value.Denom() - - quotient := new(big.Int) - remainder := new(big.Int) - quotient.QuoRem(numerator, denominator, remainder) - - // No remainder, already exact - if remainder.Sign() == 0 { - return new(big.Rat).SetFrac(quotient, scaleFactor), nil - } - - sign := quotient.Sign() - absQuotient := new(big.Int).Abs(new(big.Int).Set(quotient)) - absRemainder := new(big.Int).Abs(remainder) - absDenominator := new(big.Int).Abs(denominator) - - doubledRemainder := new(big.Int).Mul(absRemainder, big.NewInt(2)) - cmp := doubledRemainder.Cmp(absDenominator) - - shouldIncrement := false - - switch mode { - case RoundingModeDown: - shouldIncrement = false - case RoundingModeHalfUp: - if cmp >= 0 { - shouldIncrement = true - } - case RoundingModeHalfEven, RoundingModeUnspecified: - if cmp > 0 { - shouldIncrement = true - } else if cmp == 0 { - // Tie: round to even - if absQuotient.Bit(0) == 1 { - shouldIncrement = true - } - } - default: - // Default to HALF_EVEN - if cmp > 0 { - shouldIncrement = true - } else if cmp == 0 { - if absQuotient.Bit(0) == 1 { - shouldIncrement = true - } - } - } - - if shouldIncrement { - if sign < 0 { - absQuotient.Add(absQuotient, big.NewInt(1)) - quotient = absQuotient.Neg(absQuotient) - } else { - absQuotient.Add(absQuotient, big.NewInt(1)) - quotient = absQuotient - } - } - - return new(big.Rat).SetFrac(quotient, scaleFactor), nil -} - -// FormatRat formats a rational number as a decimal string with the specified scale -func FormatRat(r *big.Rat, scale uint32) string { - sign := "" - if r.Sign() < 0 { - sign = "-" - } - - absRat := new(big.Rat).Abs(r) - scaleFactor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil) - numerator := new(big.Int).Mul(absRat.Num(), scaleFactor) - numerator.Quo(numerator, absRat.Denom()) - - intStr := numerator.String() - if scale == 0 { - return sign + intStr - } - - if len(intStr) <= int(scale) { - intStr = strings.Repeat("0", int(scale)-len(intStr)+1) + intStr - } - - pointPos := len(intStr) - int(scale) - return sign + intStr[:pointPos] + "." + intStr[pointPos:] -} - -// CmpRat compares two rational numbers -// Returns -1 if a < b, 0 if a == b, 1 if a > b -func CmpRat(a, b *big.Rat) int { - return a.Cmp(b) -} - -// IsZero checks if a rational number is zero -func IsZero(r *big.Rat) bool { - return r.Sign() == 0 -} - -// IsPositive checks if a rational number is positive -func IsPositive(r *big.Rat) bool { - return r.Sign() > 0 -} - -// IsNegative checks if a rational number is negative -func IsNegative(r *big.Rat) bool { - return r.Sign() < 0 -} diff --git a/backend/pkg/decimal/rounding.go b/backend/pkg/decimal/rounding.go deleted file mode 100644 index 65b345d..0000000 --- a/backend/pkg/decimal/rounding.go +++ /dev/null @@ -1,29 +0,0 @@ -package decimal - -// RoundingMode specifies how to round decimal values -type RoundingMode int - -const ( - // RoundingModeUnspecified defaults to HALF_EVEN - RoundingModeUnspecified RoundingMode = iota - // RoundingModeHalfEven rounds to nearest, ties to even (banker's rounding) - RoundingModeHalfEven - // RoundingModeHalfUp rounds to nearest, ties away from zero - RoundingModeHalfUp - // RoundingModeDown rounds toward zero (truncate) - RoundingModeDown -) - -// String returns the string representation of the rounding mode -func (r RoundingMode) String() string { - switch r { - case RoundingModeHalfEven: - return "HALF_EVEN" - case RoundingModeHalfUp: - return "HALF_UP" - case RoundingModeDown: - return "DOWN" - default: - return "HALF_EVEN" - } -} diff --git a/backend/pkg/merrors/accounting.go b/backend/pkg/merrors/accounting.go deleted file mode 100644 index e5f34a8..0000000 --- a/backend/pkg/merrors/accounting.go +++ /dev/null @@ -1,25 +0,0 @@ -package merrors - -var ErrAccountingInvalidPosting = Define("accounting.invalid_posting") - -var ErrAccountingUnbalancedPosting = Define("accounting.unbalanced_posting") - -var ErrAccountingAssetMismatch = Define("accounting.asset_mismatch") - -var ErrAccountingAccountNotFound = Define("accounting.account_not_found") - -var ErrAccountingAccountInactive = Define("accounting.account_inactive") - -var ErrAccountingInvalidAmount = Define("accounting.invalid_amount") - -var ErrAccountingInvalidIdempotency = Define("accounting.invalid_idempotency") - -var ErrAccountingIdempotencyConflict = Define("accounting.idempotency_conflict") - -var ErrAccountingStorageFailed = Define("accounting.storage_failed") - -var ErrAccountingDuplicateKey = Define("accounting.duplicate_key") - -var ErrAccountingOptimisticConflict = Define("accounting.optimistic_conflict") - -var ErrAccountingInvalidStorageState = Define("accounting.invalid_storage_state") diff --git a/backend/pkg/merrors/aml.go b/backend/pkg/merrors/aml.go deleted file mode 100644 index c544dfc..0000000 --- a/backend/pkg/merrors/aml.go +++ /dev/null @@ -1,20 +0,0 @@ -package merrors - -import "strings" - -const ReasonAMLSourceIdempotencyConflict = "AML_SOURCE_IDEMPOTENCY_CONFLICT" - -var ErrAMLSourceIdempotencyConflict = Define("aml.source_idempotency_conflict") - -func AMLSourceIdempotencyConflict(msg string) error { - msg = strings.TrimSpace(msg) - if msg == "" { - msg = ErrAMLSourceIdempotencyConflict.Error() - } - return classifiedError{ - base: ErrDataConflict, - code: ErrAMLSourceIdempotencyConflict, - msg: msg, - reason: ReasonAMLSourceIdempotencyConflict, - } -} diff --git a/backend/pkg/merrors/aml_test.go b/backend/pkg/merrors/aml_test.go deleted file mode 100644 index bf03151..0000000 --- a/backend/pkg/merrors/aml_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package merrors - -import ( - "errors" - "testing" -) - -func TestAMLSourceIdempotencyConflictReasonRoundTrip(t *testing.T) { - err := AMLSourceIdempotencyConflict("key is bound to another source mutation") - if !errors.Is(err, ErrDataConflict) || !errors.Is(err, ErrAMLSourceIdempotencyConflict) { - t.Fatalf("AML source idempotency conflict classification mismatch: %v", err) - } - reason, ok := Reason(err) - if !ok || reason != ReasonAMLSourceIdempotencyConflict { - t.Fatalf("AML source idempotency reason mismatch: reason=%q ok=%t", reason, ok) - } - restored, ok := FromReason(reason, err.Error()) - if !ok || !errors.Is(restored, ErrAMLSourceIdempotencyConflict) { - t.Fatalf("AML source idempotency reason did not round trip: %v", restored) - } -} diff --git a/backend/pkg/merrors/chain_execution.go b/backend/pkg/merrors/chain_execution.go deleted file mode 100644 index 6dd96e0..0000000 --- a/backend/pkg/merrors/chain_execution.go +++ /dev/null @@ -1,21 +0,0 @@ -package merrors - -import "strings" - -const ( - ReasonChainExecutionSigningUnavailable = "chain_execution.signing_unavailable" - ReasonChainExecutionUnsupportedCapability = "chain_execution.unsupported_capability" - ReasonChainExecutionInsufficientBalance = "chain_execution.insufficient_balance" - ReasonChainExecutionInsufficientNativeResources = "chain_execution.insufficient_native_resources" - ReasonChainExecutionDestinationIneligible = "chain_execution.destination_ineligible" -) - -// ChainExecutionPrecondition creates a typed, reason-bearing permanent -// preparation failure. Callers can classify it with both errors.Is and Reason. -func ChainExecutionPrecondition(reason, message string) error { - return classifiedError{ - base: ErrFailedPrecondition, - msg: strings.TrimSpace(message), - reason: strings.TrimSpace(reason), - } -} diff --git a/backend/pkg/merrors/chain_execution_test.go b/backend/pkg/merrors/chain_execution_test.go deleted file mode 100644 index 85831a6..0000000 --- a/backend/pkg/merrors/chain_execution_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package merrors - -import ( - "errors" - "testing" -) - -func TestChainExecutionPreconditionCarriesClassAndReason(t *testing.T) { - err := ChainExecutionPrecondition(ReasonChainExecutionInsufficientBalance, "source balance is insufficient") - if !errors.Is(err, ErrFailedPrecondition) { - t.Fatal("expected failed-precondition classification") - } - reason, ok := Reason(err) - if !ok || reason != ReasonChainExecutionInsufficientBalance { - t.Fatalf("unexpected reason: %q", reason) - } -} diff --git a/backend/pkg/merrors/documents.go b/backend/pkg/merrors/documents.go deleted file mode 100644 index f94d13e..0000000 --- a/backend/pkg/merrors/documents.go +++ /dev/null @@ -1,44 +0,0 @@ -package merrors - -import "strings" - -const ( - ReasonDocumentsReportOwned = "DOCUMENTS_REPORT_OWNED" - ReasonDocumentsQuotationExpired = "DOCUMENTS_QUOTATION_EXPIRED" -) - -var ErrDocumentsReportOwned = Define("documents.report_owned") - -var ErrDocumentsQuotationExpired = Define("documents.quotation_expired") - -// DocumentsReportOwned reports that an active or settled payout owns a -// report, so callers must not mutate its document lifecycle. -func DocumentsReportOwned(msg string) error { - msg = strings.TrimSpace(msg) - if msg == "" { - msg = "report is owned by a payout" - } - return classifiedError{ - base: ErrDataConflict, - code: ErrDocumentsReportOwned, - msg: msg, - reason: ReasonDocumentsReportOwned, - publicMessage: true, - } -} - -// DocumentsQuotationExpired reports that a payout registry still references -// a quotation which can no longer be executed. A fresh quotation is required. -func DocumentsQuotationExpired(msg string) error { - msg = strings.TrimSpace(msg) - if msg == "" { - msg = "the payout quotation has expired; request a new quotation" - } - return classifiedError{ - base: ErrFailedPrecondition, - code: ErrDocumentsQuotationExpired, - msg: msg, - reason: ReasonDocumentsQuotationExpired, - publicMessage: true, - } -} diff --git a/backend/pkg/merrors/documents_test.go b/backend/pkg/merrors/documents_test.go deleted file mode 100644 index 6b048c6..0000000 --- a/backend/pkg/merrors/documents_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package merrors - -import ( - "errors" - "testing" -) - -func TestDocumentsReportOwnedReasonRoundTrip(t *testing.T) { - err := DocumentsReportOwned("report has an active payout") - if !errors.Is(err, ErrDataConflict) || !errors.Is(err, ErrDocumentsReportOwned) { - t.Fatalf("DocumentsReportOwned() classification = %v", err) - } - reason, ok := Reason(err) - if !ok || reason != ReasonDocumentsReportOwned { - t.Fatalf("Reason() = %q, %t", reason, ok) - } - restored, ok := FromReason(reason, "report has an active payout") - if !ok || !errors.Is(restored, ErrDataConflict) || !errors.Is(restored, ErrDocumentsReportOwned) { - t.Fatalf("FromReason() = %v, %t", restored, ok) - } -} - -func TestDocumentsQuotationExpiredReasonRoundTrip(t *testing.T) { - err := DocumentsQuotationExpired("quotation expired") - if !errors.Is(err, ErrFailedPrecondition) || !errors.Is(err, ErrDocumentsQuotationExpired) { - t.Fatalf("DocumentsQuotationExpired() classification = %v", err) - } - reason, ok := Reason(err) - if !ok || reason != ReasonDocumentsQuotationExpired { - t.Fatalf("Reason() = %q, %t", reason, ok) - } - restored, ok := FromReason(reason, "quotation expired") - if !ok || !errors.Is(restored, ErrFailedPrecondition) || - !errors.Is(restored, ErrDocumentsQuotationExpired) { - t.Fatalf("FromReason() = %v, %t", restored, ok) - } -} diff --git a/backend/pkg/merrors/errors.go b/backend/pkg/merrors/errors.go index dceb396..186b203 100644 --- a/backend/pkg/merrors/errors.go +++ b/backend/pkg/merrors/errors.go @@ -223,12 +223,6 @@ func Unauthorizedf(format string, args ...any) error { return Unauthorized(fmt.Sprintf(format, args...)) } -var ErrNoMessagingTopic = Define("messagingTopicError") - -func NoMessagingTopic(topic string) error { - return Wrapf(ErrNoMessagingTopic, "messaging topic '%s' not found", topic) -} - func InvalidArgumentWrap(err error, msg string, argumentNames ...string) error { return wrapError(ErrInvalidArg, invalidArgumentMessage(msg, argumentNames...), err) } diff --git a/backend/pkg/merrors/ledger.go b/backend/pkg/merrors/ledger.go deleted file mode 100644 index bfaa6f9..0000000 --- a/backend/pkg/merrors/ledger.go +++ /dev/null @@ -1,65 +0,0 @@ -package merrors - -import "strings" - -const ( - ReasonLedgerIdempotencyConflict = "LEDGER_IDEMPOTENCY_CONFLICT" - ReasonLedgerCommandIdentityMismatch = "LEDGER_COMMAND_IDENTITY_MISMATCH" - ReasonLedgerAccessViolation = "LEDGER_ACCESS_VIOLATION" - ReasonAccountNotEligible = "ACCOUNT_NOT_ELIGIBLE" -) - -var ErrLedgerIdempotencyConflict = Define("ledger.idempotency_conflict") - -var ErrLedgerCommandIdentityMismatch = Define("ledger.command_identity_mismatch") - -var ErrLedgerAccessViolation = Define("ledger.access_violation") - -var ErrAccountNotEligible = Define("account_not_eligible") - -func LedgerIdempotencyConflict(msg string) error { - return classifiedError{ - base: ErrDataConflict, - code: ErrLedgerIdempotencyConflict, - msg: ledgerErrorMessage(msg, ErrLedgerIdempotencyConflict), - reason: ReasonLedgerIdempotencyConflict, - } -} - -func LedgerCommandIdentityMismatch(msg string) error { - return classifiedError{ - base: ErrDataConflict, - code: ErrLedgerCommandIdentityMismatch, - msg: ledgerErrorMessage(msg, ErrLedgerCommandIdentityMismatch), - reason: ReasonLedgerCommandIdentityMismatch, - } -} - -func LedgerAccessViolation(msg string) error { - return classifiedError{ - base: ErrFailedPrecondition, - code: ErrLedgerAccessViolation, - msg: ledgerErrorMessage(msg, ErrLedgerAccessViolation), - reason: ReasonLedgerAccessViolation, - } -} - -func AccountNotEligible(msg string) error { - return classifiedError{ - base: ErrFailedPrecondition, - code: ErrAccountNotEligible, - msg: ledgerErrorMessage(msg, ErrAccountNotEligible), - reason: ReasonAccountNotEligible, - } -} - -func ledgerErrorMessage(msg string, fallback error) string { - msg = strings.TrimSpace(msg) - if msg != "" { - return msg - } - if fallback == nil { - return "" - } - return fallback.Error() -} diff --git a/backend/pkg/merrors/ledger_test.go b/backend/pkg/merrors/ledger_test.go deleted file mode 100644 index 05287d7..0000000 --- a/backend/pkg/merrors/ledger_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package merrors - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestLedgerReasonRoundTrip(t *testing.T) { - err := LedgerIdempotencyConflict("conflicting command") - reason, ok := Reason(err) - require.True(t, ok) - require.Equal(t, ReasonLedgerIdempotencyConflict, reason) - - restored, ok := FromReason(reason, "conflicting command") - require.True(t, ok) - require.True(t, errors.Is(restored, ErrLedgerIdempotencyConflict)) - require.True(t, errors.Is(restored, ErrDataConflict)) -} - -func TestAccountNotEligibleReasonRoundTrip(t *testing.T) { - err := AccountNotEligible("account is frozen") - reason, ok := Reason(err) - require.True(t, ok) - require.Equal(t, ReasonAccountNotEligible, reason) - require.True(t, errors.Is(err, ErrAccountNotEligible)) - require.True(t, errors.Is(err, ErrFailedPrecondition)) - - restored, ok := FromReason(reason, "account is frozen") - require.True(t, ok) - require.True(t, errors.Is(restored, ErrAccountNotEligible)) - require.True(t, errors.Is(restored, ErrFailedPrecondition)) -} - -func TestLedgerAccessViolationReasonRoundTrip(t *testing.T) { - err := LedgerAccessViolation("from_account: account does not belong to organization") - reason, ok := Reason(err) - require.True(t, ok) - require.Equal(t, ReasonLedgerAccessViolation, reason) - require.True(t, errors.Is(err, ErrLedgerAccessViolation)) - require.True(t, errors.Is(err, ErrFailedPrecondition)) - - restored, ok := FromReason(reason, "from_account: account does not belong to organization") - require.True(t, ok) - require.True(t, errors.Is(restored, ErrLedgerAccessViolation)) - require.True(t, errors.Is(restored, ErrFailedPrecondition)) -} diff --git a/backend/pkg/merrors/limits.go b/backend/pkg/merrors/limits.go deleted file mode 100644 index 8e16a8f..0000000 --- a/backend/pkg/merrors/limits.go +++ /dev/null @@ -1,19 +0,0 @@ -package merrors - -var ErrLimitsRuntimeInvalidRecord = Define("limits.runtime.invalid_record") - -var ErrLimitsRuntimeCASConflict = Define("limits.runtime.cas_conflict") - -var ErrLimitsRuntimeDuplicateStateKey = Define("limits.runtime.duplicate_state_key") - -var ErrLimitsRuntimeDuplicateStateRef = Define("limits.runtime.duplicate_state_ref") - -var ErrLimitsRuntimeDuplicateReservationRef = Define("limits.runtime.duplicate_reservation_ref") - -var ErrLimitsRuntimeDuplicateReservationOperation = Define("limits.runtime.duplicate_reservation_operation") - -var ErrLimitsRuntimeIdentityConflict = Define("limits.runtime.identity_conflict") - -var ErrLimitsRuntimeDataConflict = Define("limits.runtime.data_conflict") - -var ErrLimitsRuntimeRepositoryRequired = Define("limits.runtime.repository_required") diff --git a/backend/pkg/merrors/organization.go b/backend/pkg/merrors/organization.go deleted file mode 100644 index 591ada7..0000000 --- a/backend/pkg/merrors/organization.go +++ /dev/null @@ -1,66 +0,0 @@ -package merrors - -import "strings" - -// Reasons for organization provisioning outcomes. They travel to callers in -// gRPC error details so every outcome stays distinguishable regardless of the -// status code it shares with others. -const ( - ReasonOrganizationProvisioningInProgress = "organization.provisioning_in_progress" - ReasonOrganizationAlreadyActive = "organization.already_active" - ReasonOrganizationProvisioningBlocked = "organization.provisioning_blocked" - ReasonOrganizationOwnerMismatch = "organization.owner_mismatch" - ReasonOrganizationNotOperatorProvisioned = "organization.not_operator_provisioned" -) - -// ErrOrganizationProvisioningInProgress means another attempt currently holds -// the provisioning lease of the organization. -var ErrOrganizationProvisioningInProgress = Define(ReasonOrganizationProvisioningInProgress) - -// ErrOrganizationAlreadyActive means the organization finished provisioning already. -var ErrOrganizationAlreadyActive = Define(ReasonOrganizationAlreadyActive) - -// ErrOrganizationProvisioningBlocked means a failed signup compensated the -// organization and it requires manual recovery before it can be activated. -var ErrOrganizationProvisioningBlocked = Define(ReasonOrganizationProvisioningBlocked) - -// ErrOrganizationOwnerMismatch means the organization is being provisioned for -// a different account than the one supplied. -var ErrOrganizationOwnerMismatch = Define(ReasonOrganizationOwnerMismatch) - -// ErrOrganizationNotOperatorProvisioned means the organization was created through signup: -// organizations created through general signup are never finished by someone in backoffice. -var ErrOrganizationNotOperatorProvisioned = Define(ReasonOrganizationNotOperatorProvisioned) - -func OrganizationProvisioningInProgress(msg string) error { - return organizationError(ErrOrganizationProvisioningInProgress, ReasonOrganizationProvisioningInProgress, msg) -} - -func OrganizationAlreadyActive(msg string) error { - return organizationError(ErrOrganizationAlreadyActive, ReasonOrganizationAlreadyActive, msg) -} - -func OrganizationProvisioningBlocked(msg string) error { - return organizationError(ErrOrganizationProvisioningBlocked, ReasonOrganizationProvisioningBlocked, msg) -} - -func OrganizationOwnerMismatch(msg string) error { - return organizationError(ErrOrganizationOwnerMismatch, ReasonOrganizationOwnerMismatch, msg) -} - -func OrganizationNotOperatorProvisioned(msg string) error { - return organizationError(ErrOrganizationNotOperatorProvisioned, ReasonOrganizationNotOperatorProvisioned, msg) -} - -func organizationError(code error, reason, msg string) error { - msg = strings.TrimSpace(msg) - if msg == "" && code != nil { - msg = code.Error() - } - return classifiedError{ - base: ErrDataConflict, - code: code, - msg: msg, - reason: reason, - } -} diff --git a/backend/pkg/merrors/paymaster_control.go b/backend/pkg/merrors/paymaster_control.go deleted file mode 100644 index db12bec..0000000 --- a/backend/pkg/merrors/paymaster_control.go +++ /dev/null @@ -1,9 +0,0 @@ -package merrors - -var ErrPaymasterPaused = Define("paymaster_paused") - -var ErrControlWalletInsufficientNativeBalance = Define("control_wallet_insufficient_native_balance") - -var ErrPaymasterControlDurableWalletFailure = Define("paymaster_control_durable_wallet_failure") - -var ErrPaymasterControlKeyActivityMismatch = Define("paymaster_control_key_activity_mismatch") diff --git a/backend/pkg/merrors/payment_execution.go b/backend/pkg/merrors/payment_execution.go deleted file mode 100644 index 86b0b6a..0000000 --- a/backend/pkg/merrors/payment_execution.go +++ /dev/null @@ -1,47 +0,0 @@ -package merrors - -import "strings" - -const ReasonPaymentExecutionNotAccepted = "PAYMENT_EXECUTION_NOT_ACCEPTED" - -var ErrPaymentExecutionNotAccepted = Define("payment.execution_not_accepted") - -type paymentExecutionNotAcceptedError struct { - classifiedError - cause error -} - -func (e paymentExecutionNotAcceptedError) Unwrap() []error { - causes := e.classifiedError.Unwrap() - if e.cause != nil { - causes = append(causes, e.cause) - } - return causes -} - -// PaymentExecutionNotAccepted reports that the orchestrator proved an -// execution request did not create or reuse any payment. Callers may release -// request-side reservations only for this outcome; ordinary transport and -// internal errors have an ambiguous ownership result. -func PaymentExecutionNotAccepted(msg string) error { - return PaymentExecutionNotAcceptedWrap(nil, msg) -} - -// PaymentExecutionNotAcceptedWrap adds the definitive rejection -// classification while retaining the underlying cause for diagnostics. -func PaymentExecutionNotAcceptedWrap(cause error, msg string) error { - msg = strings.TrimSpace(msg) - if msg == "" { - msg = "payment execution was not accepted before a payment was created" - } - return paymentExecutionNotAcceptedError{ - classifiedError: classifiedError{ - base: ErrFailedPrecondition, - code: ErrPaymentExecutionNotAccepted, - msg: msg, - reason: ReasonPaymentExecutionNotAccepted, - publicMessage: true, - }, - cause: cause, - } -} diff --git a/backend/pkg/merrors/payment_execution_test.go b/backend/pkg/merrors/payment_execution_test.go deleted file mode 100644 index 82c5ae9..0000000 --- a/backend/pkg/merrors/payment_execution_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package merrors - -import ( - "errors" - "testing" -) - -func TestPaymentExecutionNotAcceptedPreservesClassificationAndCause(t *testing.T) { - cause := ServiceUnavailable("orchestrator unavailable") - err := PaymentExecutionNotAcceptedWrap(cause, "execution was rejected") - - if !errors.Is(err, ErrFailedPrecondition) || - !errors.Is(err, ErrPaymentExecutionNotAccepted) || - !errors.Is(err, cause) { - t.Fatalf("PaymentExecutionNotAcceptedWrap() classification = %v", err) - } - reason, ok := Reason(err) - if !ok || reason != ReasonPaymentExecutionNotAccepted { - t.Fatalf("PaymentExecutionNotAcceptedWrap() reason = %q, %t", reason, ok) - } - restored, ok := FromReason(reason, err.Error()) - if !ok || !errors.Is(restored, ErrPaymentExecutionNotAccepted) || - !errors.Is(restored, ErrFailedPrecondition) { - t.Fatalf("FromReason() = %v, %t", restored, ok) - } -} diff --git a/backend/pkg/merrors/quotation.go b/backend/pkg/merrors/quotation.go deleted file mode 100644 index b65c158..0000000 --- a/backend/pkg/merrors/quotation.go +++ /dev/null @@ -1,32 +0,0 @@ -package merrors - -const ( - QuotationFeeConfigurationMissingCode = "quotation_fee_configuration_missing" - QuotationFeeConfigurationMissingDetails = "quotation fee configuration is missing" - QuotationReplayMissingCode = "quotation_replay_missing" - QuotationReplayMissingDetails = "the expected payment quotation is no longer available" -) - -var ErrQuotationFeeConfigurationMissing = Define(QuotationFeeConfigurationMissingCode) - -var ErrQuotationReplayMissing = Define(QuotationReplayMissingCode) - -func QuotationFeeConfigurationMissing() error { - return classifiedError{ - base: ErrFailedPrecondition, - code: ErrQuotationFeeConfigurationMissing, - msg: QuotationFeeConfigurationMissingDetails, - reason: QuotationFeeConfigurationMissingCode, - publicMessage: true, - } -} - -func QuotationReplayMissing() error { - return classifiedError{ - base: ErrNoData, - code: ErrQuotationReplayMissing, - msg: QuotationReplayMissingDetails, - reason: QuotationReplayMissingCode, - publicMessage: true, - } -} diff --git a/backend/pkg/merrors/quotation_test.go b/backend/pkg/merrors/quotation_test.go deleted file mode 100644 index 584af1e..0000000 --- a/backend/pkg/merrors/quotation_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package merrors - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestQuotationFeeConfigurationMissingReasonRoundTrip(t *testing.T) { - err := QuotationFeeConfigurationMissing() - - require.True(t, errors.Is(err, ErrFailedPrecondition)) - require.True(t, errors.Is(err, ErrQuotationFeeConfigurationMissing)) - - reason, ok := Reason(err) - require.True(t, ok) - require.Equal(t, QuotationFeeConfigurationMissingCode, reason) - require.Equal(t, QuotationFeeConfigurationMissingDetails, err.Error()) - - restored, ok := FromReason(reason, "ignored message") - require.True(t, ok) - require.True(t, errors.Is(restored, ErrFailedPrecondition)) - require.True(t, errors.Is(restored, ErrQuotationFeeConfigurationMissing)) - require.Equal(t, QuotationFeeConfigurationMissingDetails, restored.Error()) -} - -func TestQuotationReplayMissingReasonRoundTrip(t *testing.T) { - err := QuotationReplayMissing() - - require.True(t, errors.Is(err, ErrNoData)) - require.True(t, errors.Is(err, ErrQuotationReplayMissing)) - - reason, ok := Reason(err) - require.True(t, ok) - require.Equal(t, QuotationReplayMissingCode, reason) - require.Equal(t, QuotationReplayMissingDetails, err.Error()) - - restored, ok := FromReason(reason, "ignored message") - require.True(t, ok) - require.True(t, errors.Is(restored, ErrNoData)) - require.True(t, errors.Is(restored, ErrQuotationReplayMissing)) - require.Equal(t, QuotationReplayMissingDetails, restored.Error()) -} diff --git a/backend/pkg/merrors/reason.go b/backend/pkg/merrors/reason.go index 9205bac..240d668 100644 --- a/backend/pkg/merrors/reason.go +++ b/backend/pkg/merrors/reason.go @@ -15,91 +15,5 @@ func Reason(err error) (string, bool) { return reason, true } } - switch { - case errors.Is(err, ErrAMLSourceIdempotencyConflict): - return ReasonAMLSourceIdempotencyConflict, true - case errors.Is(err, ErrLedgerIdempotencyConflict): - return ReasonLedgerIdempotencyConflict, true - case errors.Is(err, ErrLedgerCommandIdentityMismatch): - return ReasonLedgerCommandIdentityMismatch, true - case errors.Is(err, ErrLedgerAccessViolation): - return ReasonLedgerAccessViolation, true - case errors.Is(err, ErrAccountNotEligible): - return ReasonAccountNotEligible, true - case errors.Is(err, ErrQuotationFeeConfigurationMissing): - return QuotationFeeConfigurationMissingCode, true - case errors.Is(err, ErrQuotationReplayMissing): - return QuotationReplayMissingCode, true - default: - return "", false - } -} - -func FromReason(reason string, msg string) (error, bool) { - switch strings.TrimSpace(reason) { - case ReasonRouteNotFound: - return RouteNotFound(msg), true - case ReasonRouteAmbiguous: - return RouteAmbiguous(msg), true - case ReasonRouteDisabled: - return RouteDisabled(msg), true - case ReasonRouteInvalidSource: - return RouteInvalidSource(msg), true - case ReasonRouteDestinationNotFound: - return RouteDestinationNotFound(msg), true - case ReasonRouteDestinationInactive: - return RouteDestinationInactive(msg), true - case ReasonRouteDestinationCurrencyMismatch: - return RouteDestinationCurrencyMismatch(msg), true - case ReasonRouteDestinationOrgMismatch: - return RouteDestinationOrgMismatch(msg), true - case ReasonRouteDestinationAmbiguous: - return RouteDestinationAmbiguous(msg), true - case ReasonRouteDestinationOwnerMismatch: - return RouteDestinationOwnerMismatch(msg), true - case ReasonRouteActiveSourceConflict: - return RouteActiveSourceConflict(msg), true - case ReasonRouteDestinationTypeMismatch: - return RouteDestinationTypeMismatch(msg), true - case ReasonRouteDestinationRoleMismatch: - return RouteDestinationRoleMismatch(msg), true - case ReasonRouteIdempotencyConflict: - return RouteIdempotencyConflict(msg), true - case ReasonRouteVersionConflict: - return RouteVersionConflict(msg), true - case ReasonRouteInvalidState: - return RouteInvalidState(msg), true - case ReasonAMLSourceIdempotencyConflict: - return AMLSourceIdempotencyConflict(msg), true - case ReasonLedgerIdempotencyConflict: - return LedgerIdempotencyConflict(msg), true - case ReasonLedgerCommandIdentityMismatch: - return LedgerCommandIdentityMismatch(msg), true - case ReasonLedgerAccessViolation: - return LedgerAccessViolation(msg), true - case ReasonAccountNotEligible: - return AccountNotEligible(msg), true - case ReasonDocumentsReportOwned: - return DocumentsReportOwned(msg), true - case ReasonDocumentsQuotationExpired: - return DocumentsQuotationExpired(msg), true - case ReasonPaymentExecutionNotAccepted: - return PaymentExecutionNotAccepted(msg), true - case QuotationFeeConfigurationMissingCode: - return QuotationFeeConfigurationMissing(), true - case QuotationReplayMissingCode: - return QuotationReplayMissing(), true - case ReasonOrganizationProvisioningInProgress: - return OrganizationProvisioningInProgress(msg), true - case ReasonOrganizationAlreadyActive: - return OrganizationAlreadyActive(msg), true - case ReasonOrganizationProvisioningBlocked: - return OrganizationProvisioningBlocked(msg), true - case ReasonOrganizationOwnerMismatch: - return OrganizationOwnerMismatch(msg), true - case ReasonOrganizationNotOperatorProvisioned: - return OrganizationNotOperatorProvisioned(msg), true - default: - return nil, false - } + return "", false } diff --git a/backend/pkg/merrors/routes.go b/backend/pkg/merrors/routes.go deleted file mode 100644 index 546018c..0000000 --- a/backend/pkg/merrors/routes.go +++ /dev/null @@ -1,127 +0,0 @@ -package merrors - -import "strings" - -const routeErrorCodePrefix = "ROUTE_ERROR_CODE_" - -type routeMetadataError struct { - classifiedError - routeCode string -} - -func (e routeMetadataError) ErrorInfoMetadata() map[string]string { - return map[string]string{"route_error_code": e.routeCode} -} - -const ( - ReasonRouteNotFound = "ROUTE_NOT_FOUND" - ReasonRouteAmbiguous = "ROUTE_AMBIGUOUS" - ReasonRouteDisabled = "ROUTE_DISABLED" - ReasonRouteInvalidSource = "ROUTE_INVALID_SOURCE" - ReasonRouteDestinationNotFound = "ROUTE_DESTINATION_NOT_FOUND" - ReasonRouteDestinationInactive = "ROUTE_DESTINATION_INACTIVE" - ReasonRouteDestinationCurrencyMismatch = "ROUTE_DESTINATION_CURRENCY_MISMATCH" - ReasonRouteDestinationOrgMismatch = "ROUTE_DESTINATION_ORG_MISMATCH" - ReasonRouteDestinationAmbiguous = "ROUTE_DESTINATION_AMBIGUOUS" - ReasonRouteDestinationOwnerMismatch = "ROUTE_DESTINATION_OWNER_MISMATCH" - ReasonRouteActiveSourceConflict = "ROUTE_ACTIVE_SOURCE_CONFLICT" - ReasonRouteDestinationTypeMismatch = "ROUTE_DESTINATION_TYPE_MISMATCH" - ReasonRouteDestinationRoleMismatch = "ROUTE_DESTINATION_ROLE_MISMATCH" - ReasonRouteIdempotencyConflict = "ROUTE_IDEMPOTENCY_CONFLICT" - ReasonRouteVersionConflict = "ROUTE_VERSION_CONFLICT" - ReasonRouteInvalidState = "ROUTE_INVALID_STATE" -) - -var ( - ErrRouteNotFound = Define("route.not_found") - ErrRouteAmbiguous = Define("route.ambiguous") - ErrRouteDisabled = Define("route.disabled") - ErrRouteInvalidSource = Define("route.invalid_source") - ErrRouteDestinationNotFound = Define("route.destination_not_found") - ErrRouteDestinationInactive = Define("route.destination_inactive") - ErrRouteDestinationCurrencyMismatch = Define("route.destination_currency_mismatch") - ErrRouteDestinationOrgMismatch = Define("route.destination_org_mismatch") - ErrRouteDestinationAmbiguous = Define("route.destination_ambiguous") - ErrRouteDestinationOwnerMismatch = Define("route.destination_owner_mismatch") - ErrRouteActiveSourceConflict = Define("route.active_source_conflict") - ErrRouteDestinationTypeMismatch = Define("route.destination_type_mismatch") - ErrRouteDestinationRoleMismatch = Define("route.destination_role_mismatch") - ErrRouteIdempotencyConflict = Define("route.idempotency_conflict") - ErrRouteVersionConflict = Define("route.version_conflict") - ErrRouteInvalidState = Define("route.invalid_state") -) - -func RouteNotFound(msg string) error { - return newRouteClassifiedError(ErrNoData, ErrRouteNotFound, ReasonRouteNotFound, msg) -} - -func RouteAmbiguous(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteAmbiguous, ReasonRouteAmbiguous, msg) -} - -func RouteDisabled(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDisabled, ReasonRouteDisabled, msg) -} - -func RouteInvalidSource(msg string) error { - return newRouteClassifiedError(ErrInvalidArg, ErrRouteInvalidSource, ReasonRouteInvalidSource, msg) -} - -func RouteDestinationNotFound(msg string) error { - return newRouteClassifiedError(ErrNoData, ErrRouteDestinationNotFound, ReasonRouteDestinationNotFound, msg) -} - -func RouteDestinationInactive(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationInactive, ReasonRouteDestinationInactive, msg) -} - -func RouteDestinationCurrencyMismatch(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationCurrencyMismatch, ReasonRouteDestinationCurrencyMismatch, msg) -} - -func RouteDestinationOrgMismatch(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationOrgMismatch, ReasonRouteDestinationOrgMismatch, msg) -} - -func RouteDestinationAmbiguous(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationAmbiguous, ReasonRouteDestinationAmbiguous, msg) -} - -func RouteDestinationOwnerMismatch(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationOwnerMismatch, ReasonRouteDestinationOwnerMismatch, msg) -} - -func RouteActiveSourceConflict(msg string) error { - return newRouteClassifiedError(ErrDataConflict, ErrRouteActiveSourceConflict, ReasonRouteActiveSourceConflict, msg) -} - -func RouteDestinationTypeMismatch(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationTypeMismatch, ReasonRouteDestinationTypeMismatch, msg) -} - -func RouteDestinationRoleMismatch(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteDestinationRoleMismatch, ReasonRouteDestinationRoleMismatch, msg) -} - -func RouteIdempotencyConflict(msg string) error { - return newRouteClassifiedError(ErrDataConflict, ErrRouteIdempotencyConflict, ReasonRouteIdempotencyConflict, msg) -} - -func RouteVersionConflict(msg string) error { - return newRouteClassifiedError(ErrDataConflict, ErrRouteVersionConflict, ReasonRouteVersionConflict, msg) -} - -func RouteInvalidState(msg string) error { - return newRouteClassifiedError(ErrFailedPrecondition, ErrRouteInvalidState, ReasonRouteInvalidState, msg) -} - -func newRouteClassifiedError(base, code error, reason, msg string) error { - msg = strings.TrimSpace(msg) - if msg == "" && code != nil { - msg = code.Error() - } - return routeMetadataError{ - classifiedError: classifiedError{base: base, code: code, msg: msg, reason: reason, publicMessage: true}, - routeCode: routeErrorCodePrefix + reason, - } -} diff --git a/backend/pkg/merrors/routes_test.go b/backend/pkg/merrors/routes_test.go deleted file mode 100644 index 40f2fbd..0000000 --- a/backend/pkg/merrors/routes_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package merrors - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestRouteReasonsRoundTrip(t *testing.T) { - tests := []struct { - name string - create func(string) error - code error - base error - reason string - }{ - {name: "not found", create: RouteNotFound, code: ErrRouteNotFound, base: ErrNoData, reason: ReasonRouteNotFound}, - {name: "ambiguous", create: RouteAmbiguous, code: ErrRouteAmbiguous, base: ErrFailedPrecondition, reason: ReasonRouteAmbiguous}, - {name: "disabled", create: RouteDisabled, code: ErrRouteDisabled, base: ErrFailedPrecondition, reason: ReasonRouteDisabled}, - {name: "invalid source", create: RouteInvalidSource, code: ErrRouteInvalidSource, base: ErrInvalidArg, reason: ReasonRouteInvalidSource}, - {name: "destination not found", create: RouteDestinationNotFound, code: ErrRouteDestinationNotFound, base: ErrNoData, reason: ReasonRouteDestinationNotFound}, - {name: "destination inactive", create: RouteDestinationInactive, code: ErrRouteDestinationInactive, base: ErrFailedPrecondition, reason: ReasonRouteDestinationInactive}, - {name: "destination currency mismatch", create: RouteDestinationCurrencyMismatch, code: ErrRouteDestinationCurrencyMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationCurrencyMismatch}, - {name: "destination organization mismatch", create: RouteDestinationOrgMismatch, code: ErrRouteDestinationOrgMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationOrgMismatch}, - {name: "destination ambiguous", create: RouteDestinationAmbiguous, code: ErrRouteDestinationAmbiguous, base: ErrFailedPrecondition, reason: ReasonRouteDestinationAmbiguous}, - {name: "destination owner mismatch", create: RouteDestinationOwnerMismatch, code: ErrRouteDestinationOwnerMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationOwnerMismatch}, - {name: "active source conflict", create: RouteActiveSourceConflict, code: ErrRouteActiveSourceConflict, base: ErrDataConflict, reason: ReasonRouteActiveSourceConflict}, - {name: "destination type mismatch", create: RouteDestinationTypeMismatch, code: ErrRouteDestinationTypeMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationTypeMismatch}, - {name: "destination role mismatch", create: RouteDestinationRoleMismatch, code: ErrRouteDestinationRoleMismatch, base: ErrFailedPrecondition, reason: ReasonRouteDestinationRoleMismatch}, - {name: "idempotency conflict", create: RouteIdempotencyConflict, code: ErrRouteIdempotencyConflict, base: ErrDataConflict, reason: ReasonRouteIdempotencyConflict}, - {name: "version conflict", create: RouteVersionConflict, code: ErrRouteVersionConflict, base: ErrDataConflict, reason: ReasonRouteVersionConflict}, - {name: "invalid state", create: RouteInvalidState, code: ErrRouteInvalidState, base: ErrFailedPrecondition, reason: ReasonRouteInvalidState}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.create("route error") - require.Equal(t, "route error", err.Error()) - reason, ok := Reason(err) - require.True(t, ok) - require.Equal(t, tt.reason, reason) - require.True(t, errors.Is(err, tt.code)) - require.True(t, errors.Is(err, tt.base)) - - restored, ok := FromReason(reason, "route error") - require.True(t, ok) - require.True(t, errors.Is(restored, tt.code)) - require.True(t, errors.Is(restored, tt.base)) - }) - } -} diff --git a/backend/pkg/merrors/signup_email_policy.go b/backend/pkg/merrors/signup_email_policy.go deleted file mode 100644 index 7fdc705..0000000 --- a/backend/pkg/merrors/signup_email_policy.go +++ /dev/null @@ -1,78 +0,0 @@ -package merrors - -import ( - "errors" - "strings" -) - -const ( - SignupEmailPolicyCode = "signup_email_rejected" - - SignupEmailPolicyReasonInvalidEmail = "invalid_email" - SignupEmailPolicyReasonDisposableDomain = "disposable_domain" - SignupEmailPolicyReasonFreeDomain = "free_domain" -) - -var ErrSignupEmailPolicyRejected = Define(SignupEmailPolicyCode) - -type SignupEmailPolicyError struct { - reason string - domain string -} - -func NormalizeSignupEmailPolicyReason(reason string) string { - switch strings.TrimSpace(reason) { - case SignupEmailPolicyReasonInvalidEmail: - return SignupEmailPolicyReasonInvalidEmail - case SignupEmailPolicyReasonDisposableDomain: - return SignupEmailPolicyReasonDisposableDomain - case SignupEmailPolicyReasonFreeDomain: - return SignupEmailPolicyReasonFreeDomain - default: - return SignupEmailPolicyReasonInvalidEmail - } -} - -func IsSignupEmailPolicyReason(reason string) bool { - return strings.TrimSpace(reason) == NormalizeSignupEmailPolicyReason(reason) -} - -func (e *SignupEmailPolicyError) Error() string { - if e == nil || e.reason == "" { - return SignupEmailPolicyCode - } - return SignupEmailPolicyCode + ": " + e.reason -} - -func (e *SignupEmailPolicyError) Unwrap() error { - return ErrSignupEmailPolicyRejected -} - -func (e *SignupEmailPolicyError) Reason() string { - if e == nil { - return "" - } - return strings.TrimSpace(e.reason) -} - -func (e *SignupEmailPolicyError) Domain() string { - if e == nil { - return "" - } - return strings.TrimSpace(e.domain) -} - -func SignupEmailPolicyRejected(reason, domain string) error { - return &SignupEmailPolicyError{ - reason: NormalizeSignupEmailPolicyReason(reason), - domain: strings.TrimSpace(domain), - } -} - -func AsSignupEmailPolicyError(err error) (*SignupEmailPolicyError, bool) { - var policyErr *SignupEmailPolicyError - if errors.As(err, &policyErr) && policyErr != nil { - return policyErr, true - } - return nil, false -} diff --git a/backend/pkg/merrors/signup_email_policy_test.go b/backend/pkg/merrors/signup_email_policy_test.go deleted file mode 100644 index 40b44ba..0000000 --- a/backend/pkg/merrors/signup_email_policy_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package merrors - -import ( - "errors" - "testing" -) - -func TestSignupEmailPolicyError(t *testing.T) { - err := SignupEmailPolicyRejected(SignupEmailPolicyReasonFreeDomain, "gmail.com") - policyErr, ok := AsSignupEmailPolicyError(err) - if !ok { - t.Fatalf("expected signup email policy error") - } - if policyErr.Reason() != SignupEmailPolicyReasonFreeDomain { - t.Fatalf("reason mismatch: got=%q", policyErr.Reason()) - } - if policyErr.Domain() != "gmail.com" { - t.Fatalf("domain mismatch: got=%q", policyErr.Domain()) - } - if !errors.Is(err, ErrSignupEmailPolicyRejected) { - t.Fatalf("expected signup email policy sentinel") - } - - reason, ok := Reason(err) - if !ok { - t.Fatalf("expected generic reason") - } - if reason != SignupEmailPolicyReasonFreeDomain { - t.Fatalf("generic reason mismatch: got=%q", reason) - } -} - -func TestSignupEmailPolicyReasonNormalization(t *testing.T) { - for _, reason := range []string{ - SignupEmailPolicyReasonInvalidEmail, - SignupEmailPolicyReasonDisposableDomain, - SignupEmailPolicyReasonFreeDomain, - } { - if !IsSignupEmailPolicyReason(reason) { - t.Fatalf("expected supported reason: %q", reason) - } - } - - err := SignupEmailPolicyRejected("unexpected_reason", "") - policyErr, ok := AsSignupEmailPolicyError(err) - if !ok { - t.Fatalf("expected signup email policy error") - } - if policyErr.Reason() != SignupEmailPolicyReasonInvalidEmail { - t.Fatalf("unexpected reasons must not leak: got=%q", policyErr.Reason()) - } -} diff --git a/backend/pkg/merrors/treasury.go b/backend/pkg/merrors/treasury.go deleted file mode 100644 index d4c4d53..0000000 --- a/backend/pkg/merrors/treasury.go +++ /dev/null @@ -1,15 +0,0 @@ -package merrors - -var ErrTreasuryInvalidArgument = Define("treasury.monitoring.invalid_argument") - -var ErrTreasuryConfiguration = Define("treasury.monitoring.configuration_error") - -var ErrTreasuryNotFound = Define("treasury.monitoring.not_found") - -var ErrTreasuryOutOfOrder = Define("treasury.monitoring.out_of_order") - -var ErrTreasuryNegativeBalance = Define("treasury.monitoring.negative_balance") - -var ErrTreasuryAlreadyReversed = Define("treasury.monitoring.movement_already_reversed") - -var ErrTreasuryProjectionMismatch = Define("treasury.monitoring.projection_mismatch") diff --git a/backend/pkg/model/account.go b/backend/pkg/model/account.go index e4ab43e..a7d331a 100644 --- a/backend/pkg/model/account.go +++ b/backend/pkg/model/account.go @@ -3,7 +3,6 @@ package model import ( "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" "golang.org/x/crypto/bcrypt" ) @@ -21,14 +20,8 @@ type AccountBase struct { storable.Base `bson:",inline" json:",inline"` ArchivableBase `bson:",inline" json:",inline"` Describable `bson:",inline" json:",inline"` - LastName string `bson:"lastName" json:"lastName"` - AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` - Metadata *AccountMetadata `bson:"metadata,omitempty" json:"-"` - - // INN is the account owner's taxpayer identifier. It is the canonical source - // of the INN for personal (account-owned) payouts, flowing into - // customer.tax_identifier. - INN string `bson:"inn,omitempty" json:"inn,omitempty"` + LastName string `bson:"lastName" json:"lastName"` + AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` } func (*AccountBase) Collection() string { @@ -51,7 +44,6 @@ func (a *Account) Copy() *Account { return nil } copied := *a - copied.Metadata = a.Metadata.Copy() return &copied } @@ -74,37 +66,4 @@ func (a *Account) MatchPassword(password string) bool { return err == nil } -func AnonymousUserName(orgRef bson.ObjectID) string { - return "anonymous@" + orgRef.Hex() -} - -func AccountIsAnonymous(account *UserDataBase, orgRef bson.ObjectID) bool { - if account == nil { - return false - } - return AnonymousUserName(orgRef) == account.Login -} - -type AccountBound interface { - GetAccountRef() bson.ObjectID -} - -type AccountBoundStorable interface { - storable.Storable - OrganizationBound - GetAccountRef() *bson.ObjectID -} - -const ( - AccountRefField = "accountRef" -) - -type AccountBoundBase struct { - storable.Base `bson:",inline" json:",inline"` - OrganizationBoundBase `bson:",inline" json:",inline"` - AccountRef *bson.ObjectID `bson:"accountRef,omitempty" json:"accountRef,omitempty"` -} - -func (a *AccountBoundBase) GetAccountRef() *bson.ObjectID { - return a.AccountRef -} +const AccountRefField = "accountRef" diff --git a/backend/pkg/model/account_metadata.go b/backend/pkg/model/account_metadata.go deleted file mode 100644 index 5f0e82c..0000000 --- a/backend/pkg/model/account_metadata.go +++ /dev/null @@ -1,45 +0,0 @@ -package model - -import "time" - -type AccountMetadata struct { - PersonalLedgerProvisioning *LedgerProvisioningMetadata `bson:"personalLedgerProvisioning,omitempty" json:"-"` -} - -type LedgerProvisioningMetadata struct { - Source string `bson:"source,omitempty" json:"-"` - InvitationRef string `bson:"invitationRef,omitempty" json:"-"` - TargetCurrencies []string `bson:"targetCurrencies,omitempty" json:"-"` - Created []string `bson:"created,omitempty" json:"-"` - AlreadyExisting []string `bson:"alreadyExisting,omitempty" json:"-"` - Failed []LedgerProvisioningFailure `bson:"failed,omitempty" json:"-"` - Outcome string `bson:"outcome,omitempty" json:"-"` - LastErrorClass string `bson:"lastErrorClass,omitempty" json:"-"` - LastAttemptAt time.Time `bson:"lastAttemptAt,omitempty" json:"-"` -} - -type LedgerProvisioningFailure struct { - Currency string `bson:"currency,omitempty" json:"-"` - ErrorClass string `bson:"errorClass,omitempty" json:"-"` -} - -func (m *AccountMetadata) Copy() *AccountMetadata { - if m == nil { - return nil - } - copied := *m - copied.PersonalLedgerProvisioning = m.PersonalLedgerProvisioning.Copy() - return &copied -} - -func (m *LedgerProvisioningMetadata) Copy() *LedgerProvisioningMetadata { - if m == nil { - return nil - } - copied := *m - copied.TargetCurrencies = append([]string(nil), m.TargetCurrencies...) - copied.Created = append([]string(nil), m.Created...) - copied.AlreadyExisting = append([]string(nil), m.AlreadyExisting...) - copied.Failed = append([]LedgerProvisioningFailure(nil), m.Failed...) - return &copied -} diff --git a/backend/pkg/model/account_role/account_role.go b/backend/pkg/model/account_role/account_role.go deleted file mode 100644 index 02de143..0000000 --- a/backend/pkg/model/account_role/account_role.go +++ /dev/null @@ -1,57 +0,0 @@ -package account_role - -import ( - "strings" -) - -type AccountRole string - -const ( - AccountRoleOperating AccountRole = "operating" - AccountRoleHold AccountRole = "hold" - AccountRoleTransit AccountRole = "transit" - AccountRoleSettlement AccountRole = "settlement" - AccountRoleClearing AccountRole = "clearing" - AccountRolePending AccountRole = "pending" - AccountRoleReserve AccountRole = "reserve" - AccountRoleLiquidity AccountRole = "liquidity" - AccountRoleFee AccountRole = "fee" - AccountRoleChargeback AccountRole = "chargeback" - AccountRoleAdjustment AccountRole = "adjustment" -) - -const ( - MetadataKeyFromRole = "from_role" - MetadataKeyToRole = "to_role" -) - -func Parse(value string) (AccountRole, bool) { - switch strings.ToUpper(strings.TrimSpace(value)) { - case "ACCOUNT_ROLE_OPERATING", "OPERATING": - return AccountRoleOperating, true - case "ACCOUNT_ROLE_HOLD", "HOLD": - return AccountRoleHold, true - case "ACCOUNT_ROLE_TRANSIT", "TRANSIT": - return AccountRoleTransit, true - case "ACCOUNT_ROLE_SETTLEMENT", "SETTLEMENT": - return AccountRoleSettlement, true - case "ACCOUNT_ROLE_CLEARING", "CLEARING": - return AccountRoleClearing, true - case "ACCOUNT_ROLE_PENDING", "PENDING": - return AccountRolePending, true - case "ACCOUNT_ROLE_RESERVE", "RESERVE": - return AccountRoleReserve, true - case "ACCOUNT_ROLE_LIQUIDITY", "LIQUIDITY": - return AccountRoleLiquidity, true - case "ACCOUNT_ROLE_FEE", "FEE": - return AccountRoleFee, true - case "ACCOUNT_ROLE_CHARGEBACK", "CHARGEBACK": - return AccountRoleChargeback, true - case "ACCOUNT_ROLE_ADJUSTMENT", "ADJUSTMENT": - return AccountRoleAdjustment, true - case "ACCOUNT_ROLE_UNSPECIFIED", "UNSPECIFIED", "": - return "", true - default: - return "", false - } -} diff --git a/backend/pkg/model/account_test.go b/backend/pkg/model/account_test.go deleted file mode 100644 index 0aff70c..0000000 --- a/backend/pkg/model/account_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package model - -import ( - "testing" - "time" -) - -func TestAccountCopyDeepCopiesMetadata(t *testing.T) { - account := &Account{ - AccountPublic: AccountPublic{ - AccountBase: AccountBase{ - Metadata: &AccountMetadata{ - PersonalLedgerProvisioning: &LedgerProvisioningMetadata{ - Source: "invitation", - InvitationRef: "invitation-ref", - TargetCurrencies: []string{"RUB", "USDT"}, - Created: []string{"RUB"}, - AlreadyExisting: []string{"USDT"}, - Failed: []LedgerProvisioningFailure{{Currency: "EUR", ErrorClass: "timeout"}}, - Outcome: "partial_failure", - LastErrorClass: "timeout", - LastAttemptAt: time.Now().UTC(), - }, - }, - }, - UserDataBase: UserDataBase{Login: "invitee@example.com"}, - }, - Password: "secret", - Status: AccountActive, - } - - copied := account.Copy() - copied.Metadata.PersonalLedgerProvisioning.TargetCurrencies[0] = "EUR" - copied.Metadata.PersonalLedgerProvisioning.Created[0] = "EUR" - copied.Metadata.PersonalLedgerProvisioning.AlreadyExisting[0] = "EUR" - copied.Metadata.PersonalLedgerProvisioning.Failed[0].ErrorClass = "ledger_unavailable" - - metadata := account.Metadata.PersonalLedgerProvisioning - if metadata.TargetCurrencies[0] != "RUB" { - t.Fatalf("expected original target currency to remain RUB, got %q", metadata.TargetCurrencies[0]) - } - if metadata.Created[0] != "RUB" { - t.Fatalf("expected original created currency to remain RUB, got %q", metadata.Created[0]) - } - if metadata.AlreadyExisting[0] != "USDT" { - t.Fatalf("expected original existing currency to remain USDT, got %q", metadata.AlreadyExisting[0]) - } - if metadata.Failed[0].ErrorClass != "timeout" { - t.Fatalf("expected original failure class to remain timeout, got %q", metadata.Failed[0].ErrorClass) - } -} diff --git a/backend/pkg/model/auth.go b/backend/pkg/model/auth.go deleted file mode 100644 index 650d568..0000000 --- a/backend/pkg/model/auth.go +++ /dev/null @@ -1,84 +0,0 @@ -package model - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// Action represents a permissible action on a resource. -type Action string - -// Common actions for resources. -const ( - ActionCreate Action = "create" // Create a resource - ActionRead Action = "read" // Read or view a resource - ActionUpdate Action = "update" // Update or modify a resource - ActionDelete Action = "delete" // Delete a resource -) - -// Effect determines whether an action is allowed or denied. -type Effect string - -const ( - EffectAllow Effect = "allow" // Permit the action - EffectDeny Effect = "deny" // Deny the action -) - -// RoleDescription provides metadata about a role. -type RoleDescription struct { - storable.Base `bson:",inline" json:",inline"` // Base fields for MongoDB documents - Describable `bson:",inline" json:",inline"` // Name and description fields - OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` // Organization associated with the role -} - -// Collection specifies the MongoDB collection for RoleDescription. -func (*RoleDescription) Collection() string { - return mservice.Roles -} - -// Role represents a role assignment for an account within an organization. -type Role struct { - AccountRef bson.ObjectID `bson:"accountRef" json:"accountRef"` // Account assigned to the role - DescriptionRef bson.ObjectID `bson:"descriptionRef" json:"descriptionRef"` // Reference to the role's description - OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` // Organization where the role is applicable -} - -// ActionEffect represents a combination of an action and its effect (allow/deny). -type ActionEffect struct { - Action Action `bson:"action" json:"action"` // The action to perform (e.g., read, write) - Effect Effect `bson:"effect" json:"effect"` // Whether the action is allowed or denied -} - -// Policy defines access control rules for a role within an organization. -type Policy struct { - OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` // Organization associated with the policy - DescriptionRef bson.ObjectID `bson:"descriptionRef" json:"descriptionRef"` // Reference to the policy's metadata - ObjectRef *bson.ObjectID `bson:"objectRef,omitempty" json:"objectRef,omitempty"` // Target object (NilObjectID for all objects) - Effect ActionEffect `bson:"effect" json:"effect"` // Action and effect for the policy -} - -// RolePolicy defines access control rules for a role within an organization. -type RolePolicy struct { - Policy `bson:",inline" json:",inline"` - RoleDescriptionRef bson.ObjectID `bson:"roleDescriptionRef" json:"roleDescriptionRef"` // Reference to the associated role -} - -// PolicyDescription provides metadata for policies. -type PolicyDescription struct { - storable.Base `bson:",inline" json:",inline"` // Base fields for MongoDB documents - Describable `bson:",inline" json:",inline"` // Name and description fields - ResourceTypes *[]mservice.Type `bson:"resourceTypes,omitempty" json:"resourceTypes,omitempty"` // nil for custom policies, non-nil for built-in permissisons - OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` // nil for built-in policies, non-nil for custom -} - -// Collection specifies the MongoDB collection for PolicyDescription. -func (*PolicyDescription) Collection() string { - return mservice.Policies -} - -// Permission ties a policy to a specific account. -type Permission struct { - RolePolicy `bson:",inline" json:",inline"` // Embedded policy definition - AccountRef bson.ObjectID `bson:"accountRef" json:"accountRef"` // Account assigned the permission -} diff --git a/backend/pkg/model/card.go b/backend/pkg/model/card.go deleted file mode 100644 index 3d7a196..0000000 --- a/backend/pkg/model/card.go +++ /dev/null @@ -1,169 +0,0 @@ -package model - -import ( - "fmt" - "math" - "strconv" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type CardPaymentData struct { - Pan string `bson:"pan" json:"pan"` - FirstName string `bson:"firstName" json:"firstName"` - LastName string `bson:"lastName" json:"lastName"` - ExpMonth *int `bson:"exp_month" json:"exp_month"` - ExpYear *int `bson:"exp_year" json:"exp_year"` - Network string `bson:"network,omitempty" json:"network,omitempty"` - Country string `bson:"country,omitempty" json:"country,omitempty"` -} - -func (d *CardPaymentData) UnmarshalBSON(data []byte) error { - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - return err - } - d.Pan = paymentDataString(raw["pan"]) - d.FirstName = firstPaymentDataString(raw, "firstName", "first_name") - d.LastName = firstPaymentDataString(raw, "lastName", "last_name") - d.ExpMonth = firstPaymentDataInt(raw, "exp_month", "expMonth") - d.ExpYear = firstPaymentDataInt(raw, "exp_year", "expYear") - d.Network = paymentDataString(raw["network"]) - d.Country = paymentDataString(raw["country"]) - return nil -} - -func (m *PaymentMethod) AsCard() (*CardPaymentData, error) { - if m.Type != PaymentTypeCard { - return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not card", m.Type), "type") - } - var d CardPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - return &d, nil -} - -func firstPaymentDataString(raw map[string]any, keys ...string) string { - for _, key := range keys { - if value := paymentDataString(raw[key]); value != "" { - return value - } - } - return "" -} - -// paymentDataDocument reads a nested payment data document as a map, whatever -// document shape the BSON decoder produced: a generic decode yields bson.D, -// while a typed or hand-built payload yields a map. -func paymentDataDocument(value any) map[string]any { - switch document := value.(type) { - case map[string]any: - return document - case bson.M: - return document - case bson.D: - out := make(map[string]any, len(document)) - for _, element := range document { - out[element.Key] = element.Value - } - return out - default: - return nil - } -} - -func firstPaymentDataInt(raw map[string]any, keys ...string) *int { - for _, key := range keys { - if value := paymentDataInt(raw[key]); value != nil { - return value - } - } - return nil -} - -func paymentDataInt(value any) *int { - switch v := value.(type) { - case nil: - return nil - case int: - return &v - case int32: - out := int(v) - return &out - case int64: - out := int(v) - return &out - case uint: - out := int(v) - return &out - case uint32: - out := int(v) - return &out - case uint64: - return paymentDataIntFromUint64(v) - case float32: - out := int(v) - return &out - case float64: - out := int(v) - return &out - case string: - clean := strings.TrimSpace(v) - if clean == "" { - return nil - } - parsed, err := strconv.Atoi(clean) - if err != nil { - return nil - } - return &parsed - default: - clean := strings.TrimSpace(fmt.Sprint(v)) - if clean == "" { - return nil - } - parsed, err := strconv.Atoi(clean) - if err != nil { - return nil - } - return &parsed - } -} - -func paymentDataIntFromUint64(value uint64) *int { - if value > uint64(math.MaxInt) { - return nil - } - out := int(value) - return &out -} - -func paymentDataString(value any) string { - switch v := value.(type) { - case nil: - return "" - case string: - return strings.TrimSpace(v) - case int: - return fmt.Sprint(v) - case int32: - return fmt.Sprint(v) - case int64: - return fmt.Sprint(v) - case uint: - return fmt.Sprint(v) - case uint32: - return fmt.Sprint(v) - case uint64: - return fmt.Sprint(v) - case float32: - return strconv.FormatFloat(float64(v), 'f', -1, 32) - case float64: - return strconv.FormatFloat(v, 'f', -1, 64) - default: - return strings.TrimSpace(fmt.Sprint(v)) - } -} diff --git a/backend/pkg/model/card_test.go b/backend/pkg/model/card_test.go deleted file mode 100644 index 2ca8c0a..0000000 --- a/backend/pkg/model/card_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package model - -import ( - "testing" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestPaymentMethodAsCardReadsStoredSnakeCaseExpiry(t *testing.T) { - data, err := bson.Marshal(bson.M{ - "pan": "2200001100000001", - "firstName": "S", - "lastName": "S", - "exp_month": 12.0, - "exp_year": 2031.0, - "country": nil, - }) - if err != nil { - t.Fatalf("marshal card data: %v", err) - } - - card, err := (&PaymentMethod{ - Type: PaymentTypeCard, - Data: data, - }).AsCard() - if err != nil { - t.Fatalf("AsCard returned error: %v", err) - } - - if got, want := card.ExpMonth, 12; got == nil || *got != want { - t.Fatalf("exp_month mismatch: got=%v want=%d", got, want) - } - if got, want := card.ExpYear, 2031; got == nil || *got != want { - t.Fatalf("exp_year mismatch: got=%v want=%d", got, want) - } -} - -func TestCardPaymentDataMarshalUsesStoredSnakeCaseExpiry(t *testing.T) { - expMonth := 12 - expYear := 2031 - raw, err := bson.Marshal(CardPaymentData{ - Pan: "2200001100000001", - FirstName: "S", - LastName: "S", - ExpMonth: &expMonth, - ExpYear: &expYear, - }) - if err != nil { - t.Fatalf("marshal card data: %v", err) - } - - var data bson.M - if err := bson.Unmarshal(raw, &data); err != nil { - t.Fatalf("unmarshal card data: %v", err) - } - if got, want := paymentDataInt(data["exp_month"]), 12; got == nil || *got != want { - t.Fatalf("stored exp_month mismatch: got=%v want=%d", got, want) - } - if got, want := paymentDataInt(data["exp_year"]), 2031; got == nil || *got != want { - t.Fatalf("stored exp_year mismatch: got=%v want=%d", got, want) - } - if _, ok := data["expMonth"]; ok { - t.Fatalf("card data must not store camelCase expMonth: %#v", data) - } - if _, ok := data["expYear"]; ok { - t.Fatalf("card data must not store camelCase expYear: %#v", data) - } -} diff --git a/backend/pkg/model/chains.go b/backend/pkg/model/chains.go deleted file mode 100644 index b0e8b27..0000000 --- a/backend/pkg/model/chains.go +++ /dev/null @@ -1,97 +0,0 @@ -package model - -import "strings" - -// ChainNetwork identifies a blockchain network for routing and discovery. -type ChainNetwork string - -const ( - ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one" - ChainNetworkArbitrumSepolia ChainNetwork = "arbitrum_sepolia" - ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet" - ChainNetworkTronMainnet ChainNetwork = "tron_mainnet" - ChainNetworkTronNile ChainNetwork = "tron_nile" - ChainNetworkUnspecified ChainNetwork = "" -) - -// AllChainNetworks returns all valid chain networks. -func AllChainNetworks() []ChainNetwork { - return []ChainNetwork{ - ChainNetworkArbitrumOne, - ChainNetworkArbitrumSepolia, - ChainNetworkEthereumMainnet, - ChainNetworkTronMainnet, - ChainNetworkTronNile, - } -} - -// ParseChainNetwork parses a string into a ChainNetwork. -// Returns the network and true if valid, or empty and false if invalid. -func ParseChainNetwork(value string) (ChainNetwork, bool) { - v := strings.ToLower(strings.TrimSpace(value)) - switch v { - case "arbitrum_one", "arbitrum", "arb": - return ChainNetworkArbitrumOne, true - case "arbitrum_sepolia": - return ChainNetworkArbitrumSepolia, true - case "ethereum_mainnet", "ethereum", "eth": - return ChainNetworkEthereumMainnet, true - case "tron_mainnet", "tron": - return ChainNetworkTronMainnet, true - case "tron_nile", "nile": - return ChainNetworkTronNile, true - case "unspecified", "": - return ChainNetworkUnspecified, true - default: - return ChainNetworkUnspecified, false - } -} - -// String returns the string representation of the network. -func (n ChainNetwork) String() string { - return string(n) -} - -// IsValid returns true if the network is a known valid network. -func (n ChainNetwork) IsValid() bool { - switch n { - case ChainNetworkArbitrumOne, - ChainNetworkArbitrumSepolia, - ChainNetworkEthereumMainnet, - ChainNetworkTronMainnet, - ChainNetworkTronNile: - return true - default: - return false - } -} - -// IsTron returns true if the network is a TRON network. -func (n ChainNetwork) IsTron() bool { - switch n { - case ChainNetworkTronMainnet, ChainNetworkTronNile: - return true - default: - return false - } -} - -// IsEVM returns true if the network is an EVM-compatible network. -func (n ChainNetwork) IsEVM() bool { - switch n { - case ChainNetworkArbitrumOne, ChainNetworkArbitrumSepolia, ChainNetworkEthereumMainnet: - return true - default: - return false - } -} - -// IsTestnet returns true if the network is a testnet. -func (n ChainNetwork) IsTestnet() bool { - switch n { - case ChainNetworkTronNile, ChainNetworkArbitrumSepolia: - return true - default: - } - return false -} diff --git a/backend/pkg/model/chains_test.go b/backend/pkg/model/chains_test.go deleted file mode 100644 index 84c4a9b..0000000 --- a/backend/pkg/model/chains_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package model - -import "testing" - -func TestChainNetworkArbitrumSepoliaSupport(t *testing.T) { - network, ok := ParseChainNetwork("arbitrum_sepolia") - if !ok { - t.Fatal("ParseChainNetwork(arbitrum_sepolia) returned ok=false") - } - if network != ChainNetworkArbitrumSepolia { - t.Fatalf("ParseChainNetwork(arbitrum_sepolia) = %q, want %q", network, ChainNetworkArbitrumSepolia) - } - if !network.IsValid() { - t.Fatal("ChainNetworkArbitrumSepolia should be valid") - } - if !network.IsEVM() { - t.Fatal("ChainNetworkArbitrumSepolia should be EVM") - } - if !network.IsTestnet() { - t.Fatal("ChainNetworkArbitrumSepolia should be testnet") - } - - found := false - for _, candidate := range AllChainNetworks() { - if candidate == ChainNetworkArbitrumSepolia { - found = true - break - } - } - if !found { - t.Fatal("AllChainNetworks() is missing ChainNetworkArbitrumSepolia") - } -} diff --git a/backend/pkg/model/chainwalletroute.go b/backend/pkg/model/chainwalletroute.go deleted file mode 100644 index 27d84d9..0000000 --- a/backend/pkg/model/chainwalletroute.go +++ /dev/null @@ -1,29 +0,0 @@ -package model - -import ( - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" -) - -// ChainWalletRoute stores authoritative wallet-to-gateway routing metadata. -type ChainWalletRoute struct { - storable.Base `bson:",inline" json:",inline"` - - OrganizationRef string `bson:"organizationRef" json:"organizationRef"` - WalletRef string `bson:"walletRef" json:"walletRef"` - Network string `bson:"network" json:"network"` - GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"` -} - -func (*ChainWalletRoute) Collection() string { - return mservice.WalletRoutes -} - -func (r *ChainWalletRoute) Normalize() { - r.OrganizationRef = strings.TrimSpace(r.OrganizationRef) - r.WalletRef = strings.TrimSpace(r.WalletRef) - r.Network = strings.ToLower(strings.TrimSpace(r.Network)) - r.GatewayID = strings.TrimSpace(r.GatewayID) -} diff --git a/backend/pkg/model/cryptoaddress.go b/backend/pkg/model/cryptoaddress.go deleted file mode 100644 index 41c1c42..0000000 --- a/backend/pkg/model/cryptoaddress.go +++ /dev/null @@ -1,127 +0,0 @@ -package model - -import ( - "encoding/json" - "fmt" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type CryptoAddressPaymentData struct { - Currency Currency `bson:"currency" json:"currency"` - Address string `bson:"address" json:"address"` - Network string `bson:"network" json:"network"` - DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"` -} - -type cryptoAddressAssetJSON struct { - Chain string `json:"chain,omitempty"` - TokenSymbol string `json:"token_symbol,omitempty"` - ContractAddress string `json:"contract_address,omitempty"` -} - -func (d CryptoAddressPaymentData) MarshalJSON() ([]byte, error) { - var payload struct { - Asset *cryptoAddressAssetJSON `json:"asset,omitempty"` - Address string `json:"address"` - Memo *string `json:"memo,omitempty"` - } - payload.Address = strings.TrimSpace(d.Address) - if strings.TrimSpace(d.Network) != "" || strings.TrimSpace(string(d.Currency)) != "" { - payload.Asset = &cryptoAddressAssetJSON{ - Chain: strings.TrimSpace(d.Network), - TokenSymbol: strings.TrimSpace(string(d.Currency)), - } - } - if memo := strings.TrimSpace(strPtr(d.DestinationTag)); memo != "" { - payload.Memo = &memo - } - return json.Marshal(payload) -} - -func (d *CryptoAddressPaymentData) UnmarshalJSON(data []byte) error { - var raw struct { - Asset *cryptoAddressAssetJSON `json:"asset"` - Address string `json:"address"` - Memo string `json:"memo"` - Currency string `json:"currency"` - Network string `json:"network"` - DestinationTag *string `json:"destinationTag"` - } - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - currency := strings.TrimSpace(raw.Currency) - network := strings.TrimSpace(raw.Network) - if raw.Asset != nil { - if assetCurrency := strings.TrimSpace(raw.Asset.TokenSymbol); assetCurrency != "" { - currency = assetCurrency - } - if assetNetwork := strings.TrimSpace(raw.Asset.Chain); assetNetwork != "" { - network = assetNetwork - } - } - - d.Currency = Currency(strings.ToUpper(currency)) - d.Address = strings.TrimSpace(raw.Address) - d.Network = strings.ToUpper(network) - d.DestinationTag = clonePaymentDataStringPtr(raw.Memo) - if d.DestinationTag == nil && raw.DestinationTag != nil { - d.DestinationTag = clonePaymentDataStringPtr(*raw.DestinationTag) - } - return nil -} - -func (d *CryptoAddressPaymentData) UnmarshalBSON(data []byte) error { - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - return err - } - currency := firstPaymentDataString(raw, "currency") - network := firstPaymentDataString(raw, "network") - if asset := paymentDataDocument(raw["asset"]); asset != nil { - if assetCurrency := firstPaymentDataString(asset, "token_symbol", "tokenSymbol"); assetCurrency != "" { - currency = assetCurrency - } - if assetNetwork := firstPaymentDataString(asset, "chain"); assetNetwork != "" { - network = assetNetwork - } - } - - d.Currency = Currency(strings.ToUpper(strings.TrimSpace(currency))) - d.Address = firstPaymentDataString(raw, "address") - d.Network = strings.ToUpper(strings.TrimSpace(network)) - d.DestinationTag = clonePaymentDataStringPtr(firstPaymentDataString(raw, "destinationTag", "destination_tag", "memo")) - return nil -} - -func (m *PaymentMethod) AsCryptoAddress() (*CryptoAddressPaymentData, error) { - if m.Type != PaymentTypeCryptoAddress { - return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not cryptoAddress", m.Type), "type") - } - - var d CryptoAddressPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - - return &d, nil -} - -func clonePaymentDataStringPtr(value string) *string { - out := strings.TrimSpace(value) - if out == "" { - return nil - } - return &out -} - -func strPtr(value *string) string { - if value == nil { - return "" - } - return *value -} diff --git a/backend/pkg/model/cryptoaddress_test.go b/backend/pkg/model/cryptoaddress_test.go deleted file mode 100644 index 71601d1..0000000 --- a/backend/pkg/model/cryptoaddress_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package model - -import ( - "encoding/json" - "testing" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestCryptoAddressPaymentDataReadsAssetFromGenericDocument(t *testing.T) { - // The intermediate BSON document produced by PATCH decodes a nested asset - // as bson.D before the payment service record is encoded. - var payload any - if err := json.Unmarshal([]byte(`{"asset":{"chain":"tron_mainnet","token_symbol":"usdt"},"address":"TXabc","memo":"tag-1"}`), &payload); err != nil { - t.Fatalf("unmarshal payload: %v", err) - } - raw, err := bson.Marshal(payload) - if err != nil { - t.Fatalf("marshal payload: %v", err) - } - - var data CryptoAddressPaymentData - if err := bson.Unmarshal(raw, &data); err != nil { - t.Fatalf("unmarshal crypto data: %v", err) - } - - if got, want := string(data.Currency), "USDT"; got != want { - t.Fatalf("currency mismatch: got=%q want=%q", got, want) - } - if got, want := data.Network, "TRON_MAINNET"; got != want { - t.Fatalf("network mismatch: got=%q want=%q", got, want) - } - if got, want := data.Address, "TXabc"; got != want { - t.Fatalf("address mismatch: got=%q want=%q", got, want) - } - if got, want := strPtr(data.DestinationTag), "tag-1"; got != want { - t.Fatalf("destination tag mismatch: got=%q want=%q", got, want) - } -} - -func TestCryptoAddressPaymentDataReadsAssetFromMapDocument(t *testing.T) { - raw, err := bson.Marshal(bson.M{ - "asset": bson.M{"chain": "arbitrum_one", "tokenSymbol": "usdc"}, - "address": "0xabc", - }) - if err != nil { - t.Fatalf("marshal crypto data: %v", err) - } - - var data CryptoAddressPaymentData - if err := bson.Unmarshal(raw, &data); err != nil { - t.Fatalf("unmarshal crypto data: %v", err) - } - - if got, want := string(data.Currency), "USDC"; got != want { - t.Fatalf("currency mismatch: got=%q want=%q", got, want) - } - if got, want := data.Network, "ARBITRUM_ONE"; got != want { - t.Fatalf("network mismatch: got=%q want=%q", got, want) - } -} - -func TestCryptoAddressPaymentDataReadsStoredFlatShape(t *testing.T) { - raw, err := bson.Marshal(CryptoAddressPaymentData{ - Currency: "USDT", - Address: "TXabc", - Network: "TRON_MAINNET", - }) - if err != nil { - t.Fatalf("marshal crypto data: %v", err) - } - - var data CryptoAddressPaymentData - if err := bson.Unmarshal(raw, &data); err != nil { - t.Fatalf("unmarshal crypto data: %v", err) - } - - if got, want := string(data.Currency), "USDT"; got != want { - t.Fatalf("currency mismatch: got=%q want=%q", got, want) - } - if got, want := data.Network, "TRON_MAINNET"; got != want { - t.Fatalf("network mismatch: got=%q want=%q", got, want) - } -} diff --git a/backend/pkg/model/ctoken.go b/backend/pkg/model/ctoken.go deleted file mode 100644 index ff5191d..0000000 --- a/backend/pkg/model/ctoken.go +++ /dev/null @@ -1,47 +0,0 @@ -package model - -import ( - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// TokenPaymentData represents a network or gateway-issued card token. -type TokenPaymentData struct { - Token string `bson:"token" json:"token"` - Network string `bson:"network,omitempty" json:"network,omitempty"` - Last4 string `bson:"last4,omitempty" json:"masked_pan,omitempty"` - ExpMonth *int `bson:"exp_month,omitempty" json:"exp_month,omitempty"` - ExpYear *int `bson:"exp_year,omitempty" json:"exp_year,omitempty"` - CardholderName string `bson:"cardholderName,omitempty" json:"cardholderName,omitempty"` - Country string `bson:"country,omitempty" json:"country,omitempty"` -} - -func (d *TokenPaymentData) UnmarshalBSON(data []byte) error { - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - return err - } - d.Token = paymentDataString(raw["token"]) - d.Network = paymentDataString(raw["network"]) - d.Last4 = firstPaymentDataString(raw, "last4", "last_4", "masked_pan") - d.ExpMonth = firstPaymentDataInt(raw, "exp_month", "expMonth") - d.ExpYear = firstPaymentDataInt(raw, "exp_year", "expYear") - d.CardholderName = firstPaymentDataString(raw, "cardholderName", "cardholder_name") - d.Country = paymentDataString(raw["country"]) - return nil -} - -func (m *PaymentMethod) AsCardToken() (*TokenPaymentData, error) { - if m.Type != PaymentTypeCardToken { - return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not cardToken", m.Type), "type") - } - - var d TokenPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - - return &d, nil -} diff --git a/backend/pkg/model/ctoken_test.go b/backend/pkg/model/ctoken_test.go deleted file mode 100644 index 51f9066..0000000 --- a/backend/pkg/model/ctoken_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package model - -import ( - "testing" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestTokenPaymentDataUnmarshalReadsSnakeCaseExpiryAsInt(t *testing.T) { - raw, err := bson.Marshal(bson.M{ - "token": "card-token", - "last4": "1234", - "exp_month": "01", - "exp_year": 2031.0, - }) - if err != nil { - t.Fatalf("marshal card token data: %v", err) - } - - var data TokenPaymentData - if err := bson.Unmarshal(raw, &data); err != nil { - t.Fatalf("unmarshal card token data: %v", err) - } - if got, want := data.ExpMonth, 1; got == nil || *got != want { - t.Fatalf("exp_month mismatch: got=%v want=%d", got, want) - } - if got, want := data.ExpYear, 2031; got == nil || *got != want { - t.Fatalf("exp_year mismatch: got=%v want=%d", got, want) - } -} diff --git a/backend/pkg/model/currency.go b/backend/pkg/model/currency.go deleted file mode 100644 index d706321..0000000 --- a/backend/pkg/model/currency.go +++ /dev/null @@ -1,77 +0,0 @@ -package model - -import ( - "sort" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/moneybase" -) - -type Currency string - -const ( - CurrencyEUR Currency = "EUR" // Euro - CurrencyUSD Currency = "USD" // US Dollar - CurrencyRUB Currency = "RUB" // Russian Ruble - CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia - CurrencyPLN Currency = "PLN" // Polish Złoty - CurrencyCZK Currency = "CZK" // Czech Koruna - CurrencyUSDT Currency = "USDT" // Tether USD -) - -const MaxCurrencyScale uint32 = moneybase.MaxScale - -func NormalizeCurrency(value string) Currency { - return Currency(strings.ToUpper(strings.TrimSpace(value))) -} - -func NormalizeExplicitCurrencyScales(values map[Currency]uint32) (map[Currency]uint32, error) { - if len(values) == 0 { - return nil, merrors.InvalidArgument("currency scales are required") - } - out := make(map[Currency]uint32, len(values)) - for rawCurrency, scale := range values { - currency := NormalizeCurrency(string(rawCurrency)) - if currency == "" { - return nil, merrors.InvalidArgument("currency scale key is required") - } - if scale > MaxCurrencyScale { - return nil, merrors.InvalidArgumentf("currency scale for %s exceeds %d", currency, MaxCurrencyScale) - } - out[currency] = scale - } - return out, nil -} - -func CurrencyScale(scales map[Currency]uint32, currency string) (uint32, error) { - normalized := NormalizeCurrency(currency) - if normalized == "" { - return 0, merrors.InvalidArgument("currency is required") - } - if scales == nil { - return 0, merrors.InvalidArgument("currency scale map is required") - } - scale, ok := scales[normalized] - if !ok { - return 0, merrors.InvalidArgument("currency scale for " + string(normalized) + " is not configured") - } - return scale, nil -} - -func CurrencyScaleKeys(scales map[Currency]uint32) []string { - if scales == nil { - return nil - } - keys := make([]string, 0, len(scales)) - for currency := range scales { - keys = append(keys, string(currency)) - } - sort.Strings(keys) - return keys -} - -type Amount struct { - Total float64 `bson:"total" json:"total"` // Total amount billed - Currency Currency `bson:"currency" json:"currency"` // Currency for the invoice -} diff --git a/backend/pkg/model/currency_test.go b/backend/pkg/model/currency_test.go deleted file mode 100644 index 7904986..0000000 --- a/backend/pkg/model/currency_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package model - -import ( - "errors" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/moneybase" - "github.com/stretchr/testify/require" -) - -func TestMaxCurrencyScaleUsesSharedMoneyBaseRange(t *testing.T) { - require.Equal(t, moneybase.MaxScale, MaxCurrencyScale) -} - -func TestNormalizeExplicitCurrencyScalesNormalizesKeys(t *testing.T) { - scales, err := NormalizeExplicitCurrencyScales(map[Currency]uint32{"usd": 2, "usdt": 6}) - require.NoError(t, err) - - usd, err := CurrencyScale(scales, "USD") - require.NoError(t, err) - require.Equal(t, uint32(2), usd) - - usdt, err := CurrencyScale(scales, "USDT") - require.NoError(t, err) - require.Equal(t, uint32(6), usdt) -} - -func TestCurrencyScaleRejectsUnconfiguredCurrency(t *testing.T) { - _, err := CurrencyScale(map[Currency]uint32{"USD": 2}, "KWD") - - require.Error(t, err) - require.True(t, errors.Is(err, merrors.ErrInvalidArg), "err=%v", err) -} - -func TestNormalizeExplicitCurrencyScalesRejectsMissingScales(t *testing.T) { - _, err := NormalizeExplicitCurrencyScales(nil) - - require.Error(t, err) - require.True(t, errors.Is(err, merrors.ErrInvalidArg), "err=%v", err) -} diff --git a/backend/pkg/model/iban.go b/backend/pkg/model/iban.go deleted file mode 100644 index a4cd7ea..0000000 --- a/backend/pkg/model/iban.go +++ /dev/null @@ -1,26 +0,0 @@ -package model - -import ( - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type IbanPaymentData struct { - Iban string `bson:"iban" json:"iban"` - AccountHolder string `bson:"accountHolder" json:"accountHolder"` - Bic *string `bson:"bic,omitempty" json:"bic,omitempty"` - BankName *string `bson:"bankName,omitempty" json:"bankName,omitempty"` -} - -func (m *PaymentMethod) AsIban() (*IbanPaymentData, error) { - if m.Type != PaymentTypeIban { - return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not iban", m.Type), "type") - } - var d IbanPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - return &d, nil -} diff --git a/backend/pkg/model/indexable.go b/backend/pkg/model/indexable.go deleted file mode 100644 index 3dee3f8..0000000 --- a/backend/pkg/model/indexable.go +++ /dev/null @@ -1,12 +0,0 @@ -package model - -import "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - -type Indexable struct { - Index int `bson:"index" json:"index"` -} - -type IndexableRef struct { - storable.Ref `bson:",inline" json:",inline"` - Indexable `bson:",inline" json:",inline"` -} diff --git a/backend/pkg/model/invitation.go b/backend/pkg/model/invitation.go deleted file mode 100644 index 0135457..0000000 --- a/backend/pkg/model/invitation.go +++ /dev/null @@ -1,72 +0,0 @@ -package model - -import ( - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type InvitationStatus string - -const ( - InvitationCreated InvitationStatus = "created" - InvitationSent InvitationStatus = "sent" - InvitationAccepted InvitationStatus = "accepted" - InvitationDeclined InvitationStatus = "declined" - InvitationRevoked InvitationStatus = "revoked" -) - -type invitationDesc struct { - Email string `bson:"email" json:"email"` - Name string `bson:"name" json:"name"` - LastName string `bson:"lastName" json:"lastName"` - Comment string `bson:"comment" json:"comment"` -} - -func (id *invitationDesc) Collection() string { - return mservice.Invitations -} - -type Invitation struct { - PermissionBound `bson:",inline" json:",inline"` - OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` - RoleRef bson.ObjectID `bson:"roleRef" json:"roleRef"` - InviterRef bson.ObjectID `bson:"inviterRef" json:"inviterRef"` - Status InvitationStatus `bson:"status" json:"status"` - ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` - Content invitationDesc `bson:"description" json:"description"` -} - -func (*Invitation) Collection() string { - return mservice.Invitations -} - -type employeeDesc struct { - Description Describable `bson:"description" json:"description"` - AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` -} - -type organizationDesc struct { - Description Describable `bson:"description" json:"description"` - LogoURL *string `bson:"logoUrl,omitempty" json:"logoUrl,omitempty"` -} - -type invitationStorable struct { - storable.Base `bson:",inline" json:",inline"` - ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` -} - -type PublicInvitation struct { - Storable invitationStorable `bson:"storable" json:"storable"` - Employee employeeDesc `bson:"employee" json:"employee"` - Organization organizationDesc `bson:"organization" json:"organization"` - Role Describable `bson:"role" json:"role"` - Invitation invitationDesc `bson:"invitation" json:"invitation"` - RequiresRegistration bool `bson:"registrationRequired" json:"registrationRequired"` -} - -func (pi *PublicInvitation) Collection() string { - return mservice.Invitations -} diff --git a/backend/pkg/model/invitation_status.go b/backend/pkg/model/invitation_status.go deleted file mode 100644 index b5799c2..0000000 --- a/backend/pkg/model/invitation_status.go +++ /dev/null @@ -1,18 +0,0 @@ -package model - -func PendingInvitationStatuses() []InvitationStatus { - return []InvitationStatus{InvitationCreated, InvitationSent} -} - -func PendingInvitationStatusValues() []string { - statuses := PendingInvitationStatuses() - values := make([]string, 0, len(statuses)) - for _, status := range statuses { - values = append(values, string(status)) - } - return values -} - -func IsPendingInvitationStatus(status InvitationStatus) bool { - return status == InvitationCreated || status == InvitationSent -} diff --git a/backend/pkg/model/invitation_test.go b/backend/pkg/model/invitation_test.go deleted file mode 100644 index fd80f2b..0000000 --- a/backend/pkg/model/invitation_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package model - -import "testing" - -func TestPendingInvitationStatusValues(t *testing.T) { - got := PendingInvitationStatusValues() - want := []string{"created", "sent"} - if len(got) != len(want) { - t.Fatalf("pending status count mismatch: got %d want %d", len(got), len(want)) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("pending status %d mismatch: got %q want %q", i, got[i], want[i]) - } - } - - if !IsPendingInvitationStatus(InvitationCreated) { - t.Fatal("created must be pending") - } - if !IsPendingInvitationStatus(InvitationSent) { - t.Fatal("sent must be pending") - } - for _, status := range []InvitationStatus{InvitationAccepted, InvitationDeclined, InvitationRevoked} { - if IsPendingInvitationStatus(status) { - t.Fatalf("%q must not be pending", status) - } - } -} - -func TestInvitationValidateEmail(t *testing.T) { - tests := []struct { - name string - email string - wantErr bool - }{ - {name: "valid", email: "invitee@example.com"}, - {name: "whitespace padded valid", email: " invitee@example.com "}, - {name: "empty", email: "", wantErr: true}, - {name: "whitespace only", email: " ", wantErr: true}, - {name: "missing domain", email: "invitee@", wantErr: true}, - {name: "missing at", email: "invitee.example.com", wantErr: true}, - {name: "display name", email: "Invitee ", wantErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - inv := &Invitation{ - Status: InvitationCreated, - Content: invitationDesc{ - Email: tt.email, - }, - } - err := inv.Validate() - if tt.wantErr && err == nil { - t.Fatal("expected validation error") - } - if !tt.wantErr && err != nil { - t.Fatalf("expected valid invitation, got %v", err) - } - }) - } -} diff --git a/backend/pkg/model/invitation_validation.go b/backend/pkg/model/invitation_validation.go deleted file mode 100644 index dea9556..0000000 --- a/backend/pkg/model/invitation_validation.go +++ /dev/null @@ -1,23 +0,0 @@ -package model - -import ( - "net/mail" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" -) - -func (i *Invitation) Validate() error { - if i == nil { - return merrors.InvalidArgument("invitation is required", "invitation") - } - if strings.TrimSpace(i.Content.Email) == "" { - return merrors.InvalidArgument("invitation email must not be empty", "description.email") - } - trimmedEmail := strings.TrimSpace(i.Content.Email) - parsed, err := mail.ParseAddress(trimmedEmail) - if err != nil || parsed.Address != trimmedEmail { - return merrors.InvalidArgument("invitation email is malformed", "description.email") - } - return nil -} diff --git a/backend/pkg/model/ledger.go b/backend/pkg/model/ledger.go deleted file mode 100644 index cf8bd88..0000000 --- a/backend/pkg/model/ledger.go +++ /dev/null @@ -1,132 +0,0 @@ -package model - -import ( - "strings" - - "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/account_role" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// AccountType defines the category of account (asset, liability, revenue, expense). -type LedgerAccountType string - -const ( - LedgerAccountTypeAsset LedgerAccountType = "asset" - LedgerAccountTypeLiability LedgerAccountType = "liability" - LedgerAccountTypeRevenue LedgerAccountType = "revenue" - LedgerAccountTypeExpense LedgerAccountType = "expense" -) - -// AccountStatus tracks the operational state of an account. -type LedgerAccountStatus string - -const ( - LedgerAccountStatusActive LedgerAccountStatus = "active" - LedgerAccountStatusFrozen LedgerAccountStatus = "frozen" - LedgerAccountStatusClosed LedgerAccountStatus = "closed" -) - -type LedgerAccountScope string - -const ( - LedgerAccountScopeOrganization LedgerAccountScope = "organization" - LedgerAccountScopeSystem LedgerAccountScope = "system" -) - -type SystemAccountPurpose string - -const ( - SystemAccountPurposeExternalSource SystemAccountPurpose = "external_source" - SystemAccountPurposeExternalSink SystemAccountPurpose = "external_sink" -) - -// Account represents a ledger account that holds balances for a specific currency. -type LedgerAccount struct { - storable.Base `bson:",inline" json:",inline"` - Describable `bson:",inline" json:",inline"` - - // Scope defines whether the account belongs to an organization - // or is a system-level ledger account used for internal accounting. - Scope LedgerAccountScope `bson:"scope" json:"scope"` - - // SystemPurpose specifies the role of a system-scoped account - // (e.g., external source or sink of funds). Must be set for system accounts - // and must be nil for organization accounts. - SystemPurpose *SystemAccountPurpose `bson:"systemPurpose,omitempty" json:"systemPurpose,omitempty"` - - // OrganizationRef links the account to an organization. - // Must be set for organization accounts and nil for system accounts. - OrganizationRef *bson.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` - - // Role defines the functional purpose of the account within an organization - // (e.g., pending, operating, settlement, hold, etc.). - // Must be set for organization accounts and omitted for system accounts. - Role account_role.AccountRole `bson:"role,omitempty" json:"role,omitempty"` - - // AccountCode is a logical classification code of the account - // (e.g., "asset:cash:usd") used for reporting and grouping. - AccountCode string `bson:"accountCode" json:"accountCode"` - - // Currency is the ISO 4217 currency code the account operates in. - Currency string `bson:"currency" json:"currency"` - - // AccountType defines the accounting category of the account - // (asset, liability, revenue, expense). - AccountType LedgerAccountType `bson:"accountType" json:"accountType"` - - // Status represents the operational state of the account. - Status LedgerAccountStatus `bson:"status" json:"status"` - - // AllowNegative defines whether the account is allowed to have - // a negative balance (used for system control accounts). - AllowNegative bool `bson:"allowNegative" json:"allowNegative"` - - // OwnerRef optionally links the account to a specific owner entity - // (e.g., user or sub-entity within the organization). - OwnerRef *bson.ObjectID `bson:"ownerRef,omitempty" json:"ownerRef,omitempty"` - - // Metadata holds additional arbitrary key-value attributes. - Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` -} - -// Collection implements storable.Storable. -func (*LedgerAccount) Collection() string { - return mservice.LedgerAccounts -} - -// Validate enforces scope-specific invariants for ledger accounts. -func (a *LedgerAccount) Validate() error { - if a == nil { - return merrors.InvalidArgument("ledger account is required") - } - - switch a.Scope { - case LedgerAccountScopeOrganization: - if a.OrganizationRef == nil || a.OrganizationRef.IsZero() { - return merrors.InvalidArgument("organization_ref is required for organization accounts") - } - if strings.TrimSpace(string(a.Role)) == "" { - return merrors.InvalidArgument("role is required for organization accounts") - } - if a.SystemPurpose != nil { - return merrors.InvalidArgument("system_purpose must be nil for organization accounts") - } - case LedgerAccountScopeSystem: - if a.OrganizationRef != nil && !a.OrganizationRef.IsZero() { - return merrors.InvalidArgument("organization_ref must be nil for system accounts") - } - if strings.TrimSpace(string(a.Role)) != "" { - return merrors.InvalidArgument("role must be empty for system accounts") - } - if a.SystemPurpose == nil { - return merrors.InvalidArgument("system_purpose is required for system accounts") - } - default: - return merrors.InvalidArgument("scope is required") - } - - return nil -} diff --git a/backend/pkg/model/object.go b/backend/pkg/model/object.go deleted file mode 100644 index 7b39819..0000000 --- a/backend/pkg/model/object.go +++ /dev/null @@ -1,20 +0,0 @@ -package model - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type TypeBound struct { - Type mservice.Type `bson:"type" json:"type"` -} - -type ObjectRefs struct { - TypeBound `bson:"inline" json:"inline"` - Refs []bson.ObjectID `bson:"refs,omitempty" json:"refs,omitempty"` -} - -type ObjectRef struct { - TypeBound `bson:"inline" json:"inline"` - Ref bson.ObjectID `bson:"ref,omitempty" json:"ref,omitempty"` -} diff --git a/backend/pkg/model/organization.go b/backend/pkg/model/organization.go deleted file mode 100644 index 0f718b5..0000000 --- a/backend/pkg/model/organization.go +++ /dev/null @@ -1,111 +0,0 @@ -package model - -import ( - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type OrganizationSignupState string - -const ( - OrganizationSignupStatePending OrganizationSignupState = "pending_signup_provisioning" - OrganizationSignupStateBlocked OrganizationSignupState = "blocked_signup_provisioning" - OrganizationSignupStateActive OrganizationSignupState = "active" -) - -type OrganizationBase struct { - PermissionBound `bson:",inline" json:",inline"` - Describable `bson:",inline" json:",inline"` - TenantRef bson.ObjectID `bson:"tenantRef" json:"tenantRef"` - TimeZone string `bson:"timeZone" json:"timeZone"` - LogoURL *string `bson:"logoUrl,omitempty" json:"logoUrl,omitempty"` - SignupState OrganizationSignupState `bson:"signupState,omitempty" json:"signupState,omitempty"` -} - -func (*OrganizationBase) Collection() string { - return mservice.Organizations -} - -type Organization struct { - OrganizationBase `bson:",inline" json:",inline"` - Members []bson.ObjectID `bson:"members" json:"members"` - Provisioning *OrganizationProvisioning `bson:"provisioning,omitempty" json:"-"` -} - -// OrganizationProvisioning tracks an organization that is being provisioned and -// has no members or roles yet. It is the only link between such an organization -// and its future owner, and it also guards concurrent provisioning attempts. -// Activation clears only the lease fields: the provenance stays so a retried -// create can recognize the organization it already activated. -// -// It lives on Organization rather than OrganizationBase on purpose: update -// paths replace OrganizationBase wholesale from client payloads, which would -// otherwise wipe it. It is never exposed over the API. -type OrganizationProvisioning struct { - OwnerRef bson.ObjectID `bson:"ownerRef" json:"-"` - // Name snapshots the organization name of the first attempt. It is the - // immutable half of the dedup key: renaming the organization must not - // detach it from retries of the create that produced it. - Name string `bson:"name,omitempty" json:"-"` - Origin string `bson:"origin" json:"-"` - ActorRef string `bson:"actorRef,omitempty" json:"-"` - StartedAt time.Time `bson:"startedAt" json:"-"` - LeaseOwner string `bson:"leaseOwner,omitempty" json:"-"` - LeaseExpiresAt time.Time `bson:"leaseExpiresAt,omitempty" json:"-"` -} - -const ( - OrganizationSignupStateField = "signupState" - OrganizationProvisioningField = "provisioning" - OrganizationProvisioningOwnerRefField = "provisioning.ownerRef" - OrganizationProvisioningNameField = "provisioning.name" - OrganizationProvisioningLeaseOwnerField = "provisioning.leaseOwner" - OrganizationProvisioningLeaseField = "provisioning.leaseExpiresAt" -) - -// LeaseHeld reports whether another attempt still owns the provisioning lease. -func (p *OrganizationProvisioning) LeaseHeld(now time.Time) bool { - return p != nil && p.LeaseExpiresAt.After(now) -} - -// CanFinishProvisioning reports whether an operator can drive this organization -// to active through the backoffice create operation: only pending organizations -// stamped by operator provisioning are resumable. Signup-era organizations -// carry no provisioning stamp. -func (o *Organization) CanFinishProvisioning() bool { - return o != nil && o.Provisioning != nil && o.SignupState == OrganizationSignupStatePending -} - -func (o *Organization) NormalizeSignupState() { - if o == nil { - return - } - switch o.SignupState { - case OrganizationSignupStatePending, OrganizationSignupStateBlocked, OrganizationSignupStateActive: - default: - o.SignupState = OrganizationSignupStateActive - } -} - -type OrganizationBound interface { - GetOrganizationRef() bson.ObjectID - SetOrganizationRef(organizationRef bson.ObjectID) -} - -const ( - OrganizationRefField = "organizationRef" -) - -type OrganizationBoundBase struct { - OrganizationRef bson.ObjectID `bson:"organizationRef" json:"organizationRef"` -} - -func (a *OrganizationBoundBase) GetOrganizationRef() bson.ObjectID { - return a.OrganizationRef -} - -func (a *OrganizationBoundBase) SetOrganizationRef(organizationRef bson.ObjectID) { - a.OrganizationRef = organizationRef -} diff --git a/backend/pkg/model/ownerbound.go b/backend/pkg/model/ownerbound.go deleted file mode 100644 index 2b13b6d..0000000 --- a/backend/pkg/model/ownerbound.go +++ /dev/null @@ -1,21 +0,0 @@ -package model - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// OwnerBound is implemented by resources that follow the personal-vs-org -// ownership model (ownerKind + ownerRef). It is the type constraint for -// ScopedDB and the canonical contract for scoped-access resources. -// -// permissionRef is deliberately absent: it is only meaningful for org-owned -// resources and is supplied separately (as OrgPolicyRef) to the enforcement -// layer. Mixing it here would re-introduce the very coupling this interface -// is meant to eliminate. -type OwnerBound interface { - storable.Storable - OrganizationBound - GetOwnerKind() OwnerKind - GetOwnerRef() bson.ObjectID -} diff --git a/backend/pkg/model/ownerkind.go b/backend/pkg/model/ownerkind.go deleted file mode 100644 index 47313d9..0000000 --- a/backend/pkg/model/ownerkind.go +++ /dev/null @@ -1,24 +0,0 @@ -package model - -// OwnerKind describes who owns a scopable resource (ledger account, recipient, -// payment method, …). It is the single source of truth for the personal-vs-org -// access model: a resource is either owned by an individual account (personal) -// or by an organization (org-scoped). -// -// The string values are persisted in Mongo and used in policy checks, so they -// must stay stable. Scopable resources embed model.Owner (which carries an -// OwnerKind) to keep one canonical vocabulary across the codebase. -type OwnerKind string - -const ( - // OwnerKindAccount marks a personal resource. Access is proven by ownership - // alone: caller is allowed iff ownerRef == caller's account. No policy check. - OwnerKindAccount OwnerKind = "account" - // OwnerKindOrganization marks an org-scoped resource. Access is gated by the - // relevant org policy via the Enforcer. - OwnerKindOrganization OwnerKind = "organization" -) - -func (k OwnerKind) IsValid() bool { - return k == OwnerKindAccount || k == OwnerKindOrganization -} diff --git a/backend/pkg/model/payment.go b/backend/pkg/model/payment.go deleted file mode 100644 index f703773..0000000 --- a/backend/pkg/model/payment.go +++ /dev/null @@ -1,276 +0,0 @@ -package model - -import ( - "bytes" - "encoding/json" - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type PaymentType int - -const ( - PaymentTypeIban PaymentType = iota - PaymentTypeCard - PaymentTypeCardToken - PaymentTypeBankAccount - PaymentTypeWallet - PaymentTypeCryptoAddress - PaymentTypeLedger -) - -var paymentTypeToString = map[PaymentType]string{ - PaymentTypeIban: "iban", - PaymentTypeCard: "card", - PaymentTypeCardToken: "cardToken", - PaymentTypeBankAccount: "bankAccount", - PaymentTypeWallet: "wallet", - PaymentTypeCryptoAddress: "cryptoAddress", - PaymentTypeLedger: "ledger", -} - -var paymentTypeFromString = map[string]PaymentType{ - "iban": PaymentTypeIban, - "card": PaymentTypeCard, - "cardToken": PaymentTypeCardToken, - "bankAccount": PaymentTypeBankAccount, - "wallet": PaymentTypeWallet, - "cryptoAddress": PaymentTypeCryptoAddress, - "ledger": PaymentTypeLedger, -} - -func (t PaymentType) String() string { - if v, ok := paymentTypeToString[t]; ok { - return v - } - return "iban" -} - -func (t PaymentType) IsValid() bool { - _, ok := paymentTypeToString[t] - return ok -} - -func (t PaymentType) MarshalJSON() ([]byte, error) { - return json.Marshal(t.String()) -} - -func (t *PaymentType) UnmarshalJSON(data []byte) error { - var val string - if err := json.Unmarshal(data, &val); err != nil { - return err - } - v, ok := paymentTypeFromString[val] - if !ok { - return merrors.InvalidArgument(fmt.Sprintf("unknown PaymentType: %q", val)) - } - *t = v - return nil -} - -type PaymentMethodOwnerKind string - -const ( - PaymentMethodOwnerKindUnspecified PaymentMethodOwnerKind = "" - PaymentMethodOwnerKindAccount PaymentMethodOwnerKind = "ACCOUNT" - PaymentMethodOwnerKindRecipient PaymentMethodOwnerKind = "RECIPIENT" -) - -func (k PaymentMethodOwnerKind) String() string { - return string(k) -} - -func (k PaymentMethodOwnerKind) IsValid() bool { - switch k { - case PaymentMethodOwnerKindAccount, PaymentMethodOwnerKindRecipient: - return true - default: - return false - } -} - -type PaymentMethod struct { - PermissionBound `bson:",inline" json:",inline"` - Describable `bson:",inline" json:",inline"` - - OwnerKind PaymentMethodOwnerKind `bson:"ownerKind" json:"ownerKind"` - OwnerRef bson.ObjectID `bson:"ownerRef" json:"ownerRef"` - Type PaymentType `bson:"type" json:"type"` - Data bson.Raw `bson:"data" json:"data"` - IsMain bool `bson:"isMain" json:"isMain"` -} - -type paymentMethodJSON struct { - PermissionBound `json:",inline"` - Describable `json:",inline"` - - OwnerKind PaymentMethodOwnerKind `json:"ownerKind"` - OwnerRef bson.ObjectID `json:"ownerRef"` - Type PaymentType `json:"type"` - Data json.RawMessage `json:"data"` - IsMain bool `json:"isMain"` -} - -func (m PaymentMethod) MarshalJSON() ([]byte, error) { - marshaledData, err := m.marshalDataJSON() - if err != nil { - return nil, err - } - - payload := paymentMethodJSON{ - PermissionBound: m.PermissionBound, - Describable: m.Describable, - OwnerKind: m.OwnerKind, - OwnerRef: m.OwnerRef, - Type: m.Type, - Data: marshaledData, - IsMain: m.IsMain, - } - - return json.Marshal(payload) -} - -func (m *PaymentMethod) UnmarshalJSON(data []byte) error { - var payload paymentMethodJSON - if err := json.Unmarshal(data, &payload); err != nil { - return err - } - - m.PermissionBound = payload.PermissionBound - m.Describable = payload.Describable - m.OwnerKind = payload.OwnerKind - m.OwnerRef = payload.OwnerRef - m.Type = payload.Type - m.IsMain = payload.IsMain - - if len(payload.Data) == 0 || bytes.Equal(payload.Data, []byte("null")) { - m.Data = nil - return nil - } - - if raw, ok, err := m.unmarshalTypedDataJSON(payload.Data); ok || err != nil { - if err != nil { - return err - } - m.Data = raw - return nil - } - - var rawData map[string]any - if err := json.Unmarshal(payload.Data, &rawData); err != nil { - return err - } - - raw, err := bson.Marshal(rawData) - if err != nil { - return err - } - - m.Data = raw - return nil -} - -func (m PaymentMethod) marshalDataJSON() (json.RawMessage, error) { - if len(m.Data) == 0 { - return nil, nil - } - - var payload any - var err error - switch m.Type { - case PaymentTypeIban: - payload, err = marshalTypedPaymentData[IbanPaymentData](m.Data) - case PaymentTypeCard: - payload, err = marshalTypedPaymentData[CardPaymentData](m.Data) - case PaymentTypeCardToken: - payload, err = marshalTypedPaymentData[TokenPaymentData](m.Data) - case PaymentTypeBankAccount: - payload, err = marshalTypedPaymentData[RussianBankAccountPaymentData](m.Data) - case PaymentTypeWallet: - payload, err = marshalTypedPaymentData[WalletPaymentData](m.Data) - case PaymentTypeCryptoAddress: - payload, err = marshalTypedPaymentData[CryptoAddressPaymentData](m.Data) - case PaymentTypeLedger: - payload, err = marshalTypedPaymentData[LedgerPaymentData](m.Data) - default: - var data bson.M - if err := bson.Unmarshal(m.Data, &data); err != nil { - return nil, err - } - if data == nil { - return nil, nil - } - payload = data - } - if err != nil { - return nil, err - } - - data, err := json.Marshal(payload) - if err != nil { - return nil, err - } - return data, nil -} - -func (m PaymentMethod) unmarshalTypedDataJSON(data json.RawMessage) (bson.Raw, bool, error) { - switch m.Type { - case PaymentTypeIban: - return unmarshalTypedPaymentData[IbanPaymentData](data) - case PaymentTypeCard: - return unmarshalTypedPaymentData[CardPaymentData](data) - case PaymentTypeCardToken: - return unmarshalTypedPaymentData[TokenPaymentData](data) - case PaymentTypeBankAccount: - return unmarshalTypedPaymentData[RussianBankAccountPaymentData](data) - case PaymentTypeWallet: - return unmarshalTypedPaymentData[WalletPaymentData](data) - case PaymentTypeCryptoAddress: - return unmarshalTypedPaymentData[CryptoAddressPaymentData](data) - case PaymentTypeLedger: - return unmarshalTypedPaymentData[LedgerPaymentData](data) - default: - return nil, false, nil - } -} - -func marshalTypedPaymentData[T any](raw bson.Raw) (T, error) { - var payload T - err := bson.Unmarshal(raw, &payload) - return payload, err -} - -func unmarshalTypedPaymentData[T any](data json.RawMessage) (bson.Raw, bool, error) { - var payload T - if err := json.Unmarshal(data, &payload); err != nil { - return nil, true, err - } - raw, err := bson.Marshal(payload) - if err != nil { - return nil, true, err - } - return raw, true, nil -} - -type PaymentStatus string - -const ( - PaymentStatusUnspecified PaymentStatus = "unspecified" - - // Intent exists, no funds touched yet. - PaymentStatusCreated PaymentStatus = "created" - - // Internal Sendico operations: holds, ledger, FX, fee calc. - PaymentStatusAuthorizing PaymentStatus = "authorizing" - - // Funds are outside Sendico (rail/gateway/blockchain/provider). - // Observe lives ONLY in this state. - PaymentStatusExecuting PaymentStatus = "executing" - - // Final states. - PaymentStatusSucceeded PaymentStatus = "success" - PaymentStatusFailed PaymentStatus = "failed" - PaymentStatusCancelled PaymentStatus = "cancelled" -) diff --git a/backend/pkg/model/payment_ledger.go b/backend/pkg/model/payment_ledger.go deleted file mode 100644 index ee4c32b..0000000 --- a/backend/pkg/model/payment_ledger.go +++ /dev/null @@ -1,77 +0,0 @@ -package model - -import ( - "encoding/json" - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type LedgerPaymentData struct { - AccountRef string `bson:"accountRef,omitempty" json:"account_ref,omitempty"` - AccountCode string `bson:"accountCode,omitempty" json:"account_code,omitempty"` - ContraAccountRef string `bson:"contraAccountRef,omitempty" json:"contra_account_ref,omitempty"` - ContraAccountCode string `bson:"contraAccountCode,omitempty" json:"contra_account_code,omitempty"` -} - -func (d *LedgerPaymentData) UnmarshalJSON(data []byte) error { - var raw struct { - AccountRef string `json:"account_ref"` - LedgerAccountRef string `json:"ledger_account_ref"` - AccountCode string `json:"account_code"` - ContraAccountRef string `json:"contra_account_ref"` - ContraLedgerAccountRef string `json:"contra_ledger_account_ref"` - ContraAccountCode string `json:"contra_account_code"` - } - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - accountRef := strings.TrimSpace(raw.AccountRef) - ledgerAccountRef := strings.TrimSpace(raw.LedgerAccountRef) - if ledgerAccountRef != "" { - if accountRef != "" && accountRef != ledgerAccountRef { - return merrors.InvalidArgument("account_ref and ledger_account_ref must match") - } - accountRef = ledgerAccountRef - } - - contraAccountRef := strings.TrimSpace(raw.ContraAccountRef) - contraLedgerAccountRef := strings.TrimSpace(raw.ContraLedgerAccountRef) - if contraLedgerAccountRef != "" { - if contraAccountRef != "" && contraAccountRef != contraLedgerAccountRef { - return merrors.InvalidArgument("contra_account_ref and contra_ledger_account_ref must match") - } - contraAccountRef = contraLedgerAccountRef - } - - d.AccountRef = accountRef - d.AccountCode = strings.TrimSpace(raw.AccountCode) - d.ContraAccountRef = contraAccountRef - d.ContraAccountCode = strings.TrimSpace(raw.ContraAccountCode) - return nil -} - -func (d *LedgerPaymentData) UnmarshalBSON(data []byte) error { - var raw map[string]any - if err := bson.Unmarshal(data, &raw); err != nil { - return err - } - d.AccountRef = firstPaymentDataString(raw, "accountRef", "account_ref", "ledgerAccountRef", "ledger_account_ref") - d.AccountCode = firstPaymentDataString(raw, "accountCode", "account_code") - d.ContraAccountRef = firstPaymentDataString(raw, "contraAccountRef", "contra_account_ref", "contraLedgerAccountRef", "contra_ledger_account_ref") - d.ContraAccountCode = firstPaymentDataString(raw, "contraAccountCode", "contra_account_code") - return nil -} - -func (m *PaymentMethod) AsLedger() (*LedgerPaymentData, error) { - if m.Type != PaymentTypeLedger { - return nil, merrors.InvalidArgument("payment method type is "+m.Type.String()+", not ledger", "type") - } - var d LedgerPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - return &d, nil -} diff --git a/backend/pkg/model/payment_method_json_test.go b/backend/pkg/model/payment_method_json_test.go deleted file mode 100644 index c375a6b..0000000 --- a/backend/pkg/model/payment_method_json_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package model - -import ( - "encoding/json" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/person" - "go.mongodb.org/mongo-driver/v2/bson" -) - -func TestPaymentMethodMarshalRussianBankAccountUsesJSONRecipientNames(t *testing.T) { - raw, err := bson.Marshal(RussianBankAccountPaymentData{ - Recipient: person.Name{ - FirstName: "Success", - LastName: "Success", - }, - Inn: "7707083893", - Kpp: "770701001", - BankName: "AsterBank Test Bank", - Bik: "044525225", - AccountNumber: "40702810000000080001", - CorrespondentAccount: "30101810400000000225", - }) - if err != nil { - t.Fatalf("marshal bank account data: %v", err) - } - - payload, err := json.Marshal(PaymentMethod{ - Type: PaymentTypeBankAccount, - Data: raw, - }) - if err != nil { - t.Fatalf("marshal payment method: %v", err) - } - - var decoded struct { - Type PaymentType `json:"type"` - Data map[string]any `json:"data"` - } - if err := json.Unmarshal(payload, &decoded); err != nil { - t.Fatalf("unmarshal payload: %v", err) - } - if decoded.Type != PaymentTypeBankAccount { - t.Fatalf("type mismatch: got=%s want=%s", decoded.Type, PaymentTypeBankAccount) - } - - recipient, ok := decoded.Data["recipient"].(map[string]any) - if !ok { - t.Fatalf("recipient is not an object: %#v", decoded.Data["recipient"]) - } - if got, want := recipient["first_name"], "Success"; got != want { - t.Fatalf("recipient.first_name mismatch: got=%#v want=%q", got, want) - } - if got, want := recipient["last_name"], "Success"; got != want { - t.Fatalf("recipient.last_name mismatch: got=%#v want=%q", got, want) - } - if _, ok := recipient["firstName"]; ok { - t.Fatalf("recipient must not expose BSON firstName: %#v", recipient) - } - if _, ok := recipient["lastName"]; ok { - t.Fatalf("recipient must not expose BSON lastName: %#v", recipient) - } -} - -func TestPaymentMethodUnmarshalRussianBankAccountStoresBSONRecipientNames(t *testing.T) { - payload := []byte(`{ - "type": "bankAccount", - "data": { - "recipient": { - "first_name": "Success", - "last_name": "Success" - }, - "inn": "7707083893", - "kpp": "770701001", - "bankName": "AsterBank Test Bank", - "bik": "044525225", - "accountNumber": "40702810000000080001", - "correspondentAccount": "30101810400000000225" - } - }`) - - var method PaymentMethod - if err := json.Unmarshal(payload, &method); err != nil { - t.Fatalf("unmarshal payment method: %v", err) - } - - var stored bson.M - if err := bson.Unmarshal(method.Data, &stored); err != nil { - t.Fatalf("unmarshal stored data: %v", err) - } - recipient, ok := bsonDocumentMap(stored["recipient"]) - if !ok { - t.Fatalf("stored recipient is not an object: %#v", stored["recipient"]) - } - if got, want := recipient["firstName"], "Success"; got != want { - t.Fatalf("stored recipient.firstName mismatch: got=%#v want=%q", got, want) - } - if got, want := recipient["lastName"], "Success"; got != want { - t.Fatalf("stored recipient.lastName mismatch: got=%#v want=%q", got, want) - } - if _, ok := recipient["first_name"]; ok { - t.Fatalf("stored recipient must not use JSON first_name: %#v", recipient) - } - if _, ok := recipient["last_name"]; ok { - t.Fatalf("stored recipient must not use JSON last_name: %#v", recipient) - } -} - -func bsonDocumentMap(value any) (map[string]any, bool) { - switch typed := value.(type) { - case bson.M: - return typed, true - case bson.D: - out := make(map[string]any, len(typed)) - for _, element := range typed { - out[element.Key] = element.Value - } - return out, true - default: - return nil, false - } -} diff --git a/backend/pkg/model/permission.go b/backend/pkg/model/permission.go deleted file mode 100644 index b796f23..0000000 --- a/backend/pkg/model/permission.go +++ /dev/null @@ -1,33 +0,0 @@ -package model - -import ( - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type PermissionBoundStorable interface { - storable.Storable - OrganizationBound - Archivable - GetPermissionRef() bson.ObjectID - SetPermissionRef(permissionRef bson.ObjectID) -} - -type PermissionBound struct { - storable.Base `bson:",inline" json:",inline"` - ArchivableBase `bson:",inline" json:",inline"` - OrganizationBoundBase `bson:",inline" json:",inline"` - PermissionRef bson.ObjectID `bson:"permissionRef" json:"permissionRef"` -} - -func (b *PermissionBound) GetPermissionRef() bson.ObjectID { - return b.PermissionRef -} - -func (b *PermissionBound) GetOrganizationRef() bson.ObjectID { - return b.OrganizationRef -} - -func (b *PermissionBound) SetPermissionRef(permissionRef bson.ObjectID) { - b.PermissionRef = permissionRef -} diff --git a/backend/pkg/model/rba.go b/backend/pkg/model/rba.go deleted file mode 100644 index 6ec3ef9..0000000 --- a/backend/pkg/model/rba.go +++ /dev/null @@ -1,52 +0,0 @@ -package model - -import ( - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "git.sendico.io/tech/sxchnge/bff/pkg/person" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type RussianBankAccountPaymentData struct { - Recipient person.Name `bson:"recipient" json:"recipient"` - Inn string `bson:"inn" json:"inn"` - Kpp string `bson:"kpp" json:"kpp"` - BankName string `bson:"bankName" json:"bankName"` - Bik string `bson:"bik" json:"bik"` - AccountNumber string `bson:"accountNumber" json:"accountNumber"` - CorrespondentAccount string `bson:"correspondentAccount" json:"correspondentAccount"` -} - -// UnmarshalBSON accepts both stored recipient names and the API names in the -// intermediate document built by a payment-method PATCH. -func (d *RussianBankAccountPaymentData) UnmarshalBSON(data []byte) error { - type stored RussianBankAccountPaymentData - var decoded stored - if err := bson.Unmarshal(data, &decoded); err != nil { - return err - } - var fields struct { - Recipient map[string]any `bson:"recipient"` - } - if err := bson.Unmarshal(data, &fields); err != nil { - return err - } - decoded.Recipient.FirstName = firstPaymentDataString(fields.Recipient, "firstName", "first_name") - decoded.Recipient.LastName = firstPaymentDataString(fields.Recipient, "lastName", "last_name") - *d = RussianBankAccountPaymentData(decoded) - return nil -} - -func (m *PaymentMethod) AsRussianBankAccount() (*RussianBankAccountPaymentData, error) { - if m.Type != PaymentTypeBankAccount { - return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not bankAccount", m.Type), "type") - } - - var d RussianBankAccountPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - - return &d, nil -} diff --git a/backend/pkg/model/recipient.go b/backend/pkg/model/recipient.go deleted file mode 100644 index 10f8a18..0000000 --- a/backend/pkg/model/recipient.go +++ /dev/null @@ -1,128 +0,0 @@ -package model - -import ( - "encoding/json" - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type RecipientStatus int - -const ( - RecipientStatusReady RecipientStatus = iota - RecipientStatusRegistered - RecipientStatusNotRegistered -) - -var recipientStatusToString = map[RecipientStatus]string{ - RecipientStatusReady: "ready", - RecipientStatusRegistered: "registered", - RecipientStatusNotRegistered: "notRegistered", -} - -var recipientStatusFromString = map[string]RecipientStatus{ - "ready": RecipientStatusReady, - "registered": RecipientStatusRegistered, - "notRegistered": RecipientStatusNotRegistered, -} - -func (s RecipientStatus) String() string { - if v, ok := recipientStatusToString[s]; ok { - return v - } - return "ready" // дефолт, можно поменять -} - -// JSON: храним как строку ("ready" / "registered" / "notRegistered") -func (s RecipientStatus) MarshalJSON() ([]byte, error) { - return json.Marshal(s.String()) -} - -func (s *RecipientStatus) UnmarshalJSON(data []byte) error { - var val string - if err := json.Unmarshal(data, &val); err != nil { - return err - } - v, ok := recipientStatusFromString[val] - if !ok { - return merrors.InvalidArgument(fmt.Sprintf("unknown RecipientStatus: %q", val)) - } - *s = v - return nil -} - -// RecipientType { internal, external } - -type RecipientType int - -const ( - RecipientTypeInternal RecipientType = iota - RecipientTypeExternal -) - -var recipientTypeToString = map[RecipientType]string{ - RecipientTypeInternal: "internal", - RecipientTypeExternal: "external", -} - -var recipientTypeFromString = map[string]RecipientType{ - "internal": RecipientTypeInternal, - "external": RecipientTypeExternal, -} - -func (t RecipientType) String() string { - if v, ok := recipientTypeToString[t]; ok { - return v - } - return "internal" -} - -func (t RecipientType) MarshalJSON() ([]byte, error) { - return json.Marshal(t.String()) -} - -func (t *RecipientType) UnmarshalJSON(data []byte) error { - var val string - if err := json.Unmarshal(data, &val); err != nil { - return err - } - v, ok := recipientTypeFromString[val] - if !ok { - return merrors.InvalidArgument(fmt.Sprintf("unknown RecipientType: %q", val)) - } - *t = v - return nil -} - -// Owner is the ownership token stored under the "owner" BSON/JSON key. -// storable.Ref must stay embedded: a named field would keep the bson ",inline" -// but encoding/json has no inline option and would nest the ref under "Ref", -// while embedding promotes it, so both wire paths are owner.ref and owner.kind. -type Owner struct { - storable.Ref `bson:",inline" json:",inline"` - Kind OwnerKind `bson:"kind" json:"kind"` -} - -type Recipient struct { - PermissionBound `bson:",inline" json:",inline"` - Describable `bson:",inline" json:",inline"` - Email string `bson:"email" json:"email"` - AvatarURL *string `bson:"avatarUrl,omitempty" json:"avatarUrl,omitempty"` - - Status RecipientStatus `bson:"status" json:"status"` - Type RecipientType `bson:"type" json:"type"` - Owner Owner `bson:"owner" json:"owner"` - - // INN is the recipient's taxpayer identifier. It is the canonical source of - // the INN for payouts to this recipient (flows into customer.tax_identifier), - // replacing the per-payment-method inn field for recipient-owned methods. - INN string `bson:"inn,omitempty" json:"inn,omitempty"` -} - -func (r *Recipient) GetOwnerKind() OwnerKind { return r.Owner.Kind } -func (r *Recipient) GetOwnerRef() bson.ObjectID { return r.Owner.Ref.Ref } -func (r *Recipient) SetOwnerKind(k OwnerKind) { r.Owner.Kind = k } -func (r *Recipient) SetOwnerRef(ref bson.ObjectID) { r.Owner.Ref.Ref = ref } diff --git a/backend/pkg/model/recipient_json_test.go b/backend/pkg/model/recipient_json_test.go deleted file mode 100644 index 563ec39..0000000 --- a/backend/pkg/model/recipient_json_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package model - -import ( - "encoding/json" - "testing" - - "go.mongodb.org/mongo-driver/v2/bson" -) - -// TestOwnerJSONFlattensRef locks in the on-wire shape documented on Owner: -// owner.ref and owner.kind must both be top-level keys. Ref must stay -// anonymously embedded — a NAMED field with a `json:",inline"` tag is a -// BSON-only convention and is not flattened by encoding/json, which would -// silently nest it under the Go field name instead (a real regression this -// test caught: {"Ref":{"ref":"..."},"kind":"..."} instead of -// {"ref":"...","kind":"..."}), breaking every JSON consumer of owner.ref. -func TestOwnerJSONFlattensRef(t *testing.T) { - ownerRef := bson.NewObjectID() - owner := Owner{Kind: OwnerKindAccount} - owner.Ref.Ref = ownerRef - - b, err := json.Marshal(owner) - if err != nil { - t.Fatalf("marshal owner: %v", err) - } - - var decoded map[string]any - if err := json.Unmarshal(b, &decoded); err != nil { - t.Fatalf("unmarshal owner json: %v", err) - } - if got, want := decoded["ref"], ownerRef.Hex(); got != want { - t.Fatalf("top-level owner.ref = %v, want %q (got JSON: %s)", got, want, b) - } - if got, want := decoded["kind"], string(OwnerKindAccount); got != want { - t.Fatalf("top-level owner.kind = %v, want %q (got JSON: %s)", got, want, b) - } - if _, nested := decoded["Ref"]; nested { - t.Fatalf("owner.ref must not nest under the Go field name \"Ref\", got JSON: %s", b) - } -} - -func TestRecipientJSONRoundTripsOwner(t *testing.T) { - r := &Recipient{} - r.SetOwnerKind(OwnerKindOrganization) - r.SetOwnerRef(bson.NewObjectID()) - - b, err := json.Marshal(r) - if err != nil { - t.Fatalf("marshal recipient: %v", err) - } - - var roundTripped Recipient - if err := json.Unmarshal(b, &roundTripped); err != nil { - t.Fatalf("unmarshal recipient json: %v", err) - } - if roundTripped.GetOwnerKind() != r.GetOwnerKind() { - t.Fatalf("owner kind round-trip = %v, want %v", roundTripped.GetOwnerKind(), r.GetOwnerKind()) - } - if roundTripped.GetOwnerRef() != r.GetOwnerRef() { - t.Fatalf("owner ref round-trip = %v, want %v", roundTripped.GetOwnerRef(), r.GetOwnerRef()) - } -} diff --git a/backend/pkg/model/recipient_wire_test.go b/backend/pkg/model/recipient_wire_test.go deleted file mode 100644 index ebb254b..0000000 --- a/backend/pkg/model/recipient_wire_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package model - -import ( - "encoding/json" - "testing" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "go.mongodb.org/mongo-driver/v2/bson" -) - -// The frontend parses owner as {"ref": ..., "kind": ...}; a regression to the -// nested {"Ref": {"ref": ...}} shape breaks every recipients screen. -func TestOwnerJSONIsFlat(t *testing.T) { - ref, err := bson.ObjectIDFromHex("69834f22fff13a7457793b6c") - if err != nil { - t.Fatal(err) - } - - out, err := json.Marshal(Owner{ - Ref: storable.Ref{Ref: ref}, - Kind: "account", - }) - if err != nil { - t.Fatal(err) - } - - want := `{"ref":"69834f22fff13a7457793b6c","kind":"account"}` - if string(out) != want { - t.Fatalf("owner JSON = %s, want %s", out, want) - } - - var back Owner - if err := json.Unmarshal(out, &back); err != nil { - t.Fatal(err) - } - if back.Ref.Ref != ref || back.Kind != "account" { - t.Fatalf("round-trip lost data: %+v", back) - } -} - -// Mongo queries filter on owner.ref / owner.kind (see auth.ScopedDBImp), so -// the BSON document must keep those exact paths. -func TestOwnerBSONPathsUnchanged(t *testing.T) { - ref, err := bson.ObjectIDFromHex("69834f22fff13a7457793b6c") - if err != nil { - t.Fatal(err) - } - - out, err := bson.Marshal(Owner{ - Ref: storable.Ref{Ref: ref}, - Kind: "account", - }) - if err != nil { - t.Fatal(err) - } - - var doc bson.M - if err := bson.Unmarshal(out, &doc); err != nil { - t.Fatal(err) - } - if doc["ref"] != ref || doc["kind"] != "account" { - t.Fatalf("bson doc = %v, want flat ref/kind", doc) - } -} diff --git a/backend/pkg/model/scopedstorablemodel.go b/backend/pkg/model/scopedstorablemodel.go deleted file mode 100644 index c96b6dc..0000000 --- a/backend/pkg/model/scopedstorablemodel.go +++ /dev/null @@ -1,19 +0,0 @@ -package model - -import "go.mongodb.org/mongo-driver/v2/bson" - -// ScopedStorable is the type constraint for resources that follow the -// personal-vs-org dual-ownership model. ScopedDBImp[T] provides generic -// CRUD and bulk operations for any T satisfying this interface without -// the permissionRef==NilObjectID sentinel pattern that ProtectedDBImp requires -// for personal-owned resources. -// -// Resources that embed model.PermissionBound and model.Owner satisfy -// ScopedStorable automatically once SetOwnerKind/SetOwnerRef methods are added. -type ScopedStorable interface { - OwnerBound - SetOwnerKind(OwnerKind) - SetOwnerRef(bson.ObjectID) - GetPermissionRef() bson.ObjectID - SetPermissionRef(bson.ObjectID) -} diff --git a/backend/pkg/model/signup_provisioning.go b/backend/pkg/model/signup_provisioning.go deleted file mode 100644 index 20eb96d..0000000 --- a/backend/pkg/model/signup_provisioning.go +++ /dev/null @@ -1,150 +0,0 @@ -package model - -import ( - "strings" - "time" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type SignupProvisioningState string - -const ( - SignupProvisioningStateStarted SignupProvisioningState = "started" - SignupProvisioningStateProvisioning SignupProvisioningState = "provisioning" - SignupProvisioningStateCompensating SignupProvisioningState = "compensating" - SignupProvisioningStateFailedPartialClean SignupProvisioningState = "failed_partial_cleanup" - SignupProvisioningStateFailedClean SignupProvisioningState = "failed_clean" - SignupProvisioningStateCompleted SignupProvisioningState = "completed" -) - -type SignupProvisioningCleanupStatus string - -const ( - SignupProvisioningCleanupStatusNotNeeded SignupProvisioningCleanupStatus = "not_needed" - SignupProvisioningCleanupStatusPending SignupProvisioningCleanupStatus = "pending" - SignupProvisioningCleanupStatusRunning SignupProvisioningCleanupStatus = "running" - SignupProvisioningCleanupStatusComplete SignupProvisioningCleanupStatus = "complete" - SignupProvisioningCleanupStatusPartial SignupProvisioningCleanupStatus = "partial" -) - -type SignupProvisioningTarget struct { - Chain ChainNetwork `bson:"chain" json:"chain"` - TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"` - ContractAddress string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"` -} - -func (t *SignupProvisioningTarget) Normalize() { - if t == nil { - return - } - t.Chain = ChainNetwork(strings.TrimSpace(t.Chain.String())) - t.TokenSymbol = strings.ToUpper(strings.TrimSpace(t.TokenSymbol)) - t.ContractAddress = strings.TrimSpace(t.ContractAddress) -} - -type SignupProvisionedWallet struct { - Chain ChainNetwork `bson:"chain" json:"chain"` - WalletRef string `bson:"walletRef" json:"walletRef"` -} - -func (w *SignupProvisionedWallet) Normalize() { - if w == nil { - return - } - w.Chain = ChainNetwork(strings.TrimSpace(w.Chain.String())) - w.WalletRef = strings.TrimSpace(w.WalletRef) -} - -type SignupProvisionedRoute struct { - WalletRef string `bson:"walletRef" json:"walletRef"` - RouteRef string `bson:"routeRef" json:"routeRef"` -} - -func (r *SignupProvisionedRoute) Normalize() { - if r == nil { - return - } - r.WalletRef = strings.TrimSpace(r.WalletRef) - r.RouteRef = strings.TrimSpace(r.RouteRef) -} - -type SignupProvisioningRecord struct { - storable.Base `bson:",inline" json:",inline"` - - NormalizedLogin string `bson:"normalizedLogin" json:"normalizedLogin"` - OrganizationRef string `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"` - AttemptID string `bson:"attemptId" json:"attemptId"` - State SignupProvisioningState `bson:"state" json:"state"` - CleanupStatus SignupProvisioningCleanupStatus `bson:"cleanupStatus" json:"cleanupStatus"` - LeaseOwner string `bson:"leaseOwner" json:"leaseOwner"` - LeaseExpiresAt time.Time `bson:"leaseExpiresAt" json:"leaseExpiresAt"` - HeartbeatAt time.Time `bson:"heartbeatAt" json:"heartbeatAt"` - SelectedTargets []SignupProvisioningTarget `bson:"selectedTargets,omitempty" json:"selectedTargets,omitempty"` - Wallets []SignupProvisionedWallet `bson:"wallets,omitempty" json:"wallets,omitempty"` - Routes []SignupProvisionedRoute `bson:"routes,omitempty" json:"routes,omitempty"` - StepStatus map[string]string `bson:"stepStatus,omitempty" json:"stepStatus,omitempty"` - LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"` - LastCleanupError string `bson:"lastCleanupError,omitempty" json:"lastCleanupError,omitempty"` - ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"` -} - -func (*SignupProvisioningRecord) Collection() string { - return mservice.SignupProvisioning -} - -func (r *SignupProvisioningRecord) Normalize() { - if r == nil { - return - } - r.NormalizedLogin = strings.ToLower(strings.TrimSpace(r.NormalizedLogin)) - r.OrganizationRef = strings.TrimSpace(r.OrganizationRef) - r.AttemptID = strings.TrimSpace(r.AttemptID) - r.LeaseOwner = strings.TrimSpace(r.LeaseOwner) - r.LastError = strings.TrimSpace(r.LastError) - r.LastCleanupError = strings.TrimSpace(r.LastCleanupError) - - for i := range r.SelectedTargets { - r.SelectedTargets[i].Normalize() - } - for i := range r.Wallets { - r.Wallets[i].Normalize() - } - for i := range r.Routes { - r.Routes[i].Normalize() - } - if r.StepStatus == nil { - r.StepStatus = map[string]string{} - } -} - -func (r *SignupProvisioningRecord) Active() bool { - if r == nil { - return false - } - switch r.State { - case SignupProvisioningStateStarted, SignupProvisioningStateProvisioning, SignupProvisioningStateCompensating: - return true - default: - return false - } -} - -func (r *SignupProvisioningRecord) Terminal() bool { - if r == nil { - return false - } - switch r.State { - case SignupProvisioningStateFailedPartialClean, SignupProvisioningStateFailedClean, SignupProvisioningStateCompleted: - return true - default: - return false - } -} - -func (r *SignupProvisioningRecord) OrganizationObjectID() bson.ObjectID { - ref, _ := bson.ObjectIDFromHex(strings.TrimSpace(r.OrganizationRef)) - return ref -} diff --git a/backend/pkg/model/wallet.go b/backend/pkg/model/wallet.go deleted file mode 100644 index aae91b6..0000000 --- a/backend/pkg/model/wallet.go +++ /dev/null @@ -1,25 +0,0 @@ -package model - -import ( - "fmt" - - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - "go.mongodb.org/mongo-driver/v2/bson" -) - -type WalletPaymentData struct { - WalletID string `bson:"walletId" json:"walletId"` -} - -func (m *PaymentMethod) AsWallet() (*WalletPaymentData, error) { - if m.Type != PaymentTypeWallet { - return nil, merrors.InvalidArgument(fmt.Sprintf("payment method type is %s, not wallet", m.Type), "type") - } - - var d WalletPaymentData - if err := bson.Unmarshal(m.Data, &d); err != nil { - return nil, err - } - - return &d, nil -} diff --git a/backend/pkg/model/walletprovisioning.go b/backend/pkg/model/walletprovisioning.go deleted file mode 100644 index 616b1bd..0000000 --- a/backend/pkg/model/walletprovisioning.go +++ /dev/null @@ -1,105 +0,0 @@ -package model - -import ( - "strings" - - "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/mservice" -) - -type WalletProvisioningState string - -const ( - WalletProvisioningStateProvisioning WalletProvisioningState = "provisioning" - WalletProvisioningStateCompleted WalletProvisioningState = "completed" - WalletProvisioningStateFailed WalletProvisioningState = "failed" -) - -type WalletProvisioningTargetState string - -const ( - WalletProvisioningTargetStatePending WalletProvisioningTargetState = "pending" - WalletProvisioningTargetStateWalletReady WalletProvisioningTargetState = "wallet_ready" - WalletProvisioningTargetStateSuspended WalletProvisioningTargetState = "suspended" - WalletProvisioningTargetStateRouted WalletProvisioningTargetState = "routed" -) - -type WalletProvisioningTarget struct { - TargetID string `bson:"targetId" json:"targetId"` - Chain ChainNetwork `bson:"chain" json:"chain"` - Currency string `bson:"currency" json:"currency"` - ContractAddress string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"` - GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"` - GatewayURI string `bson:"gatewayUri,omitempty" json:"gatewayUri,omitempty"` - WalletIdempotencyKey string `bson:"walletIdempotencyKey,omitempty" json:"walletIdempotencyKey,omitempty"` - State WalletProvisioningTargetState `bson:"state" json:"state"` - WalletRef string `bson:"walletRef,omitempty" json:"walletRef,omitempty"` - Address string `bson:"address,omitempty" json:"address,omitempty"` - RouteRef string `bson:"routeRef,omitempty" json:"routeRef,omitempty"` - LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"` -} - -func (t *WalletProvisioningTarget) Normalize() { - if t == nil { - return - } - t.TargetID = strings.TrimSpace(t.TargetID) - t.Currency = strings.ToUpper(strings.TrimSpace(t.Currency)) - t.ContractAddress = strings.TrimSpace(t.ContractAddress) - if strings.HasPrefix(strings.ToLower(t.ContractAddress), "0x") { - t.ContractAddress = strings.ToLower(t.ContractAddress) - } - t.GatewayID = strings.TrimSpace(t.GatewayID) - t.GatewayURI = strings.TrimSpace(t.GatewayURI) - t.WalletIdempotencyKey = strings.TrimSpace(t.WalletIdempotencyKey) - t.WalletRef = strings.TrimSpace(t.WalletRef) - t.Address = strings.TrimSpace(t.Address) - t.RouteRef = strings.TrimSpace(t.RouteRef) - t.LastError = strings.TrimSpace(t.LastError) - if t.State == "" { - t.State = WalletProvisioningTargetStatePending - } -} - -type WalletProvisioningRecord struct { - storable.Base `bson:",inline" json:",inline"` - - ProvisioningRef string `bson:"provisioningRef" json:"provisioningRef"` - OrganizationRef string `bson:"organizationRef" json:"organizationRef"` - OwnerRef string `bson:"ownerRef,omitempty" json:"ownerRef,omitempty"` - LedgerAccountRef string `bson:"ledgerAccountRef" json:"ledgerAccountRef"` - ActorRef string `bson:"actorRef,omitempty" json:"actorRef,omitempty"` - Reason string `bson:"reason" json:"reason"` - Currency string `bson:"currency" json:"currency"` - State WalletProvisioningState `bson:"state" json:"state"` - Targets []WalletProvisioningTarget `bson:"targets" json:"targets"` - LastError string `bson:"lastError,omitempty" json:"lastError,omitempty"` - // Version fences concurrent journal writers: Save replaces only the - // revision this attempt read, so a stale attempt whose lease was taken - // over cannot roll back the checkpoints of the attempt that owns it now. - Version int64 `bson:"version" json:"-"` -} - -func (*WalletProvisioningRecord) Collection() string { - return mservice.WalletProvisioning -} - -func (r *WalletProvisioningRecord) Normalize() { - if r == nil { - return - } - r.ProvisioningRef = strings.TrimSpace(r.ProvisioningRef) - r.OrganizationRef = strings.TrimSpace(r.OrganizationRef) - r.OwnerRef = strings.TrimSpace(r.OwnerRef) - r.LedgerAccountRef = strings.TrimSpace(r.LedgerAccountRef) - r.ActorRef = strings.TrimSpace(r.ActorRef) - r.Reason = strings.TrimSpace(r.Reason) - r.Currency = strings.ToUpper(strings.TrimSpace(r.Currency)) - r.LastError = strings.TrimSpace(r.LastError) - for index := range r.Targets { - r.Targets[index].Normalize() - } - if r.State == "" { - r.State = WalletProvisioningStateProvisioning - } -} diff --git a/backend/pkg/moneybase/fx_rounding.go b/backend/pkg/moneybase/fx_rounding.go deleted file mode 100644 index ec98363..0000000 --- a/backend/pkg/moneybase/fx_rounding.go +++ /dev/null @@ -1,50 +0,0 @@ -package moneybase - -import ( - "math/big" - "strings" - - decimalbase "git.sendico.io/tech/sxchnge/bff/pkg/decimal" - "git.sendico.io/tech/sxchnge/bff/pkg/merrors" -) - -// SourceRoundedConversionMatches reports whether sourceAmount is the minimum -// source-scale amount that covers targetAmount at rate. -func SourceRoundedConversionMatches(sourceAmount, targetAmount, rate string, sourceScale uint32) (bool, error) { - if err := ValidateScale("source scale", sourceScale); err != nil { - return false, err - } - source, err := positiveRat(sourceAmount, "source amount") - if err != nil { - return false, err - } - target, err := positiveRat(targetAmount, "target amount") - if err != nil { - return false, err - } - rateRat, err := positiveRat(rate, "conversion rate") - if err != nil { - return false, err - } - exactSource := new(big.Rat).Quo(target, rateRat) - roundedSource := ceilDecimalToScale(exactSource, sourceScale) - return roundedSource.Cmp(source) == 0 && source.Cmp(exactSource) >= 0, nil -} - -func positiveRat(value string, field string) (*big.Rat, error) { - parsed, err := decimalbase.RatFromString(strings.TrimSpace(value)) - if err != nil || parsed.Sign() <= 0 { - return nil, merrors.InvalidArgument(field + " must be a positive decimal") - } - return parsed, nil -} - -func ceilDecimalToScale(value *big.Rat, scale uint32) *big.Rat { - factor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(scale)), nil) - scaled := new(big.Rat).Mul(value, new(big.Rat).SetInt(factor)) - q, r := new(big.Int).QuoRem(scaled.Num(), scaled.Denom(), new(big.Int)) - if r.Sign() > 0 { - q.Add(q, big.NewInt(1)) - } - return new(big.Rat).SetFrac(q, factor) -} diff --git a/backend/pkg/moneybase/fx_rounding_test.go b/backend/pkg/moneybase/fx_rounding_test.go deleted file mode 100644 index 424051f..0000000 --- a/backend/pkg/moneybase/fx_rounding_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package moneybase - -import ( - "testing" -) - -func TestSourceRoundedConversionMatches(t *testing.T) { - ok, err := SourceRoundedConversionMatches("0.06", "3.72", "73.1275", 2) - if err != nil { - t.Fatalf("SourceRoundedConversionMatches returned error: %v", err) - } - if !ok { - t.Fatal("expected rounded source conversion to match") - } - ok, err = SourceRoundedConversionMatches("0.07", "3.72", "73.1275", 2) - if err != nil { - t.Fatalf("SourceRoundedConversionMatches returned error: %v", err) - } - if ok { - t.Fatal("expected oversized source amount to fail") - } - ok, err = SourceRoundedConversionMatches("0.05", "3.72", "73.1275", 2) - if err != nil { - t.Fatalf("SourceRoundedConversionMatches returned error: %v", err) - } - if ok { - t.Fatal("expected undersized source amount to fail") - } -} diff --git a/backend/pkg/moneybase/scale.go b/backend/pkg/moneybase/scale.go deleted file mode 100644 index 8e33fb2..0000000 --- a/backend/pkg/moneybase/scale.go +++ /dev/null @@ -1,12 +0,0 @@ -package moneybase - -import "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - -const MaxScale uint32 = 36 - -func ValidateScale(field string, scale uint32) error { - if scale > MaxScale { - return merrors.InvalidArgumentf("%s exceeds supported money scale %d", field, MaxScale) - } - return nil -} diff --git a/backend/pkg/mservice/services.go b/backend/pkg/mservice/services.go index c279715..6a49644 100644 --- a/backend/pkg/mservice/services.go +++ b/backend/pkg/mservice/services.go @@ -1,108 +1,11 @@ package mservice -import "git.sendico.io/tech/sxchnge/bff/pkg/merrors" - type Type = string const ( - Accounts Type = "accounts" // Represents user accounts in the system - Verification Type = "verification" // Represents verification code flows - Amplitude Type = "amplitude" // Represents analytics integration with Amplitude - Discovery Type = "discovery" // Represents service discovery registry - Site Type = "site" // Represents public site endpoints - Changes Type = "changes" // Tracks changes made to resources - Clients Type = "clients" // Represents client information - ChainGateway Type = "chain_gateway" // Represents chain gateway microservice - MntxGateway Type = "mcards_gateway" // Represents Monetix gateway microservice - AuroraGateway Type = "aurora_gateway" // Represents Aurora card gateway microservice - AsterBankGateway Type = "asterbank_gateway" // Represents AsterBank local/dev bank payout emulator - MBankGateway Type = "mbank_gateway" // Represents mBank real-money bank payout gateway - PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice - FXOracle Type = "fx_oracle" // Represents FX oracle microservice - FXIngestor Type = "fx_ingestor" // Represents FX ingestor microservice - Treasury Type = "treasury" // Represents Treasury microservice - OrgMoneyFlowStats Type = "org_money_flow_stats" // Represents organization money flow statistics - FeePlans Type = "fee_plans" // Represents fee plans microservice - BillingDocuments Type = "billing_documents" // Represents billing documents microservice - BillingFees Type = "billing_fees" // Represents billing fees microservice - AMLSources Type = "aml_sources" // Represents Backoffice AML data-source management - CardBlacklist Type = "card_blacklist" // Represents backoffice card-blacklist management - ComplianceAML Type = "compliance_aml" // Represents AML screening microservice - ComplianceKYC Type = "compliance_kyc" // Represents KYC verification microservice - CompliancePolicy Type = "compliance_policy" // Represents policy enforcement microservice - ComplianceAssessment Type = "compliance_assessment" // Represents durable compliance assessment coordination - CardBlacklistIngestor Type = "card_blacklist_ingestor" // Represents automated card-blacklist ingestion - Limits Type = "limits" // Represents limit reservation and usage enforcement microservice - FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources - Invitations Type = "invitations" // Represents invitations sent to users - Invoices Type = "invoices" // Represents invoices - Logo Type = "logo" // Represents logos for organizations or projects - Ledger Type = "ledger" // Represents ledger microservice - LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice - // LedgerBalances and LedgerEntries gate the resources derived from an - // organization-owned ledger account. The edge Ledger API authorizes them in - // two steps: the account must be visible to the caller (see edge/product - // internal/ledgervisibility), and reading its balance or journal entries - // then requires the matching permission below. Owners of a personal account - // are exempt from the second step. - LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice - LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice - LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice - LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice - LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice - PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice - PaymentQuotation Type = "payment_quotation" // Represents payment quotation microservice - InboundDepositProcessor Type = "crypto_deposits" // Represents inbound crypto deposit processing microservice - ReceivingRoutes Type = "receiving_routes" // Represents inbound receiving route resolution - ChainAssets Type = "chain_assets" // Represents managed chain assets - ChainWallets Type = "chain_wallets" // Represents managed chain wallets - ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances - ChainTransfers Type = "chain_transfers" // Represents chain transfers - ChainDeposits Type = "chain_deposits" // Represents chain deposits - Callbacks Type = "callbacks" // Represents webhook callback subscriptions - ChSettle Type = "chsettle_gateway" // Represents chimera settlement gateway - Notifications Type = "notifications" // Represents notifications sent to users - Organizations Type = "organizations" // Represents organizations in the system - Payments Type = "payments" // Represents payments service - Transactions Type = "transactions" // Represents transaction reporting and investigations - PaymentRoutes Type = "payment_routes" // Represents payment routing definitions - PaymentMethods Type = "payment_methods" // Represents payment methods service - Permissions Type = "permissions" // Represents permissiosns service - Policies Type = "policies" // Represents access control policies - PolicyAssignements Type = "policy_assignments" // Represents policy assignments database - Recipients Type = "recipients" // Represents payment recipients - OrgRecipients Type = "org_recipients" // Represents organization-scoped shared recipients (policy resource type) - RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication - Roles Type = "roles" // Represents roles in access control - SignupProvisioning Type = "signup_provisioning" // Represents in-flight signup provisioning records - Storage Type = "storage" // Represents statuses of tasks or projects - TgSettle Type = "tgsettle_gateway" // Represents tg settlement gateway - TronGateway Type = "tron_gateway" // Represents tron gateway microservice - Tenants Type = "tenants" // Represents tenants managed in the system - VerificationTokens Type = "verification_tokens" //Represents verification tokens managed in the system - Wallets Type = "wallets" // Represents workflows for tasks or projects - WalletProvisioning Type = "wallet_provisioning" // Represents durable wallet and receiving-route provisioning state - WalletRoutes Type = "wallet_routes" // Represents authoritative chain wallet gateway routing - RotatedWallets Type = "rotated_wallets" // Represents backoffice rotated-wallet review and disposition access - Workflows Type = "workflows" // Represents workflows for tasks or projects + Accounts Type = "accounts" // Represents user accounts in the system + Site Type = "site" // Represents public site endpoints + Clients Type = "clients" // Represents client information + RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication + VerificationTokens Type = "verification_tokens" //Represents verification tokens managed in the system ) - -const ( - DocumentsReport Type = "documents_report" // Represents employee closing reports - DocumentsPayout Type = "documents_payout" // Represents report-bound RUB payout rows - DocumentsPayoutAttempt Type = "documents_payout_attempt" // Represents immutable payout execution attempts - DocumentsPayoutBatch Type = "documents_payout_batch" // Represents imported payout batches -) - -func StringToSType(s string) (Type, error) { - switch s { - case Accounts, Verification, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, WalletProvisioning, WalletRoutes, ChainWalletBalances, - ChainTransfers, ChainDeposits, Callbacks, MntxGateway, AuroraGateway, AsterBankGateway, MBankGateway, PaymentGateway, FXOracle, FXIngestor, Treasury, OrgMoneyFlowStats, FeePlans, BillingDocuments, DocumentsReport, DocumentsPayout, DocumentsPayoutAttempt, DocumentsPayoutBatch, BillingFees, AMLSources, CardBlacklist, ComplianceAML, ComplianceKYC, CompliancePolicy, ComplianceAssessment, CardBlacklistIngestor, Limits, FilterProjects, Invitations, Invoices, Logo, Ledger, - LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications, - Organizations, Payments, Transactions, PaymentRoutes, PaymentOrchestrator, InboundDepositProcessor, ReceivingRoutes, PaymentMethods, Permissions, Policies, PolicyAssignements, - Recipients, RefreshTokens, Roles, SignupProvisioning, Storage, Tenants, Workflows, Discovery, ChSettle, TgSettle, TronGateway, RotatedWallets: - return s, nil - default: - return "", merrors.InvalidArgument("invalid service type", s) - } -} diff --git a/backend/pkg/mservice/services_test.go b/backend/pkg/mservice/services_test.go deleted file mode 100644 index 546cbf5..0000000 --- a/backend/pkg/mservice/services_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package mservice - -import "testing" - -func TestAMLSourcesResourceTypeIsRecognized(t *testing.T) { - got, err := StringToSType(AMLSources) - if err != nil { - t.Fatalf("AMLSources resource type was rejected: %v", err) - } - if got != AMLSources { - t.Fatalf("AMLSources resource type mismatch: got=%q want=%q", got, AMLSources) - } -} - -func TestCardBlacklistIngestorServiceTypeIsRecognized(t *testing.T) { - got, err := StringToSType(CardBlacklistIngestor) - if err != nil { - t.Fatalf("card blacklist ingestor service type was rejected: %v", err) - } - if got != CardBlacklistIngestor { - t.Fatalf("card blacklist ingestor service type mismatch: got=%q want=%q", got, CardBlacklistIngestor) - } -} - -func TestDocumentsCRUDResourceTypesAreRecognized(t *testing.T) { - for _, resource := range []Type{ - DocumentsReport, - DocumentsPayout, - DocumentsPayoutAttempt, - DocumentsPayoutBatch, - } { - got, err := StringToSType(resource) - if err != nil { - t.Fatalf("documents resource type %q was rejected: %v", resource, err) - } - if got != resource { - t.Fatalf("documents resource type mismatch: got=%q want=%q", got, resource) - } - } -} diff --git a/backend/pkg/mutil/db/auth/accountbound.go b/backend/pkg/mutil/db/auth/accountbound.go deleted file mode 100644 index c550623..0000000 --- a/backend/pkg/mutil/db/auth/accountbound.go +++ /dev/null @@ -1,89 +0,0 @@ -// Package mutil provides utility functions for working with account-bound objects -// with permission enforcement. -// -// Example usage: -// -// // Using the low-level repository approach -// objects, err := mutil.GetAccountBoundObjects[model.ProjectFilter]( -// ctx, logger, accountRef, orgRef, model.ActionRead, -// repository.Query(), &model.ViewCursor{Limit: &limit, Offset: &offset, IsArchived: &isArchived}, -// enforcer, repo) -// -// // Using the AccountBoundDB interface approach -// objects, err := mutil.GetAccountBoundObjectsFromDB[model.ProjectFilter]( -// ctx, logger, accountRef, orgRef, -// repository.Query(), &model.ViewCursor{Limit: &limit, Offset: &offset, IsArchived: &isArchived}, -// accountBoundDB) -package mutil - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "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/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -// GetAccountBoundObjects retrieves account-bound objects with permission enforcement. -// This function handles the complex logic of: -// 1. Finding objects where accountRef matches OR is null/absent -// 2. Checking organization-level permissions for each object -// 3. Filtering to only objects the account has permission to read -func GetAccountBoundObjects[T any]( - ctx context.Context, - logger mlogger.Logger, - accountRef, organizationRef bson.ObjectID, - filter builder.Query, - cursor *model.ViewCursor, - enforcer auth.Enforcer, - repo repository.Repository, -) ([]T, error) { - // Build query to find objects where accountRef matches OR is null/absent - accountQuery := repository.WithOrg(accountRef, organizationRef) - - // Get all account-bound objects that match the criteria - allObjects, err := repo.ListAccountBound(ctx, repository.ApplyCursor(accountQuery, cursor)) - if err != nil { - if !errors.Is(err, merrors.ErrNoData) { - logger.Warn("Failed to fetch account bound objects", zap.Error(err), - mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), - ) - } else { - logger.Debug("No matching account bound objects found", zap.Error(err), - mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), - ) - } - return nil, err - } - - if len(allObjects) == 0 { - return nil, merrors.NoData("no_account_bound_objects_found") - } - - allowed := make([]bson.ObjectID, 0, len(allObjects)) - for _, ref := range allObjects { - allowed = append(allowed, *ref.GetID()) - } - if len(allowed) == 0 { - return nil, merrors.NoData("no_data_found_or_allowed") - } - - logger.Debug("Successfully retrieved account bound objects", - zap.Int("total_count", len(allObjects)), - mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), - zap.Any("objs", allObjects), - ) - - return mutil.GetObjects[T](ctx, logger, repository.Query().In(repository.IDField(), allowed), cursor, repo) -} diff --git a/backend/pkg/mutil/db/auth/protected.go b/backend/pkg/mutil/db/auth/protected.go deleted file mode 100644 index 352ef9c..0000000 --- a/backend/pkg/mutil/db/auth/protected.go +++ /dev/null @@ -1,58 +0,0 @@ -package mutil - -import ( - "context" - "errors" - - "git.sendico.io/tech/sxchnge/bff/pkg/auth" - "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/mlogger" - "git.sendico.io/tech/sxchnge/bff/pkg/model" - mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db" - "git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap" - "go.mongodb.org/mongo-driver/v2/bson" - "go.uber.org/zap" -) - -func GetProtectedObjects[T any]( - ctx context.Context, - logger mlogger.Logger, - accountRef, organizationRef bson.ObjectID, - action model.Action, - filter builder.Query, - cursor *model.ViewCursor, - enforcer auth.Enforcer, - repo repository.Repository, -) ([]T, error) { - refs, err := repo.ListPermissionBound(ctx, repository.ApplyCursor(filter, cursor)) - if err != nil { - if !errors.Is(err, merrors.ErrNoData) { - logger.Warn("Failed to fetch object IDs", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("action", string(action))) - } else { - logger.Debug("No matching IDs found", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("action", string(action))) - } - return nil, err - } - res, err := enforcer.EnforceBatch(ctx, refs, accountRef, action) - if err != nil { - logger.Warn("Failed to enforce object IDs", zap.Error(err), mzap.AccRef(accountRef), - mzap.OrgRef(organizationRef), zap.String("action", string(action))) - return nil, err - } - - allowed := make([]bson.ObjectID, 0, len(res)) - for _, ref := range refs { - if ok := res[*ref.GetID()]; ok { - allowed = append(allowed, *ref.GetID()) - } - } - if len(allowed) == 0 { - return nil, merrors.NoData("no_data_found_or_allowed") - } - - return mutil.GetObjects[T](ctx, logger, repository.Query().In(repository.IDField(), allowed), cursor, repo) -} diff --git a/backend/pkg/mutil/mzap/object.go b/backend/pkg/mutil/mzap/object.go index e0fdd5c..01b71fd 100644 --- a/backend/pkg/mutil/mzap/object.go +++ b/backend/pkg/mutil/mzap/object.go @@ -2,7 +2,6 @@ package mzap import ( "git.sendico.io/tech/sxchnge/bff/pkg/db/storable" - "git.sendico.io/tech/sxchnge/bff/pkg/model" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) @@ -14,15 +13,3 @@ func ObjRef(name string, objRef bson.ObjectID) zap.Field { func StorableRef(obj storable.Storable) zap.Field { return ObjRef(obj.Collection()+"_ref", *obj.GetID()) } - -func OrgRef(orgRef bson.ObjectID) zap.Field { - return ObjRef("organization_ref", orgRef) -} - -func OrganizationRef(obj model.OrganizationBound) zap.Field { - return OrgRef(obj.GetOrganizationRef()) -} - -func PermissionRef(permissionRef bson.ObjectID) zap.Field { - return ObjRef("permission_ref", permissionRef) -} diff --git a/backend/pkg/person/name.go b/backend/pkg/person/name.go deleted file mode 100644 index 862fbd7..0000000 --- a/backend/pkg/person/name.go +++ /dev/null @@ -1,38 +0,0 @@ -package person - -import "strings" - -// Name represents a person's first and last name as a reusable value object. -type Name struct { - FirstName string `bson:"firstName,omitempty" json:"first_name,omitempty"` - LastName string `bson:"lastName,omitempty" json:"last_name,omitempty"` -} - -func (n Name) Trim() Name { - return Name{ - FirstName: strings.TrimSpace(n.FirstName), - LastName: strings.TrimSpace(n.LastName), - } -} - -func (n Name) WithFallback(fallback Name) Name { - n = n.Trim() - fallback = fallback.Trim() - if n.FirstName == "" { - n.FirstName = fallback.FirstName - } - if n.LastName == "" { - n.LastName = fallback.LastName - } - return n -} - -func (n Name) FullName() string { - n = n.Trim() - return strings.TrimSpace(n.FirstName + " " + n.LastName) -} - -func (n Name) IsZero() bool { - n = n.Trim() - return n.FirstName == "" && n.LastName == "" -} diff --git a/backend/pkg/person/name_test.go b/backend/pkg/person/name_test.go deleted file mode 100644 index 828b4f7..0000000 --- a/backend/pkg/person/name_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package person - -import "testing" - -func TestNameWithFallbackUsesFallbackPerField(t *testing.T) { - name := Name{FirstName: " Ivan "} - fallback := Name{FirstName: "FallbackFirst", LastName: " Ivanov "} - - got := name.WithFallback(fallback) - want := Name{FirstName: "Ivan", LastName: "Ivanov"} - if got != want { - t.Fatalf("name mismatch: got=%+v want=%+v", got, want) - } -} diff --git a/backend/scripts/seed-policies.js b/backend/scripts/seed-policies.js deleted file mode 100644 index 16bc9f0..0000000 --- a/backend/scripts/seed-policies.js +++ /dev/null @@ -1,88 +0,0 @@ -// Adapted from Sendico's canonical Product policy seed; see PROVENANCE.md. -// Run only against the SXCHNGE-owned database. This seeds descriptions only: -// no users, organization, role grants or provider resources are created. -const databaseName = process.env.SXCHNGE_MONGO_DATABASE || 'sxchnge'; -const targetDb = db.getSiblingDB(databaseName); -const now = new Date(); - -const canonicalPolicies = [ - { - _id: ObjectId('677a61569cf14638b34423e0'), - name: 'Organization Settings', - description: 'Access to organization settings', - resourceTypes: ['organizations'], - }, - { - _id: ObjectId('67bb6ec0da91191ffeb47012'), - name: 'Accounts', - description: 'Access to accounts settings', - resourceTypes: ['accounts'], - }, - { - _id: ObjectId('67b3d7bd452192e77a26b22d'), - name: 'Roles', - description: 'Access to roles management', - resourceTypes: ['roles', 'policies'], - }, -]; - -upsertCanonicalPolicies(); - -function builtInFilter(extra) { - return Object.assign( - { - $or: [ - { organizationRef: { $exists: false } }, - { organizationRef: null }, - ], - }, - extra, - ); -} - -function upsertCanonicalPolicies() { - const result = []; - for (const entry of canonicalPolicies) { - let existing = targetDb.policies.findOne({ _id: entry._id }); - if (!existing) { - // Migration-created policies carry environment-specific ids. - existing = targetDb.policies.findOne( - builtInFilter({ resourceTypes: { $all: entry.resourceTypes } }), - ); - } - if (existing) { - const patch = {}; - if (existing.name !== entry.name) patch.name = entry.name; - if (existing.description !== entry.description) { - patch.description = entry.description; - } - if ( - JSON.stringify(existing.resourceTypes || []) !== - JSON.stringify(entry.resourceTypes) - ) { - patch.resourceTypes = entry.resourceTypes; - } - if (Object.keys(patch).length > 0) { - patch.updatedAt = now; - targetDb.policies.updateOne({ _id: existing._id }, { $set: patch }); - print(`Patched built-in policy "${entry.name}" (${existing._id.toHexString()})`); - } else { - print(`Built-in policy "${entry.name}" is up to date`); - } - result.push({ entry, id: existing._id, inserted: false }); - continue; - } - - targetDb.policies.insertOne({ - _id: entry._id, - createdAt: now, - updatedAt: now, - name: entry.name, - description: entry.description, - resourceTypes: entry.resourceTypes, - }); - print(`Seeded built-in policy "${entry.name}" (${entry._id.toHexString()})`); - result.push({ entry, id: entry._id, inserted: true }); - } - return result; -} diff --git a/docs/architecture/bff.md b/docs/architecture/bff.md index 9151fa9..a4dcd73 100644 --- a/docs/architecture/bff.md +++ b/docs/architecture/bff.md @@ -18,8 +18,8 @@ The source checkout's unrelated dependency edits were not copied. | Local component | Reused implementation | | --- | --- | | `backend/edge/shared` | Chi HTTP runtime, middleware, JWT login/pending sessions, refresh/rotation/logout, CSRF/cookies, rate limiting, Mongo verification and refresh-token stores | -| `backend/edge/product/interface/accountservice` | Account validation, bcrypt passwords, creation, local membership/roles, activation tokens, password reset and email-change token issuance, deletion | -| `backend/pkg` | Canonical local account/domain models, Mongo repositories/transactions, authorization, errors, structured logging, process bootstrap and health | +| `backend/edge/product/interface/accountservice` | Account validation, bcrypt passwords, atomic customer creation, activation tokens, password reset and email-change token issuance, deletion | +| `backend/pkg` | Canonical local account/domain models, Mongo repositories/transactions, errors, structured logging, process bootstrap and health | | `backend/internal/profile` | Small SXCHNGE composition layer selecting the copied local account service | | `backend/contracts/sendico` | Separate snapshot of Sendico's public OpenAPI contract and its referenced schemas | @@ -29,10 +29,12 @@ private Sendico module requirement, gRPC client, discovery subscription or broke connection. Platform engines and Product's platform-provisioning bootstrap were not copied. The original handwritten shell and tiny replacement helpers were removed. -The existing local account service retains local organization membership and role -semantics. Use one SXCHNGE local tenant when wiring registration; this does not -create an organization in Sendico for each customer. Local database collections, -JWT keys and registered application clients belong exclusively to SXCHNGE. +Local customers have no organization membership, roles, policies or tenant ID. +The account service validates and hashes passwords, creates an account and +activation token atomically, and deletes an account with session revocation. +Customer responses map to explicit local DTOs. Account, token and session collections and application client records belong only +to SXCHNGE. The runtime starts without any policy or tenant seed. Casbin, +invitations, financial models and provisioning machinery are excluded. ## Identity and ownership @@ -98,8 +100,9 @@ Sendico task. No private transport fallback is allowed. ## Next wiring sequence 1. Wire local signup, verification delivery/completion and profile endpoints onto - the copied account and verification services. Create SXCHNGE-owned tenant, - client and role records; keep signup independent of Sendico availability. + the copied account and verification services. Configure the SXCHNGE application + client for web/native sessions; keep signup independent of Sendico availability. + Never add local organization, membership, role or policy requirements. 2. Implement the public HTTP adapter and confirm organization/resource scoping with the integration principal. Agree on external-customer contracts where the existing public API assumes a Sendico account. @@ -120,6 +123,6 @@ operation details explain what has happened and what remains pending. This task supplies the copied starting point and isolated public contracts. The local runtime installs the existing authentication transport and account service. Local signup/profile HTTP endpoints, verification delivery/completion, -initial tenant/client provisioning, Flutter HTTP adapters and live Sendico calls +application client setup, Flutter HTTP adapters and live Sendico calls are not connected yet. The frontend continues using its existing dummy services. No new provider organization, customer or payment has been created. diff --git a/docs/implementation.md b/docs/implementation.md index 548f76f..031efe5 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -363,44 +363,56 @@ Delivery: [PR #33](https://git.sendico.io/tech/sxchnge/pulls/33) targets `main`; committed and pushed, unmerged. Issue #32 and PR #33 metadata verified: frontend label, tech owner and SXCHNGE project. No stacked dependencies. -## Copied BFF foundation and public Sendico contracts — task #34 +## Minimal customer BFF and public Sendico contracts — task #34 -Bootstrapped SXCHNGE from Sendico revision -`8bb20dcd622dd8eb92d289b6b34411e7eccab083`: shared HTTP runtime, authentication -and session stack, verification and refresh storage, account service, Mongo -repositories, authorization, canonical models/errors and structured logging. -The original handwritten shell and replacement helpers were removed. File-level -[provenance](../backend/PROVENANCE.json) records 416 copied Go files and 112 public -OpenAPI files. The source checkout and its unrelated dependency edits were untouched. +[Task #34](https://git.sendico.io/tech/sxchnge/issues/34), branch `bff-34`, +[PR #35](https://git.sendico.io/tech/sxchnge/pulls/35) into `main`. -The corrected [architecture](architecture/bff.md) reflects the product boundary: -SXCHNGE owns customers/authentication; SXCHNGE itself is one Sendico organization; -all Sendico access uses the public HTTP API. Internal gRPC, discovery, messaging, -platform service factories and organization provisioning were excluded. Public -OpenAPI is isolated from local models and tested for self-contained references. -No live provider call, organization, customer or payment was created. +The initial copy at `7062672` incorrectly retained Sendico's local tenancy model. +The correction removes organization membership, roles, Casbin, policy storage and +seeding, invitations and their models/repositories/tests. It also removes unused +financial models/errors, ledger/wallet provisioning metadata, platform service +names, S3 configuration and WebSocket scaffolding. No default tenant or bypassing +authorizer replaces them. -Restored full backend guidelines and the backend-review skill, correcting scoped -paths. Copied error constructors follow `merrors`; local adaptations also validate -startup settings, preserve listen failures/nonzero exit, close Mongo after failed -runtime creation and avoid logging database settings. Architecture checks enforce -the project error model and public-only provider boundary. +Retained Sendico source is pinned to `8bb20dcd622dd8eb92d289b6b34411e7eccab083`: +HTTP runtime, local customer/password service and Mongo repository, customer +sessions and verification storage, generic repository/transaction code, errors, +logging and health. [Provenance](../backend/PROVENANCE.json) covers 232 retained +Go files and 112 isolated public OpenAPI files; every adaptation is flagged. +The Sendico checkout remains unchanged. -The copied runtime installs local authentication transport and initializes the -account service. Signup/profile endpoints, verification delivery/completion, -initial local tenant/client provisioning, Flutter HTTP adapters and live Sendico -calls remain future wiring. Public external-customer compliance/ownership gaps -are documented; internal APIs are not an allowed workaround. +Customer creation accepts a raw password, validates/hashes it and commits the +customer and activation token together. Token transactions join an active outer +customer transaction. Deletion revokes only the selected customer's sessions and +deletes its account atomically, including when it is the final local customer. +Customer lifecycle and login use the same configured auth collections. Pending, +active and blocked customer state and session/client binding remain local. -Validation: `make test` (race-enabled backend suite with real MongoDB containers, -plus all frontend package/app tests), `make lint-backend` (architecture, gofmt, -vet), `go build` and `git diff --check` passed. Local smoke used a disposable -SXCHNGE Mongo replica set: health 200, invalid login 400, SIGTERM exit 0 and missing -configuration exit 1. Earlier extraction failures were fixed: unused import, -wrong example-config test path, an out-of-scope recipient migration test -copied without its uncopied migration, and missing built-in policy records on -fresh startup. The copied policy seed was run twice and retained three records. No security scan was requested or run. +The sole Sendico organization reference belongs to the future public HTTP adapter +configuration. [Architecture and capabilities](architecture/bff.md) document +server-side customer/resource ownership and the public external-customer contract +gaps. No internal service, shared database or provider resource creation is used. +Signup/profile HTTP routes, verification delivery/completion, application client +setup and live Sendico integration remain subsequent wiring work. -Delivery: [PR #35](https://git.sendico.io/tech/sxchnge/pulls/35), branch `bff-34` -into `main`; task #34 retains backend label, tech owner and SXCHNGE project. -This entry supersedes the earlier new-shell/delegated-provisioning proposal. +Backend completion review covered the retained copied runtime and the full +extraction diff. Inactive-customer checks and refresh client/account binding were +added after review; explicit response DTOs keep storage fields out of HTTP output. + +Validation passed: `make test` (race-enabled backend suite, including fresh Mongo +replica-set customer lifecycle/runtime tests, plus all frontend suites), +`make lint-backend` (architecture, formatting and vet), and +`go test -tags integration -run '^$' ./...` from `backend` (compilation of the +additional tagged suites; this command does not execute those tests). +Provenance/source blobs, public contract references and `git diff --check` passed. +Earlier extraction compile errors were corrected. The initial fresh-database test +attempt used an unavailable image tag and then hit Docker Desktop replica-set +address discovery; it now uses `mongo:8` and a direct connection to the disposable +single-node replica set. No security scan was requested or run. + +PR #35 remains unmerged. There is no deployed-data migration: this bootstrap has +not been deployed. Existing experimental databases may retain unused tenancy +collections; this change does not delete external database records. Customer HTTP +responses now expose only ID, name, last name, login, locale and optional avatar. +The frontend still uses dummy services and is unchanged.