Connect signup and login verification to email notifications #39

Merged
tech merged 12 commits from auth-38 into main 2026-09-12 17:46:16 +00:00
244 changed files with 651 additions and 17130 deletions
Showing only changes of commit 981e84a4b4 - Show all commits

No files matched your search

+1
View File
@@ -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.
+5 -1
View File
@@ -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.
+35 -1145
View File
File diff suppressed because it is too large. Load diff
+50 -52
View File
@@ -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.
+5 -10
View File
@@ -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.
-2
View File
@@ -8,8 +8,6 @@ database:
settings:
uri_env: SXCHNGE_MONGO_URI
database: sxchnge
enforcer:
driver: native
api:
middleware:
domain_env: SXCHNGE_DOMAIN
@@ -0,0 +1,130 @@
package accountserviceimp
import (
"context"
"net/url"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
edgemongo "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func customer(login string) *model.Account {
return (&model.AccountData{LoginData: model.LoginData{UserDataBase: model.UserDataBase{Login: login}, Password: "CustomerPassword1!"}, Describable: model.Describable{Name: "Customer"}}).ToAccount()
}
// Uses the actual copied repositories against an empty SXCHNGE database.
// There is deliberately no seed, organization, role, policy or Sendico connection.
func TestCustomerLifecycleWithoutTenancy(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
endpoint, err := url.Parse(uri)
require.NoError(t, err)
query := endpoint.Query()
query.Set("directConnection", "true")
endpoint.RawQuery = query.Encode()
uri = endpoint.String()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: model.SettingsT{"uri": uri, "database": "sxchnge_customers"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
// Custom collections prove lifecycle and login storage select the same data.
config := &api.AuthStorageConfig{AccountsCollection: "customers", VerificationTokensCollection: "customer_verification", RefreshTokensCollection: "customer_sessions"}
svc, err := NewAccountService(zap.NewNop(), factory, &middleware.PasswordConfig{Check: middleware.PasswordChecks{MinLength: 12}}, config)
require.NoError(t, err)
first, second := customer("first@example.test"), customer("second@example.test")
firstToken, err := svc.CreateAccount(ctx, first)
require.NoError(t, err)
require.NotEmpty(t, firstToken)
require.Equal(t, model.AccountPendingVerification, first.Status)
require.True(t, first.MatchPassword("CustomerPassword1!"))
_, err = svc.CreateAccount(ctx, second)
require.NoError(t, err)
require.NotEqual(t, first.ID, second.ID)
// Activation tokens cannot be used as another customer's token.
_, err = svc.vdb.Consume(ctx, second.ID, model.PurposeAccountActivation, "", firstToken)
require.Error(t, err)
_, err = svc.vdb.Consume(ctx, first.ID, model.PurposeAccountActivation, "", firstToken)
require.NoError(t, err)
_, err = svc.CreateAccount(ctx, customer("first@example.test"))
require.ErrorIs(t, err, merrors.ErrDataConflict)
require.NoError(t, svc.ValidateAccount(customer("validate@example.test")))
require.ErrorIs(t, svc.ValidateAccount(nil), merrors.ErrInvalidArg)
_, err = svc.CreateAccount(ctx, nil)
require.ErrorIs(t, err, merrors.ErrInvalidArg)
require.ErrorIs(t, svc.DeleteAccount(ctx, bson.NilObjectID), merrors.ErrInvalidArg)
for _, c := range []*model.Account{first, second} {
require.NoError(t, svc.sessions.Create(ctx, &model.RefreshToken{AccountRef: &c.ID, ExpiresAt: time.Now().Add(time.Hour), ClientRefreshToken: model.ClientRefreshToken{SessionIdentifier: model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}, RefreshToken: c.ID.Hex()}}))
}
require.NoError(t, svc.DeleteAccount(ctx, first.ID))
var stored model.Account
require.ErrorIs(t, svc.accountDB.Get(ctx, first.ID, &stored), merrors.ErrNoData)
require.NoError(t, svc.accountDB.Get(ctx, second.ID, &stored))
session := &model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}
_, err = svc.sessions.GetSession(ctx, first.ID, session)
require.Error(t, err)
_, err = svc.sessions.GetSession(ctx, second.ID, session)
require.NoError(t, err)
// The final local customer can be deleted; there is no sole-member restriction.
require.NoError(t, svc.DeleteAccount(ctx, second.ID))
deps, err := edgemongo.ResolveDependencies(factory)
require.NoError(t, err)
collections, err := deps.Database.ListCollectionNames(ctx, bson.D{})
require.NoError(t, err)
for _, forbidden := range []string{"organizations", "roles", "policies", "policy_assignments", "invitations", "accounts"} {
require.NotContains(t, collections, forbidden)
}
// A token issuance failure must roll back the preceding customer insert.
realVerification := svc.vdb
failure := merrors.Internal("injected verification failure")
svc.vdb = &failingVerification{DB: realVerification, failure: failure}
candidate := customer("rollback@example.test")
_, err = svc.CreateAccount(ctx, candidate)
require.ErrorIs(t, err, failure)
require.True(t, candidate.ID.IsZero())
_, err = svc.accountDB.GetByEmail(ctx, candidate.Login)
require.ErrorIs(t, err, merrors.ErrNoData)
svc.vdb = realVerification
// If an outer workflow fails after token issuance, neither write may commit.
outerFailure := merrors.Internal("injected outer failure")
_, err = factory.TransactionFactory().CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
_, e := svc.CreateAccount(tx, customer("outer@example.test"))
if e != nil {
return nil, e
}
return nil, outerFailure
})
require.ErrorIs(t, err, outerFailure)
_, err = svc.accountDB.GetByEmail(ctx, "outer@example.test")
require.ErrorIs(t, err, merrors.ErrNoData)
count, err := deps.Database.Collection(config.VerificationTokensCollection).CountDocuments(ctx, bson.D{})
require.NoError(t, err)
require.EqualValues(t, 2, count)
}
type failingVerification struct {
verification.DB
failure error
}
func (f *failingVerification) Create(context.Context, *verification.Request) (string, error) {
return "", f.failure
}
@@ -1,119 +0,0 @@
package accountserviceimp
import (
"context"
"testing"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
// fakeMembershipOrgDB answers membership from its own state, standing in for
// the transaction-scoped reads a real repository would serve.
type fakeMembershipOrgDB struct {
organization.DB
member bool
isMemberCalls int
addMemberCalls int
addedMember bson.ObjectID
}
func (db *fakeMembershipOrgDB) IsMember(_ context.Context, _, _ bson.ObjectID) (bool, error) {
db.isMemberCalls++
return db.member, nil
}
func (db *fakeMembershipOrgDB) AddMember(_ context.Context, _, accountRef bson.ObjectID) (bool, error) {
db.addMemberCalls++
db.addedMember = accountRef
db.member = true
return true, nil
}
type recordingRoleManager struct {
assigned []*model.Role
}
func (m *recordingRoleManager) Create(_ context.Context, orgRef bson.ObjectID, _ *model.Describable) (*model.RoleDescription, error) {
description := &model.RoleDescription{}
description.SetID(bson.NewObjectID())
description.OrganizationRef = orgRef
return description, nil
}
func (m *recordingRoleManager) Delete(_ context.Context, _ bson.ObjectID) error { return nil }
func (m *recordingRoleManager) Assign(_ context.Context, role *model.Role) error {
m.assigned = append(m.assigned, role)
return nil
}
func (m *recordingRoleManager) Revoke(_ context.Context, _, _, _ bson.ObjectID) error { return nil }
func (m *recordingRoleManager) List(_ context.Context, _ bson.ObjectID) ([]model.RoleDescription, error) {
return nil, nil
}
func joinTestService(orgDB *fakeMembershipOrgDB, roles *recordingRoleManager) *service {
return &service{
logger: zap.NewNop(),
orgDB: orgDB,
roleManager: roles,
}
}
func joinTestOrganization(members ...bson.ObjectID) *model.Organization {
org := &model.Organization{Members: members}
org.SetID(bson.NewObjectID())
return org
}
func joinTestAccount() *model.Account {
account := &model.Account{}
account.SetID(bson.NewObjectID())
return account
}
// A transaction replay rolls back the database but leaves marks on the shared
// in-memory organization. Membership decisions must come from the database,
// otherwise the replay skips the role assignment and the membership write and
// commits an organization without its owner.
func TestJoinOrganizationDecidesMembershipByDatabase(t *testing.T) {
account := joinTestAccount()
// The in-memory list already carries the account: exactly the state a
// first, aborted transaction attempt leaves behind.
org := joinTestOrganization(account.ID)
orgDB := &fakeMembershipOrgDB{member: false}
roles := &recordingRoleManager{}
svc := joinTestService(orgDB, roles)
if err := svc.JoinOrganization(context.Background(), org, account, bson.NewObjectID()); err != nil {
t.Fatalf("join failed: %v", err)
}
if len(roles.assigned) != 1 {
t.Fatalf("role assignment mismatch: got=%d want=1", len(roles.assigned))
}
if orgDB.addMemberCalls != 1 || orgDB.addedMember != account.ID {
t.Fatalf("membership write mismatch: calls=%d member=%s", orgDB.addMemberCalls, orgDB.addedMember.Hex())
}
if len(org.Members) != 1 {
t.Fatalf("in-memory members must stay deduplicated: got=%v", org.Members)
}
}
func TestJoinOrganizationSkipsExistingMember(t *testing.T) {
account := joinTestAccount()
org := joinTestOrganization()
orgDB := &fakeMembershipOrgDB{member: true}
roles := &recordingRoleManager{}
svc := joinTestService(orgDB, roles)
if err := svc.JoinOrganization(context.Background(), org, account, bson.NewObjectID()); err != nil {
t.Fatalf("join failed: %v", err)
}
if len(roles.assigned) != 0 || orgDB.addMemberCalls != 0 {
t.Fatalf("an existing member must not be re-joined: roles=%d adds=%d", len(roles.assigned), orgDB.addMemberCalls)
}
}
@@ -4,19 +4,17 @@ import (
"context"
"errors"
"fmt"
"slices"
"time"
"unicode"
authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
edgestorage "git.sendico.io/tech/sxchnge/bff/edge/shared/storage/mongo"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/organization"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
"git.sendico.io/tech/sxchnge/bff/pkg/db/transaction"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
@@ -26,18 +24,19 @@ import (
)
type service struct {
logger mlogger.Logger
accountDB account.DB
orgDB organization.DB
enforcer auth.Enforcer
roleManager management.Role
config *middleware.PasswordConfig
logger mlogger.Logger
accountDB account.DB
config *middleware.PasswordConfig
policyDB policy.DB
vdb verification.DB
vdb verification.DB
sessions refreshtokens.DB
transactions transaction.Factory
}
func validateUserRequest(u *model.Account) error {
if u == nil {
return merrors.InvalidArgument("account is required")
}
if u.Name == "" {
return merrors.InvalidArgument("Name must not be empty")
}
@@ -99,17 +98,17 @@ func (s *service) ValidatePassword(
func (s *service) ValidateAccount(acct *model.Account) error {
if err := validateUserRequest(acct); err != nil {
s.logger.Warn("Invalid signup acccount received", zap.Error(err), zap.String("account", acct.Login))
s.logger.Warn("Invalid signup account received", zap.Error(err))
return err
}
if err := s.ValidatePassword(acct.Password, nil); err != nil {
s.logger.Warn("Password validation failed", zap.Error(err), zap.String("account", acct.Login))
s.logger.Warn("Password validation failed", zap.Error(err), mzap.Login(acct))
return err
}
if err := acct.HashPassword(); err != nil {
s.logger.Warn("Failed to hash password", zap.Error(err), zap.String("account", acct.Login))
s.logger.Warn("Failed to hash password", zap.Error(err), mzap.Login(acct))
return err
}
@@ -118,45 +117,52 @@ func (s *service) ValidateAccount(acct *model.Account) error {
func (s *service) CreateAccount(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID bson.ObjectID,
) (string, error) {
if org == nil {
return "", merrors.InvalidArgument("Organization must not be nil")
if acct == nil {
return "", merrors.InvalidArgument("account is required")
}
if acct == nil || len(acct.Login) == 0 {
return "", merrors.InvalidArgument("Account must have a non-empty login")
if !acct.ID.IsZero() {
return "", merrors.InvalidArgument("new account must not have an ID")
}
if roleDescID == bson.NilObjectID {
return "", merrors.InvalidArgument("Role description must be provided")
candidate := acct.Copy()
if err := s.ValidateAccount(candidate); err != nil {
return "", err
}
// 1) Create the account
acct.Status = model.AccountPendingVerification
if err := s.accountDB.Create(ctx, acct); err != nil {
candidate.Status = model.AccountPendingVerification
// The customer and activation token either both commit or neither does.
var saved *model.Account
result, err := s.transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
saved = candidate.Copy()
if err := s.accountDB.Create(tx, saved); err != nil {
return nil, err
}
return s.VerifyAccount(tx, saved)
})
if err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
s.logger.Info("Username is already taken", zap.String("login", acct.Login))
s.logger.Info("Customer login already exists")
} else {
s.logger.Warn("Failed to signup a user", zap.Error(err), zap.String("login", acct.Login))
s.logger.Warn("Customer creation failed", zap.Error(err))
}
return "", err
}
// 2) Add to organization
if err := s.JoinOrganization(ctx, org, acct, roleDescID); err != nil {
s.logger.Warn("Failed to register new organization member", zap.Error(err), mzap.StorableRef(acct))
return "", err
token, ok := result.(string)
if !ok {
return "", merrors.Internal("unexpected account creation result")
}
// 3) Issue verification token
return s.VerifyAccount(ctx, acct)
*acct = *saved
s.logger.Info("Customer created pending verification", mzap.AccRef(acct.ID))
return token, nil
}
func (s *service) VerifyAccount(
ctx context.Context,
acct *model.Account,
) (string, error) {
if acct == nil || acct.ID.IsZero() {
return "", merrors.InvalidArgument("customer reference is required")
}
token, err := s.vdb.Create(
ctx,
verification.NewLinkRequest(*acct.GetID(), model.PurposeAccountActivation, "").
@@ -173,62 +179,22 @@ func (s *service) VerifyAccount(
func (s *service) DeleteAccount(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error {
// Check if this is the only member in the organization
if len(org.Members) <= 1 {
s.logger.Warn("Cannot delete account - it's the only member in the organization",
mzap.AccRef(accountRef), mzap.StorableRef(org))
return merrors.InvalidArgument("Cannot delete the only member of an organization")
if accountRef.IsZero() {
return merrors.InvalidArgument("account reference is required")
}
// 1) Remove from organization
if err := s.RemoveAccountFromOrganization(ctx, org, accountRef); err != nil {
s.logger.Warn("Failed to revoke account role", zap.Error(err), mzap.AccRef(accountRef))
return err
}
// 2) Delete the account document
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
return err
}
return nil
}
func (s *service) RemoveAccountFromOrganization(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error {
if org == nil {
return merrors.InvalidArgument("Organization must not be nil")
}
roles, err := s.enforcer.GetRoles(ctx, accountRef, org.ID)
_, err := s.transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
if err := s.sessions.RevokeAll(tx, accountRef, ""); err != nil {
return nil, err
}
return nil, s.accountDB.Delete(tx, accountRef)
})
if err != nil {
s.logger.Warn("Failed to fetch account permissions", zap.Error(err), mzap.StorableRef(org),
mzap.AccRef(accountRef))
s.logger.Warn("Customer deletion failed", zap.Error(err), mzap.AccRef(accountRef))
return err
}
for _, role := range roles {
if err := s.roleManager.Revoke(ctx, role.DescriptionRef, accountRef, org.ID); err != nil {
s.logger.Warn("Failed to revoke account role", zap.Error(err),
mzap.AccRef(accountRef), mzap.ObjRef("role_ref", role.DescriptionRef))
return err
}
}
for i, member := range org.Members {
if member == accountRef {
// Remove the member by slicing it out
org.Members = append(org.Members[:i], org.Members[i+1:]...)
if err := s.orgDB.Update(ctx, accountRef, org); err != nil {
s.logger.Warn("Failed to remove member from organization", zap.Error(err), mzap.AccRef(accountRef))
return err
}
break
}
}
s.logger.Info("Customer deleted and sessions revoked", mzap.AccRef(accountRef))
return nil
}
@@ -236,6 +202,9 @@ func (s *service) ResetPassword(
ctx context.Context,
acct *model.Account,
) (string, error) {
if acct == nil || acct.ID.IsZero() {
return "", merrors.InvalidArgument("customer reference is required")
}
return s.vdb.Create(
ctx,
verification.NewOTPRequest(*acct.GetID(), model.PurposePasswordReset, "").
@@ -248,6 +217,9 @@ func (s *service) UpdateLogin(
acct *model.Account,
newLogin string,
) (string, error) {
if acct == nil || acct.ID.IsZero() {
return "", merrors.InvalidArgument("customer reference is required")
}
return s.vdb.Create(
ctx,
verification.NewOTPRequest(*acct.GetID(), model.PurposeEmailChange, newLogin).
@@ -255,196 +227,22 @@ func (s *service) UpdateLogin(
)
}
func (s *service) JoinOrganization(
ctx context.Context,
org *model.Organization,
account *model.Account,
roleDescID bson.ObjectID,
) error {
// Membership is decided by the database, not the in-memory copy: this method
// runs inside provisioning transactions whose retries replay the callback,
// and a stale in-memory list would silently skip the role assignment.
member, err := s.orgDB.IsMember(ctx, org.ID, account.ID)
if err != nil {
return err
}
if member {
s.logger.Debug("Account is already a member", mzap.StorableRef(org), mzap.StorableRef(account))
return nil
}
role := &model.Role{
DescriptionRef: roleDescID,
OrganizationRef: org.ID,
AccountRef: account.ID,
}
if err := s.roleManager.Assign(ctx, role); err != nil {
s.logger.Warn("Failed to assign role to account", zap.Error(err), mzap.StorableRef(account),
mzap.StorableRef(org), mzap.ObjRef("role_description_ref", roleDescID))
return err
}
// A point patch on purpose: replacing the whole document would clobber
// concurrent writes, most notably the provisioning lease that guards
// finalization against attempts whose lease was taken over.
if _, err := s.orgDB.AddMember(ctx, org.ID, account.ID); err != nil {
s.logger.Warn("Failed to update organization members list",
zap.Error(err), mzap.StorableRef(account), mzap.StorableRef(org))
return err
}
if !slices.Contains(org.Members, account.ID) {
org.Members = append(org.Members, account.ID)
}
return nil
}
func (s *service) deleteOrganizationRoles(ctx context.Context, orgRef bson.ObjectID) error {
return s.deleteOrganizationEntities(ctx, orgRef, "roles", "role", "role_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) {
roles, err := s.roleManager.List(ctx, orgRef)
if err != nil {
return nil, err
}
roleIDs := make([]bson.ObjectID, 0, len(roles))
for _, role := range roles {
roleIDs = append(roleIDs, role.ID)
}
return roleIDs, nil
}, s.roleManager.Delete)
}
func (s *service) deleteOrganizationPolicies(ctx context.Context, orgRef bson.ObjectID) error {
return s.deleteOrganizationEntities(ctx, orgRef, "policies", "policy", "policy_ref", func(ctx context.Context, orgRef bson.ObjectID) ([]bson.ObjectID, error) {
policies, err := s.policyDB.All(ctx, orgRef)
if err != nil {
return nil, err
}
policyIDs := make([]bson.ObjectID, 0, len(policies))
for _, policy := range policies {
policyIDs = append(policyIDs, policy.ID)
}
return policyIDs, nil
}, s.policyDB.Delete)
}
func (s *service) deleteOrganizationEntities(
ctx context.Context,
orgRef bson.ObjectID,
entityName string,
entityLabel string,
entityRefField string,
load func(context.Context, bson.ObjectID) ([]bson.ObjectID, error),
deleteFn func(context.Context, bson.ObjectID) error,
) error {
s.logger.Info(fmt.Sprintf("Deleting %s for organization", entityName), mzap.OrgRef(orgRef))
entityIDs, err := load(ctx, orgRef)
if err != nil && !errors.Is(err, merrors.ErrNoData) {
s.logger.Warn(fmt.Sprintf("Failed to fetch %s for deletion", entityName), zap.Error(err), mzap.OrgRef(orgRef))
return err
}
for _, entityID := range entityIDs {
if err := deleteFn(ctx, entityID); err != nil {
s.logger.Warn(fmt.Sprintf("Failed to delete %s", entityLabel), zap.Error(err), mzap.ObjRef(entityRefField, entityID))
return err
}
}
s.logger.Info(fmt.Sprintf("Successfully deleted %s", entityName), zap.Int("count", len(entityIDs)), mzap.OrgRef(orgRef))
return nil
}
func (s *service) DeleteOrganization(
ctx context.Context,
org *model.Organization,
) error {
s.logger.Info("Starting organization deletion", mzap.StorableRef(org))
// Use transaction to ensure atomicity
// 8. Delete all roles and role descriptions in the organization
if err := s.deleteOrganizationRoles(ctx, org.ID); err != nil {
return err
}
// 9. Delete all policies in the organization
if err := s.deleteOrganizationPolicies(ctx, org.ID); err != nil {
return err
}
// 10. Finally, delete the organization itself
if err := s.orgDB.Delete(ctx, bson.NilObjectID, org.ID); err != nil {
s.logger.Warn("Failed to delete organization", zap.Error(err), mzap.StorableRef(org))
return err
}
s.logger.Info("Organization deleted successfully", mzap.StorableRef(org))
return nil
}
func (s *service) DeleteAll(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error {
s.logger.Info("Starting complete deletion (organization + account)",
mzap.StorableRef(org), mzap.AccRef(accountRef))
// 1. First delete the organization and all its data
if err := s.DeleteOrganization(ctx, org); err != nil {
return err
}
// 2. Then delete the account
if err := s.accountDB.Delete(ctx, accountRef); err != nil {
s.logger.Warn("Failed to delete account", zap.Error(err), mzap.AccRef(accountRef))
return err
}
s.logger.Info("Complete deletion successful", mzap.StorableRef(org), mzap.AccRef(accountRef))
return nil
}
// NewAccountService wires in your logger plus the three dependencies.
func NewAccountService(
l mlogger.Logger,
dbf db.Factory,
enforcer auth.Enforcer,
ra management.Role,
config *middleware.PasswordConfig,
storageConfig *api.AuthStorageConfig,
) (*service, error) {
logger := l.Named("account_service")
if config == nil {
return nil, merrors.Internal("Invalid account service configuration provides")
return nil, merrors.Internal("password configuration is required")
}
res := &service{
logger: logger,
enforcer: enforcer,
roleManager: ra,
config: config,
}
deps, err := edgestorage.ResolveDependencies(dbf)
stores, err := authstorage.OpenStores(logger, dbf, storageConfig)
if err != nil {
return nil, err
}
if res.accountDB, err = dbf.NewAccountDB(); err != nil {
logger.Error("Failed to create accounts database", zap.Error(err))
return nil, err
}
if res.orgDB, err = organization.Create(context.Background(), logger, deps.Enforcer, deps.Policies, deps.Database); err != nil {
logger.Error("Failed to create organizations database", zap.Error(err))
return nil, err
}
// Initialize database dependencies for cascade deletion
if res.policyDB, err = dbf.NewPoliciesDB(); err != nil {
logger.Error("Failed to create policies database", zap.Error(err))
return nil, err
}
if res.vdb, err = verification.Create(logger, deps.Database, deps.Transactions); err != nil {
logger.Error("Failed to create verification database", zap.Error(err))
return nil, err
}
return res, nil
return &service{logger: logger, config: config, accountDB: stores.Accounts,
vdb: stores.Verification, sessions: stores.RefreshTokens, transactions: dbf.TransactionFactory()}, nil
}
@@ -1,156 +0,0 @@
package accountserviceimp
import (
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestDeleteAccount_Validation(t *testing.T) {
t.Run("DeleteAccount_LastMemberFails", func(t *testing.T) {
orgID := bson.NewObjectID()
accountID := bson.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Single Member Org"},
},
Members: []bson.ObjectID{accountID}, // Only one member
}
org.ID = orgID
// This should fail because it's the only member
err := validateDeleteAccount(org)
require.Error(t, err)
assert.Contains(t, err.Error(), "Cannot delete the only member")
})
t.Run("DeleteAccount_MultipleMembersSuccess", func(t *testing.T) {
orgID := bson.NewObjectID()
accountID := bson.NewObjectID()
otherAccountID := bson.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Multi Member Org"},
},
Members: []bson.ObjectID{accountID, otherAccountID}, // Multiple members
}
org.ID = orgID
// This should succeed because there are multiple members
err := validateDeleteAccount(org)
require.NoError(t, err)
})
t.Run("DeleteAccount_EmptyMembersList", func(t *testing.T) {
orgID := bson.NewObjectID()
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Empty Org"},
},
Members: []bson.ObjectID{}, // No members
}
org.ID = orgID
// This should fail because there are no members
err := validateDeleteAccount(org)
require.Error(t, err)
assert.Contains(t, err.Error(), "Cannot delete the only member")
})
}
func TestDeleteOrganization_Validation(t *testing.T) {
t.Run("DeleteOrganization_NilOrganization", func(t *testing.T) {
err := validateDeleteOrganization(nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "organization cannot be nil")
})
t.Run("DeleteOrganization_EmptyOrganization", func(t *testing.T) {
org := &model.Organization{}
err := validateDeleteOrganization(org)
require.Error(t, err)
assert.Contains(t, err.Error(), "organization ID cannot be empty")
})
t.Run("DeleteOrganization_ValidOrganization", func(t *testing.T) {
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Valid Organization"},
},
}
org.ID = bson.NewObjectID()
err := validateDeleteOrganization(org)
require.NoError(t, err)
})
}
func TestDeleteAll_Validation(t *testing.T) {
t.Run("DeleteAll_NilOrganization", func(t *testing.T) {
accountID := bson.NewObjectID()
err := validateDeleteAll(nil, accountID)
require.Error(t, err)
assert.Contains(t, err.Error(), "organization cannot be nil")
})
t.Run("DeleteAll_EmptyAccountID", func(t *testing.T) {
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Valid Organization"},
},
}
org.ID = bson.NewObjectID()
err := validateDeleteAll(org, bson.NilObjectID)
require.Error(t, err)
assert.Contains(t, err.Error(), "account ID cannot be empty")
})
t.Run("DeleteAll_ValidInput", func(t *testing.T) {
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Valid Organization"},
},
}
org.ID = bson.NewObjectID()
accountID := bson.NewObjectID()
err := validateDeleteAll(org, accountID)
require.NoError(t, err)
})
}
// Helper functions that implement the validation logic from the service
func validateDeleteAccount(org *model.Organization) error {
if len(org.Members) <= 1 {
return merrors.InvalidArgument("Cannot delete the only member of an organization")
}
return nil
}
func validateDeleteOrganization(org *model.Organization) error {
if org == nil {
return merrors.InvalidArgument("organization cannot be nil")
}
if org.ID == bson.NilObjectID {
return merrors.InvalidArgument("organization ID cannot be empty")
}
return nil
}
func validateDeleteAll(org *model.Organization, accountRef bson.ObjectID) error {
if org == nil {
return merrors.InvalidArgument("organization cannot be nil")
}
if accountRef == bson.NilObjectID {
return merrors.InvalidArgument("account ID cannot be empty")
}
return nil
}
@@ -4,9 +4,8 @@ import (
"context"
accountserviceimp "git.sendico.io/tech/sxchnge/bff/edge/product/interface/accountservice/internal"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/auth/management"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
@@ -15,14 +14,6 @@ import (
// AccountService defines all account-related workflows.
type AccountService interface {
// ValidateAccount will:
// 1) check it's completeness
// 2) hash password
// 3) prepare verification token
ValidateAccount(
acct *model.Account,
) error
// ValidatePassword will:
// 1) check passsword conformance
ValidatePassword(
@@ -38,14 +29,11 @@ type AccountService interface {
) (verificationToken string, err error)
// CreateAccount will:
// 1) create the account
// 2) add it to the org’s member list
// 3) assign the given role description to it
// 1) validate and hash the password, then create the account
// 2) issue an activation token
CreateAccount(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID bson.ObjectID,
) (verificationToken string, err error)
VerifyAccount(
@@ -53,42 +41,15 @@ type AccountService interface {
acct *model.Account,
) (verificationToken string, err error)
JoinOrganization(
ctx context.Context,
org *model.Organization,
acct *model.Account,
roleDescID bson.ObjectID,
) error
UpdateLogin(
ctx context.Context,
acct *model.Account,
newLogin string,
) (verificationToken string, err error)
// DeleteAccount deletes the account and removes it from the org.
// DeleteAccount deletes the local customer account.
DeleteAccount(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error
// RemoveAccountFromOrganization just drops it from the member slice.
RemoveAccountFromOrganization(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error
DeleteOrganization(
ctx context.Context,
org *model.Organization,
) error
// DeleteAll deletes both the organization and the account.
DeleteAll(
ctx context.Context,
org *model.Organization,
accountRef bson.ObjectID,
) error
}
@@ -96,9 +57,8 @@ type AccountService interface {
func NewAccountService(
logger mlogger.Logger,
dbf db.Factory,
enforcer auth.Enforcer,
roleManeger management.Role,
config *middleware.PasswordConfig,
storageConfig *api.AuthStorageConfig,
) (AccountService, error) {
return accountserviceimp.NewAccountService(logger, dbf, enforcer, roleManeger, config)
return accountserviceimp.NewAccountService(logger, dbf, config, storageConfig)
}
-2
View File
@@ -3,7 +3,6 @@ package api
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/domainprovider"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
@@ -17,7 +16,6 @@ type API interface {
Config() *Config
ProfileConfig() ConfigProvider
DBFactory() db.Factory
Permissions() auth.Provider
RuntimeComponent(name string) any
Register() Register
}
+3 -5
View File
@@ -2,14 +2,12 @@ package api
import (
mwa "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
storageconfig "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/storage"
)
type Config struct {
Mw *mwa.Config `yaml:"middleware"`
AuthStorage *AuthStorageConfig `yaml:"auth_storage"`
Verification *VerificationConfig `yaml:"verification"`
Storage *storageconfig.Config `yaml:"storage"`
Mw *mwa.Config `yaml:"middleware"`
AuthStorage *AuthStorageConfig `yaml:"auth_storage"`
Verification *VerificationConfig `yaml:"verification"`
}
func (c *Config) SharedConfig() *Config {
@@ -2,7 +2,6 @@ package api
import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
)
@@ -11,5 +10,4 @@ type Register interface {
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
WSHandler(messageType string, handler ws.HandlerFunc)
}
+5 -6
View File
@@ -12,12 +12,11 @@ type RuntimeBootstrapFunc func(API) (RuntimeComponents, error)
type RuntimeFinishFunc func(API) error
type RuntimeConfig struct {
Name string
Services []ServiceFactory
EnableWebSocket bool
ConfigFactory ConfigFactory
Bootstrap RuntimeBootstrapFunc
Finish RuntimeFinishFunc
Name string
Services []ServiceFactory
ConfigFactory ConfigFactory
Bootstrap RuntimeBootstrapFunc
Finish RuntimeFinishFunc
}
type ServiceFactory struct {
@@ -1,12 +0,0 @@
package srequest
import (
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ChangePolicies struct {
RoleRef bson.ObjectID `json:"roleRef"`
Add *[]model.RolePolicy `json:"add,omitempty"`
Remove *[]model.RolePolicy `json:"remove,omitempty"`
}
@@ -1,8 +0,0 @@
package srequest
import "go.mongodb.org/mongo-driver/v2/bson"
type ChangeRole struct {
AccountRef bson.ObjectID `json:"accountRef"`
NewRoleDescriptionRef bson.ObjectID `json:"newRoleDescriptionRef"`
}
@@ -6,12 +6,15 @@ import (
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AccountData struct {
model.AccountPublic `json:",inline"`
IsAnonymous bool `json:"isAnonymous"`
ID string `json:"id"`
Name string `json:"name"`
LastName string `json:"lastName"`
Login string `json:"login"`
Locale string `json:"locale"`
AvatarURL *string `json:"avatarUrl,omitempty"`
}
type accountResponse struct {
@@ -19,15 +22,10 @@ type accountResponse struct {
Account AccountData `json:"account"`
}
type accountsResponse struct {
authResponse `json:",inline"`
Accounts []AccountData `json:"accounts"`
}
func NewAccountData(account *model.Account, isAnonymous bool) *AccountData {
func NewAccountData(account *model.Account) *AccountData {
return &AccountData{
AccountPublic: account.AccountPublic,
IsAnonymous: isAnonymous,
ID: account.ID.Hex(), Name: account.Name, LastName: account.LastName,
Login: account.Login, Locale: account.Locale, AvatarURL: account.AvatarURL,
}
}
@@ -35,25 +33,7 @@ func Account(logger mlogger.Logger, account *model.Account, accessToken *TokenDa
return response.Ok(
logger,
&accountResponse{
Account: *NewAccountData(account, false),
authResponse: authResponse{AccessToken: *accessToken},
},
)
}
func Accounts(logger mlogger.Logger, accounts []model.Account, orgRef bson.ObjectID, accessToken *TokenData) http.HandlerFunc {
publicAccounts := make([]AccountData, len(accounts))
for i := range accounts {
publicAccounts[i] = *NewAccountData(
&accounts[i],
model.AccountIsAnonymous(&accounts[i].UserDataBase, orgRef),
)
}
return response.Ok(
logger,
&accountsResponse{
Accounts: publicAccounts,
Account: *NewAccountData(account),
authResponse: authResponse{AccessToken: *accessToken},
},
)
@@ -25,7 +25,7 @@ func Login(logger mlogger.Logger, account *model.Account, accessToken, refreshTo
logger,
&loginResponse{
accountResponse: accountResponse{
Account: *NewAccountData(account, false),
Account: *NewAccountData(account),
authResponse: authResponse{AccessToken: *accessToken},
},
RefreshToken: responseRefreshToken,
@@ -19,7 +19,7 @@ func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *T
logger,
&pendingLoginResponse{
Account: accountResponse{
Account: *NewAccountData(account, false),
Account: *NewAccountData(account),
authResponse: authResponse{},
},
PendingToken: *pendingToken,
@@ -44,3 +44,15 @@ func TestNativeLoginKeepsRefreshCredentialInResponse(t *testing.T) {
require.Contains(t, recorder.Body.String(), refresh.Token)
require.Empty(t, recorder.Header().Get("Set-Cookie"))
}
func TestCustomerResponseExcludesStorageAndPlatformFields(t *testing.T) {
account := &model.Account{Password: "private-password-hash", Status: model.AccountActive}
account.Login = "customer@example.test"
account.Name = "Customer"
recorder := httptest.NewRecorder()
Account(zap.NewNop(), account, &TokenData{}).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/accounts/me", nil))
require.Contains(t, recorder.Body.String(), "customer@example.test")
for _, field := range []string{"private-password-hash", "password", "status\":\"active", "organization", "role", "policy", "isAnonymous", "createdAt", "isArchived"} {
require.NotContains(t, recorder.Body.String(), field)
}
}
@@ -1,35 +0,0 @@
package sresponse
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
)
type organizationsResponse struct {
authResponse `json:",inline"`
Organizations []model.Organization `json:"organizations"`
}
func Organization(logger mlogger.Logger, organization *model.Organization, accessToken *TokenData) http.HandlerFunc {
return Organizations(logger, []model.Organization{*organization}, accessToken)
}
func Organizations(logger mlogger.Logger, organizations []model.Organization, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, organizationsResponse{
Organizations: organizations,
authResponse: authResponse{AccessToken: *accessToken},
})
}
type organizationPublicResponse struct {
Organizations []model.OrganizationBase `json:"organizations"`
}
func OrganizationPublic(logger mlogger.Logger, organization *model.OrganizationBase) http.HandlerFunc {
return response.Ok(logger, organizationPublicResponse{
Organizations: []model.OrganizationBase{*organization},
})
}
@@ -1,45 +0,0 @@
package sresponse
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
)
type permissionsDescription struct {
Roles []model.RoleDescription `json:"roles"`
Policies []model.PolicyDescription `json:"policies"`
}
type permissionsData struct {
Roles []model.Role `json:"roles"`
Policies []model.RolePolicy `json:"policies"`
Permissions []model.Permission `json:"permissions"`
}
type permissionsResponse struct {
authResponse `json:",inline"`
Descriptions permissionsDescription `json:"descriptions"`
Permissions permissionsData `json:"permissions"`
}
func Permissions(logger mlogger.Logger,
rolesDescs []model.RoleDescription, policiesDescs []model.PolicyDescription,
roles []model.Role, policies []model.RolePolicy, permissions []model.Permission,
accessToken *TokenData,
) http.HandlerFunc {
return response.Ok(logger, permissionsResponse{
Descriptions: permissionsDescription{
Roles: rolesDescs,
Policies: policiesDescs,
},
Permissions: permissionsData{
Roles: roles,
Policies: policies,
Permissions: permissions,
},
authResponse: authResponse{AccessToken: *accessToken},
})
}
@@ -1,9 +0,0 @@
package ws
import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
)
type (
Config = middleware.WebSocketConfig
)
@@ -1,12 +0,0 @@
package ws
import (
"context"
"golang.org/x/net/websocket"
)
type (
ResponseHandler func(messageType string, conn *websocket.Conn)
HandlerFunc func(ctx context.Context, msg Message) ResponseHandler
)
@@ -1,9 +0,0 @@
package ws
import "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
type Message struct {
response.BaseResponse
ID string `json:"id"`
MessageType string `json:"messageType"`
}
@@ -68,11 +68,6 @@ func (c TokenConfig) Validate() error {
return nil
}
type WebSocketConfig struct {
EndpointEnv string `yaml:"endpoint_env"`
Timeout int `yaml:"timeout"`
}
type PasswordChecks struct {
Digit bool `yaml:"digit"`
Upper bool `yaml:"upper"`
@@ -92,7 +87,6 @@ type Config struct {
APIProtocolEnv string `yaml:"api_protocol_env"`
Signature SignatureConfig `yaml:"signature"`
CORS CORSSettings `yaml:"CORS"`
WebSocket WebSocketConfig `yaml:"websocket"`
Token TokenConfig `yaml:"token"`
Password PasswordConfig `yaml:"password"`
LoginRateLimits *LoginRateLimitsConfig `yaml:"login_rate_limits"`
@@ -1,25 +0,0 @@
package storage
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
type Driver string
const (
LocalFS Driver = "local_fs"
AWSS3 Driver = "aws_s3"
)
type Config = model.DriverConfig[Driver]
type LocalFSConfig struct {
RootPath string `mapstructure:"root_path" yaml:"root_path"`
}
type AWSS3Config struct {
AccessKeyIDEnv string `mapstructure:"access_key_id_env" yaml:"access_key_id_env"`
SecretAccessKeyEnv string `mapstructure:"secret_access_key_env" yaml:"secret_access_key_env"`
RegionEnv string `mapstructure:"region_env" yaml:"region_env"`
BucketNameEnv string `mapstructure:"bucket_name_env" yaml:"bucket_name_env"`
EndpointEnv string `mapstructure:"endpoint_env" yaml:"endpoint_env"`
UsePathStyle bool `mapstructure:"use_path_style" yaml:"use_path_style"`
}
@@ -6,7 +6,6 @@ import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
httptransport "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http"
amr "git.sendico.io/tech/sxchnge/bff/pkg/api/routers"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/domainprovider"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
@@ -58,10 +57,6 @@ func (a *APIImp) Register() api.Register {
return a.mw
}
func (a *APIImp) Permissions() auth.Provider {
return a.db.Permissions()
}
func (a *APIImp) RuntimeComponent(name string) any {
if a == nil || a.components == nil {
return nil
@@ -79,11 +79,9 @@ func (a *APIImp) installRuntimeBasics(
if a.mw, err = httptransport.CreateMiddleware(
logger,
db,
a.db.Permissions().Enforcer(),
apiRouter,
a.config.Mw,
a.config.AuthStorage,
a.runtime.EnableWebSocket,
debug,
); err != nil {
a.logger.Error("Failed to create middleware", zap.Error(err))
@@ -0,0 +1,63 @@
package runtime_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
sharedruntime "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/runtime"
"git.sendico.io/tech/sxchnge/bff/internal/profile"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
func TestProfileStartsOnEmptyDatabase(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
container, err := mongodb.Run(ctx, "mongo:8", mongodb.WithReplicaSet("rs"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
uri, err := container.ConnectionString(ctx)
require.NoError(t, err)
endpoint, err := url.Parse(uri)
require.NoError(t, err)
query := endpoint.Query()
query.Set("directConnection", "true")
endpoint.RawQuery = query.Encode()
uri = endpoint.String()
factory, err := db.NewConnection(zap.NewNop(), &db.Config{Driver: db.Mongo, Settings: model.SettingsT{"uri": uri, "database": "sxchnge_runtime"}})
require.NoError(t, err)
t.Cleanup(factory.CloseConnection)
data, err := os.ReadFile("../../../../config.example.yml")
require.NoError(t, err)
var example struct {
API api.Config `yaml:"api"`
}
require.NoError(t, yaml.Unmarshal(data, &example))
t.Setenv("SXCHNGE_DOMAIN", "localhost")
t.Setenv("SXCHNGE_API_PROTOCOL", "http")
t.Setenv("SXCHNGE_API_ENDPOINT", "/api/v1")
t.Setenv("SXCHNGE_JWT_SECRET", "test-only-signing-key-at-least-32-bytes")
root, routes := chi.NewRouter(), chi.NewRouter()
runtime, err := sharedruntime.CreateAPI(ctx, zap.NewNop(), &example.API, &example.API, factory, root, routes, false, profile.Runtime())
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, runtime.Finish(context.Background())) })
root.Mount("/", routes)
result := httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil))
require.Equal(t, http.StatusOK, result.Code)
// Runtime auth routes are installed even with zero customers and no seed.
result = httptest.NewRecorder()
root.ServeHTTP(result, httptest.NewRequest(http.MethodPost, "/api/v1/accounts/login", nil))
require.Equal(t, http.StatusBadRequest, result.Code)
}
@@ -9,14 +9,11 @@ import (
authstorage "git.sendico.io/tech/sxchnge/bff/edge/shared/auth/storage"
edgeapi "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
wsh "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
httprouter "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router"
mr "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/router/metrics"
ws "git.sendico.io/tech/sxchnge/bff/edge/shared/internal/transport/http/websocket"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
@@ -34,7 +31,6 @@ type Middleware struct {
router *chi.Mux
apiEndpoint string
metrics mr.Metrics
wshandler ws.Router
epdispatcher *httprouter.Dispatcher
}
@@ -50,14 +46,6 @@ func (mw *Middleware) PendingAccountHandler(service mservice.Type, endpoint stri
mw.epdispatcher.PendingAccountHandler(service, endpoint, method, handler)
}
func (mw *Middleware) WSHandler(messageType string, handler wsh.HandlerFunc) {
if mw.wshandler == nil {
mw.logger.Info("WebSocket handler registration skipped because websocket router is disabled", zap.String("message_type", messageType))
return
}
mw.wshandler.InstallHandler(messageType, handler)
}
func (mw *Middleware) Finish() {
mw.metrics.Finish()
}
@@ -116,11 +104,9 @@ func isObservabilityRequest(apiEndpoint, requestPath string) bool {
func CreateMiddleware(
logger mlogger.Logger,
db db.Factory,
enforcer auth.Enforcer,
apiRouter *chi.Mux,
config *middleware.Config,
authStorage *edgeapi.AuthStorageConfig,
installWebSocket bool,
debug bool,
) (*Middleware, error) {
if config == nil {
@@ -161,12 +147,8 @@ func CreateMiddleware(
stores.Verification,
stores.RefreshTokens,
stores.LoginAttempts,
enforcer,
config,
loginRateLimits,
)
if installWebSocket {
p.wshandler = ws.NewRouter(p.logger, p.router, &config.WebSocket, p.apiEndpoint)
}
return p, nil
}
@@ -0,0 +1,74 @@
package authorized
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type customerDB struct {
account.DB
customer model.Account
requested bson.ObjectID
}
func (d *customerDB) Get(_ context.Context, id bson.ObjectID, result *model.Account) error {
d.requested = id
if id != d.customer.ID {
return merrors.NoData("customer not found")
}
*result = d.customer
return nil
}
func (d *sessionDB) GetClient(context.Context, string) (*model.Client, error) {
return &model.Client{ClientID: "web"}, nil
}
func TestCustomerHandlerResolvesIdentityWithoutRoles(t *testing.T) {
for _, status := range []model.AccountStatus{model.AccountActive, model.AccountBlocked, model.AccountPendingVerification} {
t.Run(string(status), func(t *testing.T) {
accounts := &customerDB{}
accounts.customer.ID = bson.NewObjectID()
accounts.customer.Status = status
routes := chi.NewRouter()
signature := &middleware.Signature{Algorithm: "HS256", PrivateKey: []byte("test-only-signing-key-at-least-32-bytes")}
router := NewRouter(zap.NewNop(), "/api/v1", routes, accounts, &sessionDB{token: &model.RefreshToken{}}, &middleware.TokenConfig{Expiration: middleware.TokenExpiration{Account: 1}}, signature)
called := false
router.AccountHandler(mservice.Accounts, "/me", api.Get, func(_ *http.Request, c *model.Account, _ *sresponse.TokenData) http.HandlerFunc {
called = true
require.Equal(t, accounts.customer.ID, c.ID)
return func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
})
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, nil)
_, token, err := ja.Encode(emodel.Account2ClaimsForSession(&accounts.customer, 1, model.SessionIdentifier{ClientID: "web", DeviceID: "device", SessionVersion: "v1"}))
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/me?accountRef="+bson.NewObjectID().Hex(), nil)
req.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
routes.ServeHTTP(recorder, req)
require.Equal(t, accounts.customer.ID, accounts.requested)
if status == model.AccountActive {
require.Equal(t, http.StatusNoContent, recorder.Code)
require.True(t, called)
} else {
require.Equal(t, http.StatusUnauthorized, recorder.Code)
require.False(t, called)
}
})
}
}
@@ -128,6 +128,10 @@ func (ar *AuthorizedRouter) AccountHandler(service mservice.Type, endpoint strin
}
return response.Internal(ar.logger, ar.service, err)
}
if !a.IsActive() || a.IsArchived() {
return response.Unauthorized(ar.logger, ar.service, "customer is inactive")
}
accessToken, err := ar.imp.CreateAccessTokenForSession(&a, t.SessionIdentifier)
if err != nil {
ar.logger.Warn("Failed to generate access token", zap.Error(err))
@@ -148,6 +152,9 @@ func (ar *AuthorizedRouter) PendingAccountHandler(service mservice.Type, endpoin
}
return response.Internal(ar.logger, ar.service, err)
}
if a.Status == model.AccountBlocked || a.IsArchived() {
return response.Unauthorized(ar.logger, ar.service, "customer is inactive")
}
return handler(r, &a, t)
}
ar.tokenHandler(service, endpoint, method, hndlr)
@@ -10,7 +10,6 @@ import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
@@ -28,7 +27,7 @@ type AuthorizedRouter struct {
service mservice.Type
}
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, enforcer auth.Enforcer, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
func NewRouter(logger mlogger.Logger, apiEndpoint string, router chi.Router, db account.DB, rtdb refreshtokens.DB, config *middleware.TokenConfig, signature *middleware.Signature) *AuthorizedRouter {
ja := jwtauth.New(signature.Algorithm, signature.PrivateKey, signature.PublicKey)
router.Use(jwtauth.Verifier(ja))
router.Use(jwtauth.Authenticator(ja))
@@ -13,7 +13,6 @@ import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/verification"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db/account"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
@@ -44,7 +43,6 @@ func NewDispatcher(
vdb verification.DB,
rtdb refreshtokens.DB,
attempts ratelimits.Store,
enforcer auth.Enforcer,
config *middleware.Config,
loginRateLimits middleware.LoginRateLimits,
) *Dispatcher {
@@ -59,7 +57,7 @@ func NewDispatcher(
d.public = rpublic.NewRouter(d.logger, endpoint, db, vdb, rtdb, attempts, r, &config.Token, &signature, loginRateLimits)
})
router.Group(func(r chi.Router) {
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, enforcer, &config.Token, &signature)
d.protected = rauthorized.NewRouter(d.logger, endpoint, r, db, rtdb, &config.Token, &signature)
})
return d
@@ -46,7 +46,7 @@ func (pr *PublicRouter) authenticateAccount(ctx context.Context, req *srequest.L
return nil, true, response.Unauthorized(pr.logger, pr.service, "invalid credentials")
}
if !account.IsActive() {
if !account.IsActive() || account.IsArchived() {
return nil, false, response.Forbidden(pr.logger, pr.service, "account_not_verified", "Account verification required")
}
@@ -87,10 +87,6 @@ func (db *testAccountDB) GetByEmail(ctx context.Context, email string) (*model.A
return db.result, db.err
}
func (db *testAccountDB) GetAccountsByRefs(ctx context.Context, orgRef bson.ObjectID, refs []bson.ObjectID) ([]model.Account, error) {
return nil, merrors.NotImplemented("stub")
}
func (db *testAccountDB) Create(ctx context.Context, object *model.Account) error {
return merrors.NotImplemented("stub")
}
@@ -135,3 +135,17 @@ func TestNativeRefreshRejectsStaleTokenWithoutCookieMutation(t *testing.T) {
require.Contains(t, response.Body.String(), `"error":"unauthorized"`)
require.Empty(t, response.Header().Get("Set-Cookie"))
}
func TestRefreshRejectsInactiveCustomerAndClientMismatch(t *testing.T) {
for _, status := range []model.AccountStatus{model.AccountBlocked, model.AccountPendingVerification} {
t.Run(string(status), func(t *testing.T) {
router, _ := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour))
router.db.(*testAccountDB).result.Status = status
require.Equal(t, http.StatusUnauthorized, executeBrowserRefresh(t, router).Code)
})
}
router, sessions := newBrowserRefreshRouter(t, time.Now().Add(24*time.Hour))
other := testActiveAccount(t)
sessions.client.AccountRef = &other.ID
require.Equal(t, http.StatusUnauthorized, executeBrowserRefresh(t, router).Code)
}
@@ -88,6 +88,13 @@ func (pr *PublicRouter) validateRefreshToken(ctx context.Context, r *http.Reques
return nil, nil, nil, err
}
if !account.IsActive() || account.IsArchived() {
return nil, nil, nil, merrors.Unauthorized("customer is inactive")
}
if client.AccountRef != nil && *client.AccountRef != account.ID {
return nil, nil, nil, merrors.Unauthorized("client account mismatch")
}
accessToken, err := pr.imp.CreateAccessTokenForSession(&account, rt.SessionIdentifier)
if err != nil {
pr.logger.Warn("Failed to generate access token", zap.Error(err))
@@ -1,68 +0,0 @@
package websocket
import (
"context"
"fmt"
"net/http"
"os"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
"golang.org/x/net/websocket"
)
type DispatcherImpl struct {
logger mlogger.Logger
handlers map[string]ws.HandlerFunc
timeout int
}
func (d *DispatcherImpl) InstallHandler(messageType string, handler ws.HandlerFunc) {
d.handlers[messageType] = handler
d.logger.Info("Handler installed", zap.String("message_type", messageType))
}
func (d *DispatcherImpl) dispatchMessage(ctx context.Context, conn *websocket.Conn) {
var msg ws.Message
err := websocket.JSON.Receive(conn, &msg)
if err != nil {
d.logger.Warn("Failed to read websocket message", zap.Error(err))
return
}
if handler, exists := d.handlers[msg.MessageType]; exists {
responseHandler := handler(ctx, msg)
responseHandler(msg.MessageType, conn)
} else {
d.logger.Warn("Unknown websocket message type", zap.String("message_type", msg.MessageType), zap.Any("message", &msg))
}
}
func (d *DispatcherImpl) handle(w http.ResponseWriter, r *http.Request) {
//nolint:contextcheck // websocket.Handler callback signature does not carry request context.
websocket.Handler(func(conn *websocket.Conn) {
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(d.timeout)*time.Second)
defer cancel()
d.dispatchMessage(ctx, conn)
}).ServeHTTP(w, r)
}
func NewDispatcher(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) *DispatcherImpl {
d := &DispatcherImpl{
logger: logger.Named("websocket"),
handlers: make(map[string]ws.HandlerFunc),
timeout: config.Timeout,
}
d.logger.Debug("Installing websocket middleware...")
router.Group(func(r chi.Router) {
ep := fmt.Sprintf("%s%s", apiEndpoint, os.Getenv(config.EndpointEnv))
d.logger.Info("Installing websockets handler", zap.String("endpoint", ep))
r.Get(ep, d.handle)
})
return d
}
@@ -1,15 +0,0 @@
package websocket
import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"github.com/go-chi/chi/v5"
)
type Router interface {
InstallHandler(messageType string, handler ws.HandlerFunc)
}
func NewRouter(logger mlogger.Logger, router chi.Router, config *ws.Config, apiEndpoint string) Router {
return NewDispatcher(logger, router, config, apiEndpoint)
}
@@ -1,19 +1,15 @@
package mongo
import (
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
"git.sendico.io/tech/sxchnge/bff/pkg/db/transaction"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// Dependencies resolves shared DB/auth/transaction primitives for edge-local store wiring only.
// Dependencies resolves local DB/transaction primitives for edge-local store wiring only.
type Dependencies struct {
Database *mongo.Database
Enforcer auth.Enforcer
Policies policy.DB
Transactions transaction.Factory
}
@@ -31,20 +27,8 @@ func ResolveDependencies(dbf db.Factory) (*Dependencies, error) {
return nil, merrors.Internal("mongo database provider is not available for edge-local storage wiring")
}
permissions := dbf.Permissions()
if permissions == nil || permissions.Enforcer() == nil {
return nil, merrors.Internal("permissions provider is not available for edge-local storage wiring")
}
pdb, err := dbf.NewPoliciesDB()
if err != nil {
return nil, err
}
return &Dependencies{
Database: provider.MongoDatabase(),
Enforcer: permissions.Enforcer(),
Policies: pdb,
Transactions: dbf.TransactionFactory(),
}, nil
}
@@ -1,32 +0,0 @@
package organization
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
// SetArchived sets the archived status of an organization and optionally cascades to projects, tasks, comments, and reactions
func (db *OrganizationDB) SetArchived(ctx context.Context, accountRef, organizationRef bson.ObjectID, archived, cascade bool) error {
db.DBImp.Logger.Debug("Setting organization archived status", mzap.OrgRef(organizationRef), zap.Bool("archived", archived), zap.Bool("cascade", cascade))
// Get the organization first
var organization model.Organization
if err := db.Get(ctx, accountRef, organizationRef, &organization); err != nil {
db.DBImp.Logger.Warn("Error retrieving organization for archival", zap.Error(err), mzap.OrgRef(organizationRef))
return err
}
// Update the organization's archived status
organization.SetArchived(archived)
if err := db.Update(ctx, accountRef, &organization); err != nil {
db.DBImp.Logger.Warn("Error updating organization archived status", zap.Error(err), mzap.OrgRef(organizationRef))
return err
}
db.DBImp.Logger.Debug("Successfully set organization archived status", mzap.OrgRef(organizationRef), zap.Bool("archived", archived))
return nil
}
@@ -1,23 +0,0 @@
package organization
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
// DeleteCascade deletes an organization and all its related data (projects, tasks, comments, reactions, statuses)
func (db *OrganizationDB) DeleteCascade(ctx context.Context, organizationRef bson.ObjectID) error {
db.DBImp.Logger.Debug("Starting organization deletion with projects", mzap.OrgRef(organizationRef))
// Delete the organization itself
if err := db.Unprotected().Delete(ctx, organizationRef); err != nil {
db.DBImp.Logger.Warn("Error deleting organization", zap.Error(err), mzap.OrgRef(organizationRef))
return err
}
db.DBImp.Logger.Debug("Successfully deleted organization with projects", mzap.OrgRef(organizationRef))
return nil
}
@@ -1,19 +0,0 @@
package organization
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (db *OrganizationDB) Create(ctx context.Context, _, _ bson.ObjectID, org *model.Organization) error {
if org == nil {
return merrors.InvalidArgument("Organization object is nil", "organization")
}
org.SetID(bson.NewObjectID())
// Organizaiton reference must be set to the same value as own organization reference
org.SetOrganizationRef(*org.GetID())
return db.DBImp.Create(ctx, org)
}
@@ -1,113 +0,0 @@
package organization
import (
"context"
"errors"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db/policy"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
type OrganizationDB struct {
auth.ProtectedDBImp[*model.Organization]
}
func Create(ctx context.Context,
logger mlogger.Logger,
enforcer auth.Enforcer,
pdb policy.DB,
db *mongo.Database,
) (*OrganizationDB, error) {
p, err := auth.CreateDBImp[*model.Organization](ctx, logger, pdb, enforcer, mservice.Organizations, db)
if err != nil {
return nil, err
}
res := &OrganizationDB{
ProtectedDBImp: *p,
}
p.DBImp.SetDeleter(res.DeleteCascade)
if err := ensureProvisioningIndexes(ctx, res); err != nil {
return nil, err
}
return res, nil
}
// ensureProvisioningIndexes guards concurrent operator-driven provisioning:
// two attempts creating an organization for the same owner and name race the
// find-then-create in the provisioner, and the unique index makes the loser
// fail over to the winner's organization instead of duplicating it. The index
// spans every provisioned state, not only pending: activation keeps the
// provisioning provenance, so a retry racing a concurrent finalization is
// stopped here rather than by the pre-create lookups alone. It is keyed by the
// immutable provisioning name snapshot, not the mutable display name, so a
// rename can neither free the key for a duplicate nor collide with a neighbour.
func ensureProvisioningIndexes(ctx context.Context, db *OrganizationDB) error {
definition := &ri.Definition{
Name: "uq_organization_provisioning_owner_name",
Keys: []ri.Key{
{Field: model.OrganizationProvisioningOwnerRefField, Sort: ri.Asc},
{Field: model.OrganizationProvisioningNameField, Sort: ri.Asc},
},
Unique: true,
// Archived organizations leave the index: archiving is how an operator
// retires a name, and a retired name must be reusable. Documents from
// builds that predate the name snapshot stay out as well; sharing the
// missing-name key would otherwise make them collide with each other.
PartialFilter: repository.Query().
Comparison(repository.Field(model.OrganizationProvisioningOwnerRefField), builder.Exists, true).
Comparison(repository.Field(model.OrganizationProvisioningNameField), builder.Exists, true).
Filter(repository.Field(storable.IsArchivedField), false),
}
if err := db.DBImp.Repository.CreateIndex(definition); err != nil {
db.DBImp.Logger.Error("Failed to create provisioned organization index",
zap.String("index", definition.Name), zap.Error(err))
return merrors.InternalWrap(err, "failed to create provisioned organization index")
}
// Predecessors of this index on environments that ran earlier builds of
// this feature: the pending-only variant ignored isArchived, the display
// name variant let a rename detach the organization from its dedup key.
if err := dropSupersededProvisioningIndexes(ctx, db.DBImp.Repository); err != nil {
db.DBImp.Logger.Error("Failed to remove superseded organization provisioning indexes", zap.Error(err))
return err
}
return nil
}
var supersededProvisioningIndexes = [...]string{
"uq_organization_pending_provisioning_owner_name",
"uq_organization_provisioned_owner_name",
}
func dropSupersededProvisioningIndexes(ctx context.Context, repo repository.Repository) error {
for _, name := range supersededProvisioningIndexes {
if err := repo.DropIndex(ctx, name); err != nil {
if provisioningIndexNotFound(err) {
continue
}
return merrors.InternalWrap(err, "failed to drop superseded organization provisioning index "+name)
}
}
return nil
}
const (
mongoNamespaceNotFoundCode = 26
mongoIndexNotFoundCode = 27
)
func provisioningIndexNotFound(err error) bool {
var serverErr mongo.ServerError
return errors.As(err, &serverErr) &&
(serverErr.HasErrorCode(mongoNamespaceNotFoundCode) || serverErr.HasErrorCode(mongoIndexNotFoundCode))
}
@@ -1,12 +0,0 @@
package organization
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (db *OrganizationDB) GetByRef(ctx context.Context, organizationRef bson.ObjectID, org *model.Organization) error {
return db.Unprotected().Get(ctx, organizationRef, org)
}
@@ -1,54 +0,0 @@
package organization
import (
"context"
"errors"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/mongo"
)
func TestDropSupersededProvisioningIndexesIgnoresMissingIndexes(t *testing.T) {
repo := &dropIndexRepository{dropErrors: []error{
mongo.CommandError{Code: mongoIndexNotFoundCode},
mongo.CommandError{Code: mongoNamespaceNotFoundCode},
}}
if err := dropSupersededProvisioningIndexes(context.Background(), repo); err != nil {
t.Fatalf("missing superseded indexes must be ignored: %v", err)
}
if len(repo.dropped) != len(supersededProvisioningIndexes) {
t.Fatalf("drop count mismatch: got=%d want=%d", len(repo.dropped), len(supersededProvisioningIndexes))
}
}
func TestDropSupersededProvisioningIndexesPreservesUnexpectedFailure(t *testing.T) {
wantErr := merrors.ServiceUnavailable("MongoDB is unavailable")
repo := &dropIndexRepository{dropErrors: []error{wantErr}}
err := dropSupersededProvisioningIndexes(context.Background(), repo)
if !errors.Is(err, wantErr) {
t.Fatalf("unexpected drop failure must be preserved: got=%v want=%v", err, wantErr)
}
if len(repo.dropped) != 1 {
t.Fatalf("cleanup must stop after an unexpected failure: dropped=%v", repo.dropped)
}
}
type dropIndexRepository struct {
repository.Repository
dropErrors []error
dropped []string
}
func (r *dropIndexRepository) DropIndex(_ context.Context, name string) error {
r.dropped = append(r.dropped, name)
if len(r.dropErrors) == 0 {
return nil
}
err := r.dropErrors[0]
r.dropErrors = r.dropErrors[1:]
return err
}
@@ -1,16 +0,0 @@
package organization
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (db *OrganizationDB) List(ctx context.Context, accountRef bson.ObjectID, cursor *model.ViewCursor) ([]model.Organization, error) {
filter := repository.Query().Comparison(repository.Field("members"), builder.Eq, accountRef)
return mutil.GetObjects[model.Organization](ctx, db.DBImp.Logger, filter, cursor, db.DBImp.Repository)
}
@@ -1,49 +0,0 @@
package organization
import (
"context"
"errors"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (db *OrganizationDB) IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error) {
db.DBImp.Logger.Debug("Checking organization membership", mzap.OrgRef(orgRef), mzap.AccRef(callerRef))
filter := repository.Query().
Filter(repository.IDField(), orgRef).
Comparison(repository.Field("members"), builder.Eq, callerRef)
var org model.Organization
if err := db.Unprotected().FindOne(ctx, filter, &org); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return false, nil
}
db.DBImp.Logger.Warn("Membership check failed", zap.Error(err), mzap.OrgRef(orgRef), mzap.AccRef(callerRef))
return false, err
}
return true, nil
}
// AddMember atomically appends the account to the members list and reports
// false when it already was one. A point patch on purpose: replacing the whole
// document would clobber concurrent writes to fields the caller never read,
// most notably the provisioning lease.
func (db *OrganizationDB) AddMember(ctx context.Context, orgRef, accountRef bson.ObjectID) (bool, error) {
filter := repository.Query().
Filter(repository.IDField(), orgRef).
Comparison(repository.Field("members"), builder.Ne, accountRef)
patch := repository.Patch().AddToSet(repository.Field("members"), accountRef)
matched, err := db.DBImp.PatchMany(ctx, filter, patch)
if err != nil {
db.DBImp.Logger.Warn("Failed to add organization member", zap.Error(err), mzap.OrgRef(orgRef), mzap.AccRef(accountRef))
return false, err
}
return matched > 0, nil
}
@@ -1,153 +0,0 @@
package organization
import (
"context"
"errors"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
// membershipTestRepository shadows FindOneByFilter with a members-aware implementation.
// When findErr is set, FindOneByFilter returns it immediately.
type membershipTestRepository struct {
*memoryOrganizationRepository
findErr error
}
func (r *membershipTestRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error {
if r.findErr != nil {
return r.findErr
}
r.mu.RLock()
defer r.mu.RUnlock()
for _, id := range r.order {
if org, ok := r.data[id]; ok && memberMatchesQuery(query, org) {
dst, ok := result.(*model.Organization)
if !ok {
return merrors.InvalidDataType("expected organization result")
}
*dst = *cloneOrganization(org)
return nil
}
}
return merrors.ErrNoData
}
func memberMatchesQuery(query builder.Query, org *model.Organization) bool {
if query == nil {
return true
}
for _, elem := range query.BuildQuery() {
switch elem.Key {
case storable.IDField:
id, ok := elem.Value.(bson.ObjectID)
if !ok || *org.GetID() != id {
return false
}
case "members":
cond, ok := elem.Value.(bson.M)
if !ok {
return false
}
ref, ok := cond[string(builder.Eq)].(bson.ObjectID)
if !ok {
return false
}
found := false
for _, m := range org.Members {
if m == ref {
found = true
break
}
}
if !found {
return false
}
}
}
return true
}
func newMemberTestDB(t *testing.T, findErr error) *OrganizationDB {
t.Helper()
repo := &membershipTestRepository{
memoryOrganizationRepository: newMemoryOrganizationRepository(),
findErr: findErr,
}
dbImp := &template.DBImp[*model.Organization]{
Logger: zap.NewNop(),
Repository: repo,
}
dbImp.SetDeleter(func(ctx context.Context, objectRef bson.ObjectID) error {
return repo.Delete(ctx, objectRef)
})
return &OrganizationDB{
ProtectedDBImp: auth.ProtectedDBImp[*model.Organization]{
DBImp: dbImp,
Enforcer: allowAllEnforcer{},
PermissionRef: bson.NewObjectID(),
Collection: mservice.Organizations,
},
}
}
func TestOrganizationDB_IsMember_Member(t *testing.T) {
ctx := context.Background()
db := newMemberTestDB(t, nil)
callerRef := bson.NewObjectID()
org := &model.Organization{}
org.SetID(bson.NewObjectID())
org.Members = []bson.ObjectID{callerRef}
require.NoError(t, db.Create(ctx, bson.NewObjectID(), *org.GetID(), org))
ok, err := db.IsMember(ctx, *org.GetID(), callerRef)
require.NoError(t, err)
assert.True(t, ok)
}
func TestOrganizationDB_IsMember_NonMember(t *testing.T) {
ctx := context.Background()
db := newMemberTestDB(t, nil)
callerRef := bson.NewObjectID()
otherRef := bson.NewObjectID()
org := &model.Organization{}
org.SetID(bson.NewObjectID())
org.Members = []bson.ObjectID{otherRef}
require.NoError(t, db.Create(ctx, bson.NewObjectID(), *org.GetID(), org))
ok, err := db.IsMember(ctx, *org.GetID(), callerRef)
require.NoError(t, err)
assert.False(t, ok)
}
func TestOrganizationDB_IsMember_OrgNotFound(t *testing.T) {
ctx := context.Background()
db := newMemberTestDB(t, nil)
ok, err := db.IsMember(ctx, bson.NewObjectID(), bson.NewObjectID())
require.NoError(t, err)
assert.False(t, ok)
}
func TestOrganizationDB_IsMember_DBError(t *testing.T) {
ctx := context.Background()
boom := merrors.Internal("backend down")
db := newMemberTestDB(t, boom)
ok, err := db.IsMember(ctx, bson.NewObjectID(), bson.NewObjectID())
assert.False(t, ok)
assert.True(t, errors.Is(err, boom))
}
@@ -1,378 +0,0 @@
package organization
import (
"context"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
rd "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/decoder"
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (m *memoryOrganizationRepository) Aggregate(context.Context, builder.Pipeline, rd.DecodingFunc) error {
return merrors.NotImplemented("aggregate is not supported in memory repository")
}
func (m *memoryOrganizationRepository) FindOneByFilter(_ context.Context, query builder.Query, result storable.Storable) error {
m.mu.RLock()
defer m.mu.RUnlock()
for _, id := range m.order {
if org, ok := m.data[id]; ok && m.matchesQuery(query, org) {
dst, okCast := result.(*model.Organization)
if !okCast {
return merrors.InvalidDataType("expected organization result")
}
*dst = *cloneOrganization(org)
return nil
}
}
return merrors.ErrNoData
}
func (m *memoryOrganizationRepository) FindFirstByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
return m.FindOneByFilter(ctx, query, result)
}
func (m *memoryOrganizationRepository) FindManyByFilter(context.Context, builder.Query, rd.DecodingFunc) error {
return merrors.NotImplemented("FindManyByFilter is not supported in memory repository")
}
func (m *memoryOrganizationRepository) FindAllByFilter(_ context.Context, query builder.Query, result any) error {
m.mu.RLock()
defer m.mu.RUnlock()
dst, ok := result.(*[]model.Organization)
if !ok {
return merrors.InvalidDataType("expected organization slice result")
}
*dst = (*dst)[:0]
for _, id := range m.order {
if org, ok := m.data[id]; ok && m.matchesQuery(query, org) {
*dst = append(*dst, *cloneOrganization(org))
}
}
return nil
}
func (m *memoryOrganizationRepository) CountByFilter(_ context.Context, query builder.Query) (int64, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var count int64
for _, id := range m.order {
if org, ok := m.data[id]; ok && m.matchesQuery(query, org) {
count++
}
}
return count, nil
}
func (m *memoryOrganizationRepository) Patch(context.Context, bson.ObjectID, builder.Patch) error {
return merrors.NotImplemented("Patch is not supported in memory repository")
}
func (m *memoryOrganizationRepository) PatchOneByFilter(context.Context, builder.Query, builder.Patch, storable.Storable) (bool, error) {
return false, merrors.NotImplemented("PatchOneByFilter is not supported in memory repository")
}
func (m *memoryOrganizationRepository) PatchMany(_ context.Context, query builder.Query, patch builder.Patch) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
matched := 0
for _, id := range m.order {
org, ok := m.data[id]
if !ok || !m.matchesQuery(query, org) {
continue
}
if err := applyOrganizationPatch(org, patch); err != nil {
return matched, err
}
matched++
}
return matched, nil
}
// applyOrganizationPatch supports the operations the provisioning storage
// issues; anything else fails loudly so a test cannot pass vacuously.
func applyOrganizationPatch(org *model.Organization, patch builder.Patch) error {
for _, op := range patch.Build() {
fields, ok := op.Value.(bson.D)
if !ok {
return merrors.NotImplemented("unsupported patch value shape: " + op.Key)
}
for _, field := range fields {
switch op.Key {
case string(builder.Set):
if err := setOrganizationField(org, field.Key, field.Value); err != nil {
return err
}
case string(builder.Unset):
if err := unsetOrganizationField(org, field.Key); err != nil {
return err
}
case string(builder.AddToSet):
if field.Key != "members" {
return merrors.NotImplemented("unsupported $addToSet field: " + field.Key)
}
member, ok := field.Value.(bson.ObjectID)
if !ok {
return merrors.InvalidDataType("expected member object id")
}
if !containsObjectID(org.Members, member) {
org.Members = append(org.Members, member)
}
default:
return merrors.NotImplemented("unsupported patch operation: " + op.Key)
}
}
}
return nil
}
func setOrganizationField(org *model.Organization, field string, value any) error {
switch field {
case storable.UpdatedAtField:
if updatedAt, ok := value.(time.Time); ok {
org.UpdatedAt = updatedAt
}
case model.OrganizationSignupStateField:
switch state := value.(type) {
case model.OrganizationSignupState:
org.SignupState = state
case string:
org.SignupState = model.OrganizationSignupState(state)
default:
return merrors.InvalidDataType("expected signup state value")
}
case model.OrganizationProvisioningLeaseOwnerField:
owner, ok := value.(string)
if !ok || org.Provisioning == nil {
return merrors.InvalidDataType("expected lease owner on a provisioned organization")
}
org.Provisioning.LeaseOwner = owner
case model.OrganizationProvisioningLeaseField:
expiresAt, ok := value.(time.Time)
if !ok || org.Provisioning == nil {
return merrors.InvalidDataType("expected lease expiry on a provisioned organization")
}
org.Provisioning.LeaseExpiresAt = expiresAt
default:
return merrors.NotImplemented("unsupported $set field: " + field)
}
return nil
}
func unsetOrganizationField(org *model.Organization, field string) error {
switch field {
case model.OrganizationProvisioningField:
org.Provisioning = nil
case model.OrganizationProvisioningLeaseOwnerField:
if org.Provisioning != nil {
org.Provisioning.LeaseOwner = ""
}
case model.OrganizationProvisioningLeaseField:
if org.Provisioning != nil {
org.Provisioning.LeaseExpiresAt = time.Time{}
}
default:
return merrors.NotImplemented("unsupported $unset field: " + field)
}
return nil
}
func containsObjectID(ids []bson.ObjectID, id bson.ObjectID) bool {
for _, item := range ids {
if item == id {
return true
}
}
return false
}
func (m *memoryOrganizationRepository) DeleteMany(context.Context, builder.Query) error {
return merrors.NotImplemented("DeleteMany is not supported in memory repository")
}
func (m *memoryOrganizationRepository) CreateIndex(*ri.Definition) error {
return nil
}
func (m *memoryOrganizationRepository) DropIndex(context.Context, string) error {
return nil
}
func (m *memoryOrganizationRepository) ListIndexDefinitions(context.Context) ([]*ri.Definition, error) {
return nil, merrors.NotImplemented("ListIndexDefinitions is not supported in memory repository")
}
func (m *memoryOrganizationRepository) ListIDs(_ context.Context, query builder.Query) ([]bson.ObjectID, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var ids []bson.ObjectID
for _, id := range m.order {
if org, ok := m.data[id]; ok && m.matchesQuery(query, org) {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return nil, merrors.ErrNoData
}
return ids, nil
}
func (m *memoryOrganizationRepository) ListPermissionBound(_ context.Context, query builder.Query) ([]model.PermissionBoundStorable, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var res []model.PermissionBoundStorable
for _, org := range m.data {
if m.matchesQuery(query, org) {
res = append(res, cloneOrganization(org))
}
}
return res, nil
}
func (m *memoryOrganizationRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) {
return nil, merrors.NotImplemented("Account bound list not supported")
}
func (m *memoryOrganizationRepository) matchesQuery(query builder.Query, org *model.Organization) bool {
if query == nil {
return true
}
for _, elem := range query.BuildQuery() {
switch elem.Key {
case storable.IDField:
if !matchesObjectIDConstraint(*org.GetID(), elem.Value) {
return false
}
case storable.IsArchivedField:
value, ok := elem.Value.(bool)
if ok && org.IsArchived() != value {
return false
}
case model.OrganizationSignupStateField:
if !matchesStringConstraint(string(org.SignupState), elem.Value) {
return false
}
case model.NameField:
if !matchesStringConstraint(org.Name, elem.Value) {
return false
}
case model.OrganizationProvisioningOwnerRefField:
if org.Provisioning == nil {
return false
}
if exists, ok := existsConstraint(elem.Value); ok {
if !exists {
return false
}
continue
}
if !matchesObjectIDConstraint(org.Provisioning.OwnerRef, elem.Value) {
return false
}
case model.OrganizationProvisioningNameField:
if org.Provisioning == nil || !matchesStringConstraint(org.Provisioning.Name, elem.Value) {
return false
}
case model.OrganizationProvisioningLeaseOwnerField:
if org.Provisioning == nil || !matchesStringConstraint(org.Provisioning.LeaseOwner, elem.Value) {
return false
}
case "members":
if !matchesMembersConstraint(org.Members, elem.Value) {
return false
}
}
}
return true
}
func matchesStringConstraint(current string, value any) bool {
switch expected := value.(type) {
case string:
return current == expected
case model.OrganizationSignupState:
return current == string(expected)
default:
return false
}
}
func existsConstraint(value any) (bool, bool) {
ops, ok := value.(bson.M)
if !ok {
return false, false
}
exists, ok := ops[string(builder.Exists)].(bool)
return exists, ok
}
func matchesMembersConstraint(members []bson.ObjectID, value any) bool {
ops, ok := value.(bson.M)
if !ok {
return false
}
for op, raw := range ops {
member, ok := raw.(bson.ObjectID)
if !ok {
return false
}
switch op {
case string(builder.Eq):
if !containsObjectID(members, member) {
return false
}
case string(builder.Ne):
if containsObjectID(members, member) {
return false
}
default:
return false
}
}
return true
}
func matchesObjectIDConstraint(id bson.ObjectID, value any) bool {
if direct, ok := value.(bson.ObjectID); ok {
return id == direct
}
ops, ok := value.(bson.M)
if !ok {
return false
}
for op, raw := range ops {
ids, ok := raw.([]any)
if !ok {
return false
}
contains := false
for _, item := range ids {
if itemID, ok := item.(bson.ObjectID); ok && itemID == id {
contains = true
break
}
}
switch op {
case string(builder.In):
if !contains {
return false
}
case string(builder.NotIn):
if contains {
return false
}
default:
return false
}
}
return true
}
@@ -1,149 +0,0 @@
package organization
import (
"context"
"errors"
"sync"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/db/transaction"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
)
type memoryOrganizationRepository struct {
mu sync.RWMutex
data map[bson.ObjectID]*model.Organization
order []bson.ObjectID
}
func newMemoryOrganizationRepository() *memoryOrganizationRepository {
return &memoryOrganizationRepository{
data: make(map[bson.ObjectID]*model.Organization),
}
}
func (m *memoryOrganizationRepository) Insert(_ context.Context, obj storable.Storable, _ builder.Query) error {
m.mu.Lock()
defer m.mu.Unlock()
org, ok := obj.(*model.Organization)
if !ok {
return merrors.InvalidDataType("expected organization")
}
id := org.GetID()
if id == nil || *id == bson.NilObjectID {
return merrors.InvalidArgument("organization ID must be set")
}
if _, exists := m.data[*id]; exists {
return merrors.DataConflict("organization already exists")
}
m.data[*id] = cloneOrganization(org)
m.order = append(m.order, *id)
return nil
}
func (m *memoryOrganizationRepository) InsertMany(ctx context.Context, objects []storable.Storable) error {
for _, obj := range objects {
if err := m.Insert(ctx, obj, nil); err != nil {
return err
}
}
return nil
}
func (m *memoryOrganizationRepository) Get(_ context.Context, id bson.ObjectID, result storable.Storable) error {
m.mu.RLock()
defer m.mu.RUnlock()
org, ok := m.data[id]
if !ok {
return merrors.ErrNoData
}
dst, ok := result.(*model.Organization)
if !ok {
return merrors.InvalidDataType("expected organization result")
}
*dst = *cloneOrganization(org)
return nil
}
func (m *memoryOrganizationRepository) Update(_ context.Context, obj storable.Storable) error {
m.mu.Lock()
defer m.mu.Unlock()
org, ok := obj.(*model.Organization)
if !ok {
return merrors.InvalidDataType("expected organization")
}
id := org.GetID()
if id == nil {
return merrors.InvalidArgument("organization ID must be set")
}
if _, exists := m.data[*id]; !exists {
return merrors.ErrNoData
}
m.data[*id] = cloneOrganization(org)
return nil
}
func (m *memoryOrganizationRepository) ReplaceOneByFilter(_ context.Context, query builder.Query, obj storable.Storable) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
org, ok := obj.(*model.Organization)
if !ok {
return false, merrors.InvalidDataType("expected organization")
}
id := org.GetID()
if id == nil {
return false, merrors.InvalidArgument("organization ID must be set")
}
current, exists := m.data[*id]
if !exists || !m.matchesQuery(query, current) {
return false, nil
}
m.data[*id] = cloneOrganization(org)
return true, nil
}
func (m *memoryOrganizationRepository) Upsert(ctx context.Context, obj storable.Storable) error {
if err := m.Update(ctx, obj); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return m.Insert(ctx, obj, nil)
}
return err
}
return nil
}
func (m *memoryOrganizationRepository) Delete(_ context.Context, id bson.ObjectID) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.data[id]; !exists {
return merrors.ErrNoData
}
delete(m.data, id)
return nil
}
func (m *memoryOrganizationRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
return cb(ctx)
}
func (m *memoryOrganizationRepository) Collection() string {
return mservice.Organizations
}
func cloneOrganization(src *model.Organization) *model.Organization {
dst := *src
if len(src.Members) > 0 {
dst.Members = append([]bson.ObjectID{}, src.Members...)
}
return &dst
}
@@ -1,19 +0,0 @@
package organization
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
// DB is the interface which must be implemented by all db drivers
type DB interface {
auth.ProtectedDB[*model.Organization]
List(ctx context.Context, accountRef bson.ObjectID, cursor *model.ViewCursor) ([]model.Organization, error)
ListOwned(ctx context.Context, accountRef bson.ObjectID) ([]model.Organization, error)
SetArchived(ctx context.Context, accountRef, organizationRef bson.ObjectID, archived, cascade bool) error
IsMember(ctx context.Context, orgRef, callerRef bson.ObjectID) (bool, error)
AddMember(ctx context.Context, orgRef, accountRef bson.ObjectID) (bool, error)
}
@@ -1,14 +0,0 @@
package organization
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
mutil "git.sendico.io/tech/sxchnge/bff/pkg/mutil/db"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (db *OrganizationDB) ListOwned(ctx context.Context, accountRef bson.ObjectID) ([]model.Organization, error) {
return mutil.GetObjects[model.Organization](ctx, db.DBImp.Logger, repository.Filter("ownerRef", accountRef), nil, db.DBImp.Repository)
}
@@ -1,14 +0,0 @@
package organization
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
)
func (db *OrganizationDB) ListProvider(ctx context.Context, query builder.Query, cursor *model.ViewCursor) ([]model.Organization, error) {
orgs := make([]model.Organization, 0)
return orgs, db.DBImp.Repository.FindAllByFilter(ctx, repository.ApplyCursor(query, cursor), &orgs)
}
@@ -1,166 +0,0 @@
package organization
import (
"context"
"errors"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
// AcquireProvisioningLease claims an organization that is still being
// provisioned. The update is atomic: it only matches a pending organization
// that carries a provisioning record and whose lease has expired or is already
// owned by this attempt, so two concurrent attempts cannot provision the same
// organization. Requiring the pending state here (not only in the caller's
// earlier read) closes the race with a concurrent finalization: an organization
// that just went active stops matching even though its lease fields are gone.
func (db *OrganizationDB) AcquireProvisioningLease(
ctx context.Context,
organizationRef bson.ObjectID,
leaseOwner string,
now time.Time,
expiresAt time.Time,
) (bool, error) {
// A freshly created attempt carries no lease yet, so "missing" counts as free
// alongside "expired"; otherwise the first attempt would block itself.
unset := repository.Exists(repository.Field(model.OrganizationProvisioningLeaseField), false)
expired := repository.Query().Comparison(repository.Field(model.OrganizationProvisioningLeaseField), builder.Lte, now)
mine := repository.Query().Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner)
filter := repository.Query().
Filter(repository.IDField(), organizationRef).
Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)).
Comparison(repository.Field(model.OrganizationProvisioningOwnerRefField), builder.Exists, true).
Or(unset, expired, mine)
patch := repository.Patch().
Set(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner).
Set(repository.Field(model.OrganizationProvisioningLeaseField), expiresAt)
matched, err := db.DBImp.PatchMany(ctx, filter, patch)
if err != nil {
db.DBImp.Logger.Warn("Failed to acquire organization provisioning lease",
zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner))
return false, err
}
return matched > 0, nil
}
// ReleaseProvisioningLease frees the lease so another attempt can take over
// immediately instead of waiting out the expiry. It only matches the lease of
// the given owner: a lease taken over by a newer attempt stays untouched.
func (db *OrganizationDB) ReleaseProvisioningLease(
ctx context.Context,
organizationRef bson.ObjectID,
leaseOwner string,
) error {
filter := repository.Query().
Filter(repository.IDField(), organizationRef).
Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner)
patch := repository.Patch().
Unset(repository.Field(model.OrganizationProvisioningLeaseOwnerField)).
Unset(repository.Field(model.OrganizationProvisioningLeaseField))
if _, err := db.DBImp.PatchMany(ctx, filter, patch); err != nil {
db.DBImp.Logger.Warn("Failed to release organization provisioning lease",
zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner))
return err
}
return nil
}
// FinalizeProvisioning atomically activates a pending organization while the
// given attempt still owns its provisioning lease. It reports false when the
// organization is no longer pending or the lease was taken over, so a slow
// attempt cannot finalize an organization another attempt now works on.
// Only the lease is cleared: the provisioning provenance stays on the document
// so a retry after a lost success response can recognize the activated
// organization instead of creating a duplicate.
func (db *OrganizationDB) FinalizeProvisioning(
ctx context.Context,
organizationRef bson.ObjectID,
leaseOwner string,
) (bool, error) {
filter := repository.Query().
Filter(repository.IDField(), organizationRef).
Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)).
Filter(repository.Field(model.OrganizationProvisioningLeaseOwnerField), leaseOwner)
patch := repository.Patch().
Set(repository.Field(model.OrganizationSignupStateField), model.OrganizationSignupStateActive).
Unset(repository.Field(model.OrganizationProvisioningLeaseOwnerField)).
Unset(repository.Field(model.OrganizationProvisioningLeaseField))
matched, err := db.DBImp.PatchMany(ctx, filter, patch)
if err != nil {
db.DBImp.Logger.Warn("Failed to finalize organization provisioning",
zap.Error(err), mzap.OrgRef(organizationRef), zap.String("lease_owner", leaseOwner))
return false, err
}
return matched > 0, nil
}
// FindPendingProvisioning returns the organization that a previous attempt left
// pending for the same owner and name, if any. The name matches the immutable
// provisioning snapshot, not the mutable display name, so a rename cannot
// detach the organization from the retries of the create that produced it.
func (db *OrganizationDB) FindPendingProvisioning(
ctx context.Context,
ownerRef bson.ObjectID,
name string,
) (*model.Organization, error) {
query := repository.Query().
Filter(repository.Field(model.OrganizationProvisioningOwnerRefField), ownerRef).
Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStatePending)).
Filter(repository.Field(model.OrganizationProvisioningNameField), name)
org := &model.Organization{}
if err := db.DBImp.FindOne(ctx, query, org); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
db.DBImp.Logger.Warn("Failed to look up pending organization provisioning",
zap.Error(err), mzap.AccRef(ownerRef), zap.String("organization_name", name))
return nil, err
}
return org, nil
}
// FindActiveProvisioned returns the already activated organization a previous
// attempt provisioned for the same owner and name, if any. It is the retry
// barrier for the success that got lost on the wire: without it a repeated
// create after a timed out but committed finalization would provision a
// duplicate organization. The name matches the immutable provisioning
// snapshot, so the barrier survives a rename of the activated organization.
func (db *OrganizationDB) FindActiveProvisioned(
ctx context.Context,
ownerRef bson.ObjectID,
name string,
) (*model.Organization, error) {
// Archived organizations release their name: they are excluded here and in
// the unique index alike, so the checks and the constraint stay in step.
query := repository.Query().
Filter(repository.Field(model.OrganizationProvisioningOwnerRefField), ownerRef).
Filter(repository.Field(model.OrganizationSignupStateField), string(model.OrganizationSignupStateActive)).
Filter(repository.Field(model.OrganizationProvisioningNameField), name).
Filter(repository.Field(storable.IsArchivedField), false)
org := &model.Organization{}
if err := db.DBImp.FindOne(ctx, query, org); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, nil
}
db.DBImp.Logger.Warn("Failed to look up activated organization provisioning",
zap.Error(err), mzap.AccRef(ownerRef), zap.String("organization_name", name))
return nil, err
}
return org, nil
}
@@ -1,180 +0,0 @@
package organization
import (
"context"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
func provisionedOrganization(t *testing.T, db *OrganizationDB, ownerRef bson.ObjectID, name, leaseOwner string) *model.Organization {
t.Helper()
org := &model.Organization{Members: []bson.ObjectID{}}
org.SetID(bson.NewObjectID())
org.Name = name
org.SignupState = model.OrganizationSignupStatePending
org.Provisioning = &model.OrganizationProvisioning{
OwnerRef: ownerRef,
Name: name,
Origin: "backoffice",
StartedAt: time.Now().UTC(),
LeaseOwner: leaseOwner,
LeaseExpiresAt: time.Now().UTC().Add(10 * time.Minute),
}
if err := db.Unprotected().Create(context.Background(), org); err != nil {
t.Fatalf("failed to store organization: %v", err)
}
return org
}
func TestAddMemberIsAPointPatch(t *testing.T) {
db := newTestOrganizationDB(t)
ownerRef := bson.NewObjectID()
org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1")
memberRef := bson.NewObjectID()
added, err := db.AddMember(context.Background(), org.ID, memberRef)
if err != nil || !added {
t.Fatalf("first add must report success: added=%v err=%v", added, err)
}
// The second add must be a no-op, not an error: transaction replays and
// concurrent joins both hit this path.
added, err = db.AddMember(context.Background(), org.ID, memberRef)
if err != nil || added {
t.Fatalf("second add must report an existing member: added=%v err=%v", added, err)
}
stored := &model.Organization{}
if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil {
t.Fatalf("failed to read organization back: %v", err)
}
if len(stored.Members) != 1 || stored.Members[0] != memberRef {
t.Fatalf("members mismatch: got=%v", stored.Members)
}
// The membership write must not touch the provisioning lease: the fence in
// FinalizeProvisioning checks the stored lease, not this attempt's copy.
if stored.Provisioning == nil || stored.Provisioning.LeaseOwner != "operator/attempt-1" {
t.Fatalf("membership write clobbered the provisioning lease: %+v", stored.Provisioning)
}
}
func TestFinalizeProvisioningKeepsProvenance(t *testing.T) {
db := newTestOrganizationDB(t)
ownerRef := bson.NewObjectID()
org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1")
finalized, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1")
if err != nil || !finalized {
t.Fatalf("finalization by the lease owner must succeed: finalized=%v err=%v", finalized, err)
}
stored := &model.Organization{}
if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil {
t.Fatalf("failed to read organization back: %v", err)
}
if stored.SignupState != model.OrganizationSignupStateActive {
t.Fatalf("organization must be active, got=%s", stored.SignupState)
}
// Provenance survives activation so a retry after a lost success response
// can recognize the organization instead of provisioning a duplicate.
if stored.Provisioning == nil || stored.Provisioning.OwnerRef != ownerRef {
t.Fatalf("provisioning provenance must survive activation: %+v", stored.Provisioning)
}
if stored.Provisioning.LeaseOwner != "" || !stored.Provisioning.LeaseExpiresAt.IsZero() {
t.Fatalf("lease must be cleared on activation: %+v", stored.Provisioning)
}
// A replayed finalization must not match: the organization left pending.
finalized, err = db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1")
if err != nil || finalized {
t.Fatalf("an active organization must not finalize again: finalized=%v err=%v", finalized, err)
}
}
func TestFinalizeProvisioningRejectsTakenOverLease(t *testing.T) {
db := newTestOrganizationDB(t)
org := provisionedOrganization(t, db, bson.NewObjectID(), "ProCorp", "operator/attempt-2")
finalized, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1")
if err != nil || finalized {
t.Fatalf("an attempt whose lease was taken over must not finalize: finalized=%v err=%v", finalized, err)
}
}
func TestFindActiveProvisioned(t *testing.T) {
db := newTestOrganizationDB(t)
ownerRef := bson.NewObjectID()
org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1")
// Pending organizations belong to FindPendingProvisioning, not here.
found, err := db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp")
if err != nil || found != nil {
t.Fatalf("a pending organization must not be reported as activated: found=%v err=%v", found, err)
}
if _, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1"); err != nil {
t.Fatalf("failed to finalize: %v", err)
}
found, err = db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp")
if err != nil || found == nil || found.ID != org.ID {
t.Fatalf("the activated organization must be found: found=%v err=%v", found, err)
}
if found, err = db.FindActiveProvisioned(context.Background(), ownerRef, "Other"); err != nil || found != nil {
t.Fatalf("a different name must not match: found=%v err=%v", found, err)
}
}
// The dedup key is the provisioning name snapshot: renaming an activated
// organization must not detach it from a retry of the create that produced it,
// or the retry would provision a duplicate.
func TestFindActiveProvisionedSurvivesRename(t *testing.T) {
db := newTestOrganizationDB(t)
ownerRef := bson.NewObjectID()
org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1")
if _, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1"); err != nil {
t.Fatalf("failed to finalize: %v", err)
}
stored := &model.Organization{}
if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil {
t.Fatalf("failed to read organization back: %v", err)
}
stored.Name = "Renamed Corp"
if err := db.Unprotected().Update(context.Background(), stored); err != nil {
t.Fatalf("failed to rename organization: %v", err)
}
found, err := db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp")
if err != nil || found == nil || found.ID != org.ID {
t.Fatalf("the renamed organization must still match its provisioning name: found=%v err=%v", found, err)
}
}
// Archiving is how an operator retires a name: an archived organization must
// stop blocking the creation of a fresh one with the same owner and name.
func TestFindActiveProvisionedReleasesArchivedName(t *testing.T) {
db := newTestOrganizationDB(t)
ownerRef := bson.NewObjectID()
org := provisionedOrganization(t, db, ownerRef, "ProCorp", "operator/attempt-1")
if _, err := db.FinalizeProvisioning(context.Background(), org.ID, "operator/attempt-1"); err != nil {
t.Fatalf("failed to finalize: %v", err)
}
stored := &model.Organization{}
if err := db.Unprotected().Get(context.Background(), org.ID, stored); err != nil {
t.Fatalf("failed to read organization back: %v", err)
}
stored.Archived = true
if err := db.Unprotected().Update(context.Background(), stored); err != nil {
t.Fatalf("failed to archive organization: %v", err)
}
found, err := db.FindActiveProvisioned(context.Background(), ownerRef, "ProCorp")
if err != nil || found != nil {
t.Fatalf("an archived organization must release its name: found=%v err=%v", found, err)
}
}
@@ -1,51 +0,0 @@
package organization
import (
"context"
"errors"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestOrganizationDB_SetArchived_TogglesState(t *testing.T) {
ctx := context.Background()
accountRef := bson.NewObjectID()
orgDB := newTestOrganizationDB(t)
org := &model.Organization{
OrganizationBase: model.OrganizationBase{
Describable: model.Describable{Name: "Sendico"},
TimeZone: "UTC",
},
}
org.SetID(bson.NewObjectID())
require.NoError(t, orgDB.Create(ctx, accountRef, *org.GetID(), org))
var stored model.Organization
require.NoError(t, orgDB.Get(ctx, accountRef, *org.GetID(), &stored))
assert.False(t, stored.IsArchived())
require.NoError(t, orgDB.SetArchived(ctx, accountRef, *org.GetID(), true, false))
require.NoError(t, orgDB.Get(ctx, accountRef, *org.GetID(), &stored))
assert.True(t, stored.IsArchived())
require.NoError(t, orgDB.SetArchived(ctx, accountRef, *org.GetID(), false, false))
require.NoError(t, orgDB.Get(ctx, accountRef, *org.GetID(), &stored))
assert.False(t, stored.IsArchived())
}
func TestOrganizationDB_SetArchived_UnknownOrganization(t *testing.T) {
ctx := context.Background()
accountRef := bson.NewObjectID()
orgDB := newTestOrganizationDB(t)
err := orgDB.SetArchived(ctx, accountRef, bson.NewObjectID(), true, false)
require.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
}
@@ -1,58 +0,0 @@
package organization
import (
"context"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
// newTestOrganizationDB wires the real OrganizationDB implementation to an in-memory repository.
func newTestOrganizationDB(t *testing.T) *OrganizationDB {
t.Helper()
repo := newMemoryOrganizationRepository()
dbImp := &template.DBImp[*model.Organization]{
Logger: zap.NewNop(),
Repository: repo,
}
dbImp.SetDeleter(func(ctx context.Context, objectRef bson.ObjectID) error {
return repo.Delete(ctx, objectRef)
})
return &OrganizationDB{
ProtectedDBImp: auth.ProtectedDBImp[*model.Organization]{
DBImp: dbImp,
Enforcer: allowAllEnforcer{},
PermissionRef: bson.NewObjectID(),
Collection: mservice.Organizations,
},
}
}
type allowAllEnforcer struct{}
func (allowAllEnforcer) Enforce(context.Context, bson.ObjectID, bson.ObjectID, bson.ObjectID, bson.ObjectID, model.Action) (bool, error) {
return true, nil
}
func (allowAllEnforcer) EnforceBatch(_ context.Context, objects []model.PermissionBoundStorable, _ bson.ObjectID, _ model.Action) (map[bson.ObjectID]bool, error) {
result := make(map[bson.ObjectID]bool, len(objects))
for _, obj := range objects {
result[*obj.GetID()] = true
}
return result, nil
}
func (allowAllEnforcer) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) {
return nil, nil
}
func (allowAllEnforcer) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) {
return nil, nil, nil
}
@@ -164,7 +164,7 @@ func (db *RefreshTokenDB) GetByCRT(ctx context.Context, t *model.ClientRefreshTo
db.Logger.Debug("Refresh token expired", mzap.StorableRef(&rt),
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID),
zap.Time("expires_at", rt.ExpiresAt))
return nil, merrors.AccessDenied(mservice.RefreshTokens, string(model.ActionRead), *rt.GetID())
return nil, merrors.AccessDenied(mservice.RefreshTokens, "read", *rt.GetID())
}
// Check if token is revoked
-35
View File
@@ -1,35 +0,0 @@
package s3key
import (
"path"
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
)
// Join confines an object reference to a relative S3 prefix.
func Join(prefix, reference string) (string, error) {
cleanPrefix, err := cleanRelative(prefix, "prefix")
if err != nil {
return "", err
}
cleanReference, err := cleanRelative(reference, "obj_ref")
if err != nil {
return "", err
}
return path.Join(cleanPrefix, cleanReference), nil
}
func cleanRelative(value, argument string) (string, error) {
value = strings.TrimSpace(value)
for _, segment := range strings.Split(value, "/") {
if segment == ".." {
return "", merrors.InvalidArgument(argument+" is invalid", argument)
}
}
clean := path.Clean(value)
if value == "" || clean == "." || path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") {
return "", merrors.InvalidArgument(argument+" is invalid", argument)
}
return clean, nil
}
@@ -1,42 +0,0 @@
package s3key
import "testing"
func TestJoinConfinesReferenceToPrefix(t *testing.T) {
t.Parallel()
tests := []struct {
name string
prefix string
reference string
want string
wantError bool
}{
{name: "nested reference", prefix: "compliance-evidence", reference: "account/evidence", want: "compliance-evidence/account/evidence"},
{name: "empty reference", prefix: "accounts", wantError: true},
{name: "absolute reference", prefix: "accounts", reference: "/private", wantError: true},
{name: "parent reference", prefix: "compliance-evidence", reference: "../accounts/avatar", wantError: true},
{name: "normalized parent reference", prefix: "accounts", reference: "customer/../avatar", wantError: true},
{name: "nested parent escape", prefix: "compliance-evidence", reference: "account/../../accounts/avatar", wantError: true},
{name: "invalid prefix", prefix: "../private", reference: "object", wantError: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
got, err := Join(test.prefix, test.reference)
if test.wantError {
if err == nil {
t.Fatalf("Join(%q, %q) error = nil", test.prefix, test.reference)
}
return
}
if err != nil {
t.Fatalf("Join(%q, %q) error = %v", test.prefix, test.reference, err)
}
if got != test.want {
t.Fatalf("Join(%q, %q) = %q, want %q", test.prefix, test.reference, got, test.want)
}
})
}
}
@@ -309,14 +309,6 @@ func (m *memoryTokenRepository) ListIDs(context.Context, builder.Query) ([]bson.
return nil, merrors.NotImplemented("not needed")
}
func (m *memoryTokenRepository) ListPermissionBound(context.Context, builder.Query) ([]model.PermissionBoundStorable, error) {
return nil, merrors.NotImplemented("not needed")
}
func (m *memoryTokenRepository) ListAccountBound(context.Context, builder.Query) ([]model.AccountBoundStorable, error) {
return nil, merrors.NotImplemented("not needed")
}
func (m *memoryTokenRepository) WithTransaction(ctx context.Context, cb transaction.Callback) (any, error) {
return cb(ctx)
}
-5
View File
@@ -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
-8
View File
@@ -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=
@@ -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)
}
@@ -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)
}
}
+1 -2
View File
@@ -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
}
@@ -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))
}
@@ -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()
-3
View File
@@ -1,3 +0,0 @@
package anyobject
const ID = "*"
-35
View File
@@ -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)
}
-107
View File
@@ -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)
}
-87
View File
@@ -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
}
-147
View File
@@ -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)
}
})
}
-12
View File
@@ -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]
-38
View File
@@ -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)
}
-51
View File
@@ -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
}
-319
View File
@@ -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
}
-394
View File
@@ -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
}
-80
View File
@@ -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))
})
}
-142
View File
@@ -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
}
-848
View File
@@ -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)
}
}
-262
View File
@@ -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
}
-129
View File
@@ -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
}
-32
View File
@@ -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)
}
-58
View File
@@ -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")
}
-61
View File
@@ -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
}
-29
View File
@@ -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)
}
-182
View File
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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))
}
@@ -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
}
@@ -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
}
@@ -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)
}
}
@@ -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(),
}
}
Loaded 100 of 244 files, more files were not shown because too many files have changed in this diff. Show more