diff --git a/.github/workflows/ghcr-k3s-cicd.yml b/.github/workflows/ghcr-k3s-cicd.yml
index a641eb0e..363a9bf2 100644
--- a/.github/workflows/ghcr-k3s-cicd.yml
+++ b/.github/workflows/ghcr-k3s-cicd.yml
@@ -140,7 +140,7 @@ jobs:
- name: Login to BeanCS Harbor
if: ${{ steps.meta.outputs.harbor_backend_image != '' }}
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ${{ steps.meta.outputs.harbor_registry }}
username: ${{ secrets.BEANCS_REGISTRY_USERNAME }}
@@ -196,20 +196,27 @@ jobs:
- name: Configure kubeconfig
shell: bash
+ env:
+ KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG }}
run: |
- if [ -z "${{ secrets.KUBE_CONFIG }}" ]; then
+ if [ -z "${KUBE_CONFIG_DATA}" ]; then
echo "KUBE_CONFIG secret is required"
exit 1
fi
mkdir -p ~/.kube
- printf '%s' "${{ secrets.KUBE_CONFIG }}" > ~/.kube/config
+ printf '%s' "${KUBE_CONFIG_DATA}" > ~/.kube/config
chmod 600 ~/.kube/config
- name: Ensure namespace and pull secret
shell: bash
+ env:
+ GHCR_PULL_TOKEN: ${{ secrets.GHCR_PAT || secrets.GITHUB_TOKEN }}
+ HARBOR_HOST_RAW: ${{ secrets.BEANCS_REGISTRY_HOST }}
+ HARBOR_USERNAME: ${{ secrets.BEANCS_REGISTRY_USERNAME }}
+ HARBOR_TOKEN: ${{ secrets.BEANCS_REGISTRY_TOKEN }}
run: |
OWNER_LC="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
- HARBOR_HOST="${{ secrets.BEANCS_REGISTRY_HOST }}"
+ HARBOR_HOST="${HARBOR_HOST_RAW}"
HARBOR_HOST="${HARBOR_HOST#http://}"
HARBOR_HOST="${HARBOR_HOST#https://}"
HARBOR_HOST="${HARBOR_HOST%/}"
@@ -217,35 +224,45 @@ jobs:
kubectl -n "${NAMESPACE}" create secret docker-registry ghcr-creds \
--docker-server=ghcr.io \
--docker-username="${OWNER_LC}" \
- --docker-password="${{ secrets.GHCR_PAT || secrets.GITHUB_TOKEN }}" \
+ --docker-password="${GHCR_PULL_TOKEN}" \
--dry-run=client -o yaml | kubectl apply -f -
- if [[ -n "${HARBOR_HOST}" && -n "${{ secrets.BEANCS_REGISTRY_USERNAME }}" && -n "${{ secrets.BEANCS_REGISTRY_TOKEN }}" ]]; then
+ if [[ -n "${HARBOR_HOST}" && -n "${HARBOR_USERNAME}" && -n "${HARBOR_TOKEN}" ]]; then
kubectl -n "${NAMESPACE}" create secret docker-registry beancs-registry-pull \
--docker-server="${HARBOR_HOST}" \
- --docker-username="${{ secrets.BEANCS_REGISTRY_USERNAME }}" \
- --docker-password="${{ secrets.BEANCS_REGISTRY_TOKEN }}" \
+ --docker-username="${HARBOR_USERNAME}" \
+ --docker-password="${HARBOR_TOKEN}" \
--dry-run=client -o yaml | kubectl apply -f -
fi
- name: Configure BasaltPass backend secret
shell: bash
+ env:
+ PROD_JWT_SECRET: ${{ secrets.PROD_JWT_SECRET }}
+ BASALTPASS_DATABASE_DRIVER: ${{ vars.BASALTPASS_DATABASE_DRIVER || 'postgres' }}
+ BASALTPASS_DATABASE_DSN: ${{ secrets.BASALTPASS_DATABASE_DSN }}
+ BASALTPASS_CORS_ALLOW_ORIGINS: ${{ vars.BASALTPASS_CORS_ALLOW_ORIGINS }}
+ BASALTPASS_EMAIL_PROVIDER: ${{ vars.BASALTPASS_EMAIL_PROVIDER }}
+ BASALTPASS_EMAIL_FROM: ${{ vars.BASALTPASS_EMAIL_FROM }}
+ BASALTPASS_EMAIL_AWS_SES_REGION: ${{ secrets.BASALTPASS_EMAIL_AWS_SES_REGION }}
+ BASALTPASS_EMAIL_AWS_SES_ACCESS_KEY_ID: ${{ secrets.BASALTPASS_EMAIL_AWS_SES_ACCESS_KEY_ID }}
+ BASALTPASS_EMAIL_AWS_SES_SECRET_ACCESS_KEY: ${{ secrets.BASALTPASS_EMAIL_AWS_SES_SECRET_ACCESS_KEY }}
run: |
PUBLIC_URL="${{ vars.BASALTPASS_PUBLIC_URL }}"
PUBLIC_URL="${PUBLIC_URL%/}"
kubectl -n "${NAMESPACE}" create secret generic basaltpass-env \
- --from-literal=JWT_SECRET="${{ secrets.PROD_JWT_SECRET }}" \
+ --from-literal=JWT_SECRET="${PROD_JWT_SECRET}" \
--from-literal=BASALTPASS_ENV=production \
--from-literal=BASALTPASS_SERVER_ADDRESS=:8101 \
--from-literal=BASALTPASS_UI_BASE_URL="${PUBLIC_URL}" \
--from-literal=BASALTPASS_OIDC_ISSUER="${PUBLIC_URL}/api/v1" \
- --from-literal=BASALTPASS_DATABASE_DRIVER="${{ vars.BASALTPASS_DATABASE_DRIVER || 'postgres' }}" \
- --from-literal=BASALTPASS_DATABASE_DSN="${{ secrets.BASALTPASS_DATABASE_DSN }}" \
- --from-literal=BASALTPASS_CORS_ALLOW_ORIGINS="${{ vars.BASALTPASS_CORS_ALLOW_ORIGINS }}" \
- --from-literal=BASALTPASS_EMAIL_PROVIDER="${{ vars.BASALTPASS_EMAIL_PROVIDER }}" \
- --from-literal=BASALTPASS_EMAIL_FROM="${{ vars.BASALTPASS_EMAIL_FROM }}" \
- --from-literal=BASALTPASS_EMAIL_AWS_SES_REGION="${{ secrets.BASALTPASS_EMAIL_AWS_SES_REGION }}" \
- --from-literal=BASALTPASS_EMAIL_AWS_SES_ACCESS_KEY_ID="${{ secrets.BASALTPASS_EMAIL_AWS_SES_ACCESS_KEY_ID }}" \
- --from-literal=BASALTPASS_EMAIL_AWS_SES_SECRET_ACCESS_KEY="${{ secrets.BASALTPASS_EMAIL_AWS_SES_SECRET_ACCESS_KEY }}" \
+ --from-literal=BASALTPASS_DATABASE_DRIVER="${BASALTPASS_DATABASE_DRIVER}" \
+ --from-literal=BASALTPASS_DATABASE_DSN="${BASALTPASS_DATABASE_DSN}" \
+ --from-literal=BASALTPASS_CORS_ALLOW_ORIGINS="${BASALTPASS_CORS_ALLOW_ORIGINS}" \
+ --from-literal=BASALTPASS_EMAIL_PROVIDER="${BASALTPASS_EMAIL_PROVIDER}" \
+ --from-literal=BASALTPASS_EMAIL_FROM="${BASALTPASS_EMAIL_FROM}" \
+ --from-literal=BASALTPASS_EMAIL_AWS_SES_REGION="${BASALTPASS_EMAIL_AWS_SES_REGION}" \
+ --from-literal=BASALTPASS_EMAIL_AWS_SES_ACCESS_KEY_ID="${BASALTPASS_EMAIL_AWS_SES_ACCESS_KEY_ID}" \
+ --from-literal=BASALTPASS_EMAIL_AWS_SES_SECRET_ACCESS_KEY="${BASALTPASS_EMAIL_AWS_SES_SECRET_ACCESS_KEY}" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Deploy BasaltPass workloads
diff --git a/README.md b/README.md
index 274e3ff7..57552e4b 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,8 @@ BasaltPass/
├─ basaltpass-backend/ # Go API, auth services, domain/business modules
├─ basaltpass-frontend/ # React + TypeScript monorepo (user/tenant/admin)
├─ basaltpass-docs/ # Docusaurus documentation site
-├─ scripts/ # Dev helper scripts (dev.sh / dev.ps1)
+├─ scripts/ # Stable full-stack dev entrypoints (dev.sh / dev.ps1)
+├─ tools/ # Local dev/test scaffolding and one-off helpers
├─ docker-compose.yml # Local compose orchestration
├─ .basalt.example/ # Optional local Basalt app metadata template
├─ backend.Dockerfile
diff --git a/basaltpass-backend/README.md b/basaltpass-backend/README.md
index 8aeddc13..30b55a52 100644
--- a/basaltpass-backend/README.md
+++ b/basaltpass-backend/README.md
@@ -112,8 +112,8 @@ go run ./cmd/basaltpass
本仓库包含本地 OpenID Foundation conformance suite 辅助文件:
-- `cmd/conformance-seed`: 为本地 SQLite conformance 数据库写入 static client、用户和 profile 数据。
-- `scripts/seed_conformance_sqlite.py`: 轻量 seed 脚本,适合快速重置本地测试数据。
+- `tools/conformance-seed`: Go 版 seed 工具,用于本地 SQLite conformance 数据库。
+- `../tools/conformance/seed_conformance_sqlite.py`: 轻量 Python seed 脚本,适合快速重置本地测试数据。
- `conformance.config.yaml`: 本地 conformance 后端配置示例。
`conformance.env`、suite 日志、临时证书、测试结果 zip 和本地数据库属于运行产物,不应提交。
diff --git a/basaltpass-backend/internal/api/v1/routes/public.go b/basaltpass-backend/internal/api/v1/routes/public.go
index 3f4ce9de..23c60ed9 100644
--- a/basaltpass-backend/internal/api/v1/routes/public.go
+++ b/basaltpass-backend/internal/api/v1/routes/public.go
@@ -52,6 +52,7 @@ func RegisterPublicRoutes(v1 fiber.Router) {
// 货币系统路由(公开API,不需要认证)
currencyGroup := v1.Group("/currencies")
currencyGroup.Get("/", currency.GetCurrenciesHandler)
+ currencyGroup.Get("/rates", currency.GetCurrencyRatesHandler)
currencyGroup.Get("/:code", currency.GetCurrencyHandler)
// 公共安全路由(无需登录,带速率限制)
diff --git a/basaltpass-backend/internal/api/v1/routes/s2s.go b/basaltpass-backend/internal/api/v1/routes/s2s.go
index c59a427a..25d99036 100644
--- a/basaltpass-backend/internal/api/v1/routes/s2s.go
+++ b/basaltpass-backend/internal/api/v1/routes/s2s.go
@@ -28,6 +28,11 @@ func RegisterS2SRoutes(v1 fiber.Router) {
group.Get("/users/:id/role-codes", middleware.ClientScopeMiddleware(sc.S2SRBACRead), s2sHandler.GetUserRoleCodesHandler)
group.Get("/users/:id/permissions", middleware.ClientScopeMiddleware(sc.S2SRBACRead), s2sHandler.GetUserPermissionsHandler)
+ // RBAC manifest control plane: the authenticated app may submit and poll
+ // only its own drafts. Publishing remains tenant-admin only.
+ group.Post("/rbac/manifests", middleware.ClientScopeMiddleware(sc.S2SRBACManifestSubmit), s2sHandler.SubmitRBACManifestHandler)
+ group.Get("/rbac/manifests/:id", middleware.ClientScopeMiddleware(sc.S2SRBACManifestSubmit), s2sHandler.GetOwnRBACManifestHandler)
+
// 团队
group.Get("/teams", middleware.ClientScopeMiddleware(sc.S2STeamRead), s2sHandler.ListTeamsHandler)
group.Post("/teams", middleware.ClientScopeMiddleware(sc.S2STeamWrite), s2sHandler.CreateTeamHandler)
@@ -37,6 +42,8 @@ func RegisterS2SRoutes(v1 fiber.Router) {
// 钱包数据(需要 currency 参数)
group.Get("/users/:id/wallets", middleware.ClientScopeMiddleware(sc.S2SWalletRead), s2sHandler.GetUserWalletHandler)
group.Post("/users/:id/wallets/adjust", middleware.ClientScopeMiddleware(sc.S2SWalletWrite), s2sHandler.AdjustUserWalletHandler)
+ group.Get("/wallets/:owner_type/:owner_id", middleware.ClientScopeMiddleware(sc.S2SWalletRead), s2sHandler.GetOwnerWalletHandler)
+ group.Post("/wallets/:owner_type/:owner_id/adjust", middleware.ClientScopeMiddleware(sc.S2SWalletWrite), s2sHandler.AdjustOwnerWalletHandler)
// 用户消息(通知)与商品拥有
group.Get("/users/:id/messages", middleware.ClientScopeMiddleware(sc.S2SMessagesRead), s2sHandler.GetUserMessagesHandler)
diff --git a/basaltpass-backend/internal/api/v1/routes/tenant.go b/basaltpass-backend/internal/api/v1/routes/tenant.go
index 11beedf8..5bfec3f7 100644
--- a/basaltpass-backend/internal/api/v1/routes/tenant.go
+++ b/basaltpass-backend/internal/api/v1/routes/tenant.go
@@ -241,6 +241,8 @@ func RegisterTenantRoutes(v1 fiber.Router) {
tenantAppGroup.Delete("/:id", app.TenantDeleteAppHandler)
tenantAppGroup.Get("/:id/stats", app.TenantGetAppStatsHandler)
tenantAppGroup.Patch("/:id/status", app.TenantToggleAppStatusHandler)
+ tenantAppGroup.Get("/:id/wallet-currencies", app.TenantListAppWalletCurrenciesHandler)
+ tenantAppGroup.Put("/:id/wallet-currencies", app.TenantReplaceAppWalletCurrenciesHandler)
// 应用权限管理路由
tenantAppGroup.Get("/:app_id/permissions", app_rbac2.GetAppPermissions)
@@ -256,6 +258,24 @@ func RegisterTenantRoutes(v1 fiber.Router) {
tenantAppGroup.Put("/:app_id/roles/:role_id", app_rbac2.UpdateAppRole)
tenantAppGroup.Delete("/:app_id/roles/:role_id", app_rbac2.DeleteAppRole)
+ // App-submitted RBAC manifests. These routes are tenant-admin protected;
+ // apps can submit drafts only through the S2S endpoint above.
+ tenantAppGroup.Get("/:app_id/rbac/manifests", tenant2.ListAppRBACManifestsHandler)
+ tenantAppGroup.Get("/:app_id/rbac/manifests/:manifest_id", tenant2.GetAppRBACManifestHandler)
+ tenantAppGroup.Post("/:app_id/rbac/manifests/:manifest_id/approve", tenant2.ApproveAppRBACManifestHandler)
+ tenantAppGroup.Post("/:app_id/rbac/manifests/:manifest_id/reject", tenant2.RejectAppRBACManifestHandler)
+ tenantAppGroup.Get("/:app_id/rbac/revisions", tenant2.ListAppRBACRevisionsHandler)
+ tenantAppGroup.Post("/:app_id/rbac/revisions/:revision_id/rollback", tenant2.RollbackAppRBACRevisionHandler)
+
+ // Tenant-owned dynamic mappings. These policies are evaluated at read time
+ // and never create inherited app_user_roles/app_user_permissions rows.
+ tenantAppGroup.Get("/:app_id/rbac/mappings", tenant2.ListAppGrantMappingsHandler)
+ tenantAppGroup.Get("/:app_id/rbac/mappings/options", tenant2.GetAppGrantMappingOptionsHandler)
+ tenantAppGroup.Post("/:app_id/rbac/mappings/preview", tenant2.PreviewAppGrantMappingHandler)
+ tenantAppGroup.Post("/:app_id/rbac/mappings", tenant2.CreateAppGrantMappingHandler)
+ tenantAppGroup.Put("/:app_id/rbac/mappings/:mapping_id", tenant2.UpdateAppGrantMappingHandler)
+ tenantAppGroup.Delete("/:app_id/rbac/mappings/:mapping_id", tenant2.DeleteAppGrantMappingHandler)
+
// 应用用户管理路由(包含权限)
tenantAppGroup.Get("/:app_id/users", app_rbac2.GetAppUsers)
tenantAppGroup.Get("/:app_id/users/by-status", app_user.GetAppUsersByStatusHandler)
@@ -265,6 +285,7 @@ func RegisterTenantRoutes(v1 fiber.Router) {
// 应用用户权限管理路由
tenantAppGroup.Get("/:app_id/users/:user_id/permissions", app_rbac2.GetUserPermissions)
tenantAppGroup.Get("/:app_id/users/:user_id/roles", app_rbac2.GetUserRoles)
+ tenantAppGroup.Get("/:app_id/users/:user_id/effective-grants", tenant2.GetEffectiveAppUserGrantsHandler)
tenantAppGroup.Post("/:app_id/users/:user_id/check-access", app_rbac2.CheckUserAccess)
tenantAppGroup.Post("/:app_id/users/:user_id/permissions", app_rbac2.GrantUserPermissions)
tenantAppGroup.Delete("/:app_id/users/:user_id/permissions/:permission_id", app_rbac2.RevokeUserPermission)
diff --git a/basaltpass-backend/internal/api/v1/routes/user.go b/basaltpass-backend/internal/api/v1/routes/user.go
index 7fbf0236..2109c45d 100644
--- a/basaltpass-backend/internal/api/v1/routes/user.go
+++ b/basaltpass-backend/internal/api/v1/routes/user.go
@@ -43,6 +43,7 @@ func RegisterUserRoutes(v1 fiber.Router) {
// 用户应用授权管理
userGroup.Get("/apps", app_user.GetUserAppsHandler)
+ userGroup.Get("/apps/recharge-config", user.GetAppRechargeConfigHandler)
userGroup.Delete("/apps/:app_id", app_user.RevokeUserAppHandler)
// 用户搜索路由 (需要JWT认证)
@@ -87,6 +88,8 @@ func RegisterUserRoutes(v1 fiber.Router) {
teamGroup.Put("/:id", userTeam.UpdateTeamHandler)
teamGroup.Delete("/:id", userTeam.DeleteTeamHandler)
teamGroup.Get("/:id/members", userTeam.GetTeamMembersHandler)
+ teamGroup.Get("/:id/wallets", userTeam.GetTeamWalletsHandler)
+ teamGroup.Get("/:id/wallets/history", userTeam.GetTeamWalletHistoryHandler)
teamGroup.Post("/:id/members", userTeam.AddMemberHandler)
teamGroup.Put("/:id/members/:member_id", userTeam.UpdateMemberRoleHandler)
teamGroup.Delete("/:id/members/:member_id", userTeam.RemoveMemberHandler)
@@ -106,6 +109,7 @@ func RegisterUserRoutes(v1 fiber.Router) {
// 钱包用户系统路由(需要认证)
walletGroup := v1.Group("/wallet", middleware.JWTMiddleware())
walletGroup.Get("/balance", user.GetWalletBalanceHandler)
+ walletGroup.Get("/accounts", user.GetWalletAccountsHandler)
walletGroup.Post("/recharge", user.RechargeWalletHandler)
walletGroup.Post("/withdraw", user.WithdrawWalletHandler)
walletGroup.Get("/history", user.WalletHistoryHandler)
@@ -117,6 +121,7 @@ func RegisterUserRoutes(v1 fiber.Router) {
paymentGroup.Get("/intents", payment.ListPaymentIntentsHandler)
paymentGroup.Get("/intents/:id", payment.GetPaymentIntentHandler)
paymentGroup.Post("/sessions", payment.CreatePaymentSessionHandler)
+ paymentGroup.Post("/sessions/:session_id/reconcile-wallet-top-up", payment.ReconcileWalletTopUpSessionHandler)
paymentGroup.Get("/sessions/:session_id", payment.GetPaymentSessionHandler)
// 订单系统路由
diff --git a/basaltpass-backend/internal/handler/admin/wallet/handler.go b/basaltpass-backend/internal/handler/admin/wallet/handler.go
index af7bdfb7..f02c2d3a 100644
--- a/basaltpass-backend/internal/handler/admin/wallet/handler.go
+++ b/basaltpass-backend/internal/handler/admin/wallet/handler.go
@@ -10,18 +10,20 @@ import (
// WalletResponse represents a wallet with computed status field for API response
type WalletResponse struct {
- ID uint `json:"id"`
- TenantID uint `json:"tenant_id"`
- UserID *uint `json:"user_id"`
- TeamID *uint `json:"team_id"`
- CurrencyID *uint `json:"currency_id"`
- Balance int64 `json:"balance"`
- Status string `json:"status"` // computed from Freeze field
- CreatedAt string `json:"created_at"`
- UpdatedAt string `json:"updated_at"`
- Currency interface{} `json:"currency,omitempty"`
- User interface{} `json:"user,omitempty"`
- Team interface{} `json:"team,omitempty"`
+ ID uint `json:"id"`
+ TenantID uint `json:"tenant_id"`
+ OwnerType model.WalletOwnerType `json:"owner_type"`
+ OwnerID uint `json:"owner_id"`
+ UserID *uint `json:"user_id"`
+ TeamID *uint `json:"team_id"`
+ CurrencyID *uint `json:"currency_id"`
+ Balance int64 `json:"balance"`
+ Status string `json:"status"` // computed from Freeze field
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+ Currency interface{} `json:"currency,omitempty"`
+ User interface{} `json:"user,omitempty"`
+ Team interface{} `json:"team,omitempty"`
}
// AdminWalletHandler handles tenant wallet operations
@@ -46,6 +48,8 @@ func walletToResponse(wallet model.Wallet) WalletResponse {
return WalletResponse{
ID: wallet.ID,
TenantID: wallet.TenantID,
+ OwnerType: wallet.OwnerType,
+ OwnerID: wallet.OwnerID,
UserID: wallet.UserID,
TeamID: wallet.TeamID,
CurrencyID: wallet.CurrencyID,
@@ -79,10 +83,12 @@ type ListWalletsRequest struct {
// CreateWalletRequest represents the request for creating a wallet
type CreateWalletRequest struct {
- UserID *uint `json:"user_id"`
- TeamID *uint `json:"team_id"`
- CurrencyCode string `json:"currency_code" validate:"required"`
- InitialBalance float64 `json:"initial_balance"`
+ OwnerType model.WalletOwnerType `json:"owner_type"`
+ OwnerID *uint `json:"owner_id"`
+ UserID *uint `json:"user_id"`
+ TeamID *uint `json:"team_id"`
+ CurrencyCode string `json:"currency_code" validate:"required"`
+ InitialBalance float64 `json:"initial_balance"`
}
// AdjustBalanceRequest represents the request for adjusting wallet balance
@@ -205,13 +211,23 @@ func (h *AdminWalletHandler) CreateWallet(c *fiber.Ctx) error {
})
}
- // Validate that either UserID or TeamID is provided, but not both
- if req.UserID == nil && req.TeamID == nil {
+ usesUnifiedOwner := req.OwnerType != "" || req.OwnerID != nil
+ if usesUnifiedOwner && (req.OwnerType == "" || req.OwnerID == nil || !req.OwnerType.IsValid()) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
- "error": "Either user_id or team_id must be provided",
+ "error": "owner_type and owner_id must identify a user, app, tenant, or team",
})
}
- if req.UserID != nil && req.TeamID != nil {
+ if usesUnifiedOwner && (req.UserID != nil || req.TeamID != nil) {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "cannot mix owner_type/owner_id with legacy user_id/team_id",
+ })
+ }
+ if !usesUnifiedOwner && req.UserID == nil && req.TeamID == nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "owner_type/owner_id, user_id, or team_id must be provided",
+ })
+ }
+ if !usesUnifiedOwner && req.UserID != nil && req.TeamID != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Cannot specify both user_id and team_id",
})
@@ -220,7 +236,9 @@ func (h *AdminWalletHandler) CreateWallet(c *fiber.Ctx) error {
var wallet *model.Wallet
var err error
- if req.UserID != nil {
+ if usesUnifiedOwner {
+ wallet, err = h.service.CreateWalletForOwner(req.OwnerType, *req.OwnerID, req.CurrencyCode, req.InitialBalance)
+ } else if req.UserID != nil {
wallet, err = h.service.CreateWalletForUser(*req.UserID, req.CurrencyCode, req.InitialBalance)
} else {
wallet, err = h.service.CreateWalletForTeam(*req.TeamID, req.CurrencyCode, req.InitialBalance)
diff --git a/basaltpass-backend/internal/handler/admin/wallet/service.go b/basaltpass-backend/internal/handler/admin/wallet/service.go
index 972e3d36..ac842991 100644
--- a/basaltpass-backend/internal/handler/admin/wallet/service.go
+++ b/basaltpass-backend/internal/handler/admin/wallet/service.go
@@ -4,6 +4,7 @@ import (
"basaltpass-backend/internal/common"
"basaltpass-backend/internal/model"
"basaltpass-backend/internal/service/currency"
+ walletservice "basaltpass-backend/internal/service/wallet"
"errors"
"fmt"
"strings"
@@ -279,6 +280,57 @@ func (s *AdminWalletService) CreateWalletForTeam(teamID uint, currencyCode strin
return &newWallet, nil
}
+// CreateWalletForOwner creates a wallet for any supported tenant-scoped owner.
+func (s *AdminWalletService) CreateWalletForOwner(ownerType model.WalletOwnerType, ownerID uint, currencyCode string, initialBalance float64) (*model.Wallet, error) {
+ if initialBalance < 0 {
+ return nil, errors.New("initial balance must not be negative")
+ }
+ curr, err := currency.GetCurrencyByCode(currencyCode)
+ if err != nil {
+ return nil, fmt.Errorf("invalid currency code: %s", currencyCode)
+ }
+ db := common.DB()
+ owner, err := walletservice.ResolveOwnerRef(db, ownerType, ownerID, 0)
+ if err != nil {
+ return nil, err
+ }
+
+ var existing model.Wallet
+ err = db.Where("tenant_id = ? AND owner_type = ? AND owner_id = ? AND currency_id = ?", owner.TenantID, owner.Type, owner.ID, curr.ID).
+ First(&existing).Error
+ if err == nil {
+ return nil, errors.New("wallet already exists for this owner and currency")
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, err
+ }
+
+ balance := convertToSmallestUnit(initialBalance, curr.DecimalPlaces)
+ walletModel := model.Wallet{
+ TenantID: owner.TenantID, OwnerType: owner.Type, OwnerID: owner.ID,
+ CurrencyID: &curr.ID, Balance: balance,
+ }
+ err = db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Create(&walletModel).Error; err != nil {
+ return err
+ }
+ if balance == 0 {
+ return nil
+ }
+ return tx.Create(&model.WalletTx{
+ WalletID: walletModel.ID, Type: "admin_deposit", Amount: balance,
+ Status: "success", Reference: "Admin created owner wallet with initial balance",
+ }).Error
+ })
+ if err != nil {
+ return nil, err
+ }
+ if err := db.Preload("Currency").First(&walletModel, walletModel.ID).Error; err != nil {
+ return nil, err
+ }
+ return &walletModel, nil
+}
+
// AdjustBalance adjusts wallet balance (tenant operation)
func (s *AdminWalletService) AdjustBalance(walletID uint, amount float64, reason string, operatorID uint) error {
db := common.DB()
@@ -573,7 +625,7 @@ func (s *AdminWalletService) DeleteWallet(walletID uint, operatorID uint) error
}
// Delete wallet
- if err := tx.Delete(&walletModel).Error; err != nil {
+ if err := tx.Unscoped().Delete(&walletModel).Error; err != nil {
return err
}
diff --git a/basaltpass-backend/internal/handler/admin/wallet/service_owner_test.go b/basaltpass-backend/internal/handler/admin/wallet/service_owner_test.go
new file mode 100644
index 00000000..993d7446
--- /dev/null
+++ b/basaltpass-backend/internal/handler/admin/wallet/service_owner_test.go
@@ -0,0 +1,49 @@
+package wallet
+
+import (
+ "testing"
+
+ "basaltpass-backend/internal/common"
+ "basaltpass-backend/internal/model"
+
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+func TestCreateWalletForAppOwner(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AutoMigrate(
+ &model.User{}, &model.Tenant{}, &model.TenantUser{}, &model.Team{},
+ &model.App{}, &model.Currency{}, &model.Wallet{}, &model.WalletTx{},
+ ); err != nil {
+ t.Fatal(err)
+ }
+ common.SetDBForTest(db)
+ tenant := model.Tenant{Name: "Tenant", Code: "owner-wallet", Status: model.TenantStatusActive}
+ if err := db.Create(&tenant).Error; err != nil {
+ t.Fatal(err)
+ }
+ app := model.App{TenantID: tenant.ID, Name: "Worker", Status: model.AppStatusActive}
+ if err := db.Create(&app).Error; err != nil {
+ t.Fatal(err)
+ }
+ currency := model.Currency{Code: "CREDIT", Name: "Credit", DecimalPlaces: 0, IsActive: true}
+ if err := db.Create(¤cy).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ walletModel, err := NewAdminWalletService().CreateWalletForOwner(model.WalletOwnerApp, app.ID, "CREDIT", 500)
+ if err != nil {
+ t.Fatal(err)
+ }
+ expectedBalance := convertToSmallestUnit(500, currency.DecimalPlaces)
+ if walletModel.OwnerType != model.WalletOwnerApp || walletModel.OwnerID != app.ID || walletModel.Balance != expectedBalance {
+ t.Fatalf("unexpected wallet: %+v", walletModel)
+ }
+ if _, err := NewAdminWalletService().CreateWalletForOwner(model.WalletOwnerApp, app.ID, "CREDIT", 0); err == nil {
+ t.Fatal("expected duplicate wallet error")
+ }
+}
diff --git a/basaltpass-backend/internal/handler/public/app/app_rbac/handlers.go b/basaltpass-backend/internal/handler/public/app/app_rbac/handlers.go
index 23e3eadf..2fbaffae 100644
--- a/basaltpass-backend/internal/handler/public/app/app_rbac/handlers.go
+++ b/basaltpass-backend/internal/handler/public/app/app_rbac/handlers.go
@@ -11,6 +11,7 @@ import (
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
+ "gorm.io/gorm/clause"
)
// Permission 类型别名,方便使用
@@ -174,7 +175,6 @@ func UpdateAppPermission(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "权限不存在"})
}
-
// 更新权限
permission.Name = req.Name
permission.Description = req.Description
@@ -202,20 +202,42 @@ func DeleteAppPermission(c *fiber.Ctx) error {
}
tenantID := c.Locals("tenantID").(uint)
+ tx := common.DB().Begin()
+ if tx.Error != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "删除权限失败"})
+ }
+ defer tx.Rollback()
+ var app model.App
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
+ Where("id = ? AND tenant_id = ?", appID, tenantID).First(&app).Error; err != nil {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "应用不存在"})
+ }
// 验证权限存在且属于当前租户和应用
var permission Permission
- err = common.DB().Where("id = ? AND app_id = ? AND tenant_id = ?", permissionID, appID, tenantID).First(&permission).Error
+ err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_id = ? AND tenant_id = ?", permissionID, appID, tenantID).First(&permission).Error
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "权限不存在"})
}
+ var mappingCount int64
+ if err := tx.Model(&model.TenantAppGrantMapping{}).
+ Where("tenant_id = ? AND app_id = ? AND target_type = ? AND target_id = ?", tenantID, appID, model.TenantAppGrantTargetAppPermission, permissionID).
+ Count(&mappingCount).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "检查权限映射失败"})
+ }
+ if mappingCount > 0 {
+ return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "权限正在被 tenant→app 映射引用,无法删除"})
+ }
// 删除权限(会级联删除相关的角色权限和用户权限)
- err = common.DB().Delete(&permission).Error
+ err = tx.Delete(&permission).Error
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "删除权限失败"})
}
+ if err := tx.Commit().Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "删除权限失败"})
+ }
return c.Status(fiber.StatusNoContent).Send(nil)
}
@@ -341,7 +363,6 @@ func UpdateAppRole(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "角色不存在"})
}
-
// 验证权限ID
var permissions []Permission
if len(req.PermissionIDs) > 0 {
@@ -389,20 +410,42 @@ func DeleteAppRole(c *fiber.Ctx) error {
}
tenantID := c.Locals("tenantID").(uint)
+ tx := common.DB().Begin()
+ if tx.Error != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "删除角色失败"})
+ }
+ defer tx.Rollback()
+ var app model.App
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
+ Where("id = ? AND tenant_id = ?", appID, tenantID).First(&app).Error; err != nil {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "应用不存在"})
+ }
// 验证角色存在且属于当前租户和应用
var role Role
- err = common.DB().Where("id = ? AND app_id = ? AND tenant_id = ?", roleID, appID, tenantID).First(&role).Error
+ err = tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_id = ? AND tenant_id = ?", roleID, appID, tenantID).First(&role).Error
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "角色不存在"})
}
+ var mappingCount int64
+ if err := tx.Model(&model.TenantAppGrantMapping{}).
+ Where("tenant_id = ? AND app_id = ? AND target_type = ? AND target_id = ?", tenantID, appID, model.TenantAppGrantTargetAppRole, roleID).
+ Count(&mappingCount).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "检查角色映射失败"})
+ }
+ if mappingCount > 0 {
+ return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "角色正在被 tenant→app 映射引用,无法删除"})
+ }
// 删除角色(会级联删除相关的权限关联和用户角色)
- err = common.DB().Delete(&role).Error
+ err = tx.Delete(&role).Error
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "删除角色失败"})
}
+ if err := tx.Commit().Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "删除角色失败"})
+ }
return c.Status(fiber.StatusNoContent).Send(nil)
}
diff --git a/basaltpass-backend/internal/handler/public/app/app_rbac/user_handlers.go b/basaltpass-backend/internal/handler/public/app/app_rbac/user_handlers.go
index e0b49a96..cedb188f 100644
--- a/basaltpass-backend/internal/handler/public/app/app_rbac/user_handlers.go
+++ b/basaltpass-backend/internal/handler/public/app/app_rbac/user_handlers.go
@@ -6,6 +6,7 @@ import (
"basaltpass-backend/internal/common"
"basaltpass-backend/internal/model"
+ "basaltpass-backend/internal/service/appgrant"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
@@ -104,10 +105,18 @@ func GetUserPermissions(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "获取用户角色失败"})
}
+ effective, err := appgrant.NewService(common.DB()).Resolve(uint(userID), tenantID, uint(appID), time.Now().UTC())
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "获取有效授权失败"})
+ }
return c.JSON(fiber.Map{
- "permissions": userPermissions,
- "roles": userRoles,
+ "permissions": userPermissions,
+ "roles": userRoles,
+ "effective_permissions": effective.Permissions,
+ "effective_roles": effective.Roles,
+ "eligible": effective.Eligible,
+ "denial_reason": effective.DenialReason,
})
}
@@ -143,8 +152,12 @@ func GetUserRoles(c *fiber.Ctx) error {
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "获取用户角色失败"})
}
+ effective, err := appgrant.NewService(common.DB()).Resolve(uint(userID), tenantID, uint(appID), time.Now().UTC())
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "获取有效授权失败"})
+ }
- return c.JSON(fiber.Map{"roles": userRoles})
+ return c.JSON(fiber.Map{"roles": userRoles, "effective_roles": effective.Roles, "eligible": effective.Eligible, "denial_reason": effective.DenialReason})
}
// GrantUserPermissions 授予用户权限
@@ -408,51 +421,17 @@ func CheckUserAccess(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "permission_code(s) 或 role_code(s) 至少提供一项"})
}
- now := time.Now()
ownedPermissions := make(map[string]bool)
ownedRoles := make(map[string]bool)
-
- var directPermissionCodes []string
- if err := common.DB().
- Table("app_user_permissions").
- Select("app_permissions.code").
- Joins("JOIN app_permissions ON app_permissions.id = app_user_permissions.permission_id").
- Where("app_user_permissions.user_id = ? AND app_user_permissions.app_id = ?", userID, appID).
- Where("app_user_permissions.expires_at IS NULL OR app_user_permissions.expires_at > ?", now).
- Pluck("app_permissions.code", &directPermissionCodes).Error; err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "查询用户直接权限失败"})
- }
- for _, code := range directPermissionCodes {
- ownedPermissions[code] = true
- }
-
- var roleCodes []string
- if err := common.DB().
- Table("app_user_roles").
- Select("app_roles.code").
- Joins("JOIN app_roles ON app_roles.id = app_user_roles.role_id").
- Where("app_user_roles.user_id = ? AND app_user_roles.app_id = ?", userID, appID).
- Where("app_user_roles.expires_at IS NULL OR app_user_roles.expires_at > ?", now).
- Pluck("app_roles.code", &roleCodes).Error; err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "查询用户角色失败"})
- }
- for _, code := range roleCodes {
- ownedRoles[code] = true
+ effective, err := appgrant.NewService(common.DB()).Resolve(uint(userID), tenantID, uint(appID), time.Now().UTC())
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "查询用户有效授权失败"})
}
-
- var rolePermissionCodes []string
- if err := common.DB().
- Table("app_user_roles").
- Select("distinct app_permissions.code").
- Joins("JOIN app_role_permissions ON app_role_permissions.app_role_id = app_user_roles.role_id").
- Joins("JOIN app_permissions ON app_permissions.id = app_role_permissions.app_permission_id").
- Where("app_user_roles.user_id = ? AND app_user_roles.app_id = ?", userID, appID).
- Where("app_user_roles.expires_at IS NULL OR app_user_roles.expires_at > ?", now).
- Pluck("app_permissions.code", &rolePermissionCodes).Error; err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "查询用户角色权限失败"})
+ for _, permission := range effective.Permissions {
+ ownedPermissions[permission.Code] = true
}
- for _, code := range rolePermissionCodes {
- ownedPermissions[code] = true
+ for _, role := range effective.Roles {
+ ownedRoles[role.Code] = true
}
permissionResult := make(map[string]bool, len(normalizedPermissionInput))
@@ -484,6 +463,8 @@ func CheckUserAccess(c *fiber.Ctx) error {
"roles": roleResult,
"has_all_permissions": hasAllPermissions,
"has_all_roles": hasAllRoles,
+ "eligible": effective.Eligible,
+ "denial_reason": effective.DenialReason,
"permission_input_dup_filtered": permissionInputDuplicates,
"role_input_dup_filtered": roleInputDuplicates,
},
diff --git a/basaltpass-backend/internal/handler/public/currency/handler.go b/basaltpass-backend/internal/handler/public/currency/handler.go
index 8df9e8b6..b1926270 100644
--- a/basaltpass-backend/internal/handler/public/currency/handler.go
+++ b/basaltpass-backend/internal/handler/public/currency/handler.go
@@ -34,3 +34,14 @@ func GetCurrencyHandler(c *fiber.Ctx) error {
}
return c.JSON(currency)
}
+
+// GetCurrencyRatesHandler GET /currencies/rates - 获取所有启用交易对汇率
+func GetCurrencyRatesHandler(c *fiber.Ctx) error {
+ rates, err := currency2.GetAllCurrencyRates()
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": "Failed to fetch currency rates",
+ })
+ }
+ return c.JSON(rates)
+}
diff --git a/basaltpass-backend/internal/handler/public/oauth/oidc_e2e_test.go b/basaltpass-backend/internal/handler/public/oauth/oidc_e2e_test.go
index 6baa0e2e..21d1cd00 100644
--- a/basaltpass-backend/internal/handler/public/oauth/oidc_e2e_test.go
+++ b/basaltpass-backend/internal/handler/public/oauth/oidc_e2e_test.go
@@ -826,3 +826,44 @@ func signClientAssertion(t *testing.T, clientID string, method jwt.SigningMethod
}
return signed
}
+
+func TestIntrospectionExposesAppBillingPrincipal(t *testing.T) {
+ db := setupOIDCE2EDB(t)
+ tenant := model.Tenant{Name: "Service Tenant", Code: "service-tenant", Status: model.TenantStatusActive}
+ if err := db.Create(&tenant).Error; err != nil {
+ t.Fatal(err)
+ }
+ user := model.User{Email: "service-subject@example.com", PasswordHash: "x"}
+ if err := db.Create(&user).Error; err != nil {
+ t.Fatal(err)
+ }
+ app := model.App{TenantID: tenant.ID, Name: "Target", Status: model.AppStatusActive}
+ if err := db.Create(&app).Error; err != nil {
+ t.Fatal(err)
+ }
+ client := model.OAuthClient{AppID: app.ID, ClientID: "target-client", ClientSecret: "secret", IsActive: true}
+ if err := db.Create(&client).Error; err != nil {
+ t.Fatal(err)
+ }
+ token := model.OAuthAccessToken{
+ Token: "bp_xat_service", ClientID: client.ClientID, UserID: user.ID,
+ TenantID: tenant.ID, AppID: app.ID, SubjectType: model.OAuthSubjectApp,
+ Scopes: "llm", ExpiresAt: time.Now().Add(time.Hour), IsExchanged: true,
+ ActorClientID: "source-client", ActorAppID: 77,
+ }
+ if err := db.Create(&token).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := NewOAuthServerService().IntrospectToken(token.Token, client.ClientID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.SubjectType != model.OAuthSubjectApp || result.AppID == "" {
+ t.Fatalf("missing app principal fields: %+v", result)
+ }
+ actor, ok := result.Act.(map[string]interface{})
+ if !ok || actor["app_id"] != uint(77) {
+ t.Fatalf("missing source app actor: %#v", result.Act)
+ }
+}
diff --git a/basaltpass-backend/internal/handler/public/oauth/server_handler.go b/basaltpass-backend/internal/handler/public/oauth/server_handler.go
index 482a496b..8fbb46b8 100644
--- a/basaltpass-backend/internal/handler/public/oauth/server_handler.go
+++ b/basaltpass-backend/internal/handler/public/oauth/server_handler.go
@@ -569,13 +569,14 @@ func handleClientCredentialsGrant(c *fiber.Ctx) error {
}
if err := common.DB().Create(&model.OAuthAccessToken{
- Token: tokenStr,
- ClientID: client.ClientID,
- UserID: userID,
- TenantID: tenantID,
- AppID: client.AppID,
- Scopes: grantedScopes,
- ExpiresAt: time.Now().Add(1 * time.Hour),
+ Token: tokenStr,
+ ClientID: client.ClientID,
+ UserID: userID,
+ TenantID: tenantID,
+ AppID: client.AppID,
+ SubjectType: model.OAuthSubjectApp,
+ Scopes: grantedScopes,
+ ExpiresAt: time.Now().Add(1 * time.Hour),
}).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "server_error",
diff --git a/basaltpass-backend/internal/handler/public/oauth/server_service.go b/basaltpass-backend/internal/handler/public/oauth/server_service.go
index 26896fe5..f971e628 100644
--- a/basaltpass-backend/internal/handler/public/oauth/server_service.go
+++ b/basaltpass-backend/internal/handler/public/oauth/server_service.go
@@ -1013,28 +1013,31 @@ func (s *OAuthServerService) ValidateRefreshToken(token string) (*model.OAuthRef
}
type TokenIntrospectionResponse struct {
- Active bool `json:"active"`
- TokenType string `json:"token_type,omitempty"`
- ClientID string `json:"client_id,omitempty"`
- Username string `json:"username,omitempty"`
- Scope string `json:"scope,omitempty"`
- Exp int64 `json:"exp,omitempty"`
- Iat int64 `json:"iat,omitempty"`
- Nbf int64 `json:"nbf,omitempty"`
- Sub string `json:"sub,omitempty"`
- Aud string `json:"aud,omitempty"`
- Iss string `json:"iss,omitempty"`
- TenantID string `json:"tenant_id,omitempty"`
- TenantCode string `json:"tenant_code,omitempty"`
- Tenant interface{} `json:"tenant,omitempty"`
- Act interface{} `json:"act,omitempty"`
+ Active bool `json:"active"`
+ TokenType string `json:"token_type,omitempty"`
+ ClientID string `json:"client_id,omitempty"`
+ Username string `json:"username,omitempty"`
+ Scope string `json:"scope,omitempty"`
+ Exp int64 `json:"exp,omitempty"`
+ Iat int64 `json:"iat,omitempty"`
+ Nbf int64 `json:"nbf,omitempty"`
+ Sub string `json:"sub,omitempty"`
+ Aud string `json:"aud,omitempty"`
+ Iss string `json:"iss,omitempty"`
+ TenantID string `json:"tenant_id,omitempty"`
+ TenantCode string `json:"tenant_code,omitempty"`
+ Tenant interface{} `json:"tenant,omitempty"`
+ Act interface{} `json:"act,omitempty"`
+ AppID string `json:"app_id,omitempty"`
+ SubjectType string `json:"subject_type,omitempty"`
}
func (s *OAuthServerService) IntrospectToken(token string, authenticatedClientID string) (*TokenIntrospectionResponse, error) {
if accessToken, err := s.ValidateAccessToken(token); err == nil {
- if accessToken.ClientID != authenticatedClientID {
- return nil, errors.New("access_denied")
- }
+ // authenticatedClientID authenticates the resource server making this
+ // introspection request. Token ownership is enforced by the resource
+ // server through scopes, not by requiring it to be the issuing client.
+ _ = authenticatedClientID
resp := tokenIntrospectionFromAccessToken(accessToken)
return resp, nil
} else if err != nil && err.Error() != "invalid_token" && err.Error() != "token_expired" {
@@ -1042,9 +1045,6 @@ func (s *OAuthServerService) IntrospectToken(token string, authenticatedClientID
}
if refreshToken, err := s.ValidateRefreshToken(token); err == nil {
- if refreshToken.ClientID != authenticatedClientID {
- return nil, errors.New("access_denied")
- }
resp := tokenIntrospectionFromRefreshToken(refreshToken)
return resp, nil
} else if err != nil && err.Error() != "invalid_token" && err.Error() != "token_expired" {
@@ -1060,17 +1060,22 @@ func tokenIntrospectionFromAccessToken(token *model.OAuthAccessToken) *TokenIntr
sub = oidcSubjectForClient(token.Client, token.UserID)
}
resp := &TokenIntrospectionResponse{
- Active: true,
- TokenType: "access_token",
- ClientID: token.ClientID,
- Username: token.User.Email,
- Scope: token.Scopes,
- Exp: token.ExpiresAt.Unix(),
- Iat: token.CreatedAt.Unix(),
- Nbf: token.CreatedAt.Unix(),
- Sub: sub,
- Aud: token.ClientID,
- Iss: oidcIssuer(),
+ Active: true,
+ TokenType: "access_token",
+ ClientID: token.ClientID,
+ Username: token.User.Email,
+ Scope: token.Scopes,
+ Exp: token.ExpiresAt.Unix(),
+ Iat: token.CreatedAt.Unix(),
+ Nbf: token.CreatedAt.Unix(),
+ Sub: sub,
+ Aud: token.ClientID,
+ Iss: oidcIssuer(),
+ AppID: strconv.FormatUint(uint64(token.AppID), 10),
+ SubjectType: token.SubjectType,
+ }
+ if resp.SubjectType == "" {
+ resp.SubjectType = model.OAuthSubjectUser
}
if token.IsExchanged && token.ActorClientID != "" {
resp.Act = fiberMapTokenActor(token.ActorClientID, token.ActorAppID)
diff --git a/basaltpass-backend/internal/handler/public/payment/handler.go b/basaltpass-backend/internal/handler/public/payment/handler.go
index b3ba7ddc..f16a7194 100644
--- a/basaltpass-backend/internal/handler/public/payment/handler.go
+++ b/basaltpass-backend/internal/handler/public/payment/handler.go
@@ -165,6 +165,34 @@ func GetPaymentSessionHandler(c *fiber.Ctx) error {
return c.JSON(session)
}
+// ReconcileWalletTopUpSessionHandler POST /payment/sessions/:session_id/reconcile-wallet-top-up
+func ReconcileWalletTopUpSessionHandler(c *fiber.Ctx) error {
+ userID := c.Locals("userID").(uint)
+ activeTenantID := resolvePaymentTenantID(c)
+ sessionID := c.Params("session_id")
+
+ session, err := payment2.ReconcileWalletRechargeFromStripe(userID, activeTenantID, sessionID)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
+ "error": "Payment session not found",
+ })
+ }
+ if errors.Is(err, payment2.ErrTenantStripeNotConfigured) {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
+ "error": "当前租户未配置可用的 Stripe Key,请先在租户控制台完成配置",
+ })
+ }
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
+ "error": err.Error(),
+ })
+ }
+
+ return c.JSON(fiber.Map{
+ "session": session,
+ })
+}
+
// ListPaymentIntentsHandler GET /payment/intents - 获取支付意图列表
func ListPaymentIntentsHandler(c *fiber.Ctx) error {
userID := c.Locals("userID").(uint)
diff --git a/basaltpass-backend/internal/handler/public/settings/settings.go b/basaltpass-backend/internal/handler/public/settings/settings.go
index e6b08129..3846da2f 100644
--- a/basaltpass-backend/internal/handler/public/settings/settings.go
+++ b/basaltpass-backend/internal/handler/public/settings/settings.go
@@ -10,6 +10,7 @@ import (
type PublicConfig struct {
MarketEnabled bool `json:"market_enabled"`
WalletRechargeWithdrawEnabled bool `json:"wallet_recharge_withdraw_enabled"`
+ WalletWithdrawEnabled bool `json:"wallet_withdraw_enabled"`
SiteName string `json:"site_name"`
// 2FA 方式开关:前端可据此决定是否渲染对应的验证 UI
@@ -26,6 +27,7 @@ func GetPublicConfigHandler(c *fiber.Ctx) error {
config.MarketEnabled = settingssvc.GetBool("features.market_enabled", true)
config.WalletRechargeWithdrawEnabled = settingssvc.GetBool("features.wallet_recharge_withdraw_enabled", false)
+ config.WalletWithdrawEnabled = settingssvc.GetBool("features.wallet_withdraw_enabled", false)
config.SiteName = settingssvc.GetString("general.site_name", "BasaltPass")
config.TwoFA.TOTPEnabled = settingssvc.GetBool("auth.2fa.totp_enabled", true)
config.TwoFA.PasskeyEnabled = settingssvc.GetBool("auth.2fa.passkey_enabled", true)
diff --git a/basaltpass-backend/internal/handler/public/subscription/checkout_handler.go b/basaltpass-backend/internal/handler/public/subscription/checkout_handler.go
index ff6579ce..ec936fa4 100644
--- a/basaltpass-backend/internal/handler/public/subscription/checkout_handler.go
+++ b/basaltpass-backend/internal/handler/public/subscription/checkout_handler.go
@@ -84,6 +84,8 @@ func QuickCheckoutHandler(c *fiber.Ctx) error {
PriceID uint `json:"price_id" validate:"required"`
Quantity float64 `json:"quantity,omitempty"`
CouponCode *string `json:"coupon_code,omitempty"`
+ SuccessURL string `json:"success_url,omitempty"`
+ CancelURL string `json:"cancel_url,omitempty"`
}
if err := c.BodyParser(&req); err != nil {
@@ -93,13 +95,22 @@ func QuickCheckoutHandler(c *fiber.Ctx) error {
}
// 构建完整的checkout请求
+ successURL := req.SuccessURL
+ if successURL == "" {
+ successURL = "http://localhost:5101/subscriptions?payment=success"
+ }
+ cancelURL := req.CancelURL
+ if cancelURL == "" {
+ cancelURL = "http://localhost:5101/subscriptions?payment=canceled"
+ }
+
checkoutReq := CheckoutRequest{
UserID: userID,
PriceID: req.PriceID,
Quantity: req.Quantity,
CouponCode: req.CouponCode,
- SuccessURL: "http://localhost:5101/subscriptions?payment=success",
- CancelURL: "http://localhost:5101/subscriptions?payment=canceled",
+ SuccessURL: successURL,
+ CancelURL: cancelURL,
}
if tenantID != nil {
checkoutReq.ActiveTenantID = *tenantID
diff --git a/basaltpass-backend/internal/handler/s2s/rbac_manifest_handler.go b/basaltpass-backend/internal/handler/s2s/rbac_manifest_handler.go
new file mode 100644
index 00000000..8b13aa43
--- /dev/null
+++ b/basaltpass-backend/internal/handler/s2s/rbac_manifest_handler.go
@@ -0,0 +1,73 @@
+package s2s
+
+import (
+ "errors"
+ "strconv"
+
+ "basaltpass-backend/internal/common"
+ "basaltpass-backend/internal/service/rbacmanifest"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+// SubmitRBACManifestHandler accepts only the RBAC manifest schema. App,
+// tenant, OAuth settings and user assignments are derived from authentication
+// context or rejected as unknown JSON fields.
+func SubmitRBACManifestHandler(c *fiber.Ctx) error {
+ if authSource, _ := c.Locals("s2s_auth_source").(string); authSource == "query" {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "query-string credentials are forbidden for RBAC manifest submission"})
+ }
+ tenantID, tenantOK := c.Locals("s2s_tenant_id").(uint)
+ appID, appOK := c.Locals("s2s_app_id").(uint)
+ clientID, clientOK := c.Locals("s2s_client_id").(string)
+ if !tenantOK || !appOK || !clientOK || tenantID == 0 || appID == 0 || clientID == "" {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "authenticated app context is required"})
+ }
+ if len(c.Body()) > rbacmanifest.MaxManifestBytes {
+ return c.Status(fiber.StatusRequestEntityTooLarge).JSON(fiber.Map{"error": "RBAC manifest is too large"})
+ }
+
+ result, err := rbacmanifest.New(common.DB()).Submit(tenantID, appID, clientID, c.Body())
+ if err != nil {
+ return rbacManifestError(c, err)
+ }
+ status := fiber.StatusCreated
+ if !result.Created {
+ status = fiber.StatusOK
+ }
+ return c.Status(status).JSON(fiber.Map{"data": result})
+}
+
+func GetOwnRBACManifestHandler(c *fiber.Ctx) error {
+ if authSource, _ := c.Locals("s2s_auth_source").(string); authSource == "query" {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "query-string credentials are forbidden for RBAC manifest access"})
+ }
+ tenantID, tenantOK := c.Locals("s2s_tenant_id").(uint)
+ appID, appOK := c.Locals("s2s_app_id").(uint)
+ clientID, clientOK := c.Locals("s2s_client_id").(string)
+ manifestID, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid manifest ID"})
+ }
+ if !tenantOK || !appOK || !clientOK || tenantID == 0 || appID == 0 || clientID == "" {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "authenticated app context is required"})
+ }
+ view, err := rbacmanifest.New(common.DB()).GetManifestForClient(tenantID, appID, clientID, uint(manifestID))
+ if err != nil {
+ return rbacManifestError(c, err)
+ }
+ return c.JSON(fiber.Map{"data": view})
+}
+
+func rbacManifestError(c *fiber.Ctx, err error) error {
+ switch {
+ case errors.Is(err, rbacmanifest.ErrValidation):
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ case errors.Is(err, rbacmanifest.ErrConflict):
+ return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()})
+ case errors.Is(err, rbacmanifest.ErrNotFound):
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "RBAC manifest not found"})
+ default:
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "RBAC manifest operation failed"})
+ }
+}
diff --git a/basaltpass-backend/internal/handler/s2s/rbac_manifest_handler_test.go b/basaltpass-backend/internal/handler/s2s/rbac_manifest_handler_test.go
new file mode 100644
index 00000000..fd45154a
--- /dev/null
+++ b/basaltpass-backend/internal/handler/s2s/rbac_manifest_handler_test.go
@@ -0,0 +1,29 @@
+package s2s
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func TestManifestSubmissionRejectsQueryCredentialSource(t *testing.T) {
+ app := fiber.New()
+ app.Post("/", func(c *fiber.Ctx) error {
+ c.Locals("s2s_auth_source", "query")
+ c.Locals("s2s_tenant_id", uint(1))
+ c.Locals("s2s_app_id", uint(2))
+ c.Locals("s2s_client_id", "client")
+ return SubmitRBACManifestHandler(c)
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/", nil)
+ resp, err := app.Test(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ if resp.StatusCode != fiber.StatusUnauthorized {
+ t.Fatalf("expected query credentials to be rejected with 401, got %d", resp.StatusCode)
+ }
+}
diff --git a/basaltpass-backend/internal/handler/s2s/user_handler.go b/basaltpass-backend/internal/handler/s2s/user_handler.go
index a983ee58..81cdf140 100644
--- a/basaltpass-backend/internal/handler/s2s/user_handler.go
+++ b/basaltpass-backend/internal/handler/s2s/user_handler.go
@@ -3,6 +3,7 @@ package s2s
import (
"basaltpass-backend/internal/common"
"basaltpass-backend/internal/model"
+ "basaltpass-backend/internal/service/appgrant"
"basaltpass-backend/internal/service/wallet"
"basaltpass-backend/internal/utils"
"sort"
@@ -148,23 +149,15 @@ func GetUserRolesHandler(c *fiber.Ctx) error {
return unifiedResponse(c, status, nil, fiber.Map{"code": "invalid_parameter", "message": err.Error()})
}
- var roles []model.AppRole
- err = common.DB().Table("app_user_roles").
- Select("app_roles.*").
- Joins("JOIN app_roles ON app_roles.id = app_user_roles.role_id").
- Where("app_user_roles.user_id = ? AND app_user_roles.app_id = ?", userID, appID).
- Where("app_user_roles.expires_at IS NULL OR app_user_roles.expires_at > ?", time.Now()).
- Order("app_roles.id ASC").
- Find(&roles).Error
+ grants, err := appgrant.NewService(common.DB()).Resolve(userID, tenantID, appID, time.Now().UTC())
if err != nil {
return unifiedResponse(c, fiber.StatusInternalServerError, nil, fiber.Map{"code": "server_error", "message": err.Error()})
}
- // 只返回必要字段
- list := make([]fiber.Map, 0, len(roles))
- for _, r := range roles {
- list = append(list, fiber.Map{"id": r.ID, "code": r.Code, "name": r.Name, "description": r.Description})
+ list := make([]fiber.Map, 0, len(grants.Roles))
+ for _, role := range grants.Roles {
+ list = append(list, fiber.Map{"id": role.ID, "code": role.Code, "name": role.Name, "description": role.Description, "sources": role.Sources})
}
- return unifiedResponse(c, fiber.StatusOK, fiber.Map{"roles": list}, nil)
+ return unifiedResponse(c, fiber.StatusOK, fiber.Map{"roles": list, "eligible": grants.Eligible, "denial_reason": grants.DenialReason}, nil)
}
// GET /api/v1/s2s/users/:id/permissions?tenant_id=xxx
@@ -199,54 +192,20 @@ func GetUserPermissionsHandler(c *fiber.Ctx) error {
return unifiedResponse(c, status, nil, fiber.Map{"code": "invalid_parameter", "message": err.Error()})
}
- permissionSet := map[string]struct{}{}
-
- var directPermissionCodes []string
- err = common.DB().Table("app_user_permissions").
- Select("app_permissions.code").
- Joins("JOIN app_permissions ON app_permissions.id = app_user_permissions.permission_id").
- Where("app_user_permissions.user_id = ? AND app_user_permissions.app_id = ?", userID, appID).
- Where("app_user_permissions.expires_at IS NULL OR app_user_permissions.expires_at > ?", time.Now()).
- Pluck("app_permissions.code", &directPermissionCodes).Error
- if err != nil {
- return unifiedResponse(c, fiber.StatusInternalServerError, nil, fiber.Map{"code": "server_error", "message": err.Error()})
- }
- for _, code := range directPermissionCodes {
- permissionSet[code] = struct{}{}
- }
-
- var rolePermissionCodes []string
- err = common.DB().Table("app_user_roles").
- Select("DISTINCT app_permissions.code").
- Joins("JOIN app_roles ON app_roles.id = app_user_roles.role_id").
- Joins("JOIN app_role_permissions ON app_role_permissions.app_role_id = app_roles.id").
- Joins("JOIN app_permissions ON app_permissions.id = app_role_permissions.app_permission_id").
- Where("app_user_roles.user_id = ? AND app_user_roles.app_id = ?", userID, appID).
- Where("app_user_roles.expires_at IS NULL OR app_user_roles.expires_at > ?", time.Now()).
- Pluck("app_permissions.code", &rolePermissionCodes).Error
+ grants, err := appgrant.NewService(common.DB()).Resolve(userID, tenantID, appID, time.Now().UTC())
if err != nil {
return unifiedResponse(c, fiber.StatusInternalServerError, nil, fiber.Map{"code": "server_error", "message": err.Error()})
}
- for _, code := range rolePermissionCodes {
- permissionSet[code] = struct{}{}
- }
-
- permissionCodes := make([]string, 0, len(permissionSet))
- for code := range permissionSet {
- permissionCodes = append(permissionCodes, code)
+ permissionCodes := make([]string, 0, len(grants.Permissions))
+ for _, permission := range grants.Permissions {
+ permissionCodes = append(permissionCodes, permission.Code)
}
sort.Strings(permissionCodes)
// Backward compatibility: keep returning role codes too (previously mislabeled as permissions).
- var roleCodes []string
- err = common.DB().Table("app_user_roles").
- Select("DISTINCT app_roles.code").
- Joins("JOIN app_roles ON app_roles.id = app_user_roles.role_id").
- Where("app_user_roles.user_id = ? AND app_user_roles.app_id = ?", userID, appID).
- Where("app_user_roles.expires_at IS NULL OR app_user_roles.expires_at > ?", time.Now()).
- Pluck("app_roles.code", &roleCodes).Error
- if err != nil {
- return unifiedResponse(c, fiber.StatusInternalServerError, nil, fiber.Map{"code": "server_error", "message": err.Error()})
+ roleCodes := make([]string, 0, len(grants.Roles))
+ for _, role := range grants.Roles {
+ roleCodes = append(roleCodes, role.Code)
}
sort.Strings(roleCodes)
@@ -254,6 +213,8 @@ func GetUserPermissionsHandler(c *fiber.Ctx) error {
"permission_codes": permissionCodes,
"role_codes": roleCodes,
"roles": roleCodes,
+ "eligible": grants.Eligible,
+ "denial_reason": grants.DenialReason,
}, nil)
}
@@ -289,18 +250,16 @@ func GetUserRoleCodesHandler(c *fiber.Ctx) error {
return unifiedResponse(c, status, nil, fiber.Map{"code": "invalid_parameter", "message": err.Error()})
}
- var roleCodes []string
- err = common.DB().Table("app_user_roles").
- Select("DISTINCT app_roles.code").
- Joins("JOIN app_roles ON app_roles.id = app_user_roles.role_id").
- Where("app_user_roles.user_id = ? AND app_user_roles.app_id = ?", userID, appID).
- Where("app_user_roles.expires_at IS NULL OR app_user_roles.expires_at > ?", time.Now()).
- Pluck("app_roles.code", &roleCodes).Error
+ grants, err := appgrant.NewService(common.DB()).Resolve(userID, tenantID, appID, time.Now().UTC())
if err != nil {
return unifiedResponse(c, fiber.StatusInternalServerError, nil, fiber.Map{"code": "server_error", "message": err.Error()})
}
+ roleCodes := make([]string, 0, len(grants.Roles))
+ for _, role := range grants.Roles {
+ roleCodes = append(roleCodes, role.Code)
+ }
sort.Strings(roleCodes)
- return unifiedResponse(c, fiber.StatusOK, fiber.Map{"role_codes": roleCodes}, nil)
+ return unifiedResponse(c, fiber.StatusOK, fiber.Map{"role_codes": roleCodes, "eligible": grants.Eligible, "denial_reason": grants.DenialReason}, nil)
}
// GET /api/v1/s2s/users/lookup?email=... | phone=... | q=...
@@ -484,12 +443,12 @@ func GetUserWalletHandler(c *fiber.Ctx) error {
}
// 查询余额
- w, err := wallet.GetBalanceByCode(userID, currency)
+ w, err := wallet.GetBalanceByCodeWithTenant(userID, tenantID, currency)
if err != nil {
return unifiedResponse(c, fiber.StatusBadRequest, nil, fiber.Map{"code": "wallet_error", "message": err.Error()})
}
// 查询交易记录
- txs, err := wallet.HistoryByCode(userID, currency, limit)
+ txs, err := wallet.HistoryByCodeWithTenant(userID, tenantID, currency, limit)
if err != nil {
return unifiedResponse(c, fiber.StatusInternalServerError, nil, fiber.Map{"code": "wallet_error", "message": err.Error()})
}
@@ -556,7 +515,7 @@ func AdjustUserWalletHandler(c *fiber.Ctx) error {
txType = "s2s_wallet_decrease"
}
- w, err := wallet.AdjustByCode(userID, req.Currency, delta, txType, req.Reference)
+ w, err := wallet.AdjustByCodeWithTenant(userID, tenantID, req.Currency, delta, txType, req.Reference)
if err != nil {
status := fiber.StatusBadRequest
if err.Error() == "insufficient funds" {
diff --git a/basaltpass-backend/internal/handler/s2s/user_handler_test.go b/basaltpass-backend/internal/handler/s2s/user_handler_test.go
new file mode 100644
index 00000000..e48302fe
--- /dev/null
+++ b/basaltpass-backend/internal/handler/s2s/user_handler_test.go
@@ -0,0 +1,92 @@
+package s2s
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "basaltpass-backend/internal/common"
+ "basaltpass-backend/internal/model"
+
+ "github.com/glebarez/sqlite"
+ "github.com/gofiber/fiber/v2"
+ "gorm.io/gorm"
+)
+
+func TestS2SPermissionsUseDynamicTenantMappingAndStatus(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:s2s-grants-%d?mode=memory&cache=shared", time.Now().UnixNano())), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AutoMigrate(&model.User{}, &model.Tenant{}, &model.TenantUser{}, &model.App{}, &model.AppUser{},
+ &model.AppRole{}, &model.AppPermission{}, &model.AppUserRole{}, &model.AppUserPermission{},
+ &model.TenantRbacRole{}, &model.TenantRbacPermission{}, &model.TenantUserRbacRole{}, &model.TenantUserRbacPermission{},
+ &model.TenantRbacRolePermission{}, &model.TenantAppGrantMapping{}); err != nil {
+ t.Fatal(err)
+ }
+ common.SetDBForTest(db)
+
+ tenant := model.Tenant{Name: "Tenant", Code: "s2s-dynamic", Status: model.TenantStatusActive}
+ db.Create(&tenant)
+ appModel := model.App{TenantID: tenant.ID, Name: "App", Status: model.AppStatusActive}
+ db.Create(&appModel)
+ user := model.User{Email: "s2s-dynamic@example.com", EmailVerified: true}
+ db.Create(&user)
+ membership := model.TenantUser{TenantID: tenant.ID, UserID: user.ID, Role: model.TenantRoleMember}
+ db.Create(&membership)
+ now := time.Now().UTC()
+ db.Create(&model.AppUser{AppID: appModel.ID, UserID: user.ID, Status: model.AppUserStatusActive, FirstAuthorizedAt: now, LastAuthorizedAt: now})
+ permission := model.AppPermission{TenantID: tenant.ID, AppID: appModel.ID, Code: "demo.read", Name: "Read", Category: "demo"}
+ role := model.AppRole{TenantID: tenant.ID, AppID: appModel.ID, Code: "demo.user", Name: "User"}
+ db.Create(&permission)
+ db.Create(&role)
+ db.Model(&role).Association("Permissions").Append(&permission)
+ mapping := model.TenantAppGrantMapping{TenantID: tenant.ID, AppID: appModel.ID, SourceType: model.TenantAppGrantSourceMembershipRole,
+ SourceCode: "member", TargetType: model.TenantAppGrantTargetAppRole, TargetID: role.ID, Enabled: true, CreatedBy: user.ID, UpdatedBy: user.ID}
+ db.Create(&mapping)
+
+ api := fiber.New()
+ api.Get("/users/:id/permissions", func(c *fiber.Ctx) error {
+ c.Locals("s2s_tenant_id", tenant.ID)
+ c.Locals("s2s_app_id", appModel.ID)
+ return GetUserPermissionsHandler(c)
+ })
+
+ request := func() map[string]any {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/users/%d/permissions", user.ID), nil)
+ resp, requestErr := api.Test(req)
+ if requestErr != nil {
+ t.Fatal(requestErr)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("unexpected status %d", resp.StatusCode)
+ }
+ var payload map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ t.Fatal(err)
+ }
+ return payload["data"].(map[string]any)
+ }
+
+ data := request()
+ permissions := data["permission_codes"].([]any)
+ roles := data["role_codes"].([]any)
+ if len(permissions) != 1 || permissions[0] != "demo.read" || len(roles) != 1 || roles[0] != "demo.user" || data["eligible"] != true {
+ t.Fatalf("unexpected dynamic grants: %+v", data)
+ }
+ var physical int64
+ db.Model(&model.AppUserRole{}).Count(&physical)
+ if physical != 0 {
+ t.Fatalf("S2S materialized inherited role rows: %d", physical)
+ }
+
+ db.Model(&membership).Update("role", model.TenantRoleBanned)
+ data = request()
+ if data["eligible"] != false || len(data["permission_codes"].([]any)) != 0 || data["denial_reason"] != "tenant_membership_banned" {
+ t.Fatalf("banned membership retained grants: %+v", data)
+ }
+}
diff --git a/basaltpass-backend/internal/handler/s2s/wallet_handler.go b/basaltpass-backend/internal/handler/s2s/wallet_handler.go
new file mode 100644
index 00000000..dd4244d4
--- /dev/null
+++ b/basaltpass-backend/internal/handler/s2s/wallet_handler.go
@@ -0,0 +1,108 @@
+package s2s
+
+import (
+ "errors"
+ "strconv"
+ "strings"
+
+ "basaltpass-backend/internal/model"
+ "basaltpass-backend/internal/service/wallet"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+type adjustOwnerWalletRequest struct {
+ Operation string `json:"operation"`
+ Amount int64 `json:"amount"`
+ Currency string `json:"currency"`
+ Reference string `json:"reference"`
+}
+
+func parseWalletOwner(c *fiber.Ctx) (model.WalletOwnerType, uint, uint, error) {
+ ownerType := model.WalletOwnerType(strings.ToLower(strings.TrimSpace(c.Params("owner_type"))))
+ if !ownerType.IsValid() {
+ return "", 0, 0, errors.New("owner_type must be user, app, tenant, or team")
+ }
+ ownerID64, err := strconv.ParseUint(c.Params("owner_id"), 10, 32)
+ if err != nil || ownerID64 == 0 {
+ return "", 0, 0, errors.New("invalid owner_id")
+ }
+ tenantID, err := s2sTenantID(c)
+ if err != nil {
+ return "", 0, 0, err
+ }
+ return ownerType, uint(ownerID64), tenantID, nil
+}
+
+// GetOwnerWalletHandler reads one currency wallet for any tenant-scoped owner.
+func GetOwnerWalletHandler(c *fiber.Ctx) error {
+ ownerType, ownerID, tenantID, err := parseWalletOwner(c)
+ if err != nil {
+ return unifiedResponse(c, fiber.StatusBadRequest, nil, fiber.Map{"code": "invalid_parameter", "message": err.Error()})
+ }
+ currencyCode := strings.ToUpper(strings.TrimSpace(c.Query("currency")))
+ if currencyCode == "" {
+ return unifiedResponse(c, fiber.StatusBadRequest, nil, fiber.Map{"code": "invalid_parameter", "message": "currency is required"})
+ }
+ limit, _ := strconv.Atoi(c.Query("limit", "20"))
+ if limit <= 0 || limit > 200 {
+ limit = 20
+ }
+
+ walletModel, err := wallet.GetOwnerBalanceByCode(ownerType, ownerID, tenantID, currencyCode)
+ if err != nil {
+ return unifiedResponse(c, fiber.StatusBadRequest, nil, fiber.Map{"code": "wallet_error", "message": err.Error()})
+ }
+ txs, err := wallet.OwnerHistoryByCode(ownerType, ownerID, tenantID, currencyCode, limit)
+ if err != nil {
+ return unifiedResponse(c, fiber.StatusInternalServerError, nil, fiber.Map{"code": "wallet_error", "message": err.Error()})
+ }
+ return unifiedResponse(c, fiber.StatusOK, fiber.Map{
+ "owner_type": ownerType, "owner_id": ownerID, "tenant_id": tenantID,
+ "currency": currencyCode, "wallet_id": walletModel.ID,
+ "balance": walletModel.Balance, "transactions": txs,
+ }, nil)
+}
+
+// AdjustOwnerWalletHandler atomically adjusts one owner wallet.
+func AdjustOwnerWalletHandler(c *fiber.Ctx) error {
+ ownerType, ownerID, tenantID, err := parseWalletOwner(c)
+ if err != nil {
+ return unifiedResponse(c, fiber.StatusBadRequest, nil, fiber.Map{"code": "invalid_parameter", "message": err.Error()})
+ }
+ var req adjustOwnerWalletRequest
+ if err := c.BodyParser(&req); err != nil {
+ return unifiedResponse(c, fiber.StatusBadRequest, nil, fiber.Map{"code": "invalid_parameter", "message": "invalid JSON body"})
+ }
+ req.Operation = strings.ToLower(strings.TrimSpace(req.Operation))
+ req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
+ req.Reference = strings.TrimSpace(req.Reference)
+ if req.Operation != "increase" && req.Operation != "decrease" {
+ return unifiedResponse(c, fiber.StatusBadRequest, nil, fiber.Map{"code": "invalid_parameter", "message": "operation must be increase or decrease"})
+ }
+ if req.Amount <= 0 || req.Currency == "" {
+ return unifiedResponse(c, fiber.StatusBadRequest, nil, fiber.Map{"code": "invalid_parameter", "message": "positive amount and currency are required"})
+ }
+
+ delta := req.Amount
+ txType := "s2s_wallet_increase"
+ if req.Operation == "decrease" {
+ delta = -req.Amount
+ txType = "s2s_wallet_decrease"
+ }
+ walletModel, err := wallet.AdjustOwnerByCode(ownerType, ownerID, tenantID, req.Currency, delta, txType, req.Reference)
+ if err != nil {
+ status := fiber.StatusBadRequest
+ if err.Error() == "insufficient funds" {
+ status = fiber.StatusConflict
+ }
+ return unifiedResponse(c, status, nil, fiber.Map{"code": "wallet_error", "message": err.Error()})
+ }
+ return unifiedResponse(c, fiber.StatusOK, fiber.Map{
+ "owner_type": ownerType, "owner_id": ownerID, "tenant_id": tenantID,
+ "wallet_id": walletModel.ID, "currency": req.Currency,
+ "operation": req.Operation, "amount": req.Amount,
+ "balance_delta": delta, "balance": walletModel.Balance,
+ "reference": req.Reference,
+ }, nil)
+}
diff --git a/basaltpass-backend/internal/handler/tenant/app/handler.go b/basaltpass-backend/internal/handler/tenant/app/handler.go
index f6fafe87..228c3b90 100644
--- a/basaltpass-backend/internal/handler/tenant/app/handler.go
+++ b/basaltpass-backend/internal/handler/tenant/app/handler.go
@@ -1,6 +1,7 @@
package app
import (
+ "basaltpass-backend/internal/common"
"basaltpass-backend/internal/model"
app2 "basaltpass-backend/internal/service/app"
"strconv"
@@ -336,3 +337,123 @@ func TenantToggleAppStatusHandler(c *fiber.Ctx) error {
"message": "应用状态更新成功",
})
}
+
+// TenantListAppWalletCurrenciesHandler lists wallet currencies linked to an app.
+// GET /api/v1/tenant/apps/:id/wallet-currencies?category=top_up
+func TenantListAppWalletCurrenciesHandler(c *fiber.Ctx) error {
+ tenantID := getTenantIDFromContext(c)
+ if tenantID == 0 {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "无效的租户上下文"})
+ }
+
+ appID, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "无效的应用ID"})
+ }
+
+ category := strings.TrimSpace(c.Query("category", "top_up"))
+ if category == "" {
+ category = "top_up"
+ }
+
+ var app model.App
+ if err := common.DB().Where("id = ? AND tenant_id = ?", uint(appID), tenantID).First(&app).Error; err != nil {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "应用不存在"})
+ }
+
+ var links []model.AppWalletCurrency
+ if err := common.DB().
+ Preload("Currency").
+ Where("tenant_id = ? AND app_id = ? AND wallet_category = ?", tenantID, uint(appID), category).
+ Order("sort_order ASC, id ASC").
+ Find(&links).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ return c.JSON(fiber.Map{"data": fiber.Map{"items": links}})
+}
+
+// TenantReplaceAppWalletCurrenciesHandler replaces the app wallet currency links for one category.
+// PUT /api/v1/tenant/apps/:id/wallet-currencies
+func TenantReplaceAppWalletCurrenciesHandler(c *fiber.Ctx) error {
+ if err := requireTenantAdminRole(c); err != nil {
+ return err
+ }
+
+ tenantID := getTenantIDFromContext(c)
+ if tenantID == 0 {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "无效的租户上下文"})
+ }
+
+ appID, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "无效的应用ID"})
+ }
+
+ var req struct {
+ WalletCategory string `json:"wallet_category"`
+ CurrencyCodes []string `json:"currency_codes"`
+ }
+ if err := c.BodyParser(&req); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "请求参数错误"})
+ }
+
+ category := strings.TrimSpace(req.WalletCategory)
+ if category == "" {
+ category = "top_up"
+ }
+
+ var app model.App
+ if err := common.DB().Where("id = ? AND tenant_id = ?", uint(appID), tenantID).First(&app).Error; err != nil {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "应用不存在"})
+ }
+
+ seen := map[string]bool{}
+ var currencies []model.Currency
+ for _, raw := range req.CurrencyCodes {
+ code := strings.ToUpper(strings.TrimSpace(raw))
+ if code == "" || seen[code] {
+ continue
+ }
+ seen[code] = true
+ var curr model.Currency
+ if err := common.DB().Where("code = ? AND is_active = ?", code, true).First(&curr).Error; err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "无效的货币代码: " + code})
+ }
+ currencies = append(currencies, curr)
+ }
+
+ tx := common.DB().Begin()
+ if err := tx.Where("tenant_id = ? AND app_id = ? AND wallet_category = ?", tenantID, uint(appID), category).Delete(&model.AppWalletCurrency{}).Error; err != nil {
+ tx.Rollback()
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ for idx, curr := range currencies {
+ link := model.AppWalletCurrency{
+ TenantID: tenantID,
+ AppID: uint(appID),
+ CurrencyID: curr.ID,
+ WalletCategory: category,
+ SortOrder: idx,
+ IsDefault: idx == 0,
+ }
+ if err := tx.Create(&link).Error; err != nil {
+ tx.Rollback()
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+ }
+ if err := tx.Commit().Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ var links []model.AppWalletCurrency
+ if err := common.DB().
+ Preload("Currency").
+ Where("tenant_id = ? AND app_id = ? AND wallet_category = ?", tenantID, uint(appID), category).
+ Order("sort_order ASC, id ASC").
+ Find(&links).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ return c.JSON(fiber.Map{"data": fiber.Map{"items": links}, "message": "应用钱包币种已更新"})
+}
diff --git a/basaltpass-backend/internal/handler/tenant/app_grant_mapping_handler.go b/basaltpass-backend/internal/handler/tenant/app_grant_mapping_handler.go
new file mode 100644
index 00000000..4167c14f
--- /dev/null
+++ b/basaltpass-backend/internal/handler/tenant/app_grant_mapping_handler.go
@@ -0,0 +1,165 @@
+package tenant
+
+import (
+ "errors"
+ "strconv"
+ "time"
+
+ "basaltpass-backend/internal/common"
+ "basaltpass-backend/internal/service/aduit"
+ "basaltpass-backend/internal/service/appgrant"
+ "basaltpass-backend/internal/utils"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+var appGrantNow = func() time.Time { return time.Now().UTC() }
+
+func ListAppGrantMappingsHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := appGrantContext(c)
+ if !ok {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant or app context"})
+ }
+ items, err := appgrant.NewService(common.DB()).ListMappings(tenantID, appID)
+ if err != nil {
+ return appGrantError(c, err)
+ }
+ return c.JSON(fiber.Map{"data": fiber.Map{"mappings": items}})
+}
+
+func GetAppGrantMappingOptionsHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := appGrantContext(c)
+ if !ok {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant or app context"})
+ }
+ options, err := appgrant.NewService(common.DB()).Options(tenantID, appID)
+ if err != nil {
+ return appGrantError(c, err)
+ }
+ return c.JSON(fiber.Map{"data": options})
+}
+
+func PreviewAppGrantMappingHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := appGrantContext(c)
+ if !ok {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant or app context"})
+ }
+ var input appgrant.MappingInput
+ if err := c.BodyParser(&input); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
+ }
+ count, err := appgrant.NewService(common.DB()).PreviewAffectedUsers(tenantID, appID, input)
+ if err != nil {
+ return appGrantError(c, err)
+ }
+ return c.JSON(fiber.Map{"data": fiber.Map{"affected_user_count": count}})
+}
+
+func CreateAppGrantMappingHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := appGrantContext(c)
+ if !ok {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant or app context"})
+ }
+ actorID, err := utils.PositiveUintFromAny(c.Locals("userID"))
+ if err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid user context"})
+ }
+ var input appgrant.MappingInput
+ if err := c.BodyParser(&input); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
+ }
+ created, err := appgrant.NewService(common.DB()).CreateMapping(tenantID, appID, actorID, input)
+ if err != nil {
+ return appGrantError(c, err)
+ }
+ aduit.LogAuditWithDetails(actorID, "tenant_app_grant_mapping_create", "tenant_app_grant_mapping", strconv.FormatUint(uint64(created.ID), 10), c.IP(), c.Get("User-Agent"), created)
+ return c.Status(fiber.StatusCreated).JSON(fiber.Map{"data": created})
+}
+
+func UpdateAppGrantMappingHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := appGrantContext(c)
+ if !ok {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant or app context"})
+ }
+ mappingID, err := utils.ParsePositiveUint(c.Params("mapping_id"))
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid mapping id"})
+ }
+ actorID, err := utils.PositiveUintFromAny(c.Locals("userID"))
+ if err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid user context"})
+ }
+ var input appgrant.MappingInput
+ if err := c.BodyParser(&input); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
+ }
+ updated, err := appgrant.NewService(common.DB()).UpdateMapping(tenantID, appID, mappingID, actorID, input)
+ if err != nil {
+ return appGrantError(c, err)
+ }
+ aduit.LogAuditWithDetails(actorID, "tenant_app_grant_mapping_update", "tenant_app_grant_mapping", strconv.FormatUint(uint64(updated.ID), 10), c.IP(), c.Get("User-Agent"), updated)
+ return c.JSON(fiber.Map{"data": updated})
+}
+
+func DeleteAppGrantMappingHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := appGrantContext(c)
+ if !ok {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant or app context"})
+ }
+ mappingID, err := utils.ParsePositiveUint(c.Params("mapping_id"))
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid mapping id"})
+ }
+ actorID, err := utils.PositiveUintFromAny(c.Locals("userID"))
+ if err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid user context"})
+ }
+ deleted, err := appgrant.NewService(common.DB()).DeleteMappingWithView(tenantID, appID, mappingID)
+ if err != nil {
+ return appGrantError(c, err)
+ }
+ aduit.LogAuditWithDetails(actorID, "tenant_app_grant_mapping_delete", "tenant_app_grant_mapping", strconv.FormatUint(uint64(mappingID), 10), c.IP(), c.Get("User-Agent"), deleted)
+ return c.SendStatus(fiber.StatusNoContent)
+}
+
+func GetEffectiveAppUserGrantsHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := appGrantContext(c)
+ if !ok {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant or app context"})
+ }
+ userID, err := utils.ParsePositiveUint(c.Params("user_id"))
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid user id"})
+ }
+ result, err := appgrant.NewService(common.DB()).Resolve(userID, tenantID, appID, appGrantNow())
+ if err != nil {
+ return appGrantError(c, err)
+ }
+ return c.JSON(fiber.Map{"data": result})
+}
+
+func appGrantContext(c *fiber.Ctx) (uint, uint, bool) {
+ tenantID, err := utils.PositiveUintFromAny(c.Locals("tenantID"))
+ if err != nil {
+ return 0, 0, false
+ }
+ appID, err := utils.ParsePositiveUint(c.Params("app_id"))
+ if err != nil {
+ return 0, 0, false
+ }
+ return tenantID, appID, true
+}
+
+func appGrantError(c *fiber.Ctx, err error) error {
+ var validation *appgrant.ValidationError
+ switch {
+ case errors.Is(err, appgrant.ErrNotFound):
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": err.Error()})
+ case errors.Is(err, appgrant.ErrConflict):
+ return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()})
+ case errors.As(err, &validation):
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": validation.Error()})
+ default:
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "app grant mapping operation failed"})
+ }
+}
diff --git a/basaltpass-backend/internal/handler/tenant/permission_handler.go b/basaltpass-backend/internal/handler/tenant/permission_handler.go
index 85e0bae2..44d05ea9 100644
--- a/basaltpass-backend/internal/handler/tenant/permission_handler.go
+++ b/basaltpass-backend/internal/handler/tenant/permission_handler.go
@@ -10,6 +10,7 @@ import (
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
+ "gorm.io/gorm/clause"
)
// PermissionRequest 创建/更新权限请求
@@ -206,7 +207,6 @@ func UpdateTenantPermission(c *fiber.Ctx) error {
"error": "获取权限失败",
})
}
-
// 如果修改了代码,检查新代码是否已存在
if req.Code != permission.Code {
var existingPerm model.TenantRbacPermission
@@ -277,6 +277,15 @@ func DeleteTenantPermission(c *fiber.Ctx) error {
"error": "获取权限失败",
})
}
+ var mappingCount int64
+ if err := common.DB().Model(&model.TenantAppGrantMapping{}).
+ Where("tenant_id = ? AND source_type = ? AND source_id = ?", tenantID, model.TenantAppGrantSourceTenantPermission, permissionID).
+ Count(&mappingCount).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "检查权限映射失败"})
+ }
+ if mappingCount > 0 {
+ return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "该权限正在被 tenant→app 映射引用,无法删除"})
+ }
// 开始事务
tx := common.DB().Begin()
@@ -285,6 +294,23 @@ func DeleteTenantPermission(c *fiber.Ctx) error {
tx.Rollback()
}
}()
+ // Serialize source deletion with mapping validation and repeat the reference
+ // check in the same transaction to avoid a check/delete race.
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("id = ? AND tenant_id = ?", permissionID, tenantID).First(&permission).Error; err != nil {
+ tx.Rollback()
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "权限不存在"})
+ }
+ if err := tx.Model(&model.TenantAppGrantMapping{}).
+ Where("tenant_id = ? AND source_type = ? AND source_id = ?", tenantID, model.TenantAppGrantSourceTenantPermission, permissionID).
+ Count(&mappingCount).Error; err != nil {
+ tx.Rollback()
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "检查权限映射失败"})
+ }
+ if mappingCount > 0 {
+ tx.Rollback()
+ return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "该权限正在被 tenant→app 映射引用,无法删除"})
+ }
// 删除角色-权限关联
if err := tx.Where("permission_id = ?", permissionID).
diff --git a/basaltpass-backend/internal/handler/tenant/rbac_manifest_handler.go b/basaltpass-backend/internal/handler/tenant/rbac_manifest_handler.go
new file mode 100644
index 00000000..3297296f
--- /dev/null
+++ b/basaltpass-backend/internal/handler/tenant/rbac_manifest_handler.go
@@ -0,0 +1,149 @@
+package tenant
+
+import (
+ "errors"
+ "strconv"
+ "strings"
+
+ "basaltpass-backend/internal/common"
+ "basaltpass-backend/internal/service/rbacmanifest"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+type rejectRBACManifestRequest struct {
+ Note string `json:"note"`
+}
+
+func ListAppRBACManifestsHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := manifestTenantAppContext(c)
+ if !ok {
+ return nil
+ }
+ views, err := rbacmanifest.New(common.DB()).ListManifests(tenantID, appID)
+ if err != nil {
+ return tenantManifestError(c, err)
+ }
+ return c.JSON(fiber.Map{"data": fiber.Map{"manifests": views}})
+}
+
+func GetAppRBACManifestHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := manifestTenantAppContext(c)
+ if !ok {
+ return nil
+ }
+ manifestID, err := strconv.ParseUint(c.Params("manifest_id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid manifest ID"})
+ }
+ view, err := rbacmanifest.New(common.DB()).GetManifest(tenantID, appID, uint(manifestID))
+ if err != nil {
+ return tenantManifestError(c, err)
+ }
+ return c.JSON(fiber.Map{"data": view})
+}
+
+func ApproveAppRBACManifestHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := manifestTenantAppContext(c)
+ if !ok {
+ return nil
+ }
+ manifestID, err := strconv.ParseUint(c.Params("manifest_id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid manifest ID"})
+ }
+ reviewerID, ok := c.Locals("userID").(uint)
+ if !ok || reviewerID == 0 {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "reviewer context is required"})
+ }
+ view, err := rbacmanifest.New(common.DB()).Approve(tenantID, appID, uint(manifestID), reviewerID)
+ if err != nil {
+ return tenantManifestError(c, err)
+ }
+ return c.JSON(fiber.Map{"data": view, "message": "RBAC manifest approved and published"})
+}
+
+func RejectAppRBACManifestHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := manifestTenantAppContext(c)
+ if !ok {
+ return nil
+ }
+ manifestID, err := strconv.ParseUint(c.Params("manifest_id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid manifest ID"})
+ }
+ var request rejectRBACManifestRequest
+ if len(c.Body()) > 0 {
+ if err := c.BodyParser(&request); err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid rejection request"})
+ }
+ }
+ reviewerID, ok := c.Locals("userID").(uint)
+ if !ok || reviewerID == 0 {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "reviewer context is required"})
+ }
+ view, err := rbacmanifest.New(common.DB()).Reject(tenantID, appID, uint(manifestID), reviewerID, strings.TrimSpace(request.Note))
+ if err != nil {
+ return tenantManifestError(c, err)
+ }
+ return c.JSON(fiber.Map{"data": view, "message": "RBAC manifest rejected"})
+}
+
+func ListAppRBACRevisionsHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := manifestTenantAppContext(c)
+ if !ok {
+ return nil
+ }
+ views, err := rbacmanifest.New(common.DB()).ListRevisions(tenantID, appID)
+ if err != nil {
+ return tenantManifestError(c, err)
+ }
+ return c.JSON(fiber.Map{"data": fiber.Map{"revisions": views}})
+}
+
+func RollbackAppRBACRevisionHandler(c *fiber.Ctx) error {
+ tenantID, appID, ok := manifestTenantAppContext(c)
+ if !ok {
+ return nil
+ }
+ revisionID, err := strconv.ParseUint(c.Params("revision_id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid revision ID"})
+ }
+ reviewerID, ok := c.Locals("userID").(uint)
+ if !ok || reviewerID == 0 {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "reviewer context is required"})
+ }
+ view, err := rbacmanifest.New(common.DB()).Rollback(tenantID, appID, uint(revisionID), reviewerID)
+ if err != nil {
+ return tenantManifestError(c, err)
+ }
+ return c.JSON(fiber.Map{"data": view, "message": "RBAC revision rolled back atomically"})
+}
+
+func manifestTenantAppContext(c *fiber.Ctx) (uint, uint, bool) {
+ tenantID, ok := c.Locals("tenantID").(uint)
+ if !ok || tenantID == 0 {
+ _ = c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "tenant context is required"})
+ return 0, 0, false
+ }
+ appID, err := strconv.ParseUint(c.Params("app_id"), 10, 32)
+ if err != nil || appID == 0 {
+ _ = c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid app ID"})
+ return 0, 0, false
+ }
+ return tenantID, uint(appID), true
+}
+
+func tenantManifestError(c *fiber.Ctx, err error) error {
+ switch {
+ case errors.Is(err, rbacmanifest.ErrValidation):
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
+ case errors.Is(err, rbacmanifest.ErrConflict):
+ return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()})
+ case errors.Is(err, rbacmanifest.ErrNotFound):
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "RBAC resource not found"})
+ default:
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "RBAC manifest operation failed"})
+ }
+}
diff --git a/basaltpass-backend/internal/handler/tenant/role_handler.go b/basaltpass-backend/internal/handler/tenant/role_handler.go
index 5e8e5c55..0ff537b7 100644
--- a/basaltpass-backend/internal/handler/tenant/role_handler.go
+++ b/basaltpass-backend/internal/handler/tenant/role_handler.go
@@ -10,6 +10,7 @@ import (
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
+ "gorm.io/gorm/clause"
)
// RoleRequest 创建/更新角色请求
@@ -282,6 +283,15 @@ func DeleteTenantRole(c *fiber.Ctx) error {
if role.IsSystem {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "系统角色不能删除"})
}
+ var mappingCount int64
+ if err := common.DB().Model(&model.TenantAppGrantMapping{}).
+ Where("tenant_id = ? AND source_type = ? AND source_id = ?", tenantID, model.TenantAppGrantSourceTenantRole, roleID).
+ Count(&mappingCount).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "检查角色映射失败"})
+ }
+ if mappingCount > 0 {
+ return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "该角色正在被 tenant→app 映射引用,无法删除"})
+ }
// 检查是否有用户正在使用该角色
var userCount int64
@@ -297,6 +307,32 @@ func DeleteTenantRole(c *fiber.Ctx) error {
tx.Rollback()
}
}()
+ // Re-lock and re-check inside the delete transaction. Mapping writes lock
+ // the same source row, so a concurrent create/update cannot leave a dangling
+ // source after the checks above.
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("id = ? AND tenant_id = ?", roleID, tenantID).First(&role).Error; err != nil {
+ tx.Rollback()
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "角色不存在"})
+ }
+ if err := tx.Model(&model.TenantAppGrantMapping{}).
+ Where("tenant_id = ? AND source_type = ? AND source_id = ?", tenantID, model.TenantAppGrantSourceTenantRole, roleID).
+ Count(&mappingCount).Error; err != nil {
+ tx.Rollback()
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "检查角色映射失败"})
+ }
+ if mappingCount > 0 {
+ tx.Rollback()
+ return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "该角色正在被 tenant→app 映射引用,无法删除"})
+ }
+ if err := tx.Model(&model.TenantUserRbacRole{}).Where("role_id = ?", roleID).Count(&userCount).Error; err != nil {
+ tx.Rollback()
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "检查角色用户失败"})
+ }
+ if userCount > 0 {
+ tx.Rollback()
+ return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "该角色正在被用户使用,无法删除"})
+ }
// 删除角色权限关联
if err := tx.Where("role_id = ?", roleID).Delete(&model.TenantRbacRolePermission{}).Error; err != nil {
diff --git a/basaltpass-backend/internal/handler/user/app_recharge_handler.go b/basaltpass-backend/internal/handler/user/app_recharge_handler.go
new file mode 100644
index 00000000..d98cd936
--- /dev/null
+++ b/basaltpass-backend/internal/handler/user/app_recharge_handler.go
@@ -0,0 +1,120 @@
+package user
+
+import (
+ "basaltpass-backend/internal/common"
+ "basaltpass-backend/internal/model"
+ "strconv"
+ "strings"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func serializeAppRechargeCurrency(link model.AppWalletCurrency) fiber.Map {
+ curr := link.Currency
+ return fiber.Map{
+ "id": curr.ID,
+ "code": curr.Code,
+ "name": curr.Name,
+ "name_cn": curr.NameCN,
+ "symbol": curr.Symbol,
+ "decimal_places": curr.DecimalPlaces,
+ "type": curr.Type,
+ "payment_enabled": curr.PaymentEnabled,
+ "is_active": curr.IsActive,
+ "description": curr.Description,
+ "icon_url": curr.IconURL,
+ "wallet_category": link.WalletCategory,
+ "is_default": link.IsDefault,
+ "sort_order": link.SortOrder,
+ }
+}
+
+func resolveAppRechargeTenantID(c *fiber.Ctx) uint {
+ raw := strings.TrimSpace(c.Query("tenant"))
+ if raw == "" {
+ tenantID, _ := c.Locals("tenantID").(uint)
+ return tenantID
+ }
+ if id, err := strconv.ParseUint(raw, 10, 32); err == nil && id > 0 {
+ return uint(id)
+ }
+ var tenant model.Tenant
+ if err := common.DB().
+ Select("id").
+ Where("code = ? AND status = ?", raw, model.TenantStatusActive).
+ First(&tenant).Error; err != nil {
+ return 0
+ }
+ return tenant.ID
+}
+
+// GetAppRechargeConfigHandler returns wallet currencies linked to an app.
+// GET /api/v1/user/apps/recharge-config?app_id=1
+// GET /api/v1/user/apps/recharge-config?client_id=oauth-client-id
+func GetAppRechargeConfigHandler(c *fiber.Ctx) error {
+ activeTenantID := resolveAppRechargeTenantID(c)
+ category := strings.TrimSpace(c.Query("category", "top_up"))
+ if category == "" {
+ category = "top_up"
+ }
+
+ var app model.App
+ db := common.DB()
+ if clientID := strings.TrimSpace(c.Query("client_id")); clientID != "" {
+ var client model.OAuthClient
+ if err := db.Preload("App").Where("client_id = ? AND is_active = ?", clientID, true).First(&client).Error; err != nil {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "app not found"})
+ }
+ if client.App.ID == 0 || (activeTenantID != 0 && client.App.TenantID != activeTenantID) {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "app not found"})
+ }
+ app = client.App
+ } else {
+ appID, err := strconv.ParseUint(c.Query("app_id"), 10, 32)
+ if err != nil || appID == 0 {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "app_id or client_id is required"})
+ }
+ query := db.Where("id = ?", uint(appID))
+ if activeTenantID != 0 {
+ query = query.Where("tenant_id = ?", activeTenantID)
+ }
+ if err := query.First(&app).Error; err != nil {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "app not found"})
+ }
+ }
+
+ if app.Status != model.AppStatusActive {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "app not found"})
+ }
+
+ var links []model.AppWalletCurrency
+ if err := db.
+ Preload("Currency").
+ Where("tenant_id = ? AND app_id = ? AND wallet_category = ?", app.TenantID, app.ID, category).
+ Order("sort_order ASC, id ASC").
+ Find(&links).Error; err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
+ }
+
+ currencies := make([]fiber.Map, 0, len(links))
+ for _, link := range links {
+ if link.Currency.ID == 0 || !link.Currency.IsActive {
+ continue
+ }
+ currencies = append(currencies, serializeAppRechargeCurrency(link))
+ }
+
+ return c.JSON(fiber.Map{
+ "app": fiber.Map{
+ "id": app.ID,
+ "tenant_id": app.TenantID,
+ "name": app.Name,
+ "description": app.Description,
+ "icon_url": app.IconURL,
+ "logo_url": app.LogoURL,
+ "homepage_url": app.HomepageURL,
+ },
+ "wallet_category": category,
+ "currencies": currencies,
+ })
+}
diff --git a/basaltpass-backend/internal/handler/user/profile_handler.go b/basaltpass-backend/internal/handler/user/profile_handler.go
index c037cec7..9a732815 100644
--- a/basaltpass-backend/internal/handler/user/profile_handler.go
+++ b/basaltpass-backend/internal/handler/user/profile_handler.go
@@ -49,19 +49,21 @@ func serializeCurrency(currency *model.Currency) fiber.Map {
}
return fiber.Map{
- "id": currency.ID,
- "code": currency.Code,
- "name": currency.Name,
- "name_cn": currency.NameCN,
- "symbol": currency.Symbol,
- "decimal_places": currency.DecimalPlaces,
- "type": currency.Type,
- "is_active": currency.IsActive,
- "sort_order": currency.SortOrder,
- "description": currency.Description,
- "icon_url": currency.IconURL,
- "created_at": currency.CreatedAt,
- "updated_at": currency.UpdatedAt,
+ "id": currency.ID,
+ "code": currency.Code,
+ "name": currency.Name,
+ "name_cn": currency.NameCN,
+ "symbol": currency.Symbol,
+ "decimal_places": currency.DecimalPlaces,
+ "type": currency.Type,
+ "exchange_rate_usd": currency.ExchangeRateUSD,
+ "payment_enabled": currency.PaymentEnabled,
+ "is_active": currency.IsActive,
+ "sort_order": currency.SortOrder,
+ "description": currency.Description,
+ "icon_url": currency.IconURL,
+ "created_at": currency.CreatedAt,
+ "updated_at": currency.UpdatedAt,
}
}
diff --git a/basaltpass-backend/internal/handler/user/team/handler.go b/basaltpass-backend/internal/handler/user/team/handler.go
index 805cb742..7ee0e397 100644
--- a/basaltpass-backend/internal/handler/user/team/handler.go
+++ b/basaltpass-backend/internal/handler/user/team/handler.go
@@ -29,12 +29,16 @@ func InitHandler(db *gorm.DB) {
}
// 用户侧导出函数,供路由使用
-func CreateTeamHandler(c *fiber.Ctx) error { return userTeamHandler.CreateTeam(c) }
-func GetUserTeamsHandler(c *fiber.Ctx) error { return userTeamHandler.GetUserTeams(c) }
-func GetTeamHandler(c *fiber.Ctx) error { return userTeamHandler.GetTeam(c) }
-func UpdateTeamHandler(c *fiber.Ctx) error { return userTeamHandler.UpdateTeam(c) }
-func DeleteTeamHandler(c *fiber.Ctx) error { return userTeamHandler.DeleteTeam(c) }
-func GetTeamMembersHandler(c *fiber.Ctx) error { return userTeamHandler.GetTeamMembers(c) }
+func CreateTeamHandler(c *fiber.Ctx) error { return userTeamHandler.CreateTeam(c) }
+func GetUserTeamsHandler(c *fiber.Ctx) error { return userTeamHandler.GetUserTeams(c) }
+func GetTeamHandler(c *fiber.Ctx) error { return userTeamHandler.GetTeam(c) }
+func UpdateTeamHandler(c *fiber.Ctx) error { return userTeamHandler.UpdateTeam(c) }
+func DeleteTeamHandler(c *fiber.Ctx) error { return userTeamHandler.DeleteTeam(c) }
+func GetTeamMembersHandler(c *fiber.Ctx) error { return userTeamHandler.GetTeamMembers(c) }
+func GetTeamWalletsHandler(c *fiber.Ctx) error { return userTeamHandler.GetTeamWallets(c) }
+func GetTeamWalletHistoryHandler(c *fiber.Ctx) error {
+ return userTeamHandler.GetTeamWalletHistory(c)
+}
func AddMemberHandler(c *fiber.Ctx) error { return userTeamHandler.AddMember(c) }
func UpdateMemberRoleHandler(c *fiber.Ctx) error { return userTeamHandler.UpdateMemberRole(c) }
func RemoveMemberHandler(c *fiber.Ctx) error { return userTeamHandler.RemoveMember(c) }
@@ -180,6 +184,58 @@ func (h *Handler) GetTeamMembers(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"data": members})
}
+func (h *Handler) GetTeamWallets(c *fiber.Ctx) error {
+ teamID, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "团队ID无效"})
+ }
+ userID := c.Locals("userID").(uint)
+ wallets, err := h.service.GetTeamWallets(uint(teamID), userID)
+ if err != nil {
+ return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
+ }
+ data := make([]fiber.Map, 0, len(wallets))
+ for _, wallet := range wallets {
+ data = append(data, fiber.Map{
+ "id": wallet.ID, "tenant_id": wallet.TenantID,
+ "owner_type": wallet.OwnerType, "owner_id": wallet.OwnerID,
+ "currency_id": wallet.CurrencyID, "balance": wallet.Balance,
+ "freeze": wallet.Freeze, "currency": wallet.Currency,
+ "created_at": wallet.CreatedAt, "updated_at": wallet.UpdatedAt,
+ })
+ }
+ return c.JSON(fiber.Map{"data": data})
+}
+
+func (h *Handler) GetTeamWalletHistory(c *fiber.Ctx) error {
+ teamID, err := strconv.ParseUint(c.Params("id"), 10, 32)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "团队ID无效"})
+ }
+ currencyCode := c.Query("currency")
+ if currencyCode == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "currency is required"})
+ }
+ limit, _ := strconv.Atoi(c.Query("limit", "50"))
+ if limit <= 0 || limit > 200 {
+ limit = 50
+ }
+ userID := c.Locals("userID").(uint)
+ txs, err := h.service.GetTeamWalletHistory(uint(teamID), userID, currencyCode, limit)
+ if err != nil {
+ return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
+ }
+ data := make([]fiber.Map, 0, len(txs))
+ for _, tx := range txs {
+ data = append(data, fiber.Map{
+ "id": tx.ID, "wallet_id": tx.WalletID, "type": tx.Type,
+ "amount": tx.Amount, "status": tx.Status, "reference": tx.Reference,
+ "created_at": tx.CreatedAt,
+ })
+ }
+ return c.JSON(fiber.Map{"data": data})
+}
+
// LeaveTeam 离开团队
func (h *Handler) LeaveTeam(c *fiber.Ctx) error {
teamID, err := strconv.ParseUint(c.Params("id"), 10, 32)
diff --git a/basaltpass-backend/internal/handler/user/team/service.go b/basaltpass-backend/internal/handler/user/team/service.go
index 9edcf7d5..bfe0bdce 100644
--- a/basaltpass-backend/internal/handler/user/team/service.go
+++ b/basaltpass-backend/internal/handler/user/team/service.go
@@ -7,6 +7,7 @@ import (
userdto "basaltpass-backend/internal/dto/user"
"basaltpass-backend/internal/model"
+ walletservice "basaltpass-backend/internal/service/wallet"
"gorm.io/gorm"
)
@@ -42,6 +43,10 @@ func (s *Service) CreateTeam(userID uint, req *userdto.CreateTeamRequest) (*mode
tx.Rollback()
return nil, fmt.Errorf("创建团队成员失败: %w", err)
}
+ if err := walletservice.EnsureOwnerCreditWalletTx(tx, model.WalletOwnerTeam, team.ID, team.TenantID); err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("创建团队钱包失败: %w", err)
+ }
if err := tx.Commit().Error; err != nil {
return nil, fmt.Errorf("提交事务失败: %w", err)
@@ -260,6 +265,36 @@ func (s *Service) GetTeamMembers(teamID uint, userID uint) ([]userdto.TeamMember
return res, nil
}
+func (s *Service) GetTeamWallets(teamID uint, userID uint) ([]model.Wallet, error) {
+ member, err := s.getTeamMember(teamID, userID)
+ if err != nil {
+ return nil, err
+ }
+ if member.Status != "active" {
+ return nil, errors.New("团队成员状态不可用")
+ }
+ var team model.Team
+ if err := s.db.Select("id", "tenant_id").First(&team, teamID).Error; err != nil {
+ return nil, fmt.Errorf("获取团队失败: %w", err)
+ }
+ return walletservice.ListOwnerWallets(model.WalletOwnerTeam, team.ID, team.TenantID)
+}
+
+func (s *Service) GetTeamWalletHistory(teamID uint, userID uint, currencyCode string, limit int) ([]model.WalletTx, error) {
+ member, err := s.getTeamMember(teamID, userID)
+ if err != nil {
+ return nil, err
+ }
+ if member.Status != "active" {
+ return nil, errors.New("团队成员状态不可用")
+ }
+ var team model.Team
+ if err := s.db.Select("id", "tenant_id").First(&team, teamID).Error; err != nil {
+ return nil, fmt.Errorf("获取团队失败: %w", err)
+ }
+ return walletservice.OwnerHistoryByCode(model.WalletOwnerTeam, team.ID, team.TenantID, currencyCode, limit)
+}
+
// LeaveTeam 离开团队
func (s *Service) LeaveTeam(teamID uint, userID uint) error {
member, err := s.getTeamMember(teamID, userID)
diff --git a/basaltpass-backend/internal/handler/user/wallet_handler.go b/basaltpass-backend/internal/handler/user/wallet_handler.go
index 6d28e9df..66fd03a0 100644
--- a/basaltpass-backend/internal/handler/user/wallet_handler.go
+++ b/basaltpass-backend/internal/handler/user/wallet_handler.go
@@ -25,27 +25,23 @@ func GetWalletBalanceHandler(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"balance": w.Balance, "currency_id": w.CurrencyID, "tenant_id": w.TenantID})
}
-// RechargeWalletHandler POST /wallet/recharge {currency, amount}
-func RechargeWalletHandler(c *fiber.Ctx) error {
+// GetWalletAccountsHandler GET /wallet/accounts
+func GetWalletAccountsHandler(c *fiber.Ctx) error {
uid := c.Locals("userID").(uint)
activeTenantID, _ := c.Locals("tenantID").(uint)
- var body struct {
- Currency string `json:"currency"`
- Amount int64 `json:"amount"`
- }
- if err := c.BodyParser(&body); err != nil {
- return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
- }
- if err := wallet.RechargeByCodeWithTenant(uid, activeTenantID, body.Currency, body.Amount); err != nil {
+ wallets, err := wallet.ListUserWalletsWithTenant(uid, activeTenantID)
+ if err != nil {
if errors.Is(err, wallet.ErrNoTenantIdentity) {
- return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "wallet is unavailable for users without tenant"})
- }
- if errors.Is(err, wallet.ErrWalletRechargeWithdrawDisabled) {
- return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()})
+ return c.JSON([]interface{}{})
}
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
- return c.SendStatus(fiber.StatusNoContent)
+ return c.JSON(wallets)
+}
+
+// RechargeWalletHandler POST /wallet/recharge {currency, amount}
+func RechargeWalletHandler(c *fiber.Ctx) error {
+ return c.Status(fiber.StatusGone).JSON(fiber.Map{"error": "wallet top-up must use checkout"})
}
// WithdrawWalletHandler POST /wallet/withdraw {currency, amount}
diff --git a/basaltpass-backend/internal/middleware/s2s/chain.go b/basaltpass-backend/internal/middleware/s2s/chain.go
index f27ed430..1ce2da42 100644
--- a/basaltpass-backend/internal/middleware/s2s/chain.go
+++ b/basaltpass-backend/internal/middleware/s2s/chain.go
@@ -22,8 +22,10 @@ func ClientAuthMiddleware(requiredScopes ...string) fiber.Handler {
return func(c *fiber.Ctx) error {
clientID := strings.TrimSpace(c.Get("client_id"))
clientSecret := strings.TrimSpace(c.Get("client_secret"))
+ authSource := "header"
if clientID == "" || clientSecret == "" {
+ authSource = "form"
if clientID == "" {
clientID = strings.TrimSpace(c.FormValue("client_id"))
}
@@ -34,6 +36,7 @@ func ClientAuthMiddleware(requiredScopes ...string) fiber.Handler {
if clientID == "" || clientSecret == "" {
if config.Get().S2S.AllowQueryCredentials {
+ authSource = "query"
if clientID == "" {
clientID = strings.TrimSpace(c.Query("client_id"))
}
@@ -68,6 +71,7 @@ func ClientAuthMiddleware(requiredScopes ...string) fiber.Handler {
c.Locals("s2s_client_id", client.ClientID)
c.Locals("s2s_app_id", client.AppID)
+ c.Locals("s2s_auth_source", authSource)
if client.App.ID != 0 {
c.Locals("s2s_tenant_id", client.App.TenantID)
}
diff --git a/basaltpass-backend/internal/migration/migrate.go b/basaltpass-backend/internal/migration/migrate.go
index b9c0b5f7..2c0dbf8c 100644
--- a/basaltpass-backend/internal/migration/migrate.go
+++ b/basaltpass-backend/internal/migration/migrate.go
@@ -119,6 +119,9 @@ func RunMigrations() error {
if err := db.AutoMigrate(&model.Currency{}); err != nil {
return fmt.Errorf("[Error] Failed to create currencies table: %w", err)
}
+ if err := db.AutoMigrate(&model.CurrencyRate{}); err != nil {
+ return fmt.Errorf("[Error] Failed to create currency rates table: %w", err)
+ }
// 然后初始化默认货币
if err := currency.InitDefaultCurrencies(); err != nil {
@@ -132,6 +135,12 @@ func RunMigrations() error {
} else {
log.Printf("[Migration] Ensured CREDIT currency (created=%d, skipped=%d)", created, skipped)
}
+ if err := currency.EnsurePaymentDefaults(); err != nil {
+ log.Printf("[Migration] Failed to ensure currency payment defaults: %v", err)
+ }
+ if err := currency.EnsureDefaultCurrencyRates(); err != nil {
+ log.Printf("[Migration] Failed to ensure default currency rates: %v", err)
+ }
// 用户表历史列必须在 User AutoMigrate 之前处理,否则 AutoMigrate 会先创建
// enforced_tenant_id,导致旧 tenant_id 无法被直接重命名。
@@ -139,6 +148,13 @@ func RunMigrations() error {
// 迁移钱包货币字段(在完整AutoMigrate之前处理)
MigrateWalletCurrencyField()
+ if err := MigrateTeamTenantFields(); err != nil {
+ return fmt.Errorf("[Error] Failed to migrate legacy team tenants: %w", err)
+ }
+ MigrateWalletTenantField()
+ if err := MigrateWalletOwnerFields(); err != nil {
+ return fmt.Errorf("[Error] Failed to migrate wallet owners: %w", err)
+ }
// 执行完整的自动迁移
err := db.AutoMigrate(
@@ -194,6 +210,7 @@ func RunMigrations() error {
&model.TenantUser{}, // 租户管理员
&model.TenantInvitation{}, // 租户邀请
&model.App{},
+ &model.AppWalletCurrency{},
&model.AppUser{}, // 业务应用用户映射
&model.TenantQuota{}, // 租户配额
&model.TenantAuthSetting{},
@@ -231,6 +248,7 @@ func RunMigrations() error {
&model.PaymentIntent{},
&model.PaymentSession{},
&model.PaymentWebhookEvent{},
+ &model.CurrencyRate{},
// 订单系统模型
&model.Order{},
@@ -240,6 +258,9 @@ func RunMigrations() error {
&model.AppRole{},
&model.AppUserPermission{},
&model.AppUserRole{},
+ &model.AppRBACManifest{},
+ &model.AppRBACRevision{},
+ &model.TenantAppGrantMapping{},
// 租户RBAC权限系统模型
&model.TenantRbacPermission{},
@@ -300,6 +321,12 @@ func RunMigrations() error {
} else {
log.Printf("[Migration] Credit wallets backfilled for users (created=%d)", createdWallets)
}
+ createdTeamWallets, err := wallet.EnsureCreditWalletsForAllTeams()
+ if err != nil {
+ log.Printf("[Migration] Failed to backfill credit wallets for teams: %v", err)
+ } else {
+ log.Printf("[Migration] Credit wallets backfilled for teams (created=%d)", createdTeamWallets)
+ }
return nil
} // handleSpecialMigrations 处理特殊的迁移情况
diff --git a/basaltpass-backend/internal/migration/wallet_migration.go b/basaltpass-backend/internal/migration/wallet_migration.go
index eba386e3..a84e6d61 100644
--- a/basaltpass-backend/internal/migration/wallet_migration.go
+++ b/basaltpass-backend/internal/migration/wallet_migration.go
@@ -4,6 +4,7 @@ import (
"basaltpass-backend/internal/common"
"basaltpass-backend/internal/model"
"errors"
+ "fmt"
"log"
"gorm.io/gorm"
@@ -236,3 +237,153 @@ func MigrateWalletTenantField() {
log.Printf("[Migration] Wallet tenant_id backfill completed: %d rows updated", migrated)
}
+
+// MigrateTeamTenantFields assigns pre-tenancy teams to a real tenant. Owner
+// membership is authoritative when available; the active default tenant is the
+// compatibility home for teams created by legacy global users.
+func MigrateTeamTenantFields() error {
+ db := common.DB()
+ if !db.Migrator().HasTable(model.Team{}.TableName()) {
+ return nil
+ }
+
+ var teams []model.Team
+ if err := db.Where("tenant_id = ? AND is_active = ?", 0, true).Find(&teams).Error; err != nil {
+ return err
+ }
+ for _, team := range teams {
+ tenantID := uint(0)
+ var owner model.TeamMember
+ if err := db.Where("team_id = ? AND role = ? AND status = ?", team.ID, model.TeamRoleOwner, "active").
+ Order("created_at ASC").First(&owner).Error; err == nil {
+ var membership model.TenantUser
+ if err := db.Where("user_id = ?", owner.UserID).Order("created_at ASC").First(&membership).Error; err == nil {
+ tenantID = membership.TenantID
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ if tenantID == 0 {
+ var user model.User
+ if err := db.Select("enforced_tenant_id").First(&user, owner.UserID).Error; err == nil {
+ tenantID = user.EnforcedTenantID
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ }
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+
+ if tenantID == 0 {
+ var defaultTenant model.Tenant
+ err := db.Where("code = ? AND status = ?", "default", model.TenantStatusActive).First(&defaultTenant).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ err = db.Where("status = ?", model.TenantStatusActive).Order("id ASC").First(&defaultTenant).Error
+ }
+ if err != nil {
+ return fmt.Errorf("resolve tenant for legacy team %d: %w", team.ID, err)
+ }
+ tenantID = defaultTenant.ID
+ }
+
+ if err := db.Model(&model.Team{}).Where("id = ? AND tenant_id = ?", team.ID, 0).Update("tenant_id", tenantID).Error; err != nil {
+ return err
+ }
+ log.Printf("[Migration] Assigned legacy team %d to tenant %d", team.ID, tenantID)
+ }
+ return nil
+}
+
+// MigrateWalletOwnerFields promotes the historical user_id/team_id columns to
+// the unified owner_type/owner_id identity before AutoMigrate adds uniqueness.
+func MigrateWalletOwnerFields() error {
+ db := common.DB()
+ if !db.Migrator().HasTable("market_wallets") {
+ return nil
+ }
+
+ if !db.Migrator().HasColumn("market_wallets", "owner_type") {
+ if err := db.Migrator().AddColumn(&model.Wallet{}, "OwnerType"); err != nil {
+ return err
+ }
+ }
+ if !db.Migrator().HasColumn("market_wallets", "owner_id") {
+ if err := db.Migrator().AddColumn(&model.Wallet{}, "OwnerID"); err != nil {
+ return err
+ }
+ }
+
+ if err := db.Exec(`UPDATE market_wallets SET owner_type = 'user', owner_id = user_id
+ WHERE (owner_type IS NULL OR owner_type = '' OR owner_id = 0) AND user_id IS NOT NULL AND user_id > 0`).Error; err != nil {
+ return err
+ }
+ if err := db.Exec(`UPDATE market_wallets SET owner_type = 'team', owner_id = team_id
+ WHERE (owner_type IS NULL OR owner_type = '' OR owner_id = 0) AND team_id IS NOT NULL AND team_id > 0`).Error; err != nil {
+ return err
+ }
+ if err := db.Exec(`UPDATE market_wallets SET owner_type = 'tenant', owner_id = tenant_id
+ WHERE (owner_type IS NULL OR owner_type = '' OR owner_id = 0)
+ AND user_id IS NULL AND team_id IS NULL AND tenant_id > 0`).Error; err != nil {
+ return err
+ }
+
+ var invalid int64
+ if err := db.Unscoped().Model(&model.Wallet{}).
+ Where("owner_type NOT IN ? OR owner_id = 0", []string{"user", "app", "tenant", "team"}).
+ Count(&invalid).Error; err != nil {
+ return err
+ }
+ if invalid > 0 {
+ return errors.New("wallet owner migration left rows without a valid owner")
+ }
+
+ type duplicateGroup struct {
+ TenantID uint
+ OwnerType model.WalletOwnerType
+ OwnerID uint
+ CurrencyID uint
+ Count int64
+ }
+ var groups []duplicateGroup
+ if err := db.Unscoped().Model(&model.Wallet{}).
+ Select("tenant_id, owner_type, owner_id, currency_id, COUNT(*) AS count").
+ Where("deleted_at IS NULL").
+ Group("tenant_id, owner_type, owner_id, currency_id").
+ Having("COUNT(*) > 1").Scan(&groups).Error; err != nil {
+ return err
+ }
+
+ for _, group := range groups {
+ err := db.Transaction(func(tx *gorm.DB) error {
+ var rows []model.Wallet
+ if err := tx.Unscoped().
+ Where("tenant_id = ? AND owner_type = ? AND owner_id = ? AND currency_id = ? AND deleted_at IS NULL", group.TenantID, group.OwnerType, group.OwnerID, group.CurrencyID).
+ Order("id ASC").Find(&rows).Error; err != nil {
+ return err
+ }
+ if len(rows) < 2 {
+ return nil
+ }
+
+ canonical := rows[0]
+ balance := canonical.Balance
+ freeze := canonical.Freeze
+ for _, duplicate := range rows[1:] {
+ balance += duplicate.Balance
+ freeze += duplicate.Freeze
+ if err := tx.Model(&model.WalletTx{}).Where("wallet_id = ?", duplicate.ID).Update("wallet_id", canonical.ID).Error; err != nil {
+ return err
+ }
+ if err := tx.Unscoped().Delete(&duplicate).Error; err != nil {
+ return err
+ }
+ }
+ return tx.Model(&canonical).Updates(map[string]interface{}{"balance": balance, "freeze": freeze}).Error
+ })
+ if err != nil {
+ return err
+ }
+ log.Printf("[Migration] Merged %d duplicate wallets for tenant=%d owner=%s:%d currency=%d", group.Count, group.TenantID, group.OwnerType, group.OwnerID, group.CurrencyID)
+ }
+ return nil
+}
diff --git a/basaltpass-backend/internal/migration/wallet_owner_migration_test.go b/basaltpass-backend/internal/migration/wallet_owner_migration_test.go
new file mode 100644
index 00000000..f82dc498
--- /dev/null
+++ b/basaltpass-backend/internal/migration/wallet_owner_migration_test.go
@@ -0,0 +1,111 @@
+package migration
+
+import (
+ "testing"
+
+ "basaltpass-backend/internal/common"
+ "basaltpass-backend/internal/model"
+
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+func TestMigrateWalletOwnerFieldsMergesDuplicates(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ common.SetDBForTest(db)
+ statements := []string{
+ `CREATE TABLE market_wallets (id integer primary key autoincrement, created_at datetime, updated_at datetime, deleted_at datetime, tenant_id integer not null default 0, user_id integer, team_id integer, currency_id integer not null, balance integer, freeze integer)`,
+ `CREATE TABLE market_wallet_txes (id integer primary key autoincrement, created_at datetime, updated_at datetime, deleted_at datetime, wallet_id integer, type text, amount integer, status text, reference text)`,
+ `INSERT INTO market_wallets (tenant_id, user_id, currency_id, balance, freeze) VALUES (2, 3, 7, 100, 1)`,
+ `INSERT INTO market_wallets (tenant_id, user_id, currency_id, balance, freeze) VALUES (2, 3, 7, 25, 2)`,
+ `INSERT INTO market_wallet_txes (wallet_id, type, amount, status) VALUES (2, 'credit', 25, 'success')`,
+ }
+ for _, statement := range statements {
+ if err := db.Exec(statement).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if err := MigrateWalletOwnerFields(); err != nil {
+ t.Fatal(err)
+ }
+ var wallets []model.Wallet
+ if err := db.Unscoped().Find(&wallets).Error; err != nil {
+ t.Fatal(err)
+ }
+ if len(wallets) != 1 || wallets[0].Balance != 125 || wallets[0].Freeze != 3 {
+ t.Fatalf("duplicate wallets were not merged: %+v", wallets)
+ }
+ if wallets[0].OwnerType != model.WalletOwnerUser || wallets[0].OwnerID != 3 {
+ t.Fatalf("wallet owner was not backfilled: %+v", wallets[0])
+ }
+ var tx model.WalletTx
+ if err := db.First(&tx).Error; err != nil {
+ t.Fatal(err)
+ }
+ if tx.WalletID != wallets[0].ID {
+ t.Fatalf("transaction still points at duplicate wallet: %d", tx.WalletID)
+ }
+}
+
+func TestMigrateTeamTenantFieldsUsesOwnerMembershipAndDefaultTenant(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ common.SetDBForTest(db)
+ if err := db.AutoMigrate(&model.User{}, &model.Tenant{}, &model.TenantUser{}, &model.Team{}, &model.TeamMember{}); err != nil {
+ t.Fatal(err)
+ }
+ defaultTenant := model.Tenant{Name: "Default", Code: "default", Status: model.TenantStatusActive}
+ memberTenant := model.Tenant{Name: "Member", Code: "member", Status: model.TenantStatusActive}
+ if err := db.Create(&defaultTenant).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Create(&memberTenant).Error; err != nil {
+ t.Fatal(err)
+ }
+ globalOwner := model.User{Email: "global@example.com", PasswordHash: "x"}
+ memberOwner := model.User{Email: "member@example.com", PasswordHash: "x"}
+ if err := db.Create(&globalOwner).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Create(&memberOwner).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Create(&model.TenantUser{UserID: memberOwner.ID, TenantID: memberTenant.ID}).Error; err != nil {
+ t.Fatal(err)
+ }
+ legacyDefault := model.Team{Name: "Legacy default", IsActive: true}
+ legacyMember := model.Team{Name: "Legacy member", IsActive: true}
+ if err := db.Create(&legacyDefault).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Create(&legacyMember).Error; err != nil {
+ t.Fatal(err)
+ }
+ for _, owner := range []model.TeamMember{
+ {TeamID: legacyDefault.ID, UserID: globalOwner.ID, Role: model.TeamRoleOwner, Status: "active"},
+ {TeamID: legacyMember.ID, UserID: memberOwner.ID, Role: model.TeamRoleOwner, Status: "active"},
+ } {
+ if err := db.Create(&owner).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if err := MigrateTeamTenantFields(); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.First(&legacyDefault, legacyDefault.ID).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.First(&legacyMember, legacyMember.ID).Error; err != nil {
+ t.Fatal(err)
+ }
+ if legacyDefault.TenantID != defaultTenant.ID || legacyMember.TenantID != memberTenant.ID {
+ t.Fatalf("unexpected migrated tenants: default=%d member=%d", legacyDefault.TenantID, legacyMember.TenantID)
+ }
+}
diff --git a/basaltpass-backend/internal/model/app_rbac_manifest.go b/basaltpass-backend/internal/model/app_rbac_manifest.go
new file mode 100644
index 00000000..4a15e022
--- /dev/null
+++ b/basaltpass-backend/internal/model/app_rbac_manifest.go
@@ -0,0 +1,57 @@
+package model
+
+import "time"
+
+type AppRBACManifestStatus string
+
+const (
+ AppRBACManifestPending AppRBACManifestStatus = "pending"
+ AppRBACManifestApproved AppRBACManifestStatus = "approved"
+ AppRBACManifestRejected AppRBACManifestStatus = "rejected"
+ AppRBACManifestSuperseded AppRBACManifestStatus = "superseded"
+)
+
+// AppRBACManifest is an app-submitted, RBAC-only proposal. Payload never
+// contains OAuth client settings or user assignments, and it is not applied
+// until a tenant administrator approves it.
+type AppRBACManifest struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ TenantID uint `gorm:"not null;index;uniqueIndex:idx_app_rbac_manifest_source_revision" json:"tenant_id"`
+ AppID uint `gorm:"not null;index;uniqueIndex:idx_app_rbac_manifest_source_revision" json:"app_id"`
+ SourceClientID string `gorm:"size:64;not null;index" json:"source_client_id"`
+ SchemaVersion string `gorm:"size:16;not null" json:"schema_version"`
+ SourceRevision uint64 `gorm:"not null;uniqueIndex:idx_app_rbac_manifest_source_revision" json:"source_revision"`
+ Digest string `gorm:"size:64;not null;index:idx_app_rbac_manifest_digest" json:"digest"`
+ BaseDigest string `gorm:"size:64;not null" json:"base_digest"`
+ Status AppRBACManifestStatus `gorm:"size:20;not null;index" json:"status"`
+ Payload string `gorm:"type:text;not null" json:"-"`
+ Diff string `gorm:"type:text;not null" json:"-"`
+ SubmittedAt time.Time `gorm:"not null;index" json:"submitted_at"`
+ ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
+ ReviewedBy *uint `gorm:"index" json:"reviewed_by,omitempty"`
+ ReviewNote string `gorm:"size:500" json:"review_note,omitempty"`
+ ActiveRevisionID *uint `gorm:"index" json:"active_revision_id,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+func (AppRBACManifest) TableName() string { return "app_rbac_manifests" }
+
+// AppRBACRevision is an immutable full snapshot of the effective app RBAC
+// catalog. Rollback creates a new revision from an older snapshot.
+type AppRBACRevision struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ TenantID uint `gorm:"not null;index;uniqueIndex:idx_app_rbac_revision_number" json:"tenant_id"`
+ AppID uint `gorm:"not null;index;uniqueIndex:idx_app_rbac_revision_number" json:"app_id"`
+ Revision uint64 `gorm:"not null;uniqueIndex:idx_app_rbac_revision_number" json:"revision"`
+ Snapshot string `gorm:"type:text;not null" json:"-"`
+ Digest string `gorm:"size:64;not null" json:"digest"`
+ ManifestID *uint `gorm:"index" json:"manifest_id,omitempty"`
+ Action string `gorm:"size:20;not null" json:"action"`
+ TargetRevisionID *uint `gorm:"index" json:"target_revision_id,omitempty"`
+ IsActive bool `gorm:"not null;default:false;index" json:"is_active"`
+ CreatedBy uint `gorm:"not null;index" json:"created_by"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+func (AppRBACRevision) TableName() string { return "app_rbac_revisions" }
diff --git a/basaltpass-backend/internal/model/currency.go b/basaltpass-backend/internal/model/currency.go
index 6fa74316..98226f19 100644
--- a/basaltpass-backend/internal/model/currency.go
+++ b/basaltpass-backend/internal/model/currency.go
@@ -5,16 +5,18 @@ import "gorm.io/gorm"
// Currency represents a supported currency in the system
type Currency struct {
gorm.Model
- Code string `gorm:"size:16;uniqueIndex;not null" json:"code"` // 货币代码,如 USD, CNY, BTC
- Name string `gorm:"size:64;not null" json:"name"` // 货币名称,如 US Dollar, Chinese Yuan, Bitcoin
- NameCN string `gorm:"size:64" json:"name_cn"` // 中文名称,如 美元, 人民币, 比特币
- Symbol string `gorm:"size:8" json:"symbol"` // 货币符号,如 $, ¥, ₿
- DecimalPlaces int `gorm:"default:2" json:"decimal_places"` // 小数位数,如 2 (美元), 8 (比特币)
- Type string `gorm:"size:16;default:'fiat'" json:"type"` // 货币类型:fiat(法币), crypto(加密货币), points(积分)
- IsActive bool `gorm:"default:true" json:"is_active"` // 是否启用
- SortOrder int `gorm:"default:0" json:"sort_order"` // 排序顺序
- Description string `gorm:"size:255" json:"description"` // 描述
- IconURL string `gorm:"size:255" json:"icon_url"` // 图标URL
+ Code string `gorm:"size:16;uniqueIndex;not null" json:"code"` // 货币代码,如 USD, CNY, BTC
+ Name string `gorm:"size:64;not null" json:"name"` // 货币名称,如 US Dollar, Chinese Yuan, Bitcoin
+ NameCN string `gorm:"size:64" json:"name_cn"` // 中文名称,如 美元, 人民币, 比特币
+ Symbol string `gorm:"size:8" json:"symbol"` // 货币符号,如 $, ¥, ₿
+ DecimalPlaces int `gorm:"default:2" json:"decimal_places"` // 小数位数,如 2 (美元), 8 (比特币)
+ Type string `gorm:"size:16;default:'fiat'" json:"type"` // 货币类型:fiat(法币), crypto(加密货币), points(积分)
+ ExchangeRateUSD float64 `gorm:"default:0" json:"exchange_rate_usd"` // 1 个该币种折合多少 USD,用于钱包充值换算
+ PaymentEnabled bool `gorm:"default:false" json:"payment_enabled"` // 是否可作为收银台付款币种
+ IsActive bool `gorm:"default:true" json:"is_active"` // 是否启用
+ SortOrder int `gorm:"default:0" json:"sort_order"` // 排序顺序
+ Description string `gorm:"size:255" json:"description"` // 描述
+ IconURL string `gorm:"size:255" json:"icon_url"` // 图标URL
// 关联
Wallets []Wallet `gorm:"foreignKey:CurrencyID"`
@@ -24,3 +26,37 @@ type Currency struct {
func (Currency) TableName() string {
return "market_currencies"
}
+
+// CurrencyRate represents a directional exchange rate pair.
+// Rate means 1 BaseCurrencyCode = Rate QuoteCurrencyCode.
+type CurrencyRate struct {
+ gorm.Model
+ BaseCurrencyCode string `gorm:"size:16;not null;uniqueIndex:idx_currency_rate_pair" json:"base_currency_code"`
+ QuoteCurrencyCode string `gorm:"size:16;not null;uniqueIndex:idx_currency_rate_pair" json:"quote_currency_code"`
+ Rate float64 `gorm:"not null" json:"rate"`
+ Source string `gorm:"size:64;default:'manual'" json:"source"`
+ IsActive bool `gorm:"default:true" json:"is_active"`
+ Description string `gorm:"size:255" json:"description"`
+}
+
+func (CurrencyRate) TableName() string {
+ return "market_currency_rates"
+}
+
+// AppWalletCurrency links an application to wallet currencies it uses.
+type AppWalletCurrency struct {
+ gorm.Model
+ TenantID uint `gorm:"not null;index;uniqueIndex:idx_app_wallet_currency" json:"tenant_id"`
+ AppID uint `gorm:"not null;index;uniqueIndex:idx_app_wallet_currency" json:"app_id"`
+ CurrencyID uint `gorm:"not null;index;uniqueIndex:idx_app_wallet_currency" json:"currency_id"`
+ WalletCategory string `gorm:"size:32;not null;default:'top_up';uniqueIndex:idx_app_wallet_currency" json:"wallet_category"`
+ SortOrder int `gorm:"default:0" json:"sort_order"`
+ IsDefault bool `gorm:"default:false" json:"is_default"`
+
+ App App `gorm:"foreignKey:AppID" json:"app,omitempty"`
+ Currency Currency `gorm:"foreignKey:CurrencyID" json:"currency,omitempty"`
+}
+
+func (AppWalletCurrency) TableName() string {
+ return "app_wallet_currencies"
+}
diff --git a/basaltpass-backend/internal/model/oauth_token.go b/basaltpass-backend/internal/model/oauth_token.go
index 49b32582..64909d23 100644
--- a/basaltpass-backend/internal/model/oauth_token.go
+++ b/basaltpass-backend/internal/model/oauth_token.go
@@ -9,17 +9,18 @@ import (
// OAuthAccessToken OAuth2访问令牌
type OAuthAccessToken struct {
- ID uint `gorm:"primaryKey" json:"id"`
- Token string `gorm:"size:128;uniqueIndex;not null" json:"token"`
- ClientID string `gorm:"size:64;not null;index" json:"client_id"`
- UserID uint `gorm:"not null;index" json:"user_id"`
- TenantID uint `gorm:"not null;index" json:"tenant_id"`
- AppID uint `gorm:"not null;index" json:"app_id"`
- Scopes string `gorm:"type:text" json:"scopes"`
- Claims string `gorm:"type:text" json:"claims,omitempty"`
- ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"`
- CreatedAt time.Time `json:"created_at"`
- AuthCodeID *uint `gorm:"index" json:"auth_code_id,omitempty"`
+ ID uint `gorm:"primaryKey" json:"id"`
+ Token string `gorm:"size:128;uniqueIndex;not null" json:"token"`
+ ClientID string `gorm:"size:64;not null;index" json:"client_id"`
+ UserID uint `gorm:"not null;index" json:"user_id"`
+ TenantID uint `gorm:"not null;index" json:"tenant_id"`
+ AppID uint `gorm:"not null;index" json:"app_id"`
+ SubjectType string `gorm:"size:16;not null;default:'user';index" json:"subject_type"`
+ Scopes string `gorm:"type:text" json:"scopes"`
+ Claims string `gorm:"type:text" json:"claims,omitempty"`
+ ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"`
+ CreatedAt time.Time `json:"created_at"`
+ AuthCodeID *uint `gorm:"index" json:"auth_code_id,omitempty"`
// Token Exchange (RFC 8693) actor context — populated when this token was
// created via a token-exchange grant.
@@ -34,6 +35,11 @@ type OAuthAccessToken struct {
Client OAuthClient `gorm:"foreignKey:ClientID;references:ClientID" json:"client,omitempty"`
}
+const (
+ OAuthSubjectUser = "user"
+ OAuthSubjectApp = "app"
+)
+
// OAuthRefreshToken OAuth2刷新令牌
type OAuthRefreshToken struct {
ID uint `gorm:"primaryKey" json:"id"`
diff --git a/basaltpass-backend/internal/model/tenant_app_grant_mapping.go b/basaltpass-backend/internal/model/tenant_app_grant_mapping.go
new file mode 100644
index 00000000..774528fb
--- /dev/null
+++ b/basaltpass-backend/internal/model/tenant_app_grant_mapping.go
@@ -0,0 +1,47 @@
+package model
+
+import "time"
+
+type TenantAppGrantSourceType string
+
+const (
+ TenantAppGrantSourceMembershipRole TenantAppGrantSourceType = "membership_role"
+ TenantAppGrantSourceTenantRole TenantAppGrantSourceType = "tenant_role"
+ TenantAppGrantSourceTenantPermission TenantAppGrantSourceType = "tenant_permission"
+)
+
+type TenantAppGrantTargetType string
+
+const (
+ TenantAppGrantTargetAppRole TenantAppGrantTargetType = "app_role"
+ TenantAppGrantTargetAppPermission TenantAppGrantTargetType = "app_permission"
+)
+
+// TenantAppGrantMapping is tenant-owned policy. It never materializes an
+// AppUserRole/AppUserPermission; effective app grants are derived at read time.
+type TenantAppGrantMapping struct {
+ ID uint `json:"id" gorm:"primaryKey"`
+ TenantID uint `json:"tenant_id" gorm:"not null;index;uniqueIndex:idx_tenant_app_grant_mapping"`
+ AppID uint `json:"app_id" gorm:"not null;index;uniqueIndex:idx_tenant_app_grant_mapping"`
+ SourceType TenantAppGrantSourceType `json:"source_type" gorm:"type:varchar(32);not null;uniqueIndex:idx_tenant_app_grant_mapping"`
+ SourceID uint `json:"source_id" gorm:"not null;default:0;uniqueIndex:idx_tenant_app_grant_mapping"`
+ SourceCode string `json:"source_code" gorm:"size:100;not null;default:'';uniqueIndex:idx_tenant_app_grant_mapping"`
+ TargetType TenantAppGrantTargetType `json:"target_type" gorm:"type:varchar(32);not null;uniqueIndex:idx_tenant_app_grant_mapping"`
+ TargetID uint `json:"target_id" gorm:"not null;uniqueIndex:idx_tenant_app_grant_mapping"`
+ Enabled bool `json:"enabled" gorm:"not null;index"`
+ ValidFrom *time.Time `json:"valid_from,omitempty"`
+ ValidUntil *time.Time `json:"valid_until,omitempty"`
+ CreatedBy uint `json:"created_by" gorm:"not null"`
+ UpdatedBy uint `json:"updated_by" gorm:"not null"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+
+ // Tenant/App are the stable ownership boundary for a mapping. Cascades only
+ // apply to hard deletes; ordinary app suspension/deletion remains status based.
+ Tenant Tenant `json:"-" gorm:"foreignKey:TenantID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
+ App App `json:"-" gorm:"foreignKey:AppID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
+}
+
+func (TenantAppGrantMapping) TableName() string {
+ return "tenant_app_grant_mappings"
+}
diff --git a/basaltpass-backend/internal/model/wallet.go b/basaltpass-backend/internal/model/wallet.go
index 4fb7c76b..3abf612a 100644
--- a/basaltpass-backend/internal/model/wallet.go
+++ b/basaltpass-backend/internal/model/wallet.go
@@ -1,16 +1,42 @@
package model
-import "gorm.io/gorm"
+import (
+ "errors"
-// Wallet represents a multi-currency account for a user or team.
+ "gorm.io/gorm"
+)
+
+type WalletOwnerType string
+
+const (
+ WalletOwnerUser WalletOwnerType = "user"
+ WalletOwnerApp WalletOwnerType = "app"
+ WalletOwnerTenant WalletOwnerType = "tenant"
+ WalletOwnerTeam WalletOwnerType = "team"
+)
+
+func (ownerType WalletOwnerType) IsValid() bool {
+ switch ownerType {
+ case WalletOwnerUser, WalletOwnerApp, WalletOwnerTenant, WalletOwnerTeam:
+ return true
+ default:
+ return false
+ }
+}
+
+// Wallet represents one currency account owned by a user, app, tenant, or team.
type Wallet struct {
gorm.Model
- TenantID uint `gorm:"index;not null;default:0"` // 租户隔离字段,0表示平台级
- UserID *uint `gorm:"index"` // 用户钱包
- TeamID *uint `gorm:"index"` // 团队钱包
- CurrencyID *uint `gorm:"index;not null"` // 关联到currency表的ID字段
- Balance int64 // in smallest unit (e.g. cents, satoshi)
- Freeze int64 // frozen amount
+ TenantID uint `gorm:"index;not null;default:0;uniqueIndex:uidx_wallet_owner_currency,priority:1" json:"tenant_id"`
+ OwnerType WalletOwnerType `gorm:"size:16;not null;default:'';index;uniqueIndex:uidx_wallet_owner_currency,priority:2" json:"owner_type"`
+ OwnerID uint `gorm:"not null;default:0;index;uniqueIndex:uidx_wallet_owner_currency,priority:3" json:"owner_id"`
+ CurrencyID *uint `gorm:"index;not null;uniqueIndex:uidx_wallet_owner_currency,priority:4" json:"currency_id"`
+
+ // Deprecated compatibility columns. OwnerType and OwnerID are authoritative.
+ UserID *uint `gorm:"index" json:"user_id,omitempty"`
+ TeamID *uint `gorm:"index" json:"team_id,omitempty"`
+ Balance int64 // in smallest unit (e.g. cents, satoshi)
+ Freeze int64 // frozen amount
// 关联
User *User `gorm:"foreignKey:UserID"`
@@ -19,6 +45,41 @@ type Wallet struct {
Txns []WalletTx
}
+func (w *Wallet) BeforeSave(_ *gorm.DB) error {
+ if w.OwnerType == "" {
+ switch {
+ case w.UserID != nil && *w.UserID > 0:
+ w.OwnerType = WalletOwnerUser
+ w.OwnerID = *w.UserID
+ case w.TeamID != nil && *w.TeamID > 0:
+ w.OwnerType = WalletOwnerTeam
+ w.OwnerID = *w.TeamID
+ case w.TenantID > 0:
+ w.OwnerType = WalletOwnerTenant
+ w.OwnerID = w.TenantID
+ }
+ }
+
+ if !w.OwnerType.IsValid() || w.OwnerID == 0 {
+ return errors.New("wallet owner is invalid")
+ }
+
+ switch w.OwnerType {
+ case WalletOwnerUser:
+ ownerID := w.OwnerID
+ w.UserID = &ownerID
+ w.TeamID = nil
+ case WalletOwnerTeam:
+ ownerID := w.OwnerID
+ w.TeamID = &ownerID
+ w.UserID = nil
+ default:
+ w.UserID = nil
+ w.TeamID = nil
+ }
+ return nil
+}
+
// TableName 指定表名
func (Wallet) TableName() string {
return "market_wallets"
@@ -27,12 +88,13 @@ func (Wallet) TableName() string {
// WalletTx represents a transaction on a wallet.
type WalletTx struct {
gorm.Model
- WalletID uint `gorm:"index"`
- Type string `gorm:"size:32"` // recharge, withdraw, transfer
- Amount int64 // positive or negative depending on Type
- Status string `gorm:"size:32"` // pending, success, fail
- Reference string `gorm:"size:128"` // optional external ref
- Wallet Wallet `gorm:"foreignKey:WalletID"`
+ WalletID uint `gorm:"index;index:idx_wallet_tx_idempotency,priority:1"`
+ Type string `gorm:"size:32"` // recharge, withdraw, transfer
+ Amount int64 // positive or negative depending on Type
+ Status string `gorm:"size:32"` // pending, success, fail
+ Reference string `gorm:"size:128"` // optional external ref
+ IdempotencyKey *string `gorm:"size:128;index:idx_wallet_tx_idempotency,priority:2" json:"idempotency_key,omitempty"`
+ Wallet Wallet `gorm:"foreignKey:WalletID"`
}
// TableName 指定表名
diff --git a/basaltpass-backend/internal/service/aduit/service.go b/basaltpass-backend/internal/service/aduit/service.go
index 1014d282..f85ddd43 100644
--- a/basaltpass-backend/internal/service/aduit/service.go
+++ b/basaltpass-backend/internal/service/aduit/service.go
@@ -11,15 +11,22 @@ type AuditData struct {
ResourceType string `json:"resource_type,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
- Details string `json:"details,omitempty"`
+ Details any `json:"details,omitempty"`
}
// LogAudit logs an audit entry for sensitive operations
func LogAudit(userID uint, action, resourceType, resourceID, ip, userAgent string) {
+ LogAuditWithDetails(userID, action, resourceType, resourceID, ip, userAgent, nil)
+}
+
+// LogAuditWithDetails preserves a structured snapshot for policy changes that
+// cannot be reconstructed from the resource after it is deleted.
+func LogAuditWithDetails(userID uint, action, resourceType, resourceID, ip, userAgent string, details any) {
data := AuditData{
ResourceType: resourceType,
ResourceID: resourceID,
UserAgent: userAgent,
+ Details: details,
}
dataJSON, _ := json.Marshal(data)
diff --git a/basaltpass-backend/internal/service/appgrant/mapping.go b/basaltpass-backend/internal/service/appgrant/mapping.go
new file mode 100644
index 00000000..4e07bb27
--- /dev/null
+++ b/basaltpass-backend/internal/service/appgrant/mapping.go
@@ -0,0 +1,457 @@
+package appgrant
+
+import (
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "basaltpass-backend/internal/model"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+type Service struct {
+ db *gorm.DB
+}
+
+func NewService(db *gorm.DB) *Service {
+ return &Service{db: db}
+}
+
+func (s *Service) CreateMapping(tenantID, appID, actorID uint, input MappingInput) (MappingView, error) {
+ var view MappingView
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ service := NewService(tx)
+ if err := service.lockApp(tenantID, appID); err != nil {
+ return err
+ }
+ mapping, err := service.mappingFromInput(tenantID, appID, actorID, input)
+ if err != nil {
+ return err
+ }
+ if err := tx.Create(&mapping).Error; err != nil {
+ return mappingWriteError(err)
+ }
+ view, err = service.mappingView(mapping)
+ return err
+ })
+ if err != nil {
+ return MappingView{}, err
+ }
+ return view, nil
+}
+
+func (s *Service) UpdateMapping(tenantID, appID, mappingID, actorID uint, input MappingInput) (MappingView, error) {
+ var view MappingView
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ service := NewService(tx)
+ if err := service.lockApp(tenantID, appID); err != nil {
+ return err
+ }
+ var existing model.TenantAppGrantMapping
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("id = ? AND tenant_id = ? AND app_id = ?", mappingID, tenantID, appID).First(&existing).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrNotFound
+ }
+ return err
+ }
+ desired, err := service.mappingFromInput(tenantID, appID, actorID, input)
+ if err != nil {
+ return err
+ }
+ desired.ID = existing.ID
+ desired.CreatedBy = existing.CreatedBy
+ desired.CreatedAt = existing.CreatedAt
+ if err := tx.Save(&desired).Error; err != nil {
+ return mappingWriteError(err)
+ }
+ view, err = service.mappingView(desired)
+ return err
+ })
+ if err != nil {
+ return MappingView{}, err
+ }
+ return view, nil
+}
+
+func (s *Service) DeleteMapping(tenantID, appID, mappingID uint) error {
+ _, err := s.DeleteMappingWithView(tenantID, appID, mappingID)
+ return err
+}
+
+// DeleteMappingWithView returns the locked pre-delete policy so callers can
+// write a complete audit record even though the mapping no longer exists.
+func (s *Service) DeleteMappingWithView(tenantID, appID, mappingID uint) (MappingView, error) {
+ var view MappingView
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ if err := NewService(tx).lockApp(tenantID, appID); err != nil {
+ return err
+ }
+ var mapping model.TenantAppGrantMapping
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("id = ? AND tenant_id = ? AND app_id = ?", mappingID, tenantID, appID).First(&mapping).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrNotFound
+ }
+ return err
+ }
+ var err error
+ view, err = NewService(tx).mappingView(mapping)
+ if err != nil {
+ return err
+ }
+ result := tx.Where("id = ? AND tenant_id = ? AND app_id = ?", mappingID, tenantID, appID).Delete(&model.TenantAppGrantMapping{})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return ErrNotFound
+ }
+ return nil
+ })
+ if err != nil {
+ return MappingView{}, err
+ }
+ return view, nil
+}
+
+func (s *Service) ListMappings(tenantID, appID uint) ([]MappingView, error) {
+ if err := s.ensureApp(tenantID, appID); err != nil {
+ return nil, err
+ }
+ var mappings []model.TenantAppGrantMapping
+ if err := s.db.Where("tenant_id = ? AND app_id = ?", tenantID, appID).Order("id ASC").Find(&mappings).Error; err != nil {
+ return nil, err
+ }
+ views := make([]MappingView, 0, len(mappings))
+ for _, mapping := range mappings {
+ view, err := s.mappingView(mapping)
+ if err != nil {
+ return nil, err
+ }
+ views = append(views, view)
+ }
+ return views, nil
+}
+
+func (s *Service) PreviewAffectedUsers(tenantID, appID uint, input MappingInput) (int64, error) {
+ mapping, err := s.mappingFromInput(tenantID, appID, 1, input)
+ if err != nil {
+ return 0, err
+ }
+ return s.affectedUserCount(mapping)
+}
+
+func (s *Service) Options(tenantID, appID uint) (Options, error) {
+ if err := s.ensureApp(tenantID, appID); err != nil {
+ return Options{}, err
+ }
+ options := Options{
+ MembershipRoles: []GrantEndpoint{
+ {Type: string(model.TenantAppGrantSourceMembershipRole), Code: string(model.TenantRoleOwner), Name: "Owner"},
+ {Type: string(model.TenantAppGrantSourceMembershipRole), Code: string(model.TenantRoleAdmin), Name: "Admin"},
+ {Type: string(model.TenantAppGrantSourceMembershipRole), Code: string(model.TenantRoleMember), Name: "Member"},
+ {Type: string(model.TenantAppGrantSourceMembershipRole), Code: string(model.TenantRoleUser), Name: "User"},
+ },
+ TenantRoles: []GrantEndpoint{}, TenantPermissions: []GrantEndpoint{}, AppRoles: []GrantEndpoint{}, AppPermissions: []GrantEndpoint{},
+ }
+ var tenantRoles []model.TenantRbacRole
+ if err := s.db.Where("tenant_id = ?", tenantID).Order("code ASC").Find(&tenantRoles).Error; err != nil {
+ return Options{}, err
+ }
+ for _, item := range tenantRoles {
+ options.TenantRoles = append(options.TenantRoles, GrantEndpoint{Type: string(model.TenantAppGrantSourceTenantRole), ID: item.ID, Code: item.Code, Name: item.Name})
+ }
+ var tenantPermissions []model.TenantRbacPermission
+ if err := s.db.Where("tenant_id = ?", tenantID).Order("code ASC").Find(&tenantPermissions).Error; err != nil {
+ return Options{}, err
+ }
+ for _, item := range tenantPermissions {
+ options.TenantPermissions = append(options.TenantPermissions, GrantEndpoint{Type: string(model.TenantAppGrantSourceTenantPermission), ID: item.ID, Code: item.Code, Name: item.Name})
+ }
+ var appRoles []model.AppRole
+ if err := s.db.Where("tenant_id = ? AND app_id = ?", tenantID, appID).Order("code ASC").Find(&appRoles).Error; err != nil {
+ return Options{}, err
+ }
+ for _, item := range appRoles {
+ options.AppRoles = append(options.AppRoles, GrantEndpoint{Type: string(model.TenantAppGrantTargetAppRole), ID: item.ID, Code: item.Code, Name: item.Name})
+ }
+ var appPermissions []model.AppPermission
+ if err := s.db.Where("tenant_id = ? AND app_id = ?", tenantID, appID).Order("code ASC").Find(&appPermissions).Error; err != nil {
+ return Options{}, err
+ }
+ for _, item := range appPermissions {
+ options.AppPermissions = append(options.AppPermissions, GrantEndpoint{Type: string(model.TenantAppGrantTargetAppPermission), ID: item.ID, Code: item.Code, Name: item.Name})
+ }
+ return options, nil
+}
+
+func (s *Service) mappingFromInput(tenantID, appID, actorID uint, input MappingInput) (model.TenantAppGrantMapping, error) {
+ if tenantID == 0 || appID == 0 || actorID == 0 {
+ return model.TenantAppGrantMapping{}, &ValidationError{Message: "invalid tenant, app, or actor context"}
+ }
+ if err := s.ensureApp(tenantID, appID); err != nil {
+ return model.TenantAppGrantMapping{}, err
+ }
+ input.SourceCode = strings.TrimSpace(strings.ToLower(input.SourceCode))
+ if err := s.validateSource(tenantID, input); err != nil {
+ return model.TenantAppGrantMapping{}, err
+ }
+ if err := s.validateTarget(tenantID, appID, input); err != nil {
+ return model.TenantAppGrantMapping{}, err
+ }
+ if input.ValidFrom != nil && input.ValidUntil != nil && !input.ValidUntil.After(*input.ValidFrom) {
+ return model.TenantAppGrantMapping{}, &ValidationError{Message: "valid_until must be after valid_from"}
+ }
+ enabled := true
+ if input.Enabled != nil {
+ enabled = *input.Enabled
+ }
+ now := time.Now().UTC()
+ return model.TenantAppGrantMapping{
+ TenantID: tenantID, AppID: appID, SourceType: input.SourceType, SourceID: input.SourceID, SourceCode: input.SourceCode,
+ TargetType: input.TargetType, TargetID: input.TargetID, Enabled: enabled, ValidFrom: utcTime(input.ValidFrom), ValidUntil: utcTime(input.ValidUntil),
+ CreatedBy: actorID, UpdatedBy: actorID, CreatedAt: now, UpdatedAt: now,
+ }, nil
+}
+
+func (s *Service) validateSource(tenantID uint, input MappingInput) error {
+ switch input.SourceType {
+ case model.TenantAppGrantSourceMembershipRole:
+ if input.SourceID != 0 {
+ return &ValidationError{Message: "membership_role source_id must be zero"}
+ }
+ allowed := map[string]bool{"owner": true, "admin": true, "member": true, "user": true}
+ if !allowed[input.SourceCode] {
+ return &ValidationError{Message: "invalid membership role"}
+ }
+ case model.TenantAppGrantSourceTenantRole:
+ if input.SourceID == 0 || input.SourceCode != "" {
+ return &ValidationError{Message: "tenant_role requires source_id and no source_code"}
+ }
+ var source model.TenantRbacRole
+ if err := s.db.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
+ Where("id = ? AND tenant_id = ?", input.SourceID, tenantID).First(&source).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return &ValidationError{Message: "tenant role does not belong to tenant"}
+ }
+ return err
+ }
+ case model.TenantAppGrantSourceTenantPermission:
+ if input.SourceID == 0 || input.SourceCode != "" {
+ return &ValidationError{Message: "tenant_permission requires source_id and no source_code"}
+ }
+ var source model.TenantRbacPermission
+ if err := s.db.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
+ Where("id = ? AND tenant_id = ?", input.SourceID, tenantID).First(&source).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return &ValidationError{Message: "tenant permission does not belong to tenant"}
+ }
+ return err
+ }
+ default:
+ return &ValidationError{Message: "invalid source_type"}
+ }
+ return nil
+}
+
+func (s *Service) validateTarget(tenantID, appID uint, input MappingInput) error {
+ if input.TargetID == 0 {
+ return &ValidationError{Message: "target_id is required"}
+ }
+ switch input.TargetType {
+ case model.TenantAppGrantTargetAppRole:
+ var target model.AppRole
+ if err := s.db.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
+ Where("id = ? AND tenant_id = ? AND app_id = ?", input.TargetID, tenantID, appID).First(&target).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return &ValidationError{Message: "target does not belong to app and tenant"}
+ }
+ return err
+ }
+ case model.TenantAppGrantTargetAppPermission:
+ var target model.AppPermission
+ if err := s.db.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
+ Where("id = ? AND tenant_id = ? AND app_id = ?", input.TargetID, tenantID, appID).First(&target).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return &ValidationError{Message: "target does not belong to app and tenant"}
+ }
+ return err
+ }
+ default:
+ return &ValidationError{Message: "invalid target_type"}
+ }
+ return nil
+}
+
+func (s *Service) lockApp(tenantID, appID uint) error {
+ var app model.App
+ if err := s.db.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").
+ Where("id = ? AND tenant_id = ?", appID, tenantID).First(&app).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrNotFound
+ }
+ return err
+ }
+ return nil
+}
+
+func mappingWriteError(err error) error {
+ lower := strings.ToLower(err.Error())
+ if strings.Contains(lower, "unique") || strings.Contains(lower, "duplicate") {
+ return ErrConflict
+ }
+ return err
+}
+
+func (s *Service) ensureApp(tenantID, appID uint) error {
+ var count int64
+ if err := s.db.Model(&model.App{}).Where("id = ? AND tenant_id = ?", appID, tenantID).Count(&count).Error; err != nil {
+ return err
+ }
+ if count != 1 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+func (s *Service) mappingView(mapping model.TenantAppGrantMapping) (MappingView, error) {
+ source, err := s.describeSource(mapping)
+ if err != nil {
+ return MappingView{}, err
+ }
+ target, err := s.describeTarget(mapping)
+ if err != nil {
+ return MappingView{}, err
+ }
+ count, err := s.affectedUserCount(mapping)
+ if err != nil {
+ return MappingView{}, err
+ }
+ return MappingView{ID: mapping.ID, TenantID: mapping.TenantID, AppID: mapping.AppID, Source: source, Target: target,
+ Enabled: mapping.Enabled, ValidFrom: mapping.ValidFrom, ValidUntil: mapping.ValidUntil, AffectedUserCount: count,
+ CreatedBy: mapping.CreatedBy, UpdatedBy: mapping.UpdatedBy, CreatedAt: mapping.CreatedAt, UpdatedAt: mapping.UpdatedAt}, nil
+}
+
+func (s *Service) describeSource(mapping model.TenantAppGrantMapping) (GrantEndpoint, error) {
+ switch mapping.SourceType {
+ case model.TenantAppGrantSourceMembershipRole:
+ names := map[string]string{"owner": "Owner", "admin": "Admin", "member": "Member", "user": "User"}
+ return GrantEndpoint{Type: string(mapping.SourceType), Code: mapping.SourceCode, Name: names[mapping.SourceCode]}, nil
+ case model.TenantAppGrantSourceTenantRole:
+ var item model.TenantRbacRole
+ if err := s.db.Where("id = ? AND tenant_id = ?", mapping.SourceID, mapping.TenantID).First(&item).Error; err != nil {
+ return GrantEndpoint{}, fmt.Errorf("resolve tenant role source: %w", err)
+ }
+ return GrantEndpoint{Type: string(mapping.SourceType), ID: item.ID, Code: item.Code, Name: item.Name}, nil
+ case model.TenantAppGrantSourceTenantPermission:
+ var item model.TenantRbacPermission
+ if err := s.db.Where("id = ? AND tenant_id = ?", mapping.SourceID, mapping.TenantID).First(&item).Error; err != nil {
+ return GrantEndpoint{}, fmt.Errorf("resolve tenant permission source: %w", err)
+ }
+ return GrantEndpoint{Type: string(mapping.SourceType), ID: item.ID, Code: item.Code, Name: item.Name}, nil
+ }
+ return GrantEndpoint{}, &ValidationError{Message: "invalid stored source_type"}
+}
+
+func (s *Service) describeTarget(mapping model.TenantAppGrantMapping) (GrantEndpoint, error) {
+ switch mapping.TargetType {
+ case model.TenantAppGrantTargetAppRole:
+ var item model.AppRole
+ if err := s.db.Where("id = ? AND tenant_id = ? AND app_id = ?", mapping.TargetID, mapping.TenantID, mapping.AppID).First(&item).Error; err != nil {
+ return GrantEndpoint{}, fmt.Errorf("resolve app role target: %w", err)
+ }
+ return GrantEndpoint{Type: string(mapping.TargetType), ID: item.ID, Code: item.Code, Name: item.Name}, nil
+ case model.TenantAppGrantTargetAppPermission:
+ var item model.AppPermission
+ if err := s.db.Where("id = ? AND tenant_id = ? AND app_id = ?", mapping.TargetID, mapping.TenantID, mapping.AppID).First(&item).Error; err != nil {
+ return GrantEndpoint{}, fmt.Errorf("resolve app permission target: %w", err)
+ }
+ return GrantEndpoint{Type: string(mapping.TargetType), ID: item.ID, Code: item.Code, Name: item.Name}, nil
+ }
+ return GrantEndpoint{}, &ValidationError{Message: "invalid stored target_type"}
+}
+
+func (s *Service) affectedUserCount(mapping model.TenantAppGrantMapping) (int64, error) {
+ now := time.Now().UTC()
+ if !mapping.Enabled || (mapping.ValidFrom != nil && mapping.ValidFrom.After(now)) || (mapping.ValidUntil != nil && !mapping.ValidUntil.After(now)) {
+ return 0, nil
+ }
+ userIDs, err := s.eligibleAppUserIDs(mapping.TenantID, mapping.AppID, now)
+ if err != nil || len(userIDs) == 0 {
+ return 0, err
+ }
+ matching, err := s.usersMatchingSource(mapping.TenantID, userIDs, mapping.SourceType, mapping.SourceID, mapping.SourceCode, now)
+ return int64(len(matching)), err
+}
+
+func (s *Service) eligibleAppUserIDs(tenantID, appID uint, now time.Time) ([]uint, error) {
+ var ids []uint
+ err := s.db.Table("app_users").Distinct("app_users.user_id").
+ Joins("JOIN tenant_users ON tenant_users.user_id = app_users.user_id AND tenant_users.tenant_id = ?", tenantID).
+ Joins("JOIN apps ON apps.id = app_users.app_id AND apps.tenant_id = ?", tenantID).
+ Joins("JOIN tenants ON tenants.id = apps.tenant_id").
+ Joins("JOIN system_auth_users ON system_auth_users.id = app_users.user_id").
+ Where("app_users.app_id = ? AND tenant_users.role != ?", appID, model.TenantRoleBanned).
+ Where("apps.status = ? AND tenants.status = ?", model.AppStatusActive, model.TenantStatusActive).
+ Where("system_auth_users.banned = ? AND system_auth_users.deleted_at IS NULL", false).
+ Where("app_users.status IN ? OR (app_users.status IN ? AND app_users.banned_until IS NOT NULL AND app_users.banned_until <= ?)",
+ []model.AppUserStatus{model.AppUserStatusActive, model.AppUserStatusRestricted},
+ []model.AppUserStatus{model.AppUserStatusBanned, model.AppUserStatusSuspended}, now).
+ Pluck("app_users.user_id", &ids).Error
+ return ids, err
+}
+
+func (s *Service) usersMatchingSource(tenantID uint, candidates []uint, sourceType model.TenantAppGrantSourceType, sourceID uint, sourceCode string, now time.Time) (map[uint]struct{}, error) {
+ result := map[uint]struct{}{}
+ var ids []uint
+ switch sourceType {
+ case model.TenantAppGrantSourceMembershipRole:
+ if err := s.db.Model(&model.TenantUser{}).Where("tenant_id = ? AND user_id IN ? AND role = ?", tenantID, candidates, sourceCode).Pluck("user_id", &ids).Error; err != nil {
+ return nil, err
+ }
+ case model.TenantAppGrantSourceTenantRole:
+ if err := s.db.Model(&model.TenantUserRbacRole{}).
+ Where("tenant_id = ? AND user_id IN ? AND role_id = ?", tenantID, candidates, sourceID).
+ Where("expires_at IS NULL OR expires_at > ?", now).Distinct("user_id").Pluck("user_id", &ids).Error; err != nil {
+ return nil, err
+ }
+ case model.TenantAppGrantSourceTenantPermission:
+ var direct, throughRoles []uint
+ if err := s.db.Model(&model.TenantUserRbacPermission{}).
+ Where("tenant_id = ? AND user_id IN ? AND permission_id = ?", tenantID, candidates, sourceID).
+ Where("expires_at IS NULL OR expires_at > ?", now).Distinct("user_id").Pluck("user_id", &direct).Error; err != nil {
+ return nil, err
+ }
+ if err := s.db.Table("tenant_user_roles").Distinct("tenant_user_roles.user_id").
+ Joins("JOIN tenant_role_permissions ON tenant_role_permissions.role_id = tenant_user_roles.role_id").
+ Where("tenant_user_roles.tenant_id = ? AND tenant_user_roles.user_id IN ? AND tenant_role_permissions.permission_id = ?", tenantID, candidates, sourceID).
+ Where("tenant_user_roles.expires_at IS NULL OR tenant_user_roles.expires_at > ?", now).
+ Pluck("tenant_user_roles.user_id", &throughRoles).Error; err != nil {
+ return nil, err
+ }
+ ids = append(direct, throughRoles...)
+ default:
+ return nil, &ValidationError{Message: "invalid source_type"}
+ }
+ for _, id := range ids {
+ result[id] = struct{}{}
+ }
+ return result, nil
+}
+
+func utcTime(value *time.Time) *time.Time {
+ if value == nil {
+ return nil
+ }
+ converted := value.UTC()
+ return &converted
+}
+
+func sortEndpoints(values []GrantEndpoint) {
+ sort.Slice(values, func(i, j int) bool { return values[i].Code < values[j].Code })
+}
diff --git a/basaltpass-backend/internal/service/appgrant/resolver.go b/basaltpass-backend/internal/service/appgrant/resolver.go
new file mode 100644
index 00000000..12c62370
--- /dev/null
+++ b/basaltpass-backend/internal/service/appgrant/resolver.go
@@ -0,0 +1,339 @@
+package appgrant
+
+import (
+ "errors"
+ "fmt"
+ "sort"
+ "time"
+
+ "basaltpass-backend/internal/model"
+
+ "gorm.io/gorm"
+)
+
+type explicitRoleRow struct {
+ AssignmentID uint
+ ID uint
+ Code string
+ Name string
+ Description string
+ AppID uint
+}
+
+type explicitPermissionRow struct {
+ AssignmentID uint
+ ID uint
+ Code string
+ Name string
+ Description string
+ Category string
+ AppID uint
+}
+
+type rolePermissionRow struct {
+ RoleID uint
+ PermissionID uint
+ Code string
+ Name string
+ Description string
+ Category string
+ AppID uint
+}
+
+// Resolve computes current app grants without writing derived assignments.
+func (s *Service) Resolve(userID, tenantID, appID uint, now time.Time) (EffectiveGrants, error) {
+ result := EffectiveGrants{Roles: []EffectiveRole{}, Permissions: []EffectivePermission{}}
+ if userID == 0 || tenantID == 0 || appID == 0 {
+ return result, &ValidationError{Message: "invalid effective grant context"}
+ }
+ now = now.UTC()
+
+ var tenant model.Tenant
+ if err := s.db.Select("id", "status").First(&tenant, tenantID).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return result, ErrNotFound
+ }
+ return result, err
+ }
+ if tenant.Status != model.TenantStatusActive {
+ result.DenialReason = "tenant_inactive"
+ return result, nil
+ }
+
+ var app model.App
+ if err := s.db.Where("id = ? AND tenant_id = ?", appID, tenantID).First(&app).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return result, ErrNotFound
+ }
+ return result, err
+ }
+ if app.Status != model.AppStatusActive {
+ result.DenialReason = "app_inactive"
+ return result, nil
+ }
+
+ var membership model.TenantUser
+ if err := s.db.Where("user_id = ? AND tenant_id = ?", userID, tenantID).First(&membership).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ result.DenialReason = "tenant_membership_missing"
+ return result, nil
+ }
+ return result, err
+ }
+ if membership.Role == model.TenantRoleBanned {
+ result.DenialReason = "tenant_membership_banned"
+ return result, nil
+ }
+ var user model.User
+ if err := s.db.Select("id", "banned").First(&user, userID).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ result.DenialReason = "user_inactive"
+ return result, nil
+ }
+ return result, err
+ }
+ if user.Banned {
+ result.DenialReason = "user_banned"
+ return result, nil
+ }
+
+ var appUser model.AppUser
+ if err := s.db.Where("user_id = ? AND app_id = ?", userID, appID).First(&appUser).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ result.DenialReason = "app_authorization_missing"
+ return result, nil
+ }
+ return result, err
+ }
+ if !appUserAllowsGrants(appUser, now) {
+ result.DenialReason = "app_user_inactive"
+ return result, nil
+ }
+ result.Eligible = true
+
+ tenantRoleIDs, tenantPermissionIDs, err := s.userTenantGrantSets(userID, tenantID, now)
+ if err != nil {
+ return EffectiveGrants{}, err
+ }
+
+ roleMap := map[uint]*EffectiveRole{}
+ permissionMap := map[uint]*EffectivePermission{}
+
+ var explicitRoles []explicitRoleRow
+ if err := s.db.Table("app_user_roles").
+ Select("app_user_roles.id AS assignment_id, app_roles.id, app_roles.code, app_roles.name, app_roles.description, app_roles.app_id").
+ Joins("JOIN app_roles ON app_roles.id = app_user_roles.role_id").
+ Where("app_user_roles.user_id = ? AND app_user_roles.app_id = ? AND app_roles.tenant_id = ?", userID, appID, tenantID).
+ Where("app_user_roles.expires_at IS NULL OR app_user_roles.expires_at > ?", now).
+ Scan(&explicitRoles).Error; err != nil {
+ return EffectiveGrants{}, err
+ }
+ for _, row := range explicitRoles {
+ role := ensureRole(roleMap, row.ID, row.Code, row.Name, row.Description, row.AppID)
+ addSource(&role.Sources, GrantSource{Type: "explicit", AssignmentID: row.AssignmentID})
+ }
+
+ var explicitPermissions []explicitPermissionRow
+ if err := s.db.Table("app_user_permissions").
+ Select("app_user_permissions.id AS assignment_id, app_permissions.id, app_permissions.code, app_permissions.name, app_permissions.description, app_permissions.category, app_permissions.app_id").
+ Joins("JOIN app_permissions ON app_permissions.id = app_user_permissions.permission_id").
+ Where("app_user_permissions.user_id = ? AND app_user_permissions.app_id = ? AND app_permissions.tenant_id = ?", userID, appID, tenantID).
+ Where("app_user_permissions.expires_at IS NULL OR app_user_permissions.expires_at > ?", now).
+ Scan(&explicitPermissions).Error; err != nil {
+ return EffectiveGrants{}, err
+ }
+ for _, row := range explicitPermissions {
+ permission := ensurePermission(permissionMap, row.ID, row.Code, row.Name, row.Description, row.Category, row.AppID)
+ addSource(&permission.Sources, GrantSource{Type: "explicit", AssignmentID: row.AssignmentID})
+ }
+
+ var mappings []model.TenantAppGrantMapping
+ if err := s.db.Where("tenant_id = ? AND app_id = ? AND enabled = ?", tenantID, appID, true).
+ Where("valid_from IS NULL OR valid_from <= ?", now).
+ Where("valid_until IS NULL OR valid_until > ?", now).
+ Order("id ASC").Find(&mappings).Error; err != nil {
+ return EffectiveGrants{}, err
+ }
+ for _, mapping := range mappings {
+ if !sourceMatchesUser(mapping, membership.Role, tenantRoleIDs, tenantPermissionIDs) {
+ continue
+ }
+ sourceCode, err := s.mappingSourceCode(mapping)
+ if err != nil {
+ return EffectiveGrants{}, err
+ }
+ source := GrantSource{Type: "tenant_mapping", MappingID: mapping.ID, SourceType: string(mapping.SourceType), SourceID: mapping.SourceID, SourceCode: sourceCode}
+ switch mapping.TargetType {
+ case model.TenantAppGrantTargetAppRole:
+ var target model.AppRole
+ if err := s.db.Where("id = ? AND tenant_id = ? AND app_id = ?", mapping.TargetID, tenantID, appID).First(&target).Error; err != nil {
+ return EffectiveGrants{}, fmt.Errorf("mapped app role is missing: %w", err)
+ }
+ role := ensureRole(roleMap, target.ID, target.Code, target.Name, target.Description, target.AppID)
+ addSource(&role.Sources, source)
+ case model.TenantAppGrantTargetAppPermission:
+ var target model.AppPermission
+ if err := s.db.Where("id = ? AND tenant_id = ? AND app_id = ?", mapping.TargetID, tenantID, appID).First(&target).Error; err != nil {
+ return EffectiveGrants{}, fmt.Errorf("mapped app permission is missing: %w", err)
+ }
+ permission := ensurePermission(permissionMap, target.ID, target.Code, target.Name, target.Description, target.Category, target.AppID)
+ addSource(&permission.Sources, source)
+ default:
+ return EffectiveGrants{}, &ValidationError{Message: "invalid stored target_type"}
+ }
+ }
+
+ roleIDs := make([]uint, 0, len(roleMap))
+ for id := range roleMap {
+ roleIDs = append(roleIDs, id)
+ }
+ if len(roleIDs) > 0 {
+ var links []rolePermissionRow
+ if err := s.db.Table("app_role_permissions").
+ Select("app_role_permissions.app_role_id AS role_id, app_permissions.id AS permission_id, app_permissions.code, app_permissions.name, app_permissions.description, app_permissions.category, app_permissions.app_id").
+ Joins("JOIN app_permissions ON app_permissions.id = app_role_permissions.app_permission_id").
+ Where("app_role_permissions.app_role_id IN ? AND app_permissions.tenant_id = ? AND app_permissions.app_id = ?", roleIDs, tenantID, appID).
+ Scan(&links).Error; err != nil {
+ return EffectiveGrants{}, err
+ }
+ for _, link := range links {
+ role := roleMap[link.RoleID]
+ appPermission := model.AppPermission{ID: link.PermissionID, Code: link.Code, Name: link.Name, Description: link.Description, Category: link.Category, AppID: link.AppID, TenantID: tenantID}
+ role.Permissions = append(role.Permissions, appPermission)
+ permission := ensurePermission(permissionMap, link.PermissionID, link.Code, link.Name, link.Description, link.Category, link.AppID)
+ for _, roleSource := range role.Sources {
+ roleSource.ViaRoleCode = role.Code
+ addSource(&permission.Sources, roleSource)
+ }
+ }
+ }
+
+ for _, role := range roleMap {
+ sort.Slice(role.Permissions, func(i, j int) bool { return role.Permissions[i].Code < role.Permissions[j].Code })
+ sortSources(role.Sources)
+ result.Roles = append(result.Roles, *role)
+ }
+ for _, permission := range permissionMap {
+ sortSources(permission.Sources)
+ result.Permissions = append(result.Permissions, *permission)
+ }
+ sort.Slice(result.Roles, func(i, j int) bool { return result.Roles[i].Code < result.Roles[j].Code })
+ sort.Slice(result.Permissions, func(i, j int) bool { return result.Permissions[i].Code < result.Permissions[j].Code })
+ return result, nil
+}
+
+func appUserAllowsGrants(appUser model.AppUser, now time.Time) bool {
+ switch appUser.Status {
+ case model.AppUserStatusActive, model.AppUserStatusRestricted:
+ return true
+ case model.AppUserStatusBanned, model.AppUserStatusSuspended:
+ return appUser.BannedUntil != nil && !appUser.BannedUntil.After(now)
+ default:
+ return false
+ }
+}
+
+func (s *Service) userTenantGrantSets(userID, tenantID uint, now time.Time) (map[uint]struct{}, map[uint]struct{}, error) {
+ roleIDs := map[uint]struct{}{}
+ permissionIDs := map[uint]struct{}{}
+ var roles []uint
+ if err := s.db.Model(&model.TenantUserRbacRole{}).
+ Where("user_id = ? AND tenant_id = ?", userID, tenantID).
+ Where("expires_at IS NULL OR expires_at > ?", now).Distinct("role_id").Pluck("role_id", &roles).Error; err != nil {
+ return nil, nil, err
+ }
+ for _, id := range roles {
+ roleIDs[id] = struct{}{}
+ }
+ var directPermissions []uint
+ if err := s.db.Model(&model.TenantUserRbacPermission{}).
+ Where("user_id = ? AND tenant_id = ?", userID, tenantID).
+ Where("expires_at IS NULL OR expires_at > ?", now).Distinct("permission_id").Pluck("permission_id", &directPermissions).Error; err != nil {
+ return nil, nil, err
+ }
+ for _, id := range directPermissions {
+ permissionIDs[id] = struct{}{}
+ }
+ if len(roles) > 0 {
+ var rolePermissions []uint
+ if err := s.db.Model(&model.TenantRbacRolePermission{}).Where("role_id IN ?", roles).Distinct("permission_id").Pluck("permission_id", &rolePermissions).Error; err != nil {
+ return nil, nil, err
+ }
+ for _, id := range rolePermissions {
+ permissionIDs[id] = struct{}{}
+ }
+ }
+ return roleIDs, permissionIDs, nil
+}
+
+func sourceMatchesUser(mapping model.TenantAppGrantMapping, membershipRole model.TenantRole, tenantRoleIDs, tenantPermissionIDs map[uint]struct{}) bool {
+ switch mapping.SourceType {
+ case model.TenantAppGrantSourceMembershipRole:
+ return string(membershipRole) == mapping.SourceCode
+ case model.TenantAppGrantSourceTenantRole:
+ _, ok := tenantRoleIDs[mapping.SourceID]
+ return ok
+ case model.TenantAppGrantSourceTenantPermission:
+ _, ok := tenantPermissionIDs[mapping.SourceID]
+ return ok
+ default:
+ return false
+ }
+}
+
+func (s *Service) mappingSourceCode(mapping model.TenantAppGrantMapping) (string, error) {
+ if mapping.SourceType == model.TenantAppGrantSourceMembershipRole {
+ return mapping.SourceCode, nil
+ }
+ if mapping.SourceType == model.TenantAppGrantSourceTenantRole {
+ var role model.TenantRbacRole
+ if err := s.db.Select("code").Where("id = ? AND tenant_id = ?", mapping.SourceID, mapping.TenantID).First(&role).Error; err != nil {
+ return "", err
+ }
+ return role.Code, nil
+ }
+ if mapping.SourceType == model.TenantAppGrantSourceTenantPermission {
+ var permission model.TenantRbacPermission
+ if err := s.db.Select("code").Where("id = ? AND tenant_id = ?", mapping.SourceID, mapping.TenantID).First(&permission).Error; err != nil {
+ return "", err
+ }
+ return permission.Code, nil
+ }
+ return "", &ValidationError{Message: "invalid stored source_type"}
+}
+
+func ensureRole(values map[uint]*EffectiveRole, id uint, code, name, description string, appID uint) *EffectiveRole {
+ if existing := values[id]; existing != nil {
+ return existing
+ }
+ value := &EffectiveRole{ID: id, Code: code, Name: name, Description: description, AppID: appID, Permissions: []model.AppPermission{}, Sources: []GrantSource{}}
+ values[id] = value
+ return value
+}
+
+func ensurePermission(values map[uint]*EffectivePermission, id uint, code, name, description, category string, appID uint) *EffectivePermission {
+ if existing := values[id]; existing != nil {
+ return existing
+ }
+ value := &EffectivePermission{ID: id, Code: code, Name: name, Description: description, Category: category, AppID: appID, Sources: []GrantSource{}}
+ values[id] = value
+ return value
+}
+
+func addSource(values *[]GrantSource, value GrantSource) {
+ key := sourceKey(value)
+ for _, existing := range *values {
+ if sourceKey(existing) == key {
+ return
+ }
+ }
+ *values = append(*values, value)
+}
+
+func sourceKey(value GrantSource) string {
+ return fmt.Sprintf("%s:%d:%d:%s", value.Type, value.AssignmentID, value.MappingID, value.ViaRoleCode)
+}
+
+func sortSources(values []GrantSource) {
+ sort.Slice(values, func(i, j int) bool { return sourceKey(values[i]) < sourceKey(values[j]) })
+}
diff --git a/basaltpass-backend/internal/service/appgrant/service_test.go b/basaltpass-backend/internal/service/appgrant/service_test.go
new file mode 100644
index 00000000..8672ebc0
--- /dev/null
+++ b/basaltpass-backend/internal/service/appgrant/service_test.go
@@ -0,0 +1,309 @@
+package appgrant
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+ "time"
+
+ "basaltpass-backend/internal/model"
+
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+type grantFixture struct {
+ db *gorm.DB
+ service *Service
+ tenant model.Tenant
+ otherTenant model.Tenant
+ app model.App
+ otherApp model.App
+ user model.User
+ appUser model.AppUser
+ readPermission model.AppPermission
+ adminPermission model.AppPermission
+ userRole model.AppRole
+ adminRole model.AppRole
+ tenantRole model.TenantRbacRole
+ tenantPermission model.TenantRbacPermission
+ now time.Time
+}
+
+func newGrantFixture(t *testing.T) grantFixture {
+ t.Helper()
+ dsn := fmt.Sprintf("file:appgrant-%d?mode=memory&cache=shared", time.Now().UnixNano())
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ if err := db.AutoMigrate(
+ &model.User{}, &model.Tenant{}, &model.TenantUser{}, &model.App{}, &model.AppUser{},
+ &model.AppPermission{}, &model.AppRole{}, &model.AppUserPermission{}, &model.AppUserRole{},
+ &model.TenantRbacRole{}, &model.TenantRbacPermission{}, &model.TenantUserRbacRole{},
+ &model.TenantUserRbacPermission{}, &model.TenantRbacRolePermission{}, &model.TenantAppGrantMapping{},
+ ); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+
+ now := time.Date(2026, 7, 15, 1, 0, 0, 0, time.UTC)
+ tenant := model.Tenant{Name: "Tenant A", Code: "tenant-a"}
+ otherTenant := model.Tenant{Name: "Tenant B", Code: "tenant-b"}
+ db.Create(&tenant)
+ db.Create(&otherTenant)
+ app := model.App{TenantID: tenant.ID, Name: "App A", Status: model.AppStatusActive}
+ otherApp := model.App{TenantID: otherTenant.ID, Name: "App B", Status: model.AppStatusActive}
+ db.Create(&app)
+ db.Create(&otherApp)
+ user := model.User{Email: "mapped@example.com", EmailVerified: true}
+ db.Create(&user)
+ db.Create(&model.TenantUser{TenantID: tenant.ID, UserID: user.ID, Role: model.TenantRoleMember})
+ appUser := model.AppUser{AppID: app.ID, UserID: user.ID, Status: model.AppUserStatusActive, FirstAuthorizedAt: now, LastAuthorizedAt: now}
+ db.Create(&appUser)
+
+ readPermission := model.AppPermission{TenantID: tenant.ID, AppID: app.ID, Code: "app.read", Name: "Read", Category: "app"}
+ adminPermission := model.AppPermission{TenantID: tenant.ID, AppID: app.ID, Code: "app.admin", Name: "Admin", Category: "app"}
+ db.Create(&readPermission)
+ db.Create(&adminPermission)
+ userRole := model.AppRole{TenantID: tenant.ID, AppID: app.ID, Code: "app.user", Name: "User"}
+ adminRole := model.AppRole{TenantID: tenant.ID, AppID: app.ID, Code: "app.admin", Name: "Admin"}
+ db.Create(&userRole)
+ db.Create(&adminRole)
+ db.Model(&userRole).Association("Permissions").Append(&readPermission)
+ db.Model(&adminRole).Association("Permissions").Append(&adminPermission)
+
+ tenantPermission := model.TenantRbacPermission{TenantID: tenant.ID, Code: "tenant.apps.manage", Name: "Manage apps", Category: "app"}
+ tenantRole := model.TenantRbacRole{TenantID: tenant.ID, Code: "operators", Name: "Operators"}
+ db.Create(&tenantPermission)
+ db.Create(&tenantRole)
+ db.Create(&model.TenantRbacRolePermission{RoleID: tenantRole.ID, PermissionID: tenantPermission.ID})
+ db.Create(&model.TenantUserRbacRole{TenantID: tenant.ID, UserID: user.ID, RoleID: tenantRole.ID, AssignedAt: now, AssignedBy: user.ID})
+
+ return grantFixture{db: db, service: NewService(db), tenant: tenant, otherTenant: otherTenant, app: app, otherApp: otherApp,
+ user: user, appUser: appUser, readPermission: readPermission, adminPermission: adminPermission, userRole: userRole,
+ adminRole: adminRole, tenantRole: tenantRole, tenantPermission: tenantPermission, now: now}
+}
+
+func boolPtr(value bool) *bool { return &value }
+
+func TestResolveMergesExplicitAndDynamicGrantsWithoutMaterializing(t *testing.T) {
+ f := newGrantFixture(t)
+ explicit := model.AppUserRole{UserID: f.user.ID, AppID: f.app.ID, RoleID: f.userRole.ID, AssignedAt: f.now, AssignedBy: f.user.ID}
+ if err := f.db.Create(&explicit).Error; err != nil {
+ t.Fatalf("create explicit role: %v", err)
+ }
+
+ membership, err := f.service.CreateMapping(f.tenant.ID, f.app.ID, f.user.ID, MappingInput{
+ SourceType: model.TenantAppGrantSourceMembershipRole, SourceCode: "member",
+ TargetType: model.TenantAppGrantTargetAppRole, TargetID: f.userRole.ID,
+ })
+ if err != nil {
+ t.Fatalf("create membership mapping: %v", err)
+ }
+ if membership.AffectedUserCount != 1 {
+ t.Fatalf("expected one affected user, got %d", membership.AffectedUserCount)
+ }
+ roleMapping, err := f.service.CreateMapping(f.tenant.ID, f.app.ID, f.user.ID, MappingInput{
+ SourceType: model.TenantAppGrantSourceTenantRole, SourceID: f.tenantRole.ID,
+ TargetType: model.TenantAppGrantTargetAppRole, TargetID: f.adminRole.ID,
+ })
+ if err != nil {
+ t.Fatalf("create role mapping: %v", err)
+ }
+ _, err = f.service.CreateMapping(f.tenant.ID, f.app.ID, f.user.ID, MappingInput{
+ SourceType: model.TenantAppGrantSourceTenantPermission, SourceID: f.tenantPermission.ID,
+ TargetType: model.TenantAppGrantTargetAppPermission, TargetID: f.readPermission.ID,
+ })
+ if err != nil {
+ t.Fatalf("create permission mapping: %v", err)
+ }
+
+ grants, err := f.service.Resolve(f.user.ID, f.tenant.ID, f.app.ID, f.now)
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if !grants.Eligible || len(grants.Roles) != 2 || len(grants.Permissions) != 2 {
+ t.Fatalf("unexpected grants: %+v", grants)
+ }
+ var userSources, adminSources int
+ for _, role := range grants.Roles {
+ switch role.Code {
+ case "app.user":
+ userSources = len(role.Sources)
+ case "app.admin":
+ adminSources = len(role.Sources)
+ }
+ }
+ if userSources != 2 || adminSources != 1 {
+ t.Fatalf("expected explicit+mapped user sources and one admin source, got user=%d admin=%d", userSources, adminSources)
+ }
+ var physicalCount int64
+ f.db.Model(&model.AppUserRole{}).Where("user_id = ? AND app_id = ?", f.user.ID, f.app.ID).Count(&physicalCount)
+ if physicalCount != 1 {
+ t.Fatalf("resolver materialized inherited roles: count=%d", physicalCount)
+ }
+
+ if err := f.service.DeleteMapping(f.tenant.ID, f.app.ID, roleMapping.ID); err != nil {
+ t.Fatalf("delete mapping: %v", err)
+ }
+ grants, err = f.service.Resolve(f.user.ID, f.tenant.ID, f.app.ID, f.now)
+ if err != nil {
+ t.Fatalf("resolve after delete: %v", err)
+ }
+ for _, role := range grants.Roles {
+ if role.Code == "app.admin" {
+ t.Fatal("mapped admin role remained after policy deletion")
+ }
+ }
+}
+
+func TestMappingValidationAndDuplicateProtection(t *testing.T) {
+ f := newGrantFixture(t)
+ input := MappingInput{SourceType: model.TenantAppGrantSourceMembershipRole, SourceCode: "member", TargetType: model.TenantAppGrantTargetAppRole, TargetID: f.userRole.ID}
+ if _, err := f.service.CreateMapping(f.tenant.ID, f.app.ID, f.user.ID, input); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if _, err := f.service.CreateMapping(f.tenant.ID, f.app.ID, f.user.ID, input); !errors.Is(err, ErrConflict) {
+ t.Fatalf("expected duplicate conflict, got %v", err)
+ }
+
+ otherPermission := model.AppPermission{TenantID: f.otherTenant.ID, AppID: f.otherApp.ID, Code: "other.read", Name: "Other", Category: "other"}
+ f.db.Create(&otherPermission)
+ _, err := f.service.CreateMapping(f.tenant.ID, f.app.ID, f.user.ID, MappingInput{
+ SourceType: model.TenantAppGrantSourceMembershipRole, SourceCode: "member",
+ TargetType: model.TenantAppGrantTargetAppPermission, TargetID: otherPermission.ID,
+ })
+ var validation *ValidationError
+ if !errors.As(err, &validation) {
+ t.Fatalf("expected cross-tenant validation error, got %v", err)
+ }
+
+ until := f.now
+ from := f.now.Add(time.Hour)
+ _, err = f.service.CreateMapping(f.tenant.ID, f.app.ID, f.user.ID, MappingInput{
+ SourceType: model.TenantAppGrantSourceTenantRole, SourceID: f.tenantRole.ID,
+ TargetType: model.TenantAppGrantTargetAppRole, TargetID: f.adminRole.ID, ValidFrom: &from, ValidUntil: &until,
+ })
+ if !errors.As(err, &validation) {
+ t.Fatalf("expected invalid validity window, got %v", err)
+ }
+}
+
+func TestCreateDisabledMappingRemainsDisabled(t *testing.T) {
+ f := newGrantFixture(t)
+ created, err := f.service.CreateMapping(f.tenant.ID, f.app.ID, f.user.ID, MappingInput{
+ SourceType: model.TenantAppGrantSourceMembershipRole, SourceCode: "member",
+ TargetType: model.TenantAppGrantTargetAppRole, TargetID: f.userRole.ID, Enabled: boolPtr(false),
+ })
+ if err != nil {
+ t.Fatalf("create disabled mapping: %v", err)
+ }
+ if created.Enabled || created.AffectedUserCount != 0 {
+ t.Fatalf("disabled mapping unexpectedly active: %+v", created)
+ }
+ var stored model.TenantAppGrantMapping
+ if err := f.db.First(&stored, created.ID).Error; err != nil {
+ t.Fatalf("load disabled mapping: %v", err)
+ }
+ if stored.Enabled {
+ t.Fatal("explicit enabled=false was replaced by the database default")
+ }
+ grants, err := f.service.Resolve(f.user.ID, f.tenant.ID, f.app.ID, f.now)
+ if err != nil {
+ t.Fatalf("resolve disabled mapping: %v", err)
+ }
+ if len(grants.Roles) != 0 || len(grants.Permissions) != 0 {
+ t.Fatalf("disabled mapping granted access: %+v", grants)
+ }
+}
+
+func TestResolveEnforcesRuntimeStatusAndExpiry(t *testing.T) {
+ f := newGrantFixture(t)
+ _, err := f.service.CreateMapping(f.tenant.ID, f.app.ID, f.user.ID, MappingInput{
+ SourceType: model.TenantAppGrantSourceMembershipRole, SourceCode: "member",
+ TargetType: model.TenantAppGrantTargetAppRole, TargetID: f.userRole.ID, Enabled: boolPtr(true),
+ })
+ if err != nil {
+ t.Fatalf("create mapping: %v", err)
+ }
+
+ assertDenied := func(reason string) {
+ t.Helper()
+ grants, resolveErr := f.service.Resolve(f.user.ID, f.tenant.ID, f.app.ID, f.now)
+ if resolveErr != nil || grants.Eligible || grants.DenialReason != reason || len(grants.Roles) != 0 {
+ t.Fatalf("expected denial %s, got grants=%+v err=%v", reason, grants, resolveErr)
+ }
+ }
+
+ f.db.Model(&model.AppUser{}).Where("id = ?", f.appUser.ID).Updates(map[string]any{"status": model.AppUserStatusSuspended, "banned_until": nil})
+ assertDenied("app_user_inactive")
+ past := f.now.Add(-time.Minute)
+ f.db.Model(&model.AppUser{}).Where("id = ?", f.appUser.ID).Updates(map[string]any{"banned_until": past})
+ grants, err := f.service.Resolve(f.user.ID, f.tenant.ID, f.app.ID, f.now)
+ if err != nil || !grants.Eligible || len(grants.Roles) != 1 {
+ t.Fatalf("expired suspension should be dynamically inactive, got %+v err=%v", grants, err)
+ }
+
+ f.db.Model(&model.TenantUser{}).Where("user_id = ? AND tenant_id = ?", f.user.ID, f.tenant.ID).Update("role", model.TenantRoleBanned)
+ assertDenied("tenant_membership_banned")
+ f.db.Model(&model.TenantUser{}).Where("user_id = ? AND tenant_id = ?", f.user.ID, f.tenant.ID).Update("role", model.TenantRoleMember)
+ f.db.Model(&model.User{}).Where("id = ?", f.user.ID).Update("banned", true)
+ assertDenied("user_banned")
+ f.db.Model(&model.User{}).Where("id = ?", f.user.ID).Update("banned", false)
+ f.db.Model(&model.Tenant{}).Where("id = ?", f.tenant.ID).Update("status", model.TenantStatusSuspended)
+ assertDenied("tenant_inactive")
+ f.db.Model(&model.Tenant{}).Where("id = ?", f.tenant.ID).Update("status", model.TenantStatusActive)
+ f.db.Model(&model.App{}).Where("id = ?", f.app.ID).Update("status", model.AppStatusSuspended)
+ assertDenied("app_inactive")
+}
+
+func TestPreviewUsesSameStatusEligibilityAsResolver(t *testing.T) {
+ f := newGrantFixture(t)
+ input := MappingInput{
+ SourceType: model.TenantAppGrantSourceMembershipRole, SourceCode: "member",
+ TargetType: model.TenantAppGrantTargetAppRole, TargetID: f.userRole.ID,
+ }
+ assertCount := func(want int64) {
+ t.Helper()
+ got, err := f.service.PreviewAffectedUsers(f.tenant.ID, f.app.ID, input)
+ if err != nil || got != want {
+ t.Fatalf("preview count: want=%d got=%d err=%v", want, got, err)
+ }
+ }
+ assertCount(1)
+ f.db.Model(&model.User{}).Where("id = ?", f.user.ID).Update("banned", true)
+ assertCount(0)
+ f.db.Model(&model.User{}).Where("id = ?", f.user.ID).Update("banned", false)
+ f.db.Model(&model.Tenant{}).Where("id = ?", f.tenant.ID).Update("status", model.TenantStatusSuspended)
+ assertCount(0)
+ f.db.Model(&model.Tenant{}).Where("id = ?", f.tenant.ID).Update("status", model.TenantStatusActive)
+ f.db.Model(&model.App{}).Where("id = ?", f.app.ID).Update("status", model.AppStatusSuspended)
+ assertCount(0)
+}
+
+func TestExpiredMappingAndTenantRoleDoNotGrant(t *testing.T) {
+ f := newGrantFixture(t)
+ past := f.now.Add(-time.Hour)
+ _, err := f.service.CreateMapping(f.tenant.ID, f.app.ID, f.user.ID, MappingInput{
+ SourceType: model.TenantAppGrantSourceMembershipRole, SourceCode: "member",
+ TargetType: model.TenantAppGrantTargetAppRole, TargetID: f.userRole.ID, ValidUntil: &past,
+ })
+ if err != nil {
+ t.Fatalf("create expired mapping: %v", err)
+ }
+ f.db.Model(&model.TenantUserRbacRole{}).Where("user_id = ? AND role_id = ?", f.user.ID, f.tenantRole.ID).Update("expires_at", past)
+ _, err = f.service.CreateMapping(f.tenant.ID, f.app.ID, f.user.ID, MappingInput{
+ SourceType: model.TenantAppGrantSourceTenantRole, SourceID: f.tenantRole.ID,
+ TargetType: model.TenantAppGrantTargetAppRole, TargetID: f.adminRole.ID,
+ })
+ if err != nil {
+ t.Fatalf("create role mapping: %v", err)
+ }
+ grants, err := f.service.Resolve(f.user.ID, f.tenant.ID, f.app.ID, f.now)
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if len(grants.Roles) != 0 || len(grants.Permissions) != 0 {
+ t.Fatalf("expired sources granted access: %+v", grants)
+ }
+}
diff --git a/basaltpass-backend/internal/service/appgrant/types.go b/basaltpass-backend/internal/service/appgrant/types.go
new file mode 100644
index 00000000..850e7736
--- /dev/null
+++ b/basaltpass-backend/internal/service/appgrant/types.go
@@ -0,0 +1,98 @@
+package appgrant
+
+import (
+ "errors"
+ "time"
+
+ "basaltpass-backend/internal/model"
+)
+
+var (
+ ErrNotFound = errors.New("app grant mapping not found")
+ ErrConflict = errors.New("app grant mapping conflicts with existing policy")
+)
+
+type ValidationError struct {
+ Message string
+}
+
+func (e *ValidationError) Error() string { return e.Message }
+
+type MappingInput struct {
+ SourceType model.TenantAppGrantSourceType `json:"source_type"`
+ SourceID uint `json:"source_id"`
+ SourceCode string `json:"source_code"`
+ TargetType model.TenantAppGrantTargetType `json:"target_type"`
+ TargetID uint `json:"target_id"`
+ Enabled *bool `json:"enabled,omitempty"`
+ ValidFrom *time.Time `json:"valid_from,omitempty"`
+ ValidUntil *time.Time `json:"valid_until,omitempty"`
+}
+
+type GrantEndpoint struct {
+ Type string `json:"type"`
+ ID uint `json:"id,omitempty"`
+ Code string `json:"code"`
+ Name string `json:"name"`
+}
+
+type MappingView struct {
+ ID uint `json:"id"`
+ TenantID uint `json:"tenant_id"`
+ AppID uint `json:"app_id"`
+ Source GrantEndpoint `json:"source"`
+ Target GrantEndpoint `json:"target"`
+ Enabled bool `json:"enabled"`
+ ValidFrom *time.Time `json:"valid_from,omitempty"`
+ ValidUntil *time.Time `json:"valid_until,omitempty"`
+ AffectedUserCount int64 `json:"affected_user_count"`
+ CreatedBy uint `json:"created_by"`
+ UpdatedBy uint `json:"updated_by"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type GrantSource struct {
+ Type string `json:"type"`
+ AssignmentID uint `json:"assignment_id,omitempty"`
+ MappingID uint `json:"mapping_id,omitempty"`
+ SourceType string `json:"source_type,omitempty"`
+ SourceID uint `json:"source_id,omitempty"`
+ SourceCode string `json:"source_code,omitempty"`
+ ViaRoleCode string `json:"via_role_code,omitempty"`
+}
+
+type EffectiveRole struct {
+ ID uint `json:"id"`
+ Code string `json:"code"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ AppID uint `json:"app_id"`
+ Permissions []model.AppPermission `json:"permissions"`
+ Sources []GrantSource `json:"sources"`
+}
+
+type EffectivePermission struct {
+ ID uint `json:"id"`
+ Code string `json:"code"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Category string `json:"category"`
+ AppID uint `json:"app_id"`
+ Sources []GrantSource `json:"sources"`
+}
+
+type EffectiveGrants struct {
+ Eligible bool `json:"eligible"`
+ DenialReason string `json:"denial_reason,omitempty"`
+ Roles []EffectiveRole `json:"roles"`
+ Permissions []EffectivePermission `json:"permissions"`
+}
+
+type Options struct {
+ MembershipRoles []GrantEndpoint `json:"membership_roles"`
+ TenantRoles []GrantEndpoint `json:"tenant_roles"`
+ TenantPermissions []GrantEndpoint `json:"tenant_permissions"`
+ AppRoles []GrantEndpoint `json:"app_roles"`
+ AppPermissions []GrantEndpoint `json:"app_permissions"`
+}
diff --git a/basaltpass-backend/internal/service/currency/service.go b/basaltpass-backend/internal/service/currency/service.go
index f8c15c1a..4da4267e 100644
--- a/basaltpass-backend/internal/service/currency/service.go
+++ b/basaltpass-backend/internal/service/currency/service.go
@@ -3,6 +3,7 @@ package currency
import (
"basaltpass-backend/internal/common"
"basaltpass-backend/internal/model"
+ "fmt"
"strings"
)
@@ -30,6 +31,67 @@ func GetCurrencyByID(id uint) (model.Currency, error) {
return currency, err
}
+// GetAllCurrencyRates returns all active directional currency rates.
+func GetAllCurrencyRates() ([]model.CurrencyRate, error) {
+ var rates []model.CurrencyRate
+ db := common.DB()
+ err := db.Where("is_active = ?", true).Order("base_currency_code, quote_currency_code").Find(&rates).Error
+ return rates, err
+}
+
+// GetExchangeRate returns the rate for 1 base currency in quote currency.
+func GetExchangeRate(baseCode string, quoteCode string) (float64, error) {
+ baseCode = strings.ToUpper(strings.TrimSpace(baseCode))
+ quoteCode = strings.ToUpper(strings.TrimSpace(quoteCode))
+ if baseCode == "" || quoteCode == "" {
+ return 0, fmt.Errorf("currency pair is required")
+ }
+ if baseCode == quoteCode {
+ return 1, nil
+ }
+
+ db := common.DB()
+ var exact model.CurrencyRate
+ if err := db.Where("base_currency_code = ? AND quote_currency_code = ? AND is_active = ?", baseCode, quoteCode, true).First(&exact).Error; err == nil && exact.Rate > 0 {
+ return exact.Rate, nil
+ }
+
+ var inverse model.CurrencyRate
+ if err := db.Where("base_currency_code = ? AND quote_currency_code = ? AND is_active = ?", quoteCode, baseCode, true).First(&inverse).Error; err == nil && inverse.Rate > 0 {
+ return 1 / inverse.Rate, nil
+ }
+
+ baseUSD, baseErr := getRateToUSD(baseCode)
+ quoteUSD, quoteErr := getRateToUSD(quoteCode)
+ if baseErr == nil && quoteErr == nil && baseUSD > 0 && quoteUSD > 0 {
+ return baseUSD / quoteUSD, nil
+ }
+
+ return 0, fmt.Errorf("exchange rate is not configured for %s/%s", baseCode, quoteCode)
+}
+
+func getRateToUSD(code string) (float64, error) {
+ if code == "USD" {
+ return 1, nil
+ }
+ db := common.DB()
+ var rate model.CurrencyRate
+ if err := db.Where("base_currency_code = ? AND quote_currency_code = ? AND is_active = ?", code, "USD", true).First(&rate).Error; err == nil && rate.Rate > 0 {
+ return rate.Rate, nil
+ }
+ if err := db.Where("base_currency_code = ? AND quote_currency_code = ? AND is_active = ?", "USD", code, true).First(&rate).Error; err == nil && rate.Rate > 0 {
+ return 1 / rate.Rate, nil
+ }
+ currency, err := GetCurrencyByCode(code)
+ if err != nil {
+ return 0, err
+ }
+ if currency.ExchangeRateUSD <= 0 {
+ return 0, fmt.Errorf("exchange rate is not configured for %s", code)
+ }
+ return currency.ExchangeRateUSD, nil
+}
+
// CreateCurrency creates a new currency
func CreateCurrency(currency *model.Currency) error {
db := common.DB()
@@ -61,81 +123,95 @@ func InitDefaultCurrencies() error {
defaultCurrencies := []model.Currency{
{
- Code: "USD",
- Name: "US Dollar",
- NameCN: "美元",
- Symbol: "$",
- DecimalPlaces: 2,
- Type: "fiat",
- IsActive: true,
- SortOrder: 1,
- Description: "United States Dollar",
+ Code: "USD",
+ Name: "US Dollar",
+ NameCN: "美元",
+ Symbol: "$",
+ DecimalPlaces: 2,
+ Type: "fiat",
+ ExchangeRateUSD: 1,
+ PaymentEnabled: true,
+ IsActive: true,
+ SortOrder: 1,
+ Description: "United States Dollar",
},
{
- Code: "CNY",
- Name: "Chinese Yuan",
- NameCN: "人民币",
- Symbol: "¥",
- DecimalPlaces: 2,
- Type: "fiat",
- IsActive: true,
- SortOrder: 2,
- Description: "Chinese Yuan Renminbi",
+ Code: "CNY",
+ Name: "Chinese Yuan",
+ NameCN: "人民币",
+ Symbol: "¥",
+ DecimalPlaces: 2,
+ Type: "fiat",
+ ExchangeRateUSD: 0.14,
+ PaymentEnabled: true,
+ IsActive: true,
+ SortOrder: 2,
+ Description: "Chinese Yuan Renminbi",
},
{
- Code: "EUR",
- Name: "Euro",
- NameCN: "欧元",
- Symbol: "€",
- DecimalPlaces: 2,
- Type: "fiat",
- IsActive: true,
- SortOrder: 3,
- Description: "European Euro",
+ Code: "EUR",
+ Name: "Euro",
+ NameCN: "欧元",
+ Symbol: "€",
+ DecimalPlaces: 2,
+ Type: "fiat",
+ ExchangeRateUSD: 1.08,
+ PaymentEnabled: true,
+ IsActive: true,
+ SortOrder: 3,
+ Description: "European Euro",
},
{
- Code: "BTC",
- Name: "Bitcoin",
- NameCN: "比特币",
- Symbol: "₿",
- DecimalPlaces: 8,
- Type: "crypto",
- IsActive: true,
- SortOrder: 10,
- Description: "Bitcoin cryptocurrency",
+ Code: "BTC",
+ Name: "Bitcoin",
+ NameCN: "比特币",
+ Symbol: "₿",
+ DecimalPlaces: 8,
+ Type: "crypto",
+ ExchangeRateUSD: 1,
+ PaymentEnabled: false,
+ IsActive: true,
+ SortOrder: 10,
+ Description: "Bitcoin cryptocurrency",
},
{
- Code: "ETH",
- Name: "Ethereum",
- NameCN: "以太坊",
- Symbol: "Ξ",
- DecimalPlaces: 18,
- Type: "crypto",
- IsActive: true,
- SortOrder: 11,
- Description: "Ethereum cryptocurrency",
+ Code: "ETH",
+ Name: "Ethereum",
+ NameCN: "以太坊",
+ Symbol: "Ξ",
+ DecimalPlaces: 18,
+ Type: "crypto",
+ ExchangeRateUSD: 1,
+ PaymentEnabled: false,
+ IsActive: true,
+ SortOrder: 11,
+ Description: "Ethereum cryptocurrency",
},
{
- Code: "CREDIT",
- Name: "Credit",
- NameCN: "信用点",
- Symbol: "C",
- DecimalPlaces: 0,
- Type: "points",
- IsActive: true,
- SortOrder: 19,
- Description: "User credit wallet unit",
+ Code: "CREDIT",
+ Name: "Credit",
+ NameCN: "信用点",
+ Symbol: "C",
+ DecimalPlaces: 0,
+ Type: "points",
+ ExchangeRateUSD: 0.000001,
+ PaymentEnabled: false,
+ IsActive: true,
+ SortOrder: 19,
+ Description: "User credit point wallet unit; 1 USD = 1,000,000 CREDIT",
},
{
- Code: "POINTS",
- Name: "System Points",
- NameCN: "系统积分",
- Symbol: "P",
- DecimalPlaces: 0,
- Type: "points",
- IsActive: true,
- SortOrder: 20,
- Description: "System reward points",
+ Code: "POINTS",
+ Name: "System Points",
+ NameCN: "系统积分",
+ Symbol: "P",
+ DecimalPlaces: 0,
+ Type: "points",
+ ExchangeRateUSD: 0.01,
+ PaymentEnabled: false,
+ IsActive: true,
+ SortOrder: 20,
+ Description: "System reward points",
},
}
@@ -151,23 +227,144 @@ func InitDefaultCurrencies() error {
// predefinedCurrencyCatalog 提供一份可初始化的货币目录(代码 -> Currency 模板)
func predefinedCurrencyCatalog() map[string]model.Currency {
return map[string]model.Currency{
- "USD": {Code: "USD", Name: "US Dollar", NameCN: "美元", Symbol: "$", DecimalPlaces: 2, Type: "fiat", IsActive: true, SortOrder: 1, Description: "United States Dollar"},
- "CNY": {Code: "CNY", Name: "Chinese Yuan", NameCN: "人民币", Symbol: "¥", DecimalPlaces: 2, Type: "fiat", IsActive: true, SortOrder: 2, Description: "Chinese Yuan Renminbi"},
- "EUR": {Code: "EUR", Name: "Euro", NameCN: "欧元", Symbol: "€", DecimalPlaces: 2, Type: "fiat", IsActive: true, SortOrder: 3, Description: "European Euro"},
- "GBP": {Code: "GBP", Name: "British Pound", NameCN: "英镑", Symbol: "£", DecimalPlaces: 2, Type: "fiat", IsActive: true, SortOrder: 4, Description: "Great Britain Pound"},
- "JPY": {Code: "JPY", Name: "Japanese Yen", NameCN: "日元", Symbol: "¥", DecimalPlaces: 0, Type: "fiat", IsActive: true, SortOrder: 5, Description: "Japanese Yen"},
- "KRW": {Code: "KRW", Name: "Korean Won", NameCN: "韩元", Symbol: "₩", DecimalPlaces: 0, Type: "fiat", IsActive: true, SortOrder: 6, Description: "Korean Won"},
- "HKD": {Code: "HKD", Name: "Hong Kong Dollar", NameCN: "港币", Symbol: "$", DecimalPlaces: 2, Type: "fiat", IsActive: true, SortOrder: 7, Description: "Hong Kong Dollar"},
- "INR": {Code: "INR", Name: "Indian Rupee", NameCN: "印度卢比", Symbol: "₹", DecimalPlaces: 2, Type: "fiat", IsActive: true, SortOrder: 8, Description: "Indian Rupee"},
- "AUD": {Code: "AUD", Name: "Australian Dollar", NameCN: "澳元", Symbol: "$", DecimalPlaces: 2, Type: "fiat", IsActive: true, SortOrder: 9, Description: "Australian Dollar"},
- "CAD": {Code: "CAD", Name: "Canadian Dollar", NameCN: "加元", Symbol: "$", DecimalPlaces: 2, Type: "fiat", IsActive: true, SortOrder: 10, Description: "Canadian Dollar"},
- "BTC": {Code: "BTC", Name: "Bitcoin", NameCN: "比特币", Symbol: "₿", DecimalPlaces: 8, Type: "crypto", IsActive: true, SortOrder: 50, Description: "Bitcoin cryptocurrency"},
- "ETH": {Code: "ETH", Name: "Ethereum", NameCN: "以太坊", Symbol: "Ξ", DecimalPlaces: 18, Type: "crypto", IsActive: true, SortOrder: 51, Description: "Ethereum cryptocurrency"},
- "CREDIT": {Code: "CREDIT", Name: "Credit", NameCN: "信用点", Symbol: "C", DecimalPlaces: 0, Type: "points", IsActive: true, SortOrder: 89, Description: "User credit wallet unit"},
- "POINTS": {Code: "POINTS", Name: "System Points", NameCN: "系统积分", Symbol: "P", DecimalPlaces: 0, Type: "points", IsActive: true, SortOrder: 90, Description: "System reward points"},
+ "USD": {Code: "USD", Name: "US Dollar", NameCN: "美元", Symbol: "$", DecimalPlaces: 2, Type: "fiat", ExchangeRateUSD: 1, PaymentEnabled: true, IsActive: true, SortOrder: 1, Description: "United States Dollar"},
+ "CNY": {Code: "CNY", Name: "Chinese Yuan", NameCN: "人民币", Symbol: "¥", DecimalPlaces: 2, Type: "fiat", ExchangeRateUSD: 0.14, PaymentEnabled: true, IsActive: true, SortOrder: 2, Description: "Chinese Yuan Renminbi"},
+ "EUR": {Code: "EUR", Name: "Euro", NameCN: "欧元", Symbol: "€", DecimalPlaces: 2, Type: "fiat", ExchangeRateUSD: 1.08, PaymentEnabled: true, IsActive: true, SortOrder: 3, Description: "European Euro"},
+ "GBP": {Code: "GBP", Name: "British Pound", NameCN: "英镑", Symbol: "£", DecimalPlaces: 2, Type: "fiat", ExchangeRateUSD: 1.26, PaymentEnabled: true, IsActive: true, SortOrder: 4, Description: "Great Britain Pound"},
+ "JPY": {Code: "JPY", Name: "Japanese Yen", NameCN: "日元", Symbol: "¥", DecimalPlaces: 0, Type: "fiat", ExchangeRateUSD: 0.0063, PaymentEnabled: true, IsActive: true, SortOrder: 5, Description: "Japanese Yen"},
+ "KRW": {Code: "KRW", Name: "Korean Won", NameCN: "韩元", Symbol: "₩", DecimalPlaces: 0, Type: "fiat", ExchangeRateUSD: 0.00073, PaymentEnabled: true, IsActive: true, SortOrder: 6, Description: "Korean Won"},
+ "HKD": {Code: "HKD", Name: "Hong Kong Dollar", NameCN: "港币", Symbol: "$", DecimalPlaces: 2, Type: "fiat", ExchangeRateUSD: 0.13, PaymentEnabled: true, IsActive: true, SortOrder: 7, Description: "Hong Kong Dollar"},
+ "INR": {Code: "INR", Name: "Indian Rupee", NameCN: "印度卢比", Symbol: "₹", DecimalPlaces: 2, Type: "fiat", ExchangeRateUSD: 0.012, PaymentEnabled: true, IsActive: true, SortOrder: 8, Description: "Indian Rupee"},
+ "AUD": {Code: "AUD", Name: "Australian Dollar", NameCN: "澳元", Symbol: "$", DecimalPlaces: 2, Type: "fiat", ExchangeRateUSD: 0.66, PaymentEnabled: true, IsActive: true, SortOrder: 9, Description: "Australian Dollar"},
+ "CAD": {Code: "CAD", Name: "Canadian Dollar", NameCN: "加元", Symbol: "$", DecimalPlaces: 2, Type: "fiat", ExchangeRateUSD: 0.73, PaymentEnabled: true, IsActive: true, SortOrder: 10, Description: "Canadian Dollar"},
+ "BTC": {Code: "BTC", Name: "Bitcoin", NameCN: "比特币", Symbol: "₿", DecimalPlaces: 8, Type: "crypto", ExchangeRateUSD: 1, IsActive: true, SortOrder: 50, Description: "Bitcoin cryptocurrency"},
+ "ETH": {Code: "ETH", Name: "Ethereum", NameCN: "以太坊", Symbol: "Ξ", DecimalPlaces: 18, Type: "crypto", ExchangeRateUSD: 1, IsActive: true, SortOrder: 51, Description: "Ethereum cryptocurrency"},
+ "CREDIT": {Code: "CREDIT", Name: "Credit", NameCN: "信用点", Symbol: "C", DecimalPlaces: 0, Type: "points", ExchangeRateUSD: 0.000001, IsActive: true, SortOrder: 89, Description: "User credit point wallet unit; 1 USD = 1,000,000 CREDIT"},
+ "POINTS": {Code: "POINTS", Name: "System Points", NameCN: "系统积分", Symbol: "P", DecimalPlaces: 0, Type: "points", ExchangeRateUSD: 0.01, IsActive: true, SortOrder: 90, Description: "System reward points"},
}
}
+// EnsurePaymentDefaults backfills exchange rates and checkout-enabled flags for existing currency rows.
+func EnsurePaymentDefaults() error {
+ db := common.DB()
+ defaults := predefinedCurrencyCatalog()
+ for code, tpl := range defaults {
+ if code == "CREDIT" {
+ if err := db.Model(&model.Currency{}).
+ Where("code = ?", code).
+ Updates(map[string]interface{}{
+ "decimal_places": tpl.DecimalPlaces,
+ "type": tpl.Type,
+ "exchange_rate_usd": tpl.ExchangeRateUSD,
+ "description": tpl.Description,
+ "payment_enabled": tpl.PaymentEnabled,
+ }).Error; err != nil {
+ return err
+ }
+ continue
+ }
+ updates := map[string]interface{}{
+ "payment_enabled": tpl.PaymentEnabled,
+ }
+ if tpl.ExchangeRateUSD > 0 {
+ updates["exchange_rate_usd"] = tpl.ExchangeRateUSD
+ }
+ rateNeedsBackfill := "exchange_rate_usd IS NULL OR exchange_rate_usd <= 0"
+ if code != "USD" && code != "CREDIT" && tpl.ExchangeRateUSD != 1 {
+ rateNeedsBackfill = rateNeedsBackfill + " OR exchange_rate_usd = 1"
+ }
+ if err := db.Model(&model.Currency{}).
+ Where("code = ? AND ("+rateNeedsBackfill+")", code).
+ Updates(updates).Error; err != nil {
+ return err
+ }
+ if err := db.Model(&model.Currency{}).
+ Where("code = ? AND exchange_rate_usd > 0", code).
+ Update("payment_enabled", tpl.PaymentEnabled).Error; err != nil {
+ return err
+ }
+ }
+ return ensureDefaultCurrencyRateOverrides(defaults)
+}
+
+// EnsureDefaultCurrencyRates seeds missing directional pair rates from the built-in USD rates.
+func EnsureDefaultCurrencyRates() error {
+ db := common.DB()
+ defaults := predefinedCurrencyCatalog()
+ for baseCode, base := range defaults {
+ if base.ExchangeRateUSD <= 0 {
+ continue
+ }
+ for quoteCode, quote := range defaults {
+ if quote.ExchangeRateUSD <= 0 || baseCode == quoteCode {
+ continue
+ }
+ rate := base.ExchangeRateUSD / quote.ExchangeRateUSD
+ var count int64
+ if err := db.Model(&model.CurrencyRate{}).
+ Where("base_currency_code = ? AND quote_currency_code = ?", baseCode, quoteCode).
+ Count(&count).Error; err != nil {
+ return err
+ }
+ if count > 0 {
+ continue
+ }
+ item := model.CurrencyRate{
+ BaseCurrencyCode: baseCode,
+ QuoteCurrencyCode: quoteCode,
+ Rate: rate,
+ Source: "system_default",
+ IsActive: true,
+ Description: fmt.Sprintf("Default rate: 1 %s = %g %s", baseCode, rate, quoteCode),
+ }
+ if err := db.Create(&item).Error; err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func ensureDefaultCurrencyRateOverrides(defaults map[string]model.Currency) error {
+ db := common.DB()
+ for _, pair := range [][2]string{{"CREDIT", "USD"}, {"USD", "CREDIT"}} {
+ baseCode := pair[0]
+ quoteCode := pair[1]
+ base := defaults[baseCode]
+ quote := defaults[quoteCode]
+ if base.ExchangeRateUSD <= 0 || quote.ExchangeRateUSD <= 0 {
+ continue
+ }
+ rate := base.ExchangeRateUSD / quote.ExchangeRateUSD
+ description := fmt.Sprintf("Default rate: 1 %s = %g %s", baseCode, rate, quoteCode)
+ var existing model.CurrencyRate
+ err := db.Where("base_currency_code = ? AND quote_currency_code = ?", baseCode, quoteCode).First(&existing).Error
+ if err == nil {
+ if updateErr := db.Model(&existing).Updates(map[string]interface{}{
+ "rate": rate,
+ "source": "system_default",
+ "is_active": true,
+ "description": description,
+ }).Error; updateErr != nil {
+ return updateErr
+ }
+ continue
+ }
+ item := model.CurrencyRate{
+ BaseCurrencyCode: baseCode,
+ QuoteCurrencyCode: quoteCode,
+ Rate: rate,
+ Source: "system_default",
+ IsActive: true,
+ Description: description,
+ }
+ if createErr := db.Create(&item).Error; createErr != nil {
+ return createErr
+ }
+ }
+ return nil
+}
+
// InitCurrenciesByCodes 根据管理员选择的代码列表初始化货币。如果已存在则跳过。
func InitCurrenciesByCodes(codes []string) (created int, skipped int, err error) {
db := common.DB()
diff --git a/basaltpass-backend/internal/service/payment/service.go b/basaltpass-backend/internal/service/payment/service.go
index 1e50b58d..37e14a14 100644
--- a/basaltpass-backend/internal/service/payment/service.go
+++ b/basaltpass-backend/internal/service/payment/service.go
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
+ "math"
"net/http"
"net/url"
"strconv"
@@ -18,9 +19,11 @@ import (
"basaltpass-backend/internal/common"
"basaltpass-backend/internal/model"
+ currencyservice "basaltpass-backend/internal/service/currency"
"basaltpass-backend/internal/utils"
"gorm.io/gorm"
+ "gorm.io/gorm/clause"
)
// CreatePaymentIntentRequest 创建支付意图请求
@@ -74,7 +77,68 @@ func isWalletRechargeMetadata(meta map[string]interface{}) bool {
return false
}
s, ok := v.(string)
- return ok && s == "wallet_recharge"
+ return ok && (s == "wallet_recharge" || s == "top_up")
+}
+
+func parsePaymentIntentMetadata(paymentIntent model.PaymentIntent) map[string]interface{} {
+ metadata := map[string]interface{}{}
+ if err := json.Unmarshal([]byte(paymentIntent.Metadata), &metadata); err != nil {
+ return map[string]interface{}{}
+ }
+ return metadata
+}
+
+func walletRechargeTarget(session model.PaymentSession) (string, int64) {
+ metadata := parsePaymentIntentMetadata(session.PaymentIntent)
+ currency := strings.ToUpper(strings.TrimSpace(parseString(metadata["target_wallet_currency"])))
+ if currency == "" {
+ currency = session.Currency
+ }
+ amount := int64(parseUIntFromAny(metadata["target_wallet_amount"]))
+ if amount <= 0 {
+ amount = session.Amount
+ }
+ return currency, amount
+}
+
+func calculateWalletRechargeCharge(targetCurrencyCode string, targetAmountSmallest int64, chargeCurrencyCode string) (int64, map[string]interface{}, error) {
+ targetCurrencyCode = strings.ToUpper(strings.TrimSpace(targetCurrencyCode))
+ chargeCurrencyCode = strings.ToUpper(strings.TrimSpace(chargeCurrencyCode))
+ if targetCurrencyCode == "" || chargeCurrencyCode == "" {
+ return 0, nil, errors.New("target and payment currencies are required")
+ }
+ if targetAmountSmallest <= 0 {
+ return 0, nil, errors.New("target wallet amount must be greater than zero")
+ }
+
+ targetCurrency, err := currencyservice.GetCurrencyByCode(targetCurrencyCode)
+ if err != nil {
+ return 0, nil, fmt.Errorf("invalid target wallet currency: %s", targetCurrencyCode)
+ }
+ chargeCurrency, err := currencyservice.GetCurrencyByCode(chargeCurrencyCode)
+ if err != nil {
+ return 0, nil, fmt.Errorf("invalid payment currency: %s", chargeCurrencyCode)
+ }
+ if chargeCurrency.Type != "fiat" || !chargeCurrency.PaymentEnabled || len(chargeCurrency.Code) != 3 {
+ return 0, nil, fmt.Errorf("payment currency %s is not available for checkout", chargeCurrencyCode)
+ }
+ rate, err := currencyservice.GetExchangeRate(targetCurrencyCode, chargeCurrencyCode)
+ if err != nil {
+ return 0, nil, err
+ }
+
+ targetUnits := float64(targetAmountSmallest) / math.Pow10(targetCurrency.DecimalPlaces)
+ chargeUnits := targetUnits * rate
+ chargeAmountSmallest := int64(math.Ceil(chargeUnits*math.Pow10(chargeCurrency.DecimalPlaces) - 1e-9))
+ if chargeAmountSmallest < 1 {
+ chargeAmountSmallest = 1
+ }
+
+ return chargeAmountSmallest, map[string]interface{}{
+ "exchange_rate_pair": targetCurrencyCode + "/" + chargeCurrencyCode,
+ "exchange_rate": fmt.Sprintf("%g", rate),
+ "computed_charge_amount": strconv.FormatInt(chargeAmountSmallest, 10),
+ }, nil
}
// generateStripeID 生成模拟的Stripe ID
@@ -183,13 +247,23 @@ func mapStripeCheckoutSessionStatus(status string) model.PaymentSessionStatus {
}
func stripeRequest(secretKey string, endpoint string, form url.Values) (map[string]interface{}, error) {
- request, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(form.Encode()))
+ method := http.MethodPost
+ var body io.Reader
+ if form == nil {
+ method = http.MethodGet
+ } else {
+ body = strings.NewReader(form.Encode())
+ }
+
+ request, err := http.NewRequest(method, endpoint, body)
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+secretKey)
- request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if form != nil {
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
client := &http.Client{Timeout: 20 * time.Second}
response, err := client.Do(request)
@@ -510,8 +584,9 @@ func processStripeCheckoutSessionEvent(tx *gorm.DB, eventType string, eventObjec
}
}
- if !wasComplete {
- if err := wallet.RechargeByCodeWithTenant(session.UserID, tenantID, session.Currency, session.Amount); err != nil {
+ if !wasComplete && isWalletRechargeMetadata(parsePaymentIntentMetadata(session.PaymentIntent)) {
+ targetCurrency, targetAmount := walletRechargeTarget(session)
+ if _, err := wallet.AdjustByCodeWithTenant(session.UserID, tenantID, targetCurrency, targetAmount, "recharge", "stripe_checkout:"+session.StripeSessionID); err != nil {
return fmt.Errorf("failed to update wallet: %w", err)
}
}
@@ -780,6 +855,32 @@ func CreatePaymentIntentForTenant(userID uint, tenantID uint, req CreatePaymentI
req.Metadata["tenant_id"] = strconv.FormatUint(uint64(effectiveTenantID), 10)
}
req.Metadata["user_id"] = strconv.FormatUint(uint64(userID), 10)
+ if isWalletRechargeMetadata(req.Metadata) {
+ targetCurrency := strings.ToUpper(strings.TrimSpace(parseString(req.Metadata["target_wallet_currency"])))
+ if targetCurrency == "" {
+ targetCurrency = strings.ToUpper(strings.TrimSpace(parseString(req.Metadata["wallet_currency"])))
+ }
+ targetAmount := int64(parseUIntFromAny(req.Metadata["target_wallet_amount"]))
+ if targetCurrency == "" || targetAmount <= 0 {
+ return nil, nil, errors.New("wallet recharge requires target_wallet_currency and target_wallet_amount metadata")
+ }
+ req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
+ if req.Currency == "" {
+ req.Currency = "USD"
+ }
+ computedAmount, computedMetadata, err := calculateWalletRechargeCharge(targetCurrency, targetAmount, req.Currency)
+ if err != nil {
+ return nil, nil, err
+ }
+ req.Amount = computedAmount
+ req.Metadata["target_wallet_currency"] = targetCurrency
+ req.Metadata["target_wallet_amount"] = strconv.FormatInt(targetAmount, 10)
+ req.Metadata["charge_currency"] = req.Currency
+ req.Metadata["charge_amount"] = strconv.FormatInt(computedAmount, 10)
+ for key, value := range computedMetadata {
+ req.Metadata[key] = value
+ }
+ }
// 序列化元数据
metadataJSON, _ := json.Marshal(req.Metadata)
@@ -1011,9 +1112,10 @@ func SimulatePayment(sessionID string, success bool) (*MockStripeResponse, error
// 如果支付成功,更新用户钱包
if success {
- if !wasComplete {
+ if !wasComplete && isWalletRechargeMetadata(parsePaymentIntentMetadata(session.PaymentIntent)) {
tenantID := parseTenantIDFromRawMetadata(session.PaymentIntent.Metadata)
- if err := wallet.RechargeByCodeWithTenant(session.UserID, tenantID, session.Currency, session.Amount); err != nil {
+ targetCurrency, targetAmount := walletRechargeTarget(session)
+ if _, err := wallet.AdjustByCodeWithTenant(session.UserID, tenantID, targetCurrency, targetAmount, "recharge", "stripe_checkout:"+session.StripeSessionID); err != nil {
return nil, fmt.Errorf("failed to update wallet: %w", err)
}
}
@@ -1453,6 +1555,109 @@ func ReconcileUserOrderPaymentsFromStripe(userID uint) error {
return nil
}
+// ReconcileWalletRechargeFromStripe verifies a returned Stripe Checkout Session
+// and completes the wallet top-up when Stripe reports that payment succeeded.
+func ReconcileWalletRechargeFromStripe(userID uint, tenantID uint, sessionID string) (*model.PaymentSession, error) {
+ sessionID = strings.TrimSpace(sessionID)
+ if sessionID == "" {
+ return nil, errors.New("payment session id is required")
+ }
+
+ db := common.DB()
+ var session model.PaymentSession
+ if err := db.Preload("PaymentIntent").
+ Where("stripe_session_id = ? AND user_id = ?", sessionID, userID).
+ First(&session).Error; err != nil {
+ return nil, err
+ }
+
+ recordTenantID := parseTenantIDFromRawMetadata(session.PaymentIntent.Metadata)
+ if tenantID > 0 && recordTenantID != tenantID {
+ return nil, gorm.ErrRecordNotFound
+ }
+ if recordTenantID == 0 {
+ return nil, errors.New("payment session tenant metadata is missing")
+ }
+ if !isWalletRechargeMetadata(parsePaymentIntentMetadata(session.PaymentIntent)) {
+ return nil, errors.New("payment session is not a wallet top-up")
+ }
+ if session.Status == model.PaymentSessionStatusComplete {
+ return &session, nil
+ }
+
+ stripeConfig, err := resolveTenantStripeConfigByTenantID(db, recordTenantID)
+ if err != nil {
+ return nil, err
+ }
+ endpoint := "https://api.stripe.com/v1/checkout/sessions/" + url.PathEscape(session.StripeSessionID)
+ sessionBody, err := stripeRequest(stripeConfig.SecretKey, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ status := strings.ToLower(strings.TrimSpace(parseString(sessionBody["status"])))
+ paymentStatus := strings.ToLower(strings.TrimSpace(parseString(sessionBody["payment_status"])))
+ if paymentStatus != "paid" && status != "complete" {
+ if status == "expired" {
+ now := time.Now()
+ _ = db.Model(&model.PaymentSession{}).Where("id = ? AND status <> ?", session.ID, model.PaymentSessionStatusComplete).Updates(map[string]interface{}{
+ "status": model.PaymentSessionStatusExpired,
+ "updated_at": now,
+ }).Error
+ session.Status = model.PaymentSessionStatusExpired
+ }
+ return &session, nil
+ }
+
+ if err := db.Transaction(func(tx *gorm.DB) error {
+ var locked model.PaymentSession
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Preload("PaymentIntent").
+ Where("stripe_session_id = ? AND user_id = ?", sessionID, userID).
+ First(&locked).Error; err != nil {
+ return err
+ }
+
+ if tenantID > 0 && parseTenantIDFromRawMetadata(locked.PaymentIntent.Metadata) != tenantID {
+ return gorm.ErrRecordNotFound
+ }
+ if !isWalletRechargeMetadata(parsePaymentIntentMetadata(locked.PaymentIntent)) {
+ return errors.New("payment session is not a wallet top-up")
+ }
+ if locked.Status == model.PaymentSessionStatusComplete {
+ session = locked
+ return nil
+ }
+
+ now := time.Now()
+ locked.Status = model.PaymentSessionStatusComplete
+ locked.CompletedAt = &now
+ if err := tx.Save(&locked).Error; err != nil {
+ return err
+ }
+
+ if locked.PaymentIntent.Status != model.PaymentIntentStatusSucceeded {
+ locked.PaymentIntent.Status = model.PaymentIntentStatusSucceeded
+ locked.PaymentIntent.ProcessedAt = &now
+ if err := tx.Save(&locked.PaymentIntent).Error; err != nil {
+ return err
+ }
+ }
+
+ targetCurrency, targetAmount := walletRechargeTarget(locked)
+ if _, err := wallet.AdjustByCodeWithTenant(locked.UserID, recordTenantID, targetCurrency, targetAmount, "recharge", "stripe_checkout:"+locked.StripeSessionID); err != nil {
+ return fmt.Errorf("failed to update wallet: %w", err)
+ }
+
+ session = locked
+ return nil
+ }); err != nil {
+ return nil, err
+ }
+
+ return &session, nil
+}
+
func findLatestPaymentSessionForOrder(db *gorm.DB, userID uint, orderID uint) (*model.PaymentSession, error) {
var sessions []model.PaymentSession
if err := db.Preload("PaymentIntent").
diff --git a/basaltpass-backend/internal/service/rbacmanifest/apply.go b/basaltpass-backend/internal/service/rbacmanifest/apply.go
new file mode 100644
index 00000000..a06c69fc
--- /dev/null
+++ b/basaltpass-backend/internal/service/rbacmanifest/apply.go
@@ -0,0 +1,147 @@
+package rbacmanifest
+
+import (
+ "fmt"
+ "sort"
+ "time"
+
+ "basaltpass-backend/internal/model"
+
+ "gorm.io/gorm"
+)
+
+func applySnapshot(tx *gorm.DB, tenantID, appID uint, snapshot Snapshot) error {
+ if len(snapshot.Permissions) > 500 || len(snapshot.Roles) > 100 {
+ return validationError("snapshot exceeds RBAC limits")
+ }
+
+ var existingPermissions []model.AppPermission
+ if err := tx.Where("tenant_id = ? AND app_id = ?", tenantID, appID).Find(&existingPermissions).Error; err != nil {
+ return err
+ }
+ existingPermissionByCode := make(map[string]model.AppPermission, len(existingPermissions))
+ for _, permission := range existingPermissions {
+ existingPermissionByCode[permission.Code] = permission
+ }
+
+ permissionByCode := make(map[string]model.AppPermission, len(snapshot.Permissions))
+ now := time.Now().UTC()
+ for _, desired := range snapshot.Permissions {
+ permission, exists := existingPermissionByCode[desired.Code]
+ if !exists {
+ permission = model.AppPermission{
+ Code: desired.Code, Name: desired.Name, Description: desired.Description, Category: desired.Category,
+ AppID: appID, TenantID: tenantID, CreatedAt: now, UpdatedAt: now,
+ }
+ if err := tx.Create(&permission).Error; err != nil {
+ return err
+ }
+ } else {
+ updates := map[string]any{"name": desired.Name, "description": desired.Description, "category": desired.Category, "updated_at": now}
+ if err := tx.Model(&model.AppPermission{}).
+ Where("id = ? AND app_id = ? AND tenant_id = ?", permission.ID, appID, tenantID).
+ Updates(updates).Error; err != nil {
+ return err
+ }
+ permission.Name, permission.Description, permission.Category = desired.Name, desired.Description, desired.Category
+ }
+ permissionByCode[desired.Code] = permission
+ }
+
+ var existingRoles []model.AppRole
+ if err := tx.Where("tenant_id = ? AND app_id = ?", tenantID, appID).Find(&existingRoles).Error; err != nil {
+ return err
+ }
+ existingRoleByCode := make(map[string]model.AppRole, len(existingRoles))
+ for _, role := range existingRoles {
+ existingRoleByCode[role.Code] = role
+ }
+
+ targetRoleCodes := make(map[string]struct{}, len(snapshot.Roles))
+ for _, desired := range snapshot.Roles {
+ targetRoleCodes[desired.Code] = struct{}{}
+ role, exists := existingRoleByCode[desired.Code]
+ if !exists {
+ role = model.AppRole{
+ Code: desired.Code, Name: desired.Name, Description: desired.Description,
+ AppID: appID, TenantID: tenantID, CreatedAt: now, UpdatedAt: now,
+ }
+ if err := tx.Create(&role).Error; err != nil {
+ return err
+ }
+ } else {
+ if err := tx.Model(&model.AppRole{}).
+ Where("id = ? AND app_id = ? AND tenant_id = ?", role.ID, appID, tenantID).
+ Updates(map[string]any{"name": desired.Name, "description": desired.Description, "updated_at": now}).Error; err != nil {
+ return err
+ }
+ }
+
+ permissions := make([]model.AppPermission, 0, len(desired.PermissionCodes))
+ for _, code := range desired.PermissionCodes {
+ permission, ok := permissionByCode[code]
+ if !ok {
+ return validationError("role %q references unknown permission %q", desired.Code, code)
+ }
+ permissions = append(permissions, permission)
+ }
+ if err := tx.Model(&role).Association("Permissions").Replace(permissions); err != nil {
+ return err
+ }
+ }
+
+ for _, role := range existingRoles {
+ if _, keep := targetRoleCodes[role.Code]; keep {
+ continue
+ }
+ if err := tx.Exec("DELETE FROM app_role_permissions WHERE app_role_id = ?", role.ID).Error; err != nil {
+ return err
+ }
+ result := tx.Where("id = ? AND app_id = ? AND tenant_id = ?", role.ID, appID, tenantID).Delete(&model.AppRole{})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return conflictError("role %q changed concurrently", role.Code)
+ }
+ }
+
+ targetPermissionCodes := make(map[string]struct{}, len(snapshot.Permissions))
+ for _, permission := range snapshot.Permissions {
+ targetPermissionCodes[permission.Code] = struct{}{}
+ }
+ for _, permission := range existingPermissions {
+ if _, keep := targetPermissionCodes[permission.Code]; keep {
+ continue
+ }
+ if err := tx.Exec("DELETE FROM app_role_permissions WHERE app_permission_id = ?", permission.ID).Error; err != nil {
+ return err
+ }
+ result := tx.Where("id = ? AND app_id = ? AND tenant_id = ?", permission.ID, appID, tenantID).Delete(&model.AppPermission{})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return conflictError("permission %q changed concurrently", permission.Code)
+ }
+ }
+ return nil
+}
+
+func ensureNoAssignmentRemovalBlocks(diff Diff) error {
+ if len(diff.RemovalAssignmentBlocks) == 0 {
+ return nil
+ }
+ blocks := append([]string{}, diff.RemovalAssignmentBlocks...)
+ sort.Strings(blocks)
+ return conflictError("cannot remove referenced RBAC entities: %v", blocks)
+}
+
+func snapshotFromJSON(raw string) (Snapshot, error) {
+ var snapshot Snapshot
+ if err := decodeStrictJSON([]byte(raw), &snapshot); err != nil {
+ return snapshot, fmt.Errorf("decode revision snapshot: %w", err)
+ }
+ normalizeSnapshot(&snapshot)
+ return snapshot, nil
+}
diff --git a/basaltpass-backend/internal/service/rbacmanifest/diff.go b/basaltpass-backend/internal/service/rbacmanifest/diff.go
new file mode 100644
index 00000000..a7d9aaf9
--- /dev/null
+++ b/basaltpass-backend/internal/service/rbacmanifest/diff.go
@@ -0,0 +1,237 @@
+package rbacmanifest
+
+import (
+ "sort"
+ "strings"
+
+ "basaltpass-backend/internal/model"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+func captureSnapshot(tx *gorm.DB, tenantID, appID uint, lock bool) (Snapshot, error) {
+ snapshot := Snapshot{Permissions: []SnapshotPermission{}, Roles: []SnapshotRole{}}
+ permissionQuery := tx.Where("tenant_id = ? AND app_id = ?", tenantID, appID).Order("code ASC")
+ roleQuery := tx.Where("tenant_id = ? AND app_id = ?", tenantID, appID).Order("code ASC")
+ if lock {
+ permissionQuery = permissionQuery.Clauses(clause.Locking{Strength: "UPDATE"})
+ roleQuery = roleQuery.Clauses(clause.Locking{Strength: "UPDATE"})
+ }
+
+ var permissions []model.AppPermission
+ if err := permissionQuery.Find(&permissions).Error; err != nil {
+ return snapshot, err
+ }
+ for _, permission := range permissions {
+ snapshot.Permissions = append(snapshot.Permissions, SnapshotPermission{
+ Code: permission.Code, Name: permission.Name, Description: permission.Description, Category: permission.Category,
+ })
+ }
+
+ var roles []model.AppRole
+ if err := roleQuery.Find(&roles).Error; err != nil {
+ return snapshot, err
+ }
+ for _, role := range roles {
+ var permissionCodes []string
+ if err := tx.Table("app_permissions").
+ Select("app_permissions.code").
+ Joins("JOIN app_role_permissions ON app_role_permissions.app_permission_id = app_permissions.id").
+ Where("app_role_permissions.app_role_id = ? AND app_permissions.app_id = ? AND app_permissions.tenant_id = ?", role.ID, appID, tenantID).
+ Order("app_permissions.code ASC").
+ Pluck("app_permissions.code", &permissionCodes).Error; err != nil {
+ return snapshot, err
+ }
+ snapshot.Roles = append(snapshot.Roles, SnapshotRole{
+ Code: role.Code, Name: role.Name, Description: role.Description, PermissionCodes: permissionCodes,
+ })
+ }
+ normalizeSnapshot(&snapshot)
+ return snapshot, nil
+}
+
+func calculateDiff(tx *gorm.DB, tenantID, appID uint, current, target Snapshot) (Diff, error) {
+ diff := Diff{
+ PermissionsAdded: []string{}, PermissionsUpdated: []string{}, PermissionsRemoved: []string{},
+ RolesAdded: []string{}, RolesUpdated: []string{}, RolesRemoved: []string{},
+ RolePermissionsAdded: []string{}, RolePermissionsRemoved: []string{},
+ AssignedRolesAffected: []string{}, RemovalAssignmentBlocks: []string{},
+ }
+
+ currentPermissions := make(map[string]SnapshotPermission, len(current.Permissions))
+ targetPermissions := make(map[string]SnapshotPermission, len(target.Permissions))
+ for _, permission := range current.Permissions {
+ currentPermissions[permission.Code] = permission
+ }
+ for _, permission := range target.Permissions {
+ targetPermissions[permission.Code] = permission
+ if existing, ok := currentPermissions[permission.Code]; !ok {
+ diff.PermissionsAdded = append(diff.PermissionsAdded, permission.Code)
+ } else if existing != permission {
+ diff.PermissionsUpdated = append(diff.PermissionsUpdated, permission.Code)
+ }
+ }
+ for code := range currentPermissions {
+ if _, ok := targetPermissions[code]; !ok {
+ diff.PermissionsRemoved = append(diff.PermissionsRemoved, code)
+ }
+ }
+
+ currentRoles := make(map[string]SnapshotRole, len(current.Roles))
+ targetRoles := make(map[string]SnapshotRole, len(target.Roles))
+ changedRoleMappings := map[string]struct{}{}
+ for _, role := range current.Roles {
+ currentRoles[role.Code] = role
+ }
+ for _, role := range target.Roles {
+ targetRoles[role.Code] = role
+ existing, ok := currentRoles[role.Code]
+ if !ok {
+ diff.RolesAdded = append(diff.RolesAdded, role.Code)
+ for _, code := range role.PermissionCodes {
+ diff.RolePermissionsAdded = append(diff.RolePermissionsAdded, role.Code+" -> "+code)
+ }
+ continue
+ }
+ if existing.Name != role.Name || existing.Description != role.Description {
+ diff.RolesUpdated = append(diff.RolesUpdated, role.Code)
+ }
+ added, removed := stringSetDiff(existing.PermissionCodes, role.PermissionCodes)
+ for _, code := range added {
+ diff.RolePermissionsAdded = append(diff.RolePermissionsAdded, role.Code+" -> "+code)
+ }
+ for _, code := range removed {
+ diff.RolePermissionsRemoved = append(diff.RolePermissionsRemoved, role.Code+" -> "+code)
+ }
+ if len(added) > 0 || len(removed) > 0 {
+ changedRoleMappings[role.Code] = struct{}{}
+ }
+ }
+ for code, role := range currentRoles {
+ if _, ok := targetRoles[code]; ok {
+ continue
+ }
+ diff.RolesRemoved = append(diff.RolesRemoved, code)
+ changedRoleMappings[code] = struct{}{}
+ for _, permissionCode := range role.PermissionCodes {
+ diff.RolePermissionsRemoved = append(diff.RolePermissionsRemoved, code+" -> "+permissionCode)
+ }
+ }
+
+ if len(changedRoleMappings) > 0 {
+ codes := mapKeys(changedRoleMappings)
+ var assigned []string
+ if err := tx.Table("app_roles").Distinct("app_roles.code").
+ Joins("JOIN app_user_roles ON app_user_roles.role_id = app_roles.id").
+ Where("app_roles.app_id = ? AND app_roles.tenant_id = ? AND app_user_roles.app_id = ? AND app_roles.code IN ?", appID, tenantID, appID, codes).
+ Pluck("app_roles.code", &assigned).Error; err != nil {
+ return diff, err
+ }
+ diff.AssignedRolesAffected = assigned
+ var mapped []string
+ if err := tx.Table("tenant_app_grant_mappings").Distinct("app_roles.code").
+ Joins("JOIN app_roles ON app_roles.id = tenant_app_grant_mappings.target_id").
+ Where("tenant_app_grant_mappings.tenant_id = ? AND tenant_app_grant_mappings.app_id = ? AND tenant_app_grant_mappings.target_type = ? AND app_roles.code IN ?", tenantID, appID, model.TenantAppGrantTargetAppRole, codes).
+ Pluck("app_roles.code", &mapped).Error; err != nil {
+ return diff, err
+ }
+ diff.AssignedRolesAffected = append(diff.AssignedRolesAffected, mapped...)
+ }
+
+ for _, code := range diff.RolesRemoved {
+ var count int64
+ if err := tx.Table("app_user_roles").
+ Joins("JOIN app_roles ON app_roles.id = app_user_roles.role_id").
+ Where("app_roles.app_id = ? AND app_roles.tenant_id = ? AND app_roles.code = ? AND app_user_roles.app_id = ?", appID, tenantID, code, appID).
+ Count(&count).Error; err != nil {
+ return diff, err
+ }
+ if count > 0 {
+ diff.RemovalAssignmentBlocks = append(diff.RemovalAssignmentBlocks, "role "+code+" is assigned to users")
+ }
+ if err := tx.Table("tenant_app_grant_mappings").
+ Joins("JOIN app_roles ON app_roles.id = tenant_app_grant_mappings.target_id").
+ Where("tenant_app_grant_mappings.tenant_id = ? AND tenant_app_grant_mappings.app_id = ? AND tenant_app_grant_mappings.target_type = ? AND app_roles.code = ?", tenantID, appID, model.TenantAppGrantTargetAppRole, code).
+ Count(&count).Error; err != nil {
+ return diff, err
+ }
+ if count > 0 {
+ diff.RemovalAssignmentBlocks = append(diff.RemovalAssignmentBlocks, "role "+code+" is referenced by tenant-to-app mappings")
+ }
+ }
+ for _, code := range diff.PermissionsRemoved {
+ var count int64
+ if err := tx.Table("app_user_permissions").
+ Joins("JOIN app_permissions ON app_permissions.id = app_user_permissions.permission_id").
+ Where("app_permissions.app_id = ? AND app_permissions.tenant_id = ? AND app_permissions.code = ? AND app_user_permissions.app_id = ?", appID, tenantID, code, appID).
+ Count(&count).Error; err != nil {
+ return diff, err
+ }
+ if count > 0 {
+ diff.RemovalAssignmentBlocks = append(diff.RemovalAssignmentBlocks, "permission "+code+" is directly granted to users")
+ }
+ if err := tx.Table("tenant_app_grant_mappings").
+ Joins("JOIN app_permissions ON app_permissions.id = tenant_app_grant_mappings.target_id").
+ Where("tenant_app_grant_mappings.tenant_id = ? AND tenant_app_grant_mappings.app_id = ? AND tenant_app_grant_mappings.target_type = ? AND app_permissions.code = ?", tenantID, appID, model.TenantAppGrantTargetAppPermission, code).
+ Count(&count).Error; err != nil {
+ return diff, err
+ }
+ if count > 0 {
+ diff.RemovalAssignmentBlocks = append(diff.RemovalAssignmentBlocks, "permission "+code+" is referenced by tenant-to-app mappings")
+ }
+ }
+
+ sortDiff(&diff)
+ diff.HasChanges = len(diff.PermissionsAdded)+len(diff.PermissionsUpdated)+len(diff.PermissionsRemoved)+
+ len(diff.RolesAdded)+len(diff.RolesUpdated)+len(diff.RolesRemoved)+
+ len(diff.RolePermissionsAdded)+len(diff.RolePermissionsRemoved) > 0
+ return diff, nil
+}
+
+func stringSetDiff(current, target []string) (added, removed []string) {
+ currentSet := make(map[string]struct{}, len(current))
+ targetSet := make(map[string]struct{}, len(target))
+ for _, value := range current {
+ currentSet[value] = struct{}{}
+ }
+ for _, value := range target {
+ targetSet[value] = struct{}{}
+ if _, ok := currentSet[value]; !ok {
+ added = append(added, value)
+ }
+ }
+ for _, value := range current {
+ if _, ok := targetSet[value]; !ok {
+ removed = append(removed, value)
+ }
+ }
+ sort.Strings(added)
+ sort.Strings(removed)
+ return added, removed
+}
+
+func sortDiff(diff *Diff) {
+ groups := [][]string{
+ diff.PermissionsAdded, diff.PermissionsUpdated, diff.PermissionsRemoved,
+ diff.RolesAdded, diff.RolesUpdated, diff.RolesRemoved,
+ diff.RolePermissionsAdded, diff.RolePermissionsRemoved,
+ diff.AssignedRolesAffected, diff.RemovalAssignmentBlocks,
+ }
+ for _, group := range groups {
+ sort.Strings(group)
+ }
+}
+
+func mapKeys(values map[string]struct{}) []string {
+ keys := make([]string, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+func equalStrings(left, right []string) bool {
+ return strings.Join(left, "\x00") == strings.Join(right, "\x00")
+}
diff --git a/basaltpass-backend/internal/service/rbacmanifest/service.go b/basaltpass-backend/internal/service/rbacmanifest/service.go
new file mode 100644
index 00000000..aa0d0035
--- /dev/null
+++ b/basaltpass-backend/internal/service/rbacmanifest/service.go
@@ -0,0 +1,468 @@
+package rbacmanifest
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "time"
+
+ "basaltpass-backend/internal/model"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+type Service struct{ db *gorm.DB }
+
+func New(db *gorm.DB) *Service { return &Service{db: db} }
+
+func (s *Service) Submit(tenantID, appID uint, sourceClientID string, raw []byte) (*SubmitResult, error) {
+ if s == nil || s.db == nil {
+ return nil, errors.New("database is required")
+ }
+ if tenantID == 0 || appID == 0 || sourceClientID == "" {
+ return nil, conflictError("authenticated app context is required")
+ }
+ manifest, err := DecodeManifest(raw)
+ if err != nil {
+ return nil, err
+ }
+ canonical, digest, err := marshalCanonical(manifest)
+ if err != nil {
+ return nil, err
+ }
+
+ var result SubmitResult
+ err = s.db.Transaction(func(tx *gorm.DB) error {
+ var app model.App
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("id = ? AND tenant_id = ? AND status = ?", appID, tenantID, model.AppStatusActive).
+ First(&app).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return conflictError("authenticated app is missing, inactive, or outside the tenant")
+ }
+ return err
+ }
+
+ var client model.OAuthClient
+ if err := tx.Where("client_id = ? AND app_id = ? AND is_active = ?", sourceClientID, appID, true).First(&client).Error; err != nil {
+ return conflictError("authenticated OAuth client is not bound to the app")
+ }
+
+ var sameRevision model.AppRBACManifest
+ if err := tx.Where("app_id = ? AND tenant_id = ? AND source_revision = ?", appID, tenantID, manifest.Revision).First(&sameRevision).Error; err == nil {
+ if sameRevision.Digest == digest {
+ view, viewErr := manifestView(sameRevision)
+ if viewErr != nil {
+ return viewErr
+ }
+ result = SubmitResult{Manifest: view, Created: false}
+ return nil
+ }
+ return conflictError("revision %d already exists with a different digest", manifest.Revision)
+ } else if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+
+ var latestRevision uint64
+ if err := tx.Model(&model.AppRBACManifest{}).
+ Where("app_id = ? AND tenant_id = ?", appID, tenantID).
+ Select("COALESCE(MAX(source_revision), 0)").Scan(&latestRevision).Error; err != nil {
+ return err
+ }
+ if manifest.Revision <= latestRevision {
+ return conflictError("revision %d is stale; latest submitted revision is %d", manifest.Revision, latestRevision)
+ }
+
+ current, err := captureSnapshot(tx, tenantID, appID, false)
+ if err != nil {
+ return err
+ }
+ _, baseDigest, err := marshalCanonical(current)
+ if err != nil {
+ return err
+ }
+ target := SnapshotFromManifest(manifest)
+ diff, err := calculateDiff(tx, tenantID, appID, current, target)
+ if err != nil {
+ return err
+ }
+ diffRaw, err := json.Marshal(diff)
+ if err != nil {
+ return err
+ }
+
+ now := time.Now().UTC()
+ record := model.AppRBACManifest{
+ TenantID: tenantID, AppID: appID, SourceClientID: sourceClientID,
+ SchemaVersion: manifest.SchemaVersion, SourceRevision: manifest.Revision, Digest: digest, BaseDigest: baseDigest,
+ Status: model.AppRBACManifestPending, Payload: string(canonical), Diff: string(diffRaw), SubmittedAt: now,
+ }
+ if err := tx.Create(&record).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&model.AppRBACManifest{}).
+ Where("app_id = ? AND tenant_id = ? AND status = ? AND id <> ?", appID, tenantID, model.AppRBACManifestPending, record.ID).
+ Updates(map[string]any{"status": model.AppRBACManifestSuperseded, "reviewed_at": now, "review_note": "superseded by a newer app submission"}).Error; err != nil {
+ return err
+ }
+ if err := writeAudit(tx, client.CreatedBy, "rbac_manifest_submit", tenantID, appID, map[string]any{
+ "manifest_id": record.ID, "source_client_id": sourceClientID, "source_revision": manifest.Revision, "digest": digest, "diff": diff,
+ }); err != nil {
+ return err
+ }
+ view, err := manifestView(record)
+ if err != nil {
+ return err
+ }
+ result = SubmitResult{Manifest: view, Created: true}
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+func (s *Service) ListManifests(tenantID, appID uint) ([]ManifestView, error) {
+ if err := s.ensureAppTenant(tenantID, appID); err != nil {
+ return nil, err
+ }
+ var records []model.AppRBACManifest
+ if err := s.db.Where("tenant_id = ? AND app_id = ?", tenantID, appID).
+ Order("source_revision DESC, id DESC").Limit(100).Find(&records).Error; err != nil {
+ return nil, err
+ }
+ views := make([]ManifestView, 0, len(records))
+ for _, record := range records {
+ view, err := manifestView(record)
+ if err != nil {
+ return nil, err
+ }
+ views = append(views, view)
+ }
+ return views, nil
+}
+
+func (s *Service) GetManifest(tenantID, appID, manifestID uint) (*ManifestView, error) {
+ var record model.AppRBACManifest
+ if err := s.db.Where("id = ? AND tenant_id = ? AND app_id = ?", manifestID, tenantID, appID).First(&record).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrNotFound
+ }
+ return nil, err
+ }
+ view, err := manifestView(record)
+ return &view, err
+}
+
+func (s *Service) GetManifestForClient(tenantID, appID uint, sourceClientID string, manifestID uint) (*ManifestView, error) {
+ var record model.AppRBACManifest
+ if err := s.db.Where("id = ? AND tenant_id = ? AND app_id = ? AND source_client_id = ?", manifestID, tenantID, appID, sourceClientID).First(&record).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrNotFound
+ }
+ return nil, err
+ }
+ view, err := manifestView(record)
+ return &view, err
+}
+
+func (s *Service) Approve(tenantID, appID, manifestID, reviewerID uint) (*ManifestView, error) {
+ if reviewerID == 0 {
+ return nil, conflictError("reviewer is required")
+ }
+ var approved ManifestView
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ var app model.App
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ? AND status = ?", appID, tenantID, model.AppStatusActive).First(&app).Error; err != nil {
+ return ErrNotFound
+ }
+ var record model.AppRBACManifest
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("id = ? AND tenant_id = ? AND app_id = ?", manifestID, tenantID, appID).First(&record).Error; err != nil {
+ return ErrNotFound
+ }
+ if record.Status != model.AppRBACManifestPending {
+ return conflictError("only pending manifests can be approved; current status is %s", record.Status)
+ }
+ manifest, err := DecodeManifest([]byte(record.Payload))
+ if err != nil {
+ return err
+ }
+ target := SnapshotFromManifest(manifest)
+ current, err := captureSnapshot(tx, tenantID, appID, true)
+ if err != nil {
+ return err
+ }
+ _, currentDigest, err := marshalCanonical(current)
+ if err != nil {
+ return err
+ }
+ if currentDigest != record.BaseDigest {
+ return conflictError("effective RBAC changed after this diff was generated; the app must submit a higher revision")
+ }
+ diff, err := calculateDiff(tx, tenantID, appID, current, target)
+ if err != nil {
+ return err
+ }
+ if err := ensureNoAssignmentRemovalBlocks(diff); err != nil {
+ return err
+ }
+
+ if err := s.ensureBaselineRevision(tx, tenantID, appID, reviewerID, current); err != nil {
+ return err
+ }
+ if err := applySnapshot(tx, tenantID, appID, target); err != nil {
+ return err
+ }
+ revision, err := createActiveRevision(tx, tenantID, appID, reviewerID, "manifest", &record.ID, nil, target)
+ if err != nil {
+ return err
+ }
+ now := time.Now().UTC()
+ diffRaw, _ := json.Marshal(diff)
+ result := tx.Model(&model.AppRBACManifest{}).
+ Where("id = ? AND status = ?", record.ID, model.AppRBACManifestPending).
+ Updates(map[string]any{
+ "status": model.AppRBACManifestApproved, "reviewed_at": now, "reviewed_by": reviewerID,
+ "active_revision_id": revision.ID, "diff": string(diffRaw), "review_note": "approved",
+ })
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected != 1 {
+ return conflictError("manifest status changed concurrently")
+ }
+ if err := writeAudit(tx, reviewerID, "rbac_manifest_approve", tenantID, appID, map[string]any{
+ "manifest_id": record.ID, "source_revision": record.SourceRevision, "digest": record.Digest,
+ "active_revision_id": revision.ID, "active_revision": revision.Revision, "diff": diff,
+ }); err != nil {
+ return err
+ }
+ if err := tx.First(&record, record.ID).Error; err != nil {
+ return err
+ }
+ approved, err = manifestView(record)
+ return err
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &approved, nil
+}
+
+func (s *Service) Reject(tenantID, appID, manifestID, reviewerID uint, note string) (*ManifestView, error) {
+ if reviewerID == 0 {
+ return nil, conflictError("reviewer is required")
+ }
+ if len(note) > 500 {
+ return nil, validationError("review note exceeds 500 characters")
+ }
+ var view ManifestView
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ var record model.AppRBACManifest
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
+ Where("id = ? AND tenant_id = ? AND app_id = ?", manifestID, tenantID, appID).First(&record).Error; err != nil {
+ return ErrNotFound
+ }
+ if record.Status != model.AppRBACManifestPending {
+ return conflictError("only pending manifests can be rejected")
+ }
+ now := time.Now().UTC()
+ if note == "" {
+ note = "rejected by tenant administrator"
+ }
+ if err := tx.Model(&record).Updates(map[string]any{
+ "status": model.AppRBACManifestRejected, "reviewed_at": now, "reviewed_by": reviewerID, "review_note": note,
+ }).Error; err != nil {
+ return err
+ }
+ if err := writeAudit(tx, reviewerID, "rbac_manifest_reject", tenantID, appID, map[string]any{
+ "manifest_id": record.ID, "source_revision": record.SourceRevision, "digest": record.Digest, "note": note,
+ }); err != nil {
+ return err
+ }
+ if err := tx.First(&record, record.ID).Error; err != nil {
+ return err
+ }
+ var err error
+ view, err = manifestView(record)
+ return err
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &view, nil
+}
+
+func (s *Service) ListRevisions(tenantID, appID uint) ([]RevisionView, error) {
+ if err := s.ensureAppTenant(tenantID, appID); err != nil {
+ return nil, err
+ }
+ var records []model.AppRBACRevision
+ if err := s.db.Where("tenant_id = ? AND app_id = ?", tenantID, appID).Order("revision DESC").Limit(100).Find(&records).Error; err != nil {
+ return nil, err
+ }
+ views := make([]RevisionView, 0, len(records))
+ for _, record := range records {
+ views = append(views, revisionView(record))
+ }
+ return views, nil
+}
+
+func (s *Service) Rollback(tenantID, appID, targetRevisionID, reviewerID uint) (*RevisionView, error) {
+ if reviewerID == 0 {
+ return nil, conflictError("reviewer is required")
+ }
+ var view RevisionView
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ var app model.App
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ? AND status = ?", appID, tenantID, model.AppStatusActive).First(&app).Error; err != nil {
+ return ErrNotFound
+ }
+ var targetRecord model.AppRBACRevision
+ if err := tx.Where("id = ? AND tenant_id = ? AND app_id = ?", targetRevisionID, tenantID, appID).First(&targetRecord).Error; err != nil {
+ return ErrNotFound
+ }
+ if targetRecord.IsActive {
+ return conflictError("revision %d is already active", targetRecord.Revision)
+ }
+ target, err := snapshotFromJSON(targetRecord.Snapshot)
+ if err != nil {
+ return err
+ }
+ current, err := captureSnapshot(tx, tenantID, appID, true)
+ if err != nil {
+ return err
+ }
+ diff, err := calculateDiff(tx, tenantID, appID, current, target)
+ if err != nil {
+ return err
+ }
+ if err := ensureNoAssignmentRemovalBlocks(diff); err != nil {
+ return err
+ }
+ if err := applySnapshot(tx, tenantID, appID, target); err != nil {
+ return err
+ }
+ revision, err := createActiveRevision(tx, tenantID, appID, reviewerID, "rollback", nil, &targetRecord.ID, target)
+ if err != nil {
+ return err
+ }
+ if err := writeAudit(tx, reviewerID, "rbac_revision_rollback", tenantID, appID, map[string]any{
+ "target_revision_id": targetRecord.ID, "target_revision": targetRecord.Revision,
+ "active_revision_id": revision.ID, "active_revision": revision.Revision, "diff": diff,
+ }); err != nil {
+ return err
+ }
+ view = revisionView(*revision)
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &view, nil
+}
+
+func (s *Service) ensureBaselineRevision(tx *gorm.DB, tenantID, appID, creatorID uint, snapshot Snapshot) error {
+ var count int64
+ if err := tx.Model(&model.AppRBACRevision{}).Where("tenant_id = ? AND app_id = ?", tenantID, appID).Count(&count).Error; err != nil {
+ return err
+ }
+ if count > 0 {
+ return nil
+ }
+ _, err := createActiveRevision(tx, tenantID, appID, creatorID, "baseline", nil, nil, snapshot)
+ return err
+}
+
+func createActiveRevision(tx *gorm.DB, tenantID, appID, creatorID uint, action string, manifestID, targetRevisionID *uint, snapshot Snapshot) (*model.AppRBACRevision, error) {
+ snapshotRaw, digest, err := marshalCanonical(snapshot)
+ if err != nil {
+ return nil, err
+ }
+ var latest uint64
+ if err := tx.Model(&model.AppRBACRevision{}).Where("tenant_id = ? AND app_id = ?", tenantID, appID).
+ Select("COALESCE(MAX(revision), 0)").Scan(&latest).Error; err != nil {
+ return nil, err
+ }
+ if err := tx.Model(&model.AppRBACRevision{}).Where("tenant_id = ? AND app_id = ? AND is_active = ?", tenantID, appID, true).
+ Update("is_active", false).Error; err != nil {
+ return nil, err
+ }
+ record := model.AppRBACRevision{
+ TenantID: tenantID, AppID: appID, Revision: latest + 1, Snapshot: string(snapshotRaw), Digest: digest,
+ ManifestID: manifestID, Action: action, TargetRevisionID: targetRevisionID, IsActive: true,
+ CreatedBy: creatorID, CreatedAt: time.Now().UTC(),
+ }
+ if err := tx.Create(&record).Error; err != nil {
+ return nil, err
+ }
+ return &record, nil
+}
+
+func (s *Service) ensureAppTenant(tenantID, appID uint) error {
+ var count int64
+ if err := s.db.Model(&model.App{}).Where("id = ? AND tenant_id = ?", appID, tenantID).Count(&count).Error; err != nil {
+ return err
+ }
+ if count != 1 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+func manifestView(record model.AppRBACManifest) (ManifestView, error) {
+ var manifest Manifest
+ if err := decodeStrictJSON([]byte(record.Payload), &manifest); err != nil {
+ return ManifestView{}, err
+ }
+ var diff Diff
+ if err := decodeStrictJSON([]byte(record.Diff), &diff); err != nil {
+ return ManifestView{}, err
+ }
+ return ManifestView{
+ ID: record.ID, TenantID: record.TenantID, AppID: record.AppID, SourceClientID: record.SourceClientID,
+ SchemaVersion: record.SchemaVersion, SourceRevision: record.SourceRevision, Digest: record.Digest, BaseDigest: record.BaseDigest,
+ Status: record.Status, Diff: diff, Manifest: manifest, SubmittedAt: record.SubmittedAt,
+ ReviewedAt: record.ReviewedAt, ReviewedBy: record.ReviewedBy, ReviewNote: record.ReviewNote,
+ ActiveRevisionID: record.ActiveRevisionID,
+ }, nil
+}
+
+func revisionView(record model.AppRBACRevision) RevisionView {
+ return RevisionView{
+ ID: record.ID, TenantID: record.TenantID, AppID: record.AppID, Revision: record.Revision,
+ Digest: record.Digest, ManifestID: record.ManifestID, Action: record.Action,
+ TargetRevisionID: record.TargetRevisionID, IsActive: record.IsActive,
+ CreatedBy: record.CreatedBy, CreatedAt: record.CreatedAt,
+ }
+}
+
+func decodeStrictJSON(raw []byte, dest any) error {
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(dest); err != nil {
+ return err
+ }
+ var trailing any
+ if err := dec.Decode(&trailing); err != io.EOF {
+ return errors.New("trailing JSON value")
+ }
+ return nil
+}
+
+func writeAudit(tx *gorm.DB, userID uint, action string, tenantID, appID uint, details map[string]any) error {
+ payload := map[string]any{
+ "resource_type": "app_rbac", "resource_id": fmt.Sprint(appID),
+ "tenant_id": tenantID, "app_id": appID, "details": details,
+ }
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ return tx.Create(&model.AuditLog{UserID: userID, Action: action, Data: string(raw)}).Error
+}
diff --git a/basaltpass-backend/internal/service/rbacmanifest/service_test.go b/basaltpass-backend/internal/service/rbacmanifest/service_test.go
new file mode 100644
index 00000000..7f7ff54a
--- /dev/null
+++ b/basaltpass-backend/internal/service/rbacmanifest/service_test.go
@@ -0,0 +1,319 @@
+package rbacmanifest
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "basaltpass-backend/internal/model"
+
+ "github.com/glebarez/sqlite"
+ "gorm.io/gorm"
+)
+
+type manifestFixture struct {
+ tenant model.Tenant
+ app model.App
+ client model.OAuthClient
+ reviewer model.User
+}
+
+func setupManifestTestDB(t *testing.T) (*gorm.DB, manifestFixture) {
+ t.Helper()
+ dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
+ db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ if err := db.AutoMigrate(
+ &model.User{}, &model.Tenant{}, &model.App{}, &model.OAuthClient{},
+ &model.AppPermission{}, &model.AppRole{}, &model.AppUserPermission{}, &model.AppUserRole{}, &model.TenantAppGrantMapping{},
+ &model.AppRBACManifest{}, &model.AppRBACRevision{}, &model.AuditLog{},
+ ); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+
+ tenant := model.Tenant{Name: "Acme", Code: "acme-" + strings.ReplaceAll(t.Name(), "/", "-"), Status: model.TenantStatusActive}
+ if err := db.Create(&tenant).Error; err != nil {
+ t.Fatalf("create tenant: %v", err)
+ }
+ reviewer := model.User{Email: "reviewer-" + strings.ReplaceAll(t.Name(), "/", "-") + "@example.com", PasswordHash: "x", EnforcedTenantID: tenant.ID}
+ if err := db.Create(&reviewer).Error; err != nil {
+ t.Fatalf("create reviewer: %v", err)
+ }
+ app := model.App{TenantID: tenant.ID, Name: "Demo", Status: model.AppStatusActive}
+ if err := db.Create(&app).Error; err != nil {
+ t.Fatalf("create app: %v", err)
+ }
+ client := model.OAuthClient{AppID: app.ID, ClientID: fmt.Sprintf("client-%d", app.ID), ClientSecret: "unused", IsActive: true, CreatedBy: reviewer.ID}
+ if err := db.Create(&client).Error; err != nil {
+ t.Fatalf("create client: %v", err)
+ }
+ return db, manifestFixture{tenant: tenant, app: app, client: client, reviewer: reviewer}
+}
+
+func testManifest(revision uint64, permissionCodes []string, roles map[string][]string) []byte {
+ manifest := Manifest{SchemaVersion: "1.0.0", Type: "basalt_rbac_bundle", Revision: revision}
+ for _, code := range permissionCodes {
+ manifest.Permissions = append(manifest.Permissions, PermissionDef{
+ PermissionKey: code, DisplayName: strings.ToUpper(code), Resource: "demo", Action: "read", Scope: "app", Status: "active",
+ })
+ }
+ for code, permissions := range roles {
+ manifest.Roles = append(manifest.Roles, RoleDef{RoleKey: code, DisplayName: strings.ToUpper(code), Assignable: true, Status: "active"})
+ for _, permission := range permissions {
+ manifest.RolePermissions = append(manifest.RolePermissions, RolePermissionLink{RoleKey: code, PermissionKey: permission, Effect: "allow"})
+ }
+ }
+ raw, err := json.Marshal(manifest)
+ if err != nil {
+ panic(err)
+ }
+ return raw
+}
+
+func TestDecodeManifestRejectsNonRBACAndReservedFields(t *testing.T) {
+ nonRBAC := []byte(`{"schema_version":"1.0.0","type":"basalt_rbac_bundle","revision":1,"permissions":[],"roles":[],"role_permissions":[],"oauth_clients":[]}`)
+ if _, err := DecodeManifest(nonRBAC); !errors.Is(err, ErrValidation) {
+ t.Fatalf("expected unknown OAuth field to be rejected, got %v", err)
+ }
+ assignments := []byte(`{"schema_version":"1.0.0","type":"basalt_rbac_bundle","revision":1,"permissions":[],"roles":[],"role_permissions":[],"user_assignments":[]}`)
+ if _, err := DecodeManifest(assignments); !errors.Is(err, ErrValidation) {
+ t.Fatalf("expected user assignments to be rejected, got %v", err)
+ }
+
+ reserved := testManifest(1, []string{"tenant.users.delete"}, nil)
+ if _, err := DecodeManifest(reserved); !errors.Is(err, ErrValidation) {
+ t.Fatalf("expected reserved permission prefix to be rejected, got %v", err)
+ }
+}
+
+func TestApproveRejectsStaleDiffWithoutPublishing(t *testing.T) {
+ db, fixture := setupManifestTestDB(t)
+ svc := New(db)
+ submitted, err := svc.Submit(fixture.tenant.ID, fixture.app.ID, fixture.client.ClientID, testManifest(1, []string{"demo.read"}, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ manual := model.AppPermission{Code: "demo.manual", Name: "Manual", Category: "demo", AppID: fixture.app.ID, TenantID: fixture.tenant.ID}
+ if err := db.Create(&manual).Error; err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.Approve(fixture.tenant.ID, fixture.app.ID, submitted.Manifest.ID, fixture.reviewer.ID); !errors.Is(err, ErrConflict) {
+ t.Fatalf("expected baseline drift to block approval, got %v", err)
+ }
+ var manualCount, proposedCount int64
+ db.Model(&model.AppPermission{}).Where("id = ?", manual.ID).Count(&manualCount)
+ db.Model(&model.AppPermission{}).Where("app_id = ? AND code = ?", fixture.app.ID, "demo.read").Count(&proposedCount)
+ if manualCount != 1 || proposedCount != 0 {
+ t.Fatalf("stale approval changed effective RBAC: manual=%d proposed=%d", manualCount, proposedCount)
+ }
+}
+
+func TestSubmitIsStrictlyBoundAndIdempotent(t *testing.T) {
+ db, fixture := setupManifestTestDB(t)
+ svc := New(db)
+ raw := testManifest(1, []string{"demo.read"}, map[string][]string{"demo.viewer": {"demo.read"}})
+
+ if _, err := svc.Submit(fixture.tenant.ID, fixture.app.ID+999, fixture.client.ClientID, raw); !errors.Is(err, ErrConflict) {
+ t.Fatalf("expected cross-app submission to fail, got %v", err)
+ }
+ created, err := svc.Submit(fixture.tenant.ID, fixture.app.ID, fixture.client.ClientID, raw)
+ if err != nil || !created.Created {
+ t.Fatalf("submit failed: result=%+v err=%v", created, err)
+ }
+ duplicate, err := svc.Submit(fixture.tenant.ID, fixture.app.ID, fixture.client.ClientID, raw)
+ if err != nil || duplicate.Created || duplicate.Manifest.ID != created.Manifest.ID {
+ t.Fatalf("expected idempotent duplicate, result=%+v err=%v", duplicate, err)
+ }
+ stale := testManifest(1, []string{"demo.write"}, map[string][]string{"demo.editor": {"demo.write"}})
+ if _, err := svc.Submit(fixture.tenant.ID, fixture.app.ID, fixture.client.ClientID, stale); !errors.Is(err, ErrConflict) {
+ t.Fatalf("expected same revision with different digest to fail, got %v", err)
+ }
+}
+
+func TestApprovePublishesAtomicallyAndCreatesAuditAndRollbackBaseline(t *testing.T) {
+ db, fixture := setupManifestTestDB(t)
+ seedPermission := model.AppPermission{Code: "demo.legacy", Name: "Legacy", Category: "demo", AppID: fixture.app.ID, TenantID: fixture.tenant.ID}
+ if err := db.Create(&seedPermission).Error; err != nil {
+ t.Fatal(err)
+ }
+ svc := New(db)
+ raw := testManifest(1, []string{"demo.read", "demo.write"}, map[string][]string{
+ "demo.viewer": {"demo.read"}, "demo.editor": {"demo.read", "demo.write"},
+ })
+ submitted, err := svc.Submit(fixture.tenant.ID, fixture.app.ID, fixture.client.ClientID, raw)
+ if err != nil {
+ t.Fatalf("submit: %v", err)
+ }
+ approved, err := svc.Approve(fixture.tenant.ID, fixture.app.ID, submitted.Manifest.ID, fixture.reviewer.ID)
+ if err != nil {
+ t.Fatalf("approve: %v", err)
+ }
+ if approved.Status != model.AppRBACManifestApproved || approved.ActiveRevisionID == nil {
+ t.Fatalf("unexpected approval: %+v", approved)
+ }
+
+ var permissions []model.AppPermission
+ if err := db.Where("app_id = ?", fixture.app.ID).Order("code").Find(&permissions).Error; err != nil {
+ t.Fatal(err)
+ }
+ if len(permissions) != 2 || permissions[0].Code != "demo.read" || permissions[1].Code != "demo.write" {
+ t.Fatalf("unexpected effective permissions: %+v", permissions)
+ }
+ var revisions []model.AppRBACRevision
+ if err := db.Where("app_id = ?", fixture.app.ID).Order("revision").Find(&revisions).Error; err != nil {
+ t.Fatal(err)
+ }
+ if len(revisions) != 2 || revisions[0].Action != "baseline" || revisions[1].Action != "manifest" || !revisions[1].IsActive {
+ t.Fatalf("unexpected revisions: %+v", revisions)
+ }
+ var audits int64
+ if err := db.Model(&model.AuditLog{}).Where("action IN ?", []string{"rbac_manifest_submit", "rbac_manifest_approve"}).Count(&audits).Error; err != nil {
+ t.Fatal(err)
+ }
+ if audits != 2 {
+ t.Fatalf("expected submit and approval audit records, got %d", audits)
+ }
+}
+
+func TestApproveRefusesToDeleteAssignedRoleWithoutPartialChanges(t *testing.T) {
+ db, fixture := setupManifestTestDB(t)
+ permission := model.AppPermission{Code: "demo.read", Name: "Read", Category: "demo", AppID: fixture.app.ID, TenantID: fixture.tenant.ID}
+ if err := db.Create(&permission).Error; err != nil {
+ t.Fatal(err)
+ }
+ role := model.AppRole{Code: "demo.viewer", Name: "Viewer", AppID: fixture.app.ID, TenantID: fixture.tenant.ID}
+ if err := db.Create(&role).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Model(&role).Association("Permissions").Append(&permission); err != nil {
+ t.Fatal(err)
+ }
+ assignment := model.AppUserRole{UserID: fixture.reviewer.ID, AppID: fixture.app.ID, RoleID: role.ID, AssignedAt: time.Now(), AssignedBy: fixture.reviewer.ID}
+ if err := db.Create(&assignment).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ svc := New(db)
+ submitted, err := svc.Submit(fixture.tenant.ID, fixture.app.ID, fixture.client.ClientID, testManifest(1, []string{"demo.write"}, nil))
+ if err != nil {
+ t.Fatalf("submit: %v", err)
+ }
+ if _, err := svc.Approve(fixture.tenant.ID, fixture.app.ID, submitted.Manifest.ID, fixture.reviewer.ID); !errors.Is(err, ErrConflict) {
+ t.Fatalf("expected assigned role deletion to be blocked, got %v", err)
+ }
+ var roleCount, writePermissionCount, assignmentCount int64
+ db.Model(&model.AppRole{}).Where("id = ?", role.ID).Count(&roleCount)
+ db.Model(&model.AppPermission{}).Where("code = ? AND app_id = ?", "demo.write", fixture.app.ID).Count(&writePermissionCount)
+ db.Model(&model.AppUserRole{}).Where("id = ?", assignment.ID).Count(&assignmentCount)
+ if roleCount != 1 || writePermissionCount != 0 || assignmentCount != 1 {
+ t.Fatalf("approval was not atomic: role=%d write=%d assignment=%d", roleCount, writePermissionCount, assignmentCount)
+ }
+}
+
+func TestApproveRefusesToDeleteMappedTargets(t *testing.T) {
+ db, fixture := setupManifestTestDB(t)
+ permission := model.AppPermission{Code: "demo.read", Name: "Read", Category: "demo", AppID: fixture.app.ID, TenantID: fixture.tenant.ID}
+ role := model.AppRole{Code: "demo.viewer", Name: "Viewer", AppID: fixture.app.ID, TenantID: fixture.tenant.ID}
+ if err := db.Create(&permission).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Create(&role).Error; err != nil {
+ t.Fatal(err)
+ }
+ mappings := []model.TenantAppGrantMapping{
+ {TenantID: fixture.tenant.ID, AppID: fixture.app.ID, SourceType: model.TenantAppGrantSourceMembershipRole, SourceCode: "member", TargetType: model.TenantAppGrantTargetAppRole, TargetID: role.ID, Enabled: true, CreatedBy: fixture.reviewer.ID, UpdatedBy: fixture.reviewer.ID},
+ {TenantID: fixture.tenant.ID, AppID: fixture.app.ID, SourceType: model.TenantAppGrantSourceMembershipRole, SourceCode: "admin", TargetType: model.TenantAppGrantTargetAppPermission, TargetID: permission.ID, Enabled: true, CreatedBy: fixture.reviewer.ID, UpdatedBy: fixture.reviewer.ID},
+ }
+ if err := db.Create(&mappings).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ svc := New(db)
+ submitted, err := svc.Submit(fixture.tenant.ID, fixture.app.ID, fixture.client.ClientID, testManifest(1, nil, nil))
+ if err != nil {
+ t.Fatalf("submit: %v", err)
+ }
+ blocks := strings.Join(submitted.Manifest.Diff.RemovalAssignmentBlocks, "\n")
+ if !strings.Contains(blocks, "role demo.viewer is referenced") || !strings.Contains(blocks, "permission demo.read is referenced") {
+ t.Fatalf("mapping blockers missing from diff: %v", submitted.Manifest.Diff.RemovalAssignmentBlocks)
+ }
+ if _, err := svc.Approve(fixture.tenant.ID, fixture.app.ID, submitted.Manifest.ID, fixture.reviewer.ID); !errors.Is(err, ErrConflict) {
+ t.Fatalf("expected mapped target deletion to be blocked, got %v", err)
+ }
+ var roleCount, permissionCount, mappingCount int64
+ db.Model(&model.AppRole{}).Where("id = ?", role.ID).Count(&roleCount)
+ db.Model(&model.AppPermission{}).Where("id = ?", permission.ID).Count(&permissionCount)
+ db.Model(&model.TenantAppGrantMapping{}).Count(&mappingCount)
+ if roleCount != 1 || permissionCount != 1 || mappingCount != 2 {
+ t.Fatalf("blocked approval changed data: role=%d permission=%d mappings=%d", roleCount, permissionCount, mappingCount)
+ }
+}
+
+func TestRollbackCreatesNewRevisionAndPreservesAssignments(t *testing.T) {
+ db, fixture := setupManifestTestDB(t)
+ svc := New(db)
+ first, err := svc.Submit(fixture.tenant.ID, fixture.app.ID, fixture.client.ClientID, testManifest(1, []string{"demo.read"}, map[string][]string{"demo.viewer": {"demo.read"}}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.Approve(fixture.tenant.ID, fixture.app.ID, first.Manifest.ID, fixture.reviewer.ID); err != nil {
+ t.Fatal(err)
+ }
+ second, err := svc.Submit(fixture.tenant.ID, fixture.app.ID, fixture.client.ClientID, testManifest(2, []string{"demo.read", "demo.write"}, map[string][]string{"demo.viewer": {"demo.read"}, "demo.editor": {"demo.write"}}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.Approve(fixture.tenant.ID, fixture.app.ID, second.Manifest.ID, fixture.reviewer.ID); err != nil {
+ t.Fatal(err)
+ }
+ revisions, err := svc.ListRevisions(fixture.tenant.ID, fixture.app.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var firstPublished RevisionView
+ for _, revision := range revisions {
+ if revision.Action == "manifest" && revision.ManifestID != nil && *revision.ManifestID == first.Manifest.ID {
+ firstPublished = revision
+ }
+ }
+ if firstPublished.ID == 0 {
+ t.Fatalf("first published revision not found: %+v", revisions)
+ }
+ var editor model.AppRole
+ if err := db.Where("app_id = ? AND code = ?", fixture.app.ID, "demo.editor").First(&editor).Error; err != nil {
+ t.Fatal(err)
+ }
+ assignment := model.AppUserRole{UserID: fixture.reviewer.ID, AppID: fixture.app.ID, RoleID: editor.ID, AssignedAt: time.Now(), AssignedBy: fixture.reviewer.ID}
+ if err := db.Create(&assignment).Error; err != nil {
+ t.Fatal(err)
+ }
+ if _, err := svc.Rollback(fixture.tenant.ID, fixture.app.ID, firstPublished.ID, fixture.reviewer.ID); !errors.Is(err, ErrConflict) {
+ t.Fatalf("expected rollback that deletes an assigned role to be blocked, got %v", err)
+ }
+ var assignmentCount int64
+ db.Model(&model.AppUserRole{}).Where("id = ?", assignment.ID).Count(&assignmentCount)
+ if assignmentCount != 1 {
+ t.Fatal("blocked rollback modified the user assignment")
+ }
+ if err := db.Delete(&assignment).Error; err != nil {
+ t.Fatal(err)
+ }
+ rolledBack, err := svc.Rollback(fixture.tenant.ID, fixture.app.ID, firstPublished.ID, fixture.reviewer.ID)
+ if err != nil {
+ t.Fatalf("rollback: %v", err)
+ }
+ if rolledBack.Action != "rollback" || !rolledBack.IsActive || rolledBack.TargetRevisionID == nil || *rolledBack.TargetRevisionID != firstPublished.ID {
+ t.Fatalf("unexpected rollback revision: %+v", rolledBack)
+ }
+ var writeCount, editorCount int64
+ db.Model(&model.AppPermission{}).Where("app_id = ? AND code = ?", fixture.app.ID, "demo.write").Count(&writeCount)
+ db.Model(&model.AppRole{}).Where("app_id = ? AND code = ?", fixture.app.ID, "demo.editor").Count(&editorCount)
+ if writeCount != 0 || editorCount != 0 {
+ t.Fatalf("rollback did not restore snapshot: write=%d editor=%d", writeCount, editorCount)
+ }
+}
diff --git a/basaltpass-backend/internal/service/rbacmanifest/types.go b/basaltpass-backend/internal/service/rbacmanifest/types.go
new file mode 100644
index 00000000..0b325464
--- /dev/null
+++ b/basaltpass-backend/internal/service/rbacmanifest/types.go
@@ -0,0 +1,122 @@
+package rbacmanifest
+
+import (
+ "errors"
+ "time"
+
+ "basaltpass-backend/internal/model"
+)
+
+var (
+ ErrValidation = errors.New("rbac manifest validation failed")
+ ErrConflict = errors.New("rbac manifest conflict")
+ ErrNotFound = errors.New("rbac manifest not found")
+)
+
+// Keep the normalized payload below MySQL TEXT's portable storage limit.
+const MaxManifestBytes = 60 * 1024
+
+type Manifest struct {
+ SchemaVersion string `json:"schema_version"`
+ Type string `json:"type"`
+ Revision uint64 `json:"revision"`
+ Permissions []PermissionDef `json:"permissions"`
+ Roles []RoleDef `json:"roles"`
+ RolePermissions []RolePermissionLink `json:"role_permissions"`
+}
+
+type PermissionDef struct {
+ PermissionKey string `json:"permission_key"`
+ DisplayName string `json:"display_name"`
+ Resource string `json:"resource"`
+ Action string `json:"action"`
+ Scope string `json:"scope"`
+ Description string `json:"description"`
+ Status string `json:"status"`
+}
+
+type RoleDef struct {
+ RoleKey string `json:"role_key"`
+ DisplayName string `json:"display_name"`
+ Description string `json:"description"`
+ Assignable bool `json:"assignable"`
+ Priority int `json:"priority"`
+ Status string `json:"status"`
+}
+
+type RolePermissionLink struct {
+ RoleKey string `json:"role_key"`
+ PermissionKey string `json:"permission_key"`
+ Effect string `json:"effect"`
+}
+
+type Snapshot struct {
+ Permissions []SnapshotPermission `json:"permissions"`
+ Roles []SnapshotRole `json:"roles"`
+}
+
+type SnapshotPermission struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Category string `json:"category"`
+}
+
+type SnapshotRole struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ PermissionCodes []string `json:"permission_codes"`
+}
+
+type Diff struct {
+ HasChanges bool `json:"has_changes"`
+ PermissionsAdded []string `json:"permissions_added"`
+ PermissionsUpdated []string `json:"permissions_updated"`
+ PermissionsRemoved []string `json:"permissions_removed"`
+ RolesAdded []string `json:"roles_added"`
+ RolesUpdated []string `json:"roles_updated"`
+ RolesRemoved []string `json:"roles_removed"`
+ RolePermissionsAdded []string `json:"role_permissions_added"`
+ RolePermissionsRemoved []string `json:"role_permissions_removed"`
+ AssignedRolesAffected []string `json:"assigned_roles_affected"`
+ RemovalAssignmentBlocks []string `json:"removal_assignment_blocks"`
+}
+
+type ManifestView struct {
+ ID uint `json:"id"`
+ TenantID uint `json:"tenant_id"`
+ AppID uint `json:"app_id"`
+ SourceClientID string `json:"source_client_id"`
+ SchemaVersion string `json:"schema_version"`
+ SourceRevision uint64 `json:"source_revision"`
+ Digest string `json:"digest"`
+ BaseDigest string `json:"base_digest"`
+ Status model.AppRBACManifestStatus `json:"status"`
+ Diff Diff `json:"diff"`
+ Manifest Manifest `json:"manifest"`
+ SubmittedAt time.Time `json:"submitted_at"`
+ ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
+ ReviewedBy *uint `json:"reviewed_by,omitempty"`
+ ReviewNote string `json:"review_note,omitempty"`
+ ActiveRevisionID *uint `json:"active_revision_id,omitempty"`
+}
+
+type RevisionView struct {
+ ID uint `json:"id"`
+ TenantID uint `json:"tenant_id"`
+ AppID uint `json:"app_id"`
+ Revision uint64 `json:"revision"`
+ Digest string `json:"digest"`
+ ManifestID *uint `json:"manifest_id,omitempty"`
+ Action string `json:"action"`
+ TargetRevisionID *uint `json:"target_revision_id,omitempty"`
+ IsActive bool `json:"is_active"`
+ CreatedBy uint `json:"created_by"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type SubmitResult struct {
+ Manifest ManifestView `json:"manifest"`
+ Created bool `json:"created"`
+}
diff --git a/basaltpass-backend/internal/service/rbacmanifest/validation.go b/basaltpass-backend/internal/service/rbacmanifest/validation.go
new file mode 100644
index 00000000..41683f22
--- /dev/null
+++ b/basaltpass-backend/internal/service/rbacmanifest/validation.go
@@ -0,0 +1,250 @@
+package rbacmanifest
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "regexp"
+ "sort"
+ "strings"
+ "unicode/utf8"
+)
+
+var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_.:-]{1,99}$`)
+
+var reservedCodePrefixes = []string{"tenant.", "system.", "s2s."}
+
+func DecodeManifest(raw []byte) (*Manifest, error) {
+ if len(raw) == 0 {
+ return nil, validationError("request body is required")
+ }
+ if len(raw) > MaxManifestBytes {
+ return nil, validationError("manifest exceeds %d bytes", MaxManifestBytes)
+ }
+
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.DisallowUnknownFields()
+ var manifest Manifest
+ if err := dec.Decode(&manifest); err != nil {
+ return nil, validationError("invalid RBAC-only manifest: %v", err)
+ }
+ if err := ensureJSONEOF(dec); err != nil {
+ return nil, err
+ }
+ if err := ValidateAndNormalize(&manifest); err != nil {
+ return nil, err
+ }
+ return &manifest, nil
+}
+
+func ensureJSONEOF(dec *json.Decoder) error {
+ var trailing any
+ if err := dec.Decode(&trailing); err != io.EOF {
+ if err == nil {
+ return validationError("manifest must contain exactly one JSON object")
+ }
+ return validationError("invalid trailing JSON: %v", err)
+ }
+ return nil
+}
+
+func ValidateAndNormalize(manifest *Manifest) error {
+ if manifest == nil {
+ return validationError("manifest is required")
+ }
+ manifest.SchemaVersion = strings.TrimSpace(manifest.SchemaVersion)
+ if manifest.SchemaVersion != "0.1.0" && manifest.SchemaVersion != "1.0.0" {
+ return validationError("unsupported schema_version %q", manifest.SchemaVersion)
+ }
+ manifest.Type = strings.TrimSpace(manifest.Type)
+ if manifest.Type != "basalt_rbac_bundle" {
+ return validationError("type must be basalt_rbac_bundle")
+ }
+ if manifest.Revision == 0 {
+ return validationError("revision must be greater than zero")
+ }
+ if len(manifest.Permissions) > 500 {
+ return validationError("at most 500 permissions are allowed")
+ }
+ if len(manifest.Roles) > 100 {
+ return validationError("at most 100 roles are allowed")
+ }
+ if len(manifest.RolePermissions) > 5000 {
+ return validationError("at most 5000 role_permissions are allowed")
+ }
+
+ permissionKeys := make(map[string]struct{}, len(manifest.Permissions))
+ for i := range manifest.Permissions {
+ permission := &manifest.Permissions[i]
+ permission.PermissionKey = strings.ToLower(strings.TrimSpace(permission.PermissionKey))
+ permission.DisplayName = strings.TrimSpace(permission.DisplayName)
+ permission.Resource = strings.TrimSpace(permission.Resource)
+ permission.Action = strings.TrimSpace(permission.Action)
+ permission.Scope = strings.TrimSpace(permission.Scope)
+ permission.Description = strings.TrimSpace(permission.Description)
+ permission.Status = strings.ToLower(strings.TrimSpace(permission.Status))
+ if err := validateCode("permission_key", permission.PermissionKey); err != nil {
+ return err
+ }
+ if _, exists := permissionKeys[permission.PermissionKey]; exists {
+ return validationError("duplicate permission_key %q", permission.PermissionKey)
+ }
+ permissionKeys[permission.PermissionKey] = struct{}{}
+ if permission.DisplayName == "" || runeLen(permission.DisplayName) > 100 {
+ return validationError("permission %q display_name must contain 1-100 characters", permission.PermissionKey)
+ }
+ if runeLen(permission.Description) > 500 || runeLen(permission.Resource) > 50 || runeLen(permission.Action) > 50 || runeLen(permission.Scope) > 50 {
+ return validationError("permission %q contains an oversized field", permission.PermissionKey)
+ }
+ if permission.Status != "" && permission.Status != "active" {
+ return validationError("permission %q status must be active", permission.PermissionKey)
+ }
+ }
+
+ roleKeys := make(map[string]struct{}, len(manifest.Roles))
+ for i := range manifest.Roles {
+ role := &manifest.Roles[i]
+ role.RoleKey = strings.ToLower(strings.TrimSpace(role.RoleKey))
+ role.DisplayName = strings.TrimSpace(role.DisplayName)
+ role.Description = strings.TrimSpace(role.Description)
+ role.Status = strings.ToLower(strings.TrimSpace(role.Status))
+ if err := validateCode("role_key", role.RoleKey); err != nil {
+ return err
+ }
+ if _, exists := roleKeys[role.RoleKey]; exists {
+ return validationError("duplicate role_key %q", role.RoleKey)
+ }
+ roleKeys[role.RoleKey] = struct{}{}
+ if role.DisplayName == "" || runeLen(role.DisplayName) > 100 {
+ return validationError("role %q display_name must contain 1-100 characters", role.RoleKey)
+ }
+ if runeLen(role.Description) > 500 {
+ return validationError("role %q description exceeds 500 characters", role.RoleKey)
+ }
+ if role.Priority < 0 || role.Priority > 1000000 {
+ return validationError("role %q priority is out of range", role.RoleKey)
+ }
+ if role.Status != "" && role.Status != "active" {
+ return validationError("role %q status must be active", role.RoleKey)
+ }
+ }
+
+ linkKeys := make(map[string]struct{}, len(manifest.RolePermissions))
+ for i := range manifest.RolePermissions {
+ link := &manifest.RolePermissions[i]
+ link.RoleKey = strings.ToLower(strings.TrimSpace(link.RoleKey))
+ link.PermissionKey = strings.ToLower(strings.TrimSpace(link.PermissionKey))
+ link.Effect = strings.ToLower(strings.TrimSpace(link.Effect))
+ if _, exists := roleKeys[link.RoleKey]; !exists {
+ return validationError("role_permissions references unknown role_key %q", link.RoleKey)
+ }
+ if _, exists := permissionKeys[link.PermissionKey]; !exists {
+ return validationError("role_permissions references unknown permission_key %q", link.PermissionKey)
+ }
+ if link.Effect != "" && link.Effect != "allow" {
+ return validationError("role_permissions effect must be allow")
+ }
+ link.Effect = "allow"
+ key := link.RoleKey + "\x00" + link.PermissionKey
+ if _, exists := linkKeys[key]; exists {
+ return validationError("duplicate role_permissions link %q -> %q", link.RoleKey, link.PermissionKey)
+ }
+ linkKeys[key] = struct{}{}
+ }
+
+ sort.Slice(manifest.Permissions, func(i, j int) bool {
+ return manifest.Permissions[i].PermissionKey < manifest.Permissions[j].PermissionKey
+ })
+ sort.Slice(manifest.Roles, func(i, j int) bool { return manifest.Roles[i].RoleKey < manifest.Roles[j].RoleKey })
+ sort.Slice(manifest.RolePermissions, func(i, j int) bool {
+ left := manifest.RolePermissions[i].RoleKey + "\x00" + manifest.RolePermissions[i].PermissionKey
+ right := manifest.RolePermissions[j].RoleKey + "\x00" + manifest.RolePermissions[j].PermissionKey
+ return left < right
+ })
+ return nil
+}
+
+func validateCode(field, code string) error {
+ if !codePattern.MatchString(code) {
+ return validationError("%s %q must match %s", field, code, codePattern.String())
+ }
+ for _, prefix := range reservedCodePrefixes {
+ if strings.HasPrefix(code, prefix) {
+ return validationError("%s %q uses reserved prefix %q", field, code, prefix)
+ }
+ }
+ return nil
+}
+
+func SnapshotFromManifest(manifest *Manifest) Snapshot {
+ snapshot := Snapshot{Permissions: []SnapshotPermission{}, Roles: []SnapshotRole{}}
+ permissionsByRole := make(map[string][]string, len(manifest.Roles))
+ for _, link := range manifest.RolePermissions {
+ permissionsByRole[link.RoleKey] = append(permissionsByRole[link.RoleKey], link.PermissionKey)
+ }
+ for _, permission := range manifest.Permissions {
+ category := firstNonEmpty(permission.Resource, permission.Scope, "app")
+ snapshot.Permissions = append(snapshot.Permissions, SnapshotPermission{
+ Code: permission.PermissionKey, Name: permission.DisplayName,
+ Description: permission.Description, Category: category,
+ })
+ }
+ for _, role := range manifest.Roles {
+ codes := append([]string{}, permissionsByRole[role.RoleKey]...)
+ sort.Strings(codes)
+ snapshot.Roles = append(snapshot.Roles, SnapshotRole{
+ Code: role.RoleKey, Name: role.DisplayName, Description: role.Description,
+ PermissionCodes: codes,
+ })
+ }
+ normalizeSnapshot(&snapshot)
+ return snapshot
+}
+
+func marshalCanonical(value any) ([]byte, string, error) {
+ raw, err := json.Marshal(value)
+ if err != nil {
+ return nil, "", err
+ }
+ sum := sha256.Sum256(raw)
+ return raw, hex.EncodeToString(sum[:]), nil
+}
+
+func normalizeSnapshot(snapshot *Snapshot) {
+ if snapshot.Permissions == nil {
+ snapshot.Permissions = []SnapshotPermission{}
+ }
+ if snapshot.Roles == nil {
+ snapshot.Roles = []SnapshotRole{}
+ }
+ for i := range snapshot.Roles {
+ if snapshot.Roles[i].PermissionCodes == nil {
+ snapshot.Roles[i].PermissionCodes = []string{}
+ }
+ sort.Strings(snapshot.Roles[i].PermissionCodes)
+ }
+ sort.Slice(snapshot.Permissions, func(i, j int) bool { return snapshot.Permissions[i].Code < snapshot.Permissions[j].Code })
+ sort.Slice(snapshot.Roles, func(i, j int) bool { return snapshot.Roles[i].Code < snapshot.Roles[j].Code })
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if trimmed := strings.TrimSpace(value); trimmed != "" {
+ return trimmed
+ }
+ }
+ return ""
+}
+
+func runeLen(value string) int { return utf8.RuneCountInString(value) }
+
+func validationError(format string, args ...any) error {
+ return fmt.Errorf("%w: %s", ErrValidation, fmt.Sprintf(format, args...))
+}
+
+func conflictError(format string, args ...any) error {
+ return fmt.Errorf("%w: %s", ErrConflict, fmt.Sprintf(format, args...))
+}
diff --git a/basaltpass-backend/internal/service/scope/scopes.go b/basaltpass-backend/internal/service/scope/scopes.go
index c12f9a75..32ca785c 100644
--- a/basaltpass-backend/internal/service/scope/scopes.go
+++ b/basaltpass-backend/internal/service/scope/scopes.go
@@ -22,6 +22,7 @@ func DefaultAllowedScopes() []string {
S2SUserRead,
S2SUserWrite,
S2SRBACRead,
+ S2SRBACManifestSubmit,
S2STeamRead,
S2STeamWrite,
S2SWalletRead,
@@ -69,6 +70,8 @@ func Describe(s string) Meta {
return Meta{Scope: s, Category: "s2s", Title: "S2S User Write", Description: "修改用户基础资料(PATCH /api/v1/s2s/users/:id,仅限昵称等测试写入)"}
case S2SRBACRead:
return Meta{Scope: s, Category: "s2s", Title: "S2S RBAC Read", Description: "读取用户角色/权限(/roles、/permissions)"}
+ case S2SRBACManifestSubmit:
+ return Meta{Scope: s, Category: "s2s", Title: "RBAC Manifest Submit", Description: "仅允许当前应用提交待管理员审批的 RBAC 配置草案"}
case S2STeamRead:
return Meta{Scope: s, Category: "s2s", Title: "S2S Team Read", Description: "读取团队信息与用户所属团队(/teams)"}
case S2STeamWrite:
@@ -112,6 +115,7 @@ const (
S2SUserRead = "s2s.user.read"
S2SUserWrite = "s2s.user.write"
S2SRBACRead = "s2s.rbac.read"
+ S2SRBACManifestSubmit = "s2s.rbac.manifest.submit"
S2STeamRead = "s2s.team.read"
S2STeamWrite = "s2s.team.write"
S2SWalletRead = "s2s.wallet.read"
diff --git a/basaltpass-backend/internal/service/scope/scopes_test.go b/basaltpass-backend/internal/service/scope/scopes_test.go
new file mode 100644
index 00000000..e0239533
--- /dev/null
+++ b/basaltpass-backend/internal/service/scope/scopes_test.go
@@ -0,0 +1,12 @@
+package scope
+
+import "testing"
+
+func TestLegacyReadScopeDoesNotGrantManifestSubmission(t *testing.T) {
+ if HasScope([]string{S2SRead}, S2SRBACManifestSubmit) {
+ t.Fatal("legacy s2s.read must not grant the privileged manifest submission scope")
+ }
+ if !HasScope([]string{S2SRBACManifestSubmit}, S2SRBACManifestSubmit) {
+ t.Fatal("dedicated manifest submission scope should be accepted")
+ }
+}
diff --git a/basaltpass-backend/internal/service/settings/service.go b/basaltpass-backend/internal/service/settings/service.go
index 27411c63..4b178194 100644
--- a/basaltpass-backend/internal/service/settings/service.go
+++ b/basaltpass-backend/internal/service/settings/service.go
@@ -139,6 +139,7 @@ func defaultItems() map[string]SettingItem {
"s2s.user.read",
"s2s.user.write",
"s2s.rbac.read",
+ "s2s.rbac.manifest.submit",
"s2s.team.read",
"s2s.team.write",
"s2s.wallet.read",
@@ -203,10 +204,12 @@ func defaultItems() map[string]SettingItem {
"jwt.audience": {Value: "", Category: "jwt", Description: "JWT 受众(aud)"},
// Maintenance & Features
- "maintenance.enabled": {Value: false, Category: "maintenance", Description: "维护模式:启用后仅管理员可访问"},
- "maintenance.message": {Value: "系统维护中,请稍后访问。", Category: "maintenance", Description: "维护模式提示信息"},
- "features.registration_invite_only": {Value: false, Category: "features", Description: "仅限邀请注册"},
- "features.beta_features": {Value: []string{}, Category: "features", Description: "开启的 Beta 功能列表"},
+ "maintenance.enabled": {Value: false, Category: "maintenance", Description: "维护模式:启用后仅管理员可访问"},
+ "maintenance.message": {Value: "系统维护中,请稍后访问。", Category: "maintenance", Description: "维护模式提示信息"},
+ "features.registration_invite_only": {Value: false, Category: "features", Description: "仅限邀请注册"},
+ "features.wallet_recharge_withdraw_enabled": {Value: true, Category: "features", Description: "是否开放用户钱包充值;充值必须通过统一收银台完成"},
+ "features.wallet_withdraw_enabled": {Value: false, Category: "features", Description: "是否开放用户钱包提现"},
+ "features.beta_features": {Value: []string{}, Category: "features", Description: "开启的 Beta 功能列表"},
// Analytics
"analytics.enabled": {Value: false, Category: "analytics", Description: "是否启用统计分析"},
diff --git a/basaltpass-backend/internal/service/tokenexchange/service.go b/basaltpass-backend/internal/service/tokenexchange/service.go
index 0f6c5b1e..6aca6797 100644
--- a/basaltpass-backend/internal/service/tokenexchange/service.go
+++ b/basaltpass-backend/internal/service/tokenexchange/service.go
@@ -111,13 +111,16 @@ func (s *Service) Exchange(clientID string, clientAppID uint, clientTenantID uin
return nil, ErrNoTrustRelation
}
- // Step 5: Verify user has authorized the target app
- var appUserCount int64
- if err := db.Model(&model.AppUser{}).
- Where("app_id = ? AND user_id = ? AND status = ?", targetApp.ID, userID, model.AppUserStatusActive).
- Count(&appUserCount).Error; err != nil || appUserCount == 0 {
- s.logExchange(tenantID, userID, clientID, clientAppID, targetApp.ID, trust.ID, req.Scope, "", 0, "denied", "user not authorized for target app", ip)
- return nil, ErrUserNotAuthorized
+ // User subjects require user consent. App service subjects are authorized by
+ // the app-to-app trust relation itself and do not impersonate a user.
+ if subjectToken.SubjectType != model.OAuthSubjectApp {
+ var appUserCount int64
+ if err := db.Model(&model.AppUser{}).
+ Where("app_id = ? AND user_id = ? AND status = ?", targetApp.ID, userID, model.AppUserStatusActive).
+ Count(&appUserCount).Error; err != nil || appUserCount == 0 {
+ s.logExchange(tenantID, userID, clientID, clientAppID, targetApp.ID, trust.ID, req.Scope, "", 0, "denied", "user not authorized for target app", ip)
+ return nil, ErrUserNotAuthorized
+ }
}
// Step 6: Narrow scope
@@ -156,12 +159,16 @@ func (s *Service) Exchange(clientID string, clientAppID uint, clientTenantID uin
UserID: userID,
TenantID: tenantID,
AppID: targetApp.ID,
+ SubjectType: subjectToken.SubjectType,
Scopes: grantedScopeStr,
ExpiresAt: time.Now().Add(time.Duration(ttl) * time.Second),
ActorClientID: clientID,
ActorAppID: clientAppID,
IsExchanged: true,
}
+ if accessToken.SubjectType == "" {
+ accessToken.SubjectType = model.OAuthSubjectUser
+ }
if err := db.Create(accessToken).Error; err != nil {
return nil, err
}
diff --git a/basaltpass-backend/internal/service/wallet/enabled.go b/basaltpass-backend/internal/service/wallet/enabled.go
index b4bac7da..4d9e3a82 100644
--- a/basaltpass-backend/internal/service/wallet/enabled.go
+++ b/basaltpass-backend/internal/service/wallet/enabled.go
@@ -13,3 +13,7 @@ var ErrWalletRechargeWithdrawDisabled = errors.New("钱包充值与提现功能
func RechargeWithdrawEnabled() bool {
return settingssvc.GetBool("features.wallet_recharge_withdraw_enabled", false)
}
+
+func WithdrawEnabled() bool {
+ return settingssvc.GetBool("features.wallet_withdraw_enabled", false)
+}
diff --git a/basaltpass-backend/internal/service/wallet/owner.go b/basaltpass-backend/internal/service/wallet/owner.go
new file mode 100644
index 00000000..92912a4b
--- /dev/null
+++ b/basaltpass-backend/internal/service/wallet/owner.go
@@ -0,0 +1,311 @@
+package wallet
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "basaltpass-backend/internal/common"
+ "basaltpass-backend/internal/model"
+
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+type OwnerRef struct {
+ Type model.WalletOwnerType
+ ID uint
+ TenantID uint
+}
+
+func ResolveOwnerRef(tx *gorm.DB, ownerType model.WalletOwnerType, ownerID, tenantID uint) (OwnerRef, error) {
+ if !ownerType.IsValid() || ownerID == 0 {
+ return OwnerRef{}, errors.New("invalid wallet owner")
+ }
+
+ switch ownerType {
+ case model.WalletOwnerUser:
+ resolvedTenantID, err := resolveEffectiveTenantID(tx, ownerID, tenantID)
+ if err != nil {
+ return OwnerRef{}, err
+ }
+ tenantID = resolvedTenantID
+ case model.WalletOwnerTeam:
+ var team model.Team
+ if err := tx.Select("id", "tenant_id").First(&team, ownerID).Error; err != nil {
+ return OwnerRef{}, err
+ }
+ if tenantID != 0 && team.TenantID != tenantID {
+ return OwnerRef{}, errors.New("team does not belong to requested tenant")
+ }
+ tenantID = team.TenantID
+ case model.WalletOwnerApp:
+ var app model.App
+ if err := tx.Select("id", "tenant_id").First(&app, ownerID).Error; err != nil {
+ return OwnerRef{}, err
+ }
+ if tenantID != 0 && app.TenantID != tenantID {
+ return OwnerRef{}, errors.New("app does not belong to requested tenant")
+ }
+ tenantID = app.TenantID
+ case model.WalletOwnerTenant:
+ if tenantID != 0 && tenantID != ownerID {
+ return OwnerRef{}, errors.New("tenant wallet owner must match tenant context")
+ }
+ var count int64
+ if err := tx.Model(&model.Tenant{}).Where("id = ?", ownerID).Count(&count).Error; err != nil {
+ return OwnerRef{}, err
+ }
+ if count == 0 {
+ return OwnerRef{}, gorm.ErrRecordNotFound
+ }
+ tenantID = ownerID
+ }
+
+ if tenantID == 0 {
+ return OwnerRef{}, errors.New("wallet owner has no tenant identity")
+ }
+ return OwnerRef{Type: ownerType, ID: ownerID, TenantID: tenantID}, nil
+}
+
+func walletOwnerQuery(tx *gorm.DB, owner OwnerRef) *gorm.DB {
+ return tx.Where(
+ "tenant_id = ? AND owner_type = ? AND owner_id = ?",
+ owner.TenantID,
+ owner.Type,
+ owner.ID,
+ )
+}
+
+func findCurrencyByCode(tx *gorm.DB, code string) (model.Currency, error) {
+ var curr model.Currency
+ if err := tx.Where("code = ? AND is_active = ?", strings.ToUpper(strings.TrimSpace(code)), true).First(&curr).Error; err != nil {
+ return model.Currency{}, errors.New("invalid currency code")
+ }
+ return curr, nil
+}
+
+func ensureOwnerWalletTx(tx *gorm.DB, owner OwnerRef, currencyID uint) (model.Wallet, error) {
+ var current model.Wallet
+ err := walletOwnerQuery(tx, owner).Where("currency_id = ?", currencyID).First(¤t).Error
+ if err == nil {
+ return current, nil
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return model.Wallet{}, err
+ }
+
+ current = model.Wallet{
+ TenantID: owner.TenantID,
+ OwnerType: owner.Type,
+ OwnerID: owner.ID,
+ CurrencyID: ¤cyID,
+ }
+ if err := tx.Create(¤t).Error; err != nil {
+ // A concurrent request may have created the unique owner/currency row.
+ if queryErr := walletOwnerQuery(tx, owner).Where("currency_id = ?", currencyID).First(¤t).Error; queryErr == nil {
+ return current, nil
+ }
+ return model.Wallet{}, err
+ }
+ return current, nil
+}
+
+func EnsureOwnerCreditWalletTx(tx *gorm.DB, ownerType model.WalletOwnerType, ownerID, tenantID uint) error {
+ owner, err := ResolveOwnerRef(tx, ownerType, ownerID, tenantID)
+ if err != nil {
+ return err
+ }
+ curr, err := resolveCreditCurrency(tx)
+ if err != nil {
+ return err
+ }
+ _, err = ensureOwnerWalletTx(tx, owner, curr.ID)
+ return err
+}
+
+func GetOwnerBalanceByCode(ownerType model.WalletOwnerType, ownerID, tenantID uint, currencyCode string) (model.Wallet, error) {
+ db := common.DB()
+ owner, err := ResolveOwnerRef(db, ownerType, ownerID, tenantID)
+ if err != nil {
+ return model.Wallet{}, err
+ }
+ curr, err := findCurrencyByCode(db, currencyCode)
+ if err != nil {
+ return model.Wallet{}, err
+ }
+
+ var result model.Wallet
+ err = db.Transaction(func(tx *gorm.DB) error {
+ walletModel, err := ensureOwnerWalletTx(tx, owner, curr.ID)
+ if err != nil {
+ return err
+ }
+ result = walletModel
+ return nil
+ })
+ if err != nil {
+ return model.Wallet{}, err
+ }
+ result.Currency = &curr
+ return result, nil
+}
+
+func ListOwnerWallets(ownerType model.WalletOwnerType, ownerID, tenantID uint) ([]model.Wallet, error) {
+ db := common.DB()
+ owner, err := ResolveOwnerRef(db, ownerType, ownerID, tenantID)
+ if err != nil {
+ return nil, err
+ }
+
+ var wallets []model.Wallet
+ err = walletOwnerQuery(db.Preload("Currency"), owner).
+ Order("currency_id ASC").
+ Find(&wallets).Error
+ return wallets, err
+}
+
+func OwnerHistoryByCode(ownerType model.WalletOwnerType, ownerID, tenantID uint, currencyCode string, limit int) ([]model.WalletTx, error) {
+ if limit <= 0 {
+ limit = 20
+ }
+ db := common.DB()
+ owner, err := ResolveOwnerRef(db, ownerType, ownerID, tenantID)
+ if err != nil {
+ return nil, err
+ }
+ curr, err := findCurrencyByCode(db, currencyCode)
+ if err != nil {
+ return nil, err
+ }
+
+ var walletModel model.Wallet
+ if err := walletOwnerQuery(db, owner).Where("currency_id = ?", curr.ID).First(&walletModel).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return []model.WalletTx{}, nil
+ }
+ return nil, err
+ }
+
+ var txs []model.WalletTx
+ err = db.Preload("Wallet.Currency").Where("wallet_id = ?", walletModel.ID).
+ Order("created_at DESC").Limit(limit).Find(&txs).Error
+ return txs, err
+}
+
+func AdjustOwnerByCode(ownerType model.WalletOwnerType, ownerID, tenantID uint, currencyCode string, delta int64, txType, reference string) (model.Wallet, error) {
+ if delta == 0 {
+ return model.Wallet{}, errors.New("amount must not be zero")
+ }
+ db := common.DB()
+ owner, err := ResolveOwnerRef(db, ownerType, ownerID, tenantID)
+ if err != nil {
+ return model.Wallet{}, err
+ }
+ curr, err := findCurrencyByCode(db, currencyCode)
+ if err != nil {
+ return model.Wallet{}, err
+ }
+
+ var updated model.Wallet
+ err = db.Transaction(func(tx *gorm.DB) error {
+ walletModel, err := ensureOwnerWalletTx(tx, owner, curr.ID)
+ if err != nil {
+ return err
+ }
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&walletModel, walletModel.ID).Error; err != nil {
+ return err
+ }
+
+ normalizedReference := strings.TrimSpace(reference)
+ if len(normalizedReference) > 128 {
+ return errors.New("reference must not exceed 128 characters")
+ }
+ normalizedType := strings.TrimSpace(txType)
+ if normalizedType == "" {
+ if delta > 0 {
+ normalizedType = "adjust_increase"
+ } else {
+ normalizedType = "adjust_decrease"
+ }
+ }
+ if normalizedReference != "" {
+ var existing model.WalletTx
+ err := tx.Where("wallet_id = ? AND idempotency_key = ?", walletModel.ID, normalizedReference).First(&existing).Error
+ if err == nil {
+ if existing.Amount != delta || existing.Type != normalizedType {
+ return errors.New("idempotency key was already used for a different wallet adjustment")
+ }
+ walletModel.Currency = &curr
+ updated = walletModel
+ return nil
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ }
+
+ newBalance := walletModel.Balance + delta
+ if newBalance < 0 {
+ return errors.New("insufficient funds")
+ }
+ if err := tx.Model(&walletModel).Update("balance", newBalance).Error; err != nil {
+ return err
+ }
+
+ walletTx := model.WalletTx{
+ WalletID: walletModel.ID,
+ Type: normalizedType,
+ Amount: delta,
+ Status: "success",
+ Reference: normalizedReference,
+ }
+ if normalizedReference != "" {
+ walletTx.IdempotencyKey = &normalizedReference
+ }
+ if err := tx.Create(&walletTx).Error; err != nil {
+ return err
+ }
+
+ walletModel.Balance = newBalance
+ walletModel.Currency = &curr
+ updated = walletModel
+ return nil
+ })
+ if err != nil {
+ return model.Wallet{}, err
+ }
+ return updated, nil
+}
+
+func EnsureCreditWalletsForAllTeams() (int64, error) {
+ db := common.DB()
+ var teams []model.Team
+ if err := db.Select("id", "tenant_id").Where("tenant_id > 0 AND is_active = ?", true).Find(&teams).Error; err != nil {
+ return 0, err
+ }
+
+ var created int64
+ err := db.Transaction(func(tx *gorm.DB) error {
+ curr, err := resolveCreditCurrency(tx)
+ if err != nil {
+ return err
+ }
+ for _, team := range teams {
+ owner := OwnerRef{Type: model.WalletOwnerTeam, ID: team.ID, TenantID: team.TenantID}
+ var count int64
+ if err := walletOwnerQuery(tx.Model(&model.Wallet{}), owner).Where("currency_id = ?", curr.ID).Count(&count).Error; err != nil {
+ return err
+ }
+ if count > 0 {
+ continue
+ }
+ if _, err := ensureOwnerWalletTx(tx, owner, curr.ID); err != nil {
+ return fmt.Errorf("ensure team %d credit wallet: %w", team.ID, err)
+ }
+ created++
+ }
+ return nil
+ })
+ return created, err
+}
diff --git a/basaltpass-backend/internal/service/wallet/owner_test.go b/basaltpass-backend/internal/service/wallet/owner_test.go
new file mode 100644
index 00000000..6e5072e2
--- /dev/null
+++ b/basaltpass-backend/internal/service/wallet/owner_test.go
@@ -0,0 +1,97 @@
+package wallet
+
+import (
+ "testing"
+
+ "basaltpass-backend/internal/common"
+ "basaltpass-backend/internal/model"
+
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+)
+
+func setupOwnerWalletTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := db.AutoMigrate(
+ &model.User{}, &model.Tenant{}, &model.TenantUser{}, &model.Team{},
+ &model.App{}, &model.Currency{}, &model.Wallet{}, &model.WalletTx{},
+ ); err != nil {
+ t.Fatal(err)
+ }
+ common.SetDBForTest(db)
+ return db
+}
+
+func TestAdjustOwnerByCodeCreatesAppWallet(t *testing.T) {
+ db := setupOwnerWalletTestDB(t)
+ tenant := model.Tenant{Name: "Tenant", Code: "tenant", Status: model.TenantStatusActive}
+ if err := db.Create(&tenant).Error; err != nil {
+ t.Fatal(err)
+ }
+ app := model.App{TenantID: tenant.ID, Name: "Service", Status: model.AppStatusActive}
+ if err := db.Create(&app).Error; err != nil {
+ t.Fatal(err)
+ }
+ currency := model.Currency{Code: "CREDIT", Name: "Credit", DecimalPlaces: 0, IsActive: true}
+ if err := db.Create(¤cy).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ walletModel, err := AdjustOwnerByCode(model.WalletOwnerApp, app.ID, tenant.ID, "CREDIT", 250, "test_credit", "request-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if walletModel.OwnerType != model.WalletOwnerApp || walletModel.OwnerID != app.ID || walletModel.Balance != 250 {
+ t.Fatalf("unexpected app wallet: %+v", walletModel)
+ }
+ if walletModel.UserID != nil || walletModel.TeamID != nil {
+ t.Fatalf("app wallet populated legacy ownership columns: %+v", walletModel)
+ }
+
+ replayed, err := AdjustOwnerByCode(model.WalletOwnerApp, app.ID, tenant.ID, "CREDIT", 250, "test_credit", "request-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if replayed.Balance != 250 {
+ t.Fatalf("idempotent replay changed balance: %+v", replayed)
+ }
+ if _, err := AdjustOwnerByCode(model.WalletOwnerApp, app.ID, tenant.ID, "CREDIT", 1, "test_credit", "request-1"); err == nil {
+ t.Fatal("expected idempotency key conflict")
+ }
+
+ if _, err := AdjustOwnerByCode(model.WalletOwnerApp, app.ID, tenant.ID, "CREDIT", -300, "test_debit", "request-2"); err == nil {
+ t.Fatal("expected insufficient funds error")
+ }
+ var txCount int64
+ if err := db.Model(&model.WalletTx{}).Where("wallet_id = ?", walletModel.ID).Count(&txCount).Error; err != nil {
+ t.Fatal(err)
+ }
+ if txCount != 1 {
+ t.Fatalf("expected one committed transaction, got %d", txCount)
+ }
+}
+
+func TestLegacyUserWalletPopulatesUnifiedOwner(t *testing.T) {
+ db := setupOwnerWalletTestDB(t)
+ user := model.User{Email: "legacy-wallet@example.com", PasswordHash: "x"}
+ if err := db.Create(&user).Error; err != nil {
+ t.Fatal(err)
+ }
+ currency := model.Currency{Code: "USD", Name: "US Dollar", DecimalPlaces: 2, IsActive: true}
+ if err := db.Create(¤cy).Error; err != nil {
+ t.Fatal(err)
+ }
+ userID := user.ID
+ currencyID := currency.ID
+ walletModel := model.Wallet{TenantID: 9, UserID: &userID, CurrencyID: ¤cyID}
+ if err := db.Create(&walletModel).Error; err != nil {
+ t.Fatal(err)
+ }
+ if walletModel.OwnerType != model.WalletOwnerUser || walletModel.OwnerID != userID {
+ t.Fatalf("legacy ownership was not normalized: %+v", walletModel)
+ }
+}
diff --git a/basaltpass-backend/internal/service/wallet/service.go b/basaltpass-backend/internal/service/wallet/service.go
index 8475340a..bfa8a885 100644
--- a/basaltpass-backend/internal/service/wallet/service.go
+++ b/basaltpass-backend/internal/service/wallet/service.go
@@ -3,7 +3,6 @@ package wallet
import (
"basaltpass-backend/internal/service/currency"
"errors"
- "strings"
"basaltpass-backend/internal/common"
"basaltpass-backend/internal/model"
@@ -178,6 +177,24 @@ func GetBalanceByCodeWithTenant(userID uint, tenantID uint, currencyCode string)
return GetBalanceWithTenant(userID, curr.ID, tenantID)
}
+// ListUserWalletsWithTenant returns existing wallets for a user in tenant context.
+func ListUserWalletsWithTenant(userID uint, tenantID uint) ([]model.Wallet, error) {
+ db := common.DB()
+ effectiveTenantID, err := resolveEffectiveTenantID(db, userID, tenantID)
+ if err != nil {
+ return nil, err
+ }
+
+ var wallets []model.Wallet
+ if err := db.Preload("Currency").
+ Where("user_id = ? AND tenant_id = ?", userID, effectiveTenantID).
+ Order("currency_id ASC").
+ Find(&wallets).Error; err != nil {
+ return nil, err
+ }
+ return wallets, nil
+}
+
// Recharge adds amount to balance and creates transaction (mock auto success)
func Recharge(userID uint, currencyID uint, amount int64) error {
return RechargeWithTenant(userID, 0, currencyID, amount)
@@ -238,7 +255,7 @@ func Withdraw(userID uint, currencyID uint, amount int64) error {
}
func WithdrawWithTenant(userID uint, tenantID uint, currencyID uint, amount int64) error {
- if !RechargeWithdrawEnabled() {
+ if !WithdrawEnabled() {
return ErrWalletRechargeWithdrawDisabled
}
if amount <= 0 {
@@ -293,66 +310,12 @@ func AdjustByCode(userID uint, currencyCode string, delta int64, txType string,
}
func AdjustByCodeWithTenant(userID uint, tenantID uint, currencyCode string, delta int64, txType string, reference string) (model.Wallet, error) {
- if delta == 0 {
- return model.Wallet{}, errors.New("amount must not be zero")
- }
-
- curr, err := currency.GetCurrencyByCode(currencyCode)
- if err != nil {
- return model.Wallet{}, errors.New("invalid currency code")
- }
-
db := common.DB()
effectiveTenantID, err := resolveEffectiveTenantID(db, userID, tenantID)
if err != nil {
return model.Wallet{}, err
}
-
- var updated model.Wallet
- err = db.Transaction(func(tx *gorm.DB) error {
- var w model.Wallet
- if err := tx.Where("user_id = ? AND currency_id = ? AND tenant_id = ?", userID, curr.ID, effectiveTenantID).
- FirstOrCreate(&w, model.Wallet{TenantID: effectiveTenantID, UserID: &userID, CurrencyID: &curr.ID}).Error; err != nil {
- return err
- }
-
- newBalance := w.Balance + delta
- if newBalance < 0 {
- return errors.New("insufficient funds")
- }
-
- w.Balance = newBalance
- if err := tx.Save(&w).Error; err != nil {
- return err
- }
-
- txType = strings.TrimSpace(txType)
- if txType == "" {
- if delta > 0 {
- txType = "adjust_increase"
- } else {
- txType = "adjust_decrease"
- }
- }
-
- walletTx := model.WalletTx{
- WalletID: w.ID,
- Type: txType,
- Amount: delta,
- Status: "success",
- Reference: strings.TrimSpace(reference),
- }
- if err := tx.Create(&walletTx).Error; err != nil {
- return err
- }
-
- updated = w
- return nil
- })
- if err != nil {
- return model.Wallet{}, err
- }
- return updated, nil
+ return AdjustOwnerByCode(model.WalletOwnerUser, userID, effectiveTenantID, currencyCode, delta, txType, reference)
}
// History returns last n transactions
@@ -375,7 +338,7 @@ func HistoryWithTenant(userID uint, tenantID uint, currencyID uint, limit int) (
return nil, err
}
var txs []model.WalletTx
- db.Where("wallet_id = ?", w.ID).Order("created_at desc").Limit(limit).Find(&txs)
+ db.Preload("Wallet.Currency").Where("wallet_id = ?", w.ID).Order("created_at desc").Limit(limit).Find(&txs)
return txs, nil
}
@@ -420,7 +383,8 @@ func HistoryAllByUserWithTenant(userID uint, tenantID uint, limit int) ([]model.
}
var txs []model.WalletTx
- if err := db.Where("wallet_id IN ?", walletIDs).
+ if err := db.Preload("Wallet.Currency").
+ Where("wallet_id IN ?", walletIDs).
Order("created_at desc").
Limit(limit).
Find(&txs).Error; err != nil {
diff --git a/basaltpass-backend/tools/README.md b/basaltpass-backend/tools/README.md
new file mode 100644
index 00000000..73630079
--- /dev/null
+++ b/basaltpass-backend/tools/README.md
@@ -0,0 +1,14 @@
+# Backend Tools
+
+Backend-local development tools live here when they need to import
+`basaltpass-backend/internal` packages.
+
+- `conformance-seed/`: seeds the local SQLite database used by OIDC conformance runs.
+- `create-test-subscription/`: creates subscription product, plan, and price data for local payment testing.
+
+Run them from `basaltpass-backend`, for example:
+
+```bash
+go run ./tools/conformance-seed
+go run ./tools/create-test-subscription
+```
diff --git a/basaltpass-backend/cmd/conformance-seed/main.go b/basaltpass-backend/tools/conformance-seed/main.go
similarity index 100%
rename from basaltpass-backend/cmd/conformance-seed/main.go
rename to basaltpass-backend/tools/conformance-seed/main.go
diff --git a/basaltpass-backend/cmd/create_test_subscription/main.go b/basaltpass-backend/tools/create-test-subscription/main.go
similarity index 100%
rename from basaltpass-backend/cmd/create_test_subscription/main.go
rename to basaltpass-backend/tools/create-test-subscription/main.go
diff --git a/basaltpass-docs/docs/integration/rbac-manifest.md b/basaltpass-docs/docs/integration/rbac-manifest.md
new file mode 100644
index 00000000..451618d3
--- /dev/null
+++ b/basaltpass-docs/docs/integration/rbac-manifest.md
@@ -0,0 +1,87 @@
+---
+title: Automatic RBAC manifest import
+---
+
+# Automatic RBAC manifest import
+
+An authenticated app can submit an RBAC-only configuration draft to BasaltPass. Submission never changes live permissions: a tenant administrator must review the generated diff and approve it in the tenant console.
+
+## Required client capability
+
+Grant the app's OAuth client this dedicated scope:
+
+```text
+s2s.rbac.manifest.submit
+```
+
+Do not send credentials in the query string. Use the same `client_id` and `client_secret` headers as other S2S endpoints over HTTPS.
+
+## Submit a manifest
+
+```http
+POST /api/v1/s2s/rbac/manifests
+Content-Type: application/json
+client_id:
{t('tenantAppDetail.accessControl.cards.manifestManagement.description')}
+{t('tenantAppDetail.accessControl.cards.grantMapping.description')}
+{mapping.source.code}{mapping.target.code}{t('tenantAppUserManagement.permissionModal.effectiveDescription')}
{role.code}{permission.code}{value})}
+ {t('tenantRBACManifests.integration.description')}
+POST /api/v1/s2s/rbac/manifests
+ scope: s2s.rbac.manifest.submit
+ {t('tenantRBACManifests.manifests.emptyTitle')}
+{t('tenantRBACManifests.manifests.emptyDescription')}
+{t('pages.appRecharge.guide.body', { app: config?.app.name || t('pages.appRecharge.header.appFallback') })}
+{t('pages.appRecharge.guide.currencyCount', { count: config?.currencies.length || 0 })}
+{t('pages.appRecharge.success.pendingDescription')}
++ {t('pages.appRecharge.success.description', { app: appName || t('pages.appRecharge.header.appFallback') })} +
++ {t('pages.userSubscriptionProducts.productCode')}: {product.Code} +
+Selected
+{formatPrice(selectedEntry.price)}
+{t('pages.userSubscriptionProducts.version')} v{plan.PlanVersion}
+Plan
+{selectedEntry.plan.DisplayName}
+Price
+{formatPrice(selectedEntry.price)}
+{t('pages.userSubscriptionProducts.noPrice')}
+ )} +{t('pages.userSubscriptionProducts.productCode')}: {product.Code}
{t('pages.teamWallet.empty')}
+{selectedAccount.currency?.code}
+{t('pages.teamWallet.transactions.empty')}
+ ) : null} + {!historyLoading && transactions.length > 0 ? ( +{tx.type}
++ {new Date(tx.created_at).toLocaleString(locale)}{tx.reference ? ` · ${tx.reference}` : ''} +
++ {incoming ? '+' : '-'}{formatAmount(Math.abs(tx.amount), selectedAccount)} +
+{tx.status}
+- {selectedCurrency - ? t('pages.wallet.overview.currentBalanceWithCode', { code: selectedCurrency.code }) - : t('pages.wallet.stats.currentBalance')} -
-- {selectedCurrency && balance !== null - ? formatBalance(balance, selectedCurrency) - : '--' - } +
{t('pages.wallet.overview.totalUsd')}
+{formatUSD(totalUSD)}
++ {t('pages.wallet.overview.accountCount', { count: accounts.length })}
-+
{t('pages.wallet.overview.lastUpdated', { time: new Date().toLocaleString(locale) })}
- {stat.name} -
-- {stat.value} -
-{t('pages.wallet.stats.totalUsd')}
+{formatUSD(totalUSD)}
+{t('pages.wallet.stats.monthlyIncome')}
++ +{selectedCurrency ? formatCurrency(monthly.income, selectedCurrency) : '--'} +
+{t('pages.wallet.stats.monthlyExpense')}
++ -{selectedCurrency ? formatCurrency(monthly.expense, selectedCurrency) : '--'} +
+{t('pages.wallet.quickActions.recharge')}
{t('pages.wallet.quickActions.rechargeDesc')}
{t('pages.wallet.quickActions.recharge')}
-{t('pages.wallet.quickActions.notAvailable')}
-{t('pages.wallet.quickActions.withdraw')}
{t('pages.wallet.quickActions.withdrawDesc')}
{t('pages.wallet.quickActions.withdraw')}
-{t('pages.wallet.quickActions.notAvailable')}
-{t('pages.wallet.quickActions.history')}
{t('pages.wallet.quickActions.historyDesc')}
{t('pages.wallet.quickActions.redeemGiftCard')}
{t('pages.wallet.quickActions.redeemGiftCardDesc')}
@@ -356,65 +353,51 @@ export default function WalletIndex() {- {getTypeLabel(tx.Type)} #{tx.ID} -
-- {new Date(tx.CreatedAt).toLocaleString(locale)} -
+{getTypeLabel(tx.Type)} #{tx.ID}
+{new Date(tx.CreatedAt).toLocaleString(locale)}
{formatTxAmount()}
++ {isIncoming ? '+' : '-'}{formatCurrency(Math.abs(tx.Amount), txCurrency)} +
{t('pages.walletRecharge.success.redirecting')}
-{t(`pages.walletRecharge.paymentMethods.${method.id}.name`)}
-{t(`pages.walletRecharge.paymentMethods.${method.id}.description`)}
-