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