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: +client_secret: +``` + +```json +{ + "schema_version": "1.0.0", + "type": "basalt_rbac_bundle", + "revision": 1, + "permissions": [ + { + "permission_key": "demo.read", + "display_name": "Read demo data", + "resource": "demo", + "action": "read", + "scope": "app", + "description": "Read access", + "status": "active" + } + ], + "roles": [ + { + "role_key": "demo.viewer", + "display_name": "Viewer", + "description": "Read-only access", + "assignable": true, + "priority": 10, + "status": "active" + } + ], + "role_permissions": [ + { + "role_key": "demo.viewer", + "permission_key": "demo.read", + "effect": "allow" + } + ] +} +``` + +`revision` must increase for every new submission. Repeating the same revision and payload is idempotent; reusing a revision with different content is rejected. + +BasaltPass rejects unknown fields, OAuth client configuration, user assignments, reserved permission prefixes, unsupported effects, invalid references, and oversized manifests. The authenticated client determines the app and tenant; no app or tenant identifier is accepted from the body. + +## Review and rollback + +Open **Tenant Console → Apps → App details → Automatic RBAC Import** to: + +- inspect added, updated, and removed roles and permissions; +- see assigned roles affected by mapping changes; +- approve and atomically publish a pending manifest; +- reject a pending manifest; +- inspect immutable published revisions; +- roll back by creating a new revision from an earlier snapshot. + +Publishing or rollback is refused when it would delete a role assigned to a user or a permission directly granted to a user. BasaltPass never deletes or changes user-role and user-permission assignments through the manifest workflow. + +Approval verifies the effective RBAC baseline digest inside the publish transaction. If RBAC changed after the displayed diff was generated, approval returns a conflict and the app must submit a higher revision. This prevents an administrator from approving a stale diff. + +The app can poll its own submission status with: + +```http +GET /api/v1/s2s/rbac/manifests/{manifest_id} +``` diff --git a/basaltpass-docs/docs/integration/tenant-app-grant-mappings.md b/basaltpass-docs/docs/integration/tenant-app-grant-mappings.md new file mode 100644 index 00000000..bf5f0548 --- /dev/null +++ b/basaltpass-docs/docs/integration/tenant-app-grant-mappings.md @@ -0,0 +1,82 @@ +# Tenant → App grant mappings + +Tenant administrators can map current tenant access to an application's roles or permissions. These mappings are tenant-owned policy and cannot be submitted by an application manifest. + +## Evaluation model + +BasaltPass does not create an `app_user_roles` or `app_user_permissions` record for an inherited grant. Each S2S authorization read computes: + +```text +effective app roles = active explicit app roles ∪ active mapped app roles +effective app permissions = active explicit app permissions + ∪ permissions of effective app roles + ∪ active mapped app permissions +``` + +Supported tenant sources are: + +- membership roles from `tenant_users.role`: `owner`, `admin`, `member`, or `user`; +- active Tenant RBAC role assignments; +- effective Tenant permissions, whether directly granted or inherited through an active Tenant RBAC role. + +Supported app targets are App roles and App permissions. Mappings use entity IDs, so changing a display name or code does not break the relationship. + +## Runtime eligibility + +Effective grants are empty when any of the following is true: + +- the tenant is not active; +- the app is not active; +- the global user is banned; +- the user is no longer a tenant member or the membership is banned; +- the user has not authorized the app; +- the app-user status is banned or suspended and its deadline has not expired. + +An expired source assignment, disabled mapping, mapping before `valid_from`, or mapping after `valid_until` is ignored immediately. A restricted app user remains eligible because restrictions are enforced separately by the app. + +Applications that require immediate revocation must query the S2S authorization endpoints on each protected request (or use a cache with an explicitly accepted short TTL). Copying grants into a long-lived application token would reintroduce stale authorization state. + +## Tenant management API + +All endpoints require an authenticated tenant owner or tenant administrator and are scoped to the current tenant: + +```text +GET /api/v1/tenant/apps/:app_id/rbac/mappings +GET /api/v1/tenant/apps/:app_id/rbac/mappings/options +POST /api/v1/tenant/apps/:app_id/rbac/mappings/preview +POST /api/v1/tenant/apps/:app_id/rbac/mappings +PUT /api/v1/tenant/apps/:app_id/rbac/mappings/:mapping_id +DELETE /api/v1/tenant/apps/:app_id/rbac/mappings/:mapping_id +GET /api/v1/tenant/apps/:app_id/users/:user_id/effective-grants +``` + +Example mapping: + +```json +{ + "source_type": "membership_role", + "source_id": 0, + "source_code": "admin", + "target_type": "app_role", + "target_id": 42, + "enabled": true, + "valid_from": null, + "valid_until": null +} +``` + +For `tenant_role` and `tenant_permission`, provide `source_id` and leave `source_code` empty. + +## Provenance and revocation + +The effective-grants response includes a `sources` array for every role and permission. Explicit assignments contain an `assignment_id`; inherited grants contain the mapping ID and tenant source. An App role may have both sources. Removing one source does not remove the other. + +Inherited grants are read-only in the App user screen. Revoke them by changing the user's Tenant access or by disabling/deleting the mapping. + +Roles and permissions referenced by a mapping cannot be deleted manually. RBAC manifest approval and rollback also refuse to remove referenced targets. Source Tenant roles and permissions cannot be deleted until their mappings are removed. + +Mapping writes and App RBAC publication/deletion serialize on database row locks. Source and target validation is repeated while locked, preventing a concurrent delete from leaving a dangling mapping. + +## OAuth behavior + +OAuth authorization creates or updates only the `app_users` authorization relationship. It does not copy inherited roles or permissions. This prevents access from depending on whether or when a user last logged in. diff --git a/basaltpass-docs/docs/reference/repo-structure.md b/basaltpass-docs/docs/reference/repo-structure.md index b496921e..fec81084 100644 --- a/basaltpass-docs/docs/reference/repo-structure.md +++ b/basaltpass-docs/docs/reference/repo-structure.md @@ -11,11 +11,14 @@ BasaltPass is a monorepo containing both backend and frontend code. - `basaltpass-backend/`: Go (Fiber + GORM) application. - `basaltpass-frontend/`: React (Vite) application. - `basaltpass-docs/`: This documentation site. -- `scripts/`: Utility scripts for development and deployment. +- `scripts/`: Stable full-stack development entrypoints. +- `tools/`: Local development, test data, and one-off helper scripts. ## Backend Structure - `cmd/basaltpass/`: Entry point. +- `cmd/basaltpass-import/`: Basalt bundle import CLI. +- `tools/`: Backend-local development tools that import internal packages. - `internal/api/v1/`: Route definitions. - `internal/handler/`: HTTP Request handlers. - `internal/middleware/`: Auth, Tenant Context, Rate Limiting. diff --git a/basaltpass-docs/docs/reference/s2s-api.md b/basaltpass-docs/docs/reference/s2s-api.md index 51d648e8..38e97205 100644 --- a/basaltpass-docs/docs/reference/s2s-api.md +++ b/basaltpass-docs/docs/reference/s2s-api.md @@ -62,6 +62,7 @@ Available S2S scopes: - `s2s.user.read` - `s2s.user.write` - `s2s.rbac.read` +- `s2s.rbac.manifest.submit` - `s2s.team.read` - `s2s.team.write` - `s2s.wallet.read` @@ -414,6 +415,10 @@ Each item in `results` may contain: - `sent_at` - `error` +## Effective App grants + +The user role and permission endpoints return the union of explicit App assignments and active Tenant → App mappings. Inherited grants are evaluated dynamically and are never written to `app_user_roles` or `app_user_permissions`. Responses include `eligible` and `denial_reason`; role objects also include grant provenance. See [Tenant → App grant mappings](../integration/tenant-app-grant-mappings.md). + ## Common Errors - `invalid_client` diff --git a/basaltpass-docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/repo-structure.md b/basaltpass-docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/repo-structure.md index f977476b..b8866392 100644 --- a/basaltpass-docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/repo-structure.md +++ b/basaltpass-docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/repo-structure.md @@ -11,11 +11,14 @@ BasaltPass 是一个包含后端和前端代码的 monorepo (单仓库)。 - `basaltpass-backend/`: Go (Fiber + GORM) 应用。 - `basaltpass-frontend/`: React (Vite) 应用。 - `basaltpass-docs/`: 本文档站点。 -- `scripts/`: 用于开发和部署的实用脚本。 +- `scripts/`: 稳定的全栈开发入口脚本。 +- `tools/`: 本地开发、测试数据和一次性辅助脚本。 ## 后端结构 - `cmd/basaltpass/`: 入口点。 +- `cmd/basaltpass-import/`: Basalt bundle 导入 CLI。 +- `tools/`: 需要引用 internal 包的后端本地开发工具。 - `internal/api/v1/`: 路由定义。 - `internal/handler/`: HTTP 请求处理器。 - `internal/middleware/`: 认证、租户上下文、速率限制。 diff --git a/basaltpass-frontend/apps/admin/package.json b/basaltpass-frontend/apps/admin/package.json index 755af72f..d61246f0 100644 --- a/basaltpass-frontend/apps/admin/package.json +++ b/basaltpass-frontend/apps/admin/package.json @@ -11,18 +11,18 @@ "@heroicons/react": "^2.2.0", "axios": "^1.18.1", "clsx": "^2.1.1", - "lucide-react": "^1.18.0", + "lucide-react": "^1.23.0", "react": "19.2.7", "react-dom": "19.2.7", - "react-router-dom": "^7.16.0" + "react-router-dom": "^7.18.1" }, "devDependencies": { - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "autoprefixer": "^10.5.0", - "postcss": "^8.4.31", + "@vitejs/plugin-react": "^6.0.3", + "autoprefixer": "^10.5.2", + "postcss": "^8.5.16", "tailwindcss": "^3.4.17", "typescript": "^6.0.3", "vite": "^8.0.16" diff --git a/basaltpass-frontend/apps/tenant/package.json b/basaltpass-frontend/apps/tenant/package.json index dc9f7d29..d00c78ac 100644 --- a/basaltpass-frontend/apps/tenant/package.json +++ b/basaltpass-frontend/apps/tenant/package.json @@ -11,18 +11,18 @@ "@heroicons/react": "^2.2.0", "axios": "^1.18.1", "clsx": "^2.1.1", - "lucide-react": "^1.18.0", + "lucide-react": "^1.23.0", "react": "19.2.7", "react-dom": "19.2.7", - "react-router-dom": "^7.16.0" + "react-router-dom": "^7.18.1" }, "devDependencies": { - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "autoprefixer": "^10.5.0", - "postcss": "^8.4.31", + "@vitejs/plugin-react": "^6.0.3", + "autoprefixer": "^10.5.2", + "postcss": "^8.5.16", "tailwindcss": "^3.4.17", "typescript": "^6.0.3", "vite": "^8.0.16" diff --git a/basaltpass-frontend/apps/tenant/src/router.tsx b/basaltpass-frontend/apps/tenant/src/router.tsx index ed90704c..2da34b16 100644 --- a/basaltpass-frontend/apps/tenant/src/router.tsx +++ b/basaltpass-frontend/apps/tenant/src/router.tsx @@ -26,6 +26,8 @@ import TenantNotifications from '../../../src/features/tenant/notification/Notif import AppUserManagement from '../../../src/features/tenant/app/AppUserManagement' import AppRoleManagement from '../../../src/features/tenant/app/AppRoleManagement' import AppPermissionManagement from '../../../src/features/tenant/app/AppPermissionManagement' +import RBACManifests from '../../../src/features/tenant/app/RBACManifests' +import AppGrantMappings from '../../../src/features/tenant/app/AppGrantMappings' import TenantSubscriptionDashboard from '../../../src/features/tenant/subscription/Dashboard' import TenantProducts from '../../../src/features/tenant/subscription/Products' @@ -98,6 +100,8 @@ export default function AppRouter() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/basaltpass-frontend/apps/user/package.json b/basaltpass-frontend/apps/user/package.json index a20ba2c7..576a5933 100644 --- a/basaltpass-frontend/apps/user/package.json +++ b/basaltpass-frontend/apps/user/package.json @@ -11,18 +11,18 @@ "@heroicons/react": "^2.2.0", "axios": "^1.18.1", "clsx": "^2.1.1", - "lucide-react": "^1.18.0", + "lucide-react": "^1.23.0", "react": "19.2.7", "react-dom": "19.2.7", - "react-router-dom": "^7.16.0" + "react-router-dom": "^7.18.1" }, "devDependencies": { - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "autoprefixer": "^10.5.0", - "postcss": "^8.4.31", + "@vitejs/plugin-react": "^6.0.3", + "autoprefixer": "^10.5.2", + "postcss": "^8.5.16", "tailwindcss": "^3.4.17", "typescript": "^6.0.3", "vite": "^8.0.16" diff --git a/basaltpass-frontend/apps/user/src/router.tsx b/basaltpass-frontend/apps/user/src/router.tsx index 74683b24..f23fb566 100644 --- a/basaltpass-frontend/apps/user/src/router.tsx +++ b/basaltpass-frontend/apps/user/src/router.tsx @@ -33,6 +33,7 @@ import Withdraw from '../../../src/features/user/wallet/Withdraw' import History from '../../../src/features/user/wallet/History' import RedeemGiftCard from '../../../src/features/user/wallet/RedeemGiftCard' import Payment from '../../../src/features/user/payment/Payment' +import Cashier from '../../../src/features/user/payment/Cashier' import SecuritySettings from '../../../src/features/user/security/SecuritySettings' import TwoFA from '../../../src/features/user/security/TwoFA' @@ -41,9 +42,11 @@ import LoginHistory from '../../../src/features/user/security/LoginHistory' import UserAppsIndex from '../../../src/features/user/apps/Index' import UserAppDetail from '../../../src/features/user/apps/Detail' +import AppRecharge from '../../../src/features/user/apps/AppRecharge' import SubscriptionIndex from '../../../src/features/user/subscription/Index' import ProductsPage from '../../../src/features/user/subscription/Products' +import ProductDetailPage from '../../../src/features/user/subscription/ProductDetail' import SubscriptionCheckout from '../../../src/features/user/subscription/Checkout' import OrderConfirm from '../../../src/features/user/order/OrderConfirm' @@ -195,6 +198,7 @@ export default function AppRouter() { } /> } /> } /> + } /> {/* Security */} } /> @@ -205,9 +209,13 @@ export default function AppRouter() { {/* Apps */} } /> } /> + } /> + } /> {/* Subscriptions */} } /> + } /> + } /> } /> } /> diff --git a/basaltpass-frontend/package-lock.json b/basaltpass-frontend/package-lock.json index 81bdcf8c..97af26ce 100644 --- a/basaltpass-frontend/package-lock.json +++ b/basaltpass-frontend/package-lock.json @@ -14,18 +14,18 @@ "@heroicons/react": "^2.2.0", "axios": "^1.18.1", "clsx": "^2.1.1", - "lucide-react": "^1.18.0", + "lucide-react": "^1.23.0", "react": "19.2.7", "react-dom": "19.2.7", - "react-router-dom": "^7.16.0" + "react-router-dom": "^7.18.1" }, "devDependencies": { - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "autoprefixer": "^10.5.0", - "postcss": "^8.4.31", + "@vitejs/plugin-react": "^6.0.3", + "autoprefixer": "^10.5.2", + "postcss": "^8.5.16", "tailwindcss": "^3.4.17", "typescript": "^6.0.3", "vite": "^8.0.16" @@ -38,18 +38,18 @@ "@heroicons/react": "^2.2.0", "axios": "^1.18.1", "clsx": "^2.1.1", - "lucide-react": "^1.18.0", + "lucide-react": "^1.23.0", "react": "19.2.7", "react-dom": "19.2.7", - "react-router-dom": "^7.16.0" + "react-router-dom": "^7.18.1" }, "devDependencies": { - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "autoprefixer": "^10.5.0", - "postcss": "^8.4.31", + "@vitejs/plugin-react": "^6.0.3", + "autoprefixer": "^10.5.2", + "postcss": "^8.5.16", "tailwindcss": "^3.4.17", "typescript": "^6.0.3", "vite": "^8.0.16" @@ -62,18 +62,18 @@ "@heroicons/react": "^2.2.0", "axios": "^1.18.1", "clsx": "^2.1.1", - "lucide-react": "^1.18.0", + "lucide-react": "^1.23.0", "react": "19.2.7", "react-dom": "19.2.7", - "react-router-dom": "^7.16.0" + "react-router-dom": "^7.18.1" }, "devDependencies": { - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "autoprefixer": "^10.5.0", - "postcss": "^8.4.31", + "@vitejs/plugin-react": "^6.0.3", + "autoprefixer": "^10.5.2", + "postcss": "^8.5.16", "tailwindcss": "^3.4.17", "typescript": "^6.0.3", "vite": "^8.0.16" @@ -86,18 +86,18 @@ "@heroicons/react": "^2.2.0", "axios": "^1.18.1", "clsx": "^2.1.1", - "lucide-react": "^1.18.0", + "lucide-react": "^1.23.0", "react": "19.2.7", "react-dom": "19.2.7", - "react-router-dom": "^7.16.0" + "react-router-dom": "^7.18.1" }, "devDependencies": { - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "autoprefixer": "^10.5.0", - "postcss": "^8.4.31", + "@vitejs/plugin-react": "^6.0.3", + "autoprefixer": "^10.5.2", + "postcss": "^8.5.16", "tailwindcss": "^3.4.17", "typescript": "^6.0.3", "vite": "^8.0.16" @@ -553,9 +553,9 @@ } }, "node_modules/@types/node": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", - "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "dev": true, "license": "MIT", "dependencies": { @@ -583,13 +583,13 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -655,9 +655,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", - "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", + "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==", "dev": true, "funding": [ { @@ -675,8 +675,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.2", - "caniuse-lite": "^1.0.30001787", + "browserslist": "^4.28.4", + "caniuse-lite": "^1.0.30001799", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -704,9 +704,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.38", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", - "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", "dev": true, "license": "Apache-2.0", "bin": { @@ -743,9 +743,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", "dev": true, "funding": [ { @@ -763,10 +763,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -800,9 +800,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", "dev": true, "funding": [ { @@ -987,9 +987,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.376", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", - "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", + "version": "1.5.388", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.388.tgz", + "integrity": "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw==", "dev": true, "license": "ISC" }, @@ -1351,18 +1351,6 @@ "node": ">=0.12.0" } }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1645,9 +1633,9 @@ "license": "MIT" }, "node_modules/lucide-react": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", - "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", + "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1745,9 +1733,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.48", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", - "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, "license": "MIT", "engines": { @@ -1832,9 +1820,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -2046,9 +2034,9 @@ } }, "node_modules/react-router": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", - "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -2068,12 +2056,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", - "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "license": "MIT", "dependencies": { - "react-router": "7.18.0" + "react-router": "7.18.1" }, "engines": { "node": ">=20.0.0" diff --git a/basaltpass-frontend/package.json b/basaltpass-frontend/package.json index 2afb5ced..da4cb52c 100644 --- a/basaltpass-frontend/package.json +++ b/basaltpass-frontend/package.json @@ -23,18 +23,18 @@ "@heroicons/react": "^2.2.0", "axios": "^1.18.1", "clsx": "^2.1.1", - "lucide-react": "^1.18.0", + "lucide-react": "^1.23.0", "react": "19.2.7", "react-dom": "19.2.7", - "react-router-dom": "^7.16.0" + "react-router-dom": "^7.18.1" }, "devDependencies": { - "@types/node": "^26.0.0", + "@types/node": "^26.1.0", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "autoprefixer": "^10.5.0", - "postcss": "^8.4.31", + "@vitejs/plugin-react": "^6.0.3", + "autoprefixer": "^10.5.2", + "postcss": "^8.5.16", "tailwindcss": "^3.4.17", "typescript": "^6.0.3", "vite": "^8.0.16" diff --git a/basaltpass-frontend/src/features/tenant/app/AppDetail.tsx b/basaltpass-frontend/src/features/tenant/app/AppDetail.tsx index 8fbea553..f9d83e0a 100644 --- a/basaltpass-frontend/src/features/tenant/app/AppDetail.tsx +++ b/basaltpass-frontend/src/features/tenant/app/AppDetail.tsx @@ -14,7 +14,9 @@ import { ShieldCheckIcon, LockClosedIcon, UserGroupIcon, - ArrowRightIcon + ArrowRightIcon, + DocumentCheckIcon, + ArrowsRightLeftIcon } from '@heroicons/react/24/outline' import TenantLayout from '@features/tenant/components/TenantLayout' import { tenantAppApi, TenantApp } from '@api/tenant/tenantApp' @@ -251,7 +253,7 @@ export default function AppDetail() {

-
+
{/* */}
+ + +
+
+ +
+

{t('tenantAppDetail.accessControl.cards.manifestManagement.title')}

+

{t('tenantAppDetail.accessControl.cards.manifestManagement.description')}

+
+
+ +
+
{t('tenantAppDetail.accessControl.cards.manifestManagement.badge')}
+ + + +
+
+ +
+

{t('tenantAppDetail.accessControl.cards.grantMapping.title')}

+

{t('tenantAppDetail.accessControl.cards.grantMapping.description')}

+
+
+ +
+
{t('tenantAppDetail.accessControl.cards.grantMapping.badge')}
+
{/* */} diff --git a/basaltpass-frontend/src/features/tenant/app/AppGrantMappings.tsx b/basaltpass-frontend/src/features/tenant/app/AppGrantMappings.tsx new file mode 100644 index 00000000..79f72304 --- /dev/null +++ b/basaltpass-frontend/src/features/tenant/app/AppGrantMappings.tsx @@ -0,0 +1,222 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { ArrowLeftIcon, ArrowRightIcon, ArrowsRightLeftIcon, PlusIcon, TrashIcon, XMarkIcon } from '@heroicons/react/24/outline' +import TenantLayout from '@features/tenant/components/TenantLayout' +import { PAlert, PBadge, PButton, PCard, PPageHeader, PSkeleton } from '@ui' +import { uiConfirm } from '@contexts/DialogContext' +import { useI18n } from '@shared/i18n' +import { + appGrantMappingApi, + type AppGrantMapping, + type AppGrantMappingInput, + type AppGrantMappingOptions, + type GrantEndpoint, + type GrantSourceType, + type GrantTargetType, +} from '@api/tenant/appGrantMapping' + +const emptyOptions: AppGrantMappingOptions = { membership_roles: [], tenant_roles: [], tenant_permissions: [], app_roles: [], app_permissions: [] } + +function emptyForm(): AppGrantMappingInput { + return { source_type: 'membership_role', source_id: 0, source_code: '', target_type: 'app_role', target_id: 0, enabled: true } +} + +function toLocalDateTime(value?: string) { + if (!value) return '' + const date = new Date(value) + const offset = date.getTimezoneOffset() * 60000 + return new Date(date.getTime() - offset).toISOString().slice(0, 16) +} + +function toPayloadDate(value: string) { + return value ? new Date(value).toISOString() : undefined +} + +export default function AppGrantMappings() { + const { t, locale } = useI18n() + const { id: appId } = useParams<{ id: string }>() + const navigate = useNavigate() + const [mappings, setMappings] = useState([]) + const [options, setOptions] = useState(emptyOptions) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [showEditor, setShowEditor] = useState(false) + const [editing, setEditing] = useState(null) + const [form, setForm] = useState(emptyForm()) + const [validFrom, setValidFrom] = useState('') + const [validUntil, setValidUntil] = useState('') + const [previewCount, setPreviewCount] = useState(null) + + const load = async () => { + if (!appId) return + try { + setLoading(true) + setError('') + const [mappingItems, mappingOptions] = await Promise.all([appGrantMappingApi.list(appId), appGrantMappingApi.options(appId)]) + setMappings(mappingItems) + setOptions(mappingOptions) + } catch (err: any) { + setError(err?.response?.data?.error || t('tenantAppGrantMappings.errors.load')) + } finally { + setLoading(false) + } + } + + useEffect(() => { load() }, [appId]) + + const sourceOptions = useMemo(() => { + if (form.source_type === 'membership_role') return options.membership_roles + if (form.source_type === 'tenant_role') return options.tenant_roles + return options.tenant_permissions + }, [form.source_type, options]) + + const targetOptions = useMemo(() => form.target_type === 'app_role' ? options.app_roles : options.app_permissions, [form.target_type, options]) + + const selectedSource = form.source_type === 'membership_role' ? form.source_code : String(form.source_id || '') + + const setSourceType = (sourceType: GrantSourceType) => { + setForm({ ...form, source_type: sourceType, source_id: 0, source_code: '' }) + setPreviewCount(null) + } + + const setSource = (value: string) => { + setForm({ ...form, source_id: form.source_type === 'membership_role' ? 0 : Number(value), source_code: form.source_type === 'membership_role' ? value : '' }) + setPreviewCount(null) + } + + const openCreate = () => { + setEditing(null) + setForm(emptyForm()) + setValidFrom('') + setValidUntil('') + setPreviewCount(null) + setShowEditor(true) + } + + const openEdit = (mapping: AppGrantMapping) => { + setEditing(mapping) + setForm({ + source_type: mapping.source.type as GrantSourceType, + source_id: mapping.source.type === 'membership_role' ? 0 : Number(mapping.source.id || 0), + source_code: mapping.source.type === 'membership_role' ? mapping.source.code : '', + target_type: mapping.target.type as GrantTargetType, + target_id: Number(mapping.target.id || 0), + enabled: mapping.enabled, + valid_from: mapping.valid_from, + valid_until: mapping.valid_until, + }) + setValidFrom(toLocalDateTime(mapping.valid_from)) + setValidUntil(toLocalDateTime(mapping.valid_until)) + setPreviewCount(mapping.affected_user_count) + setShowEditor(true) + } + + const payload = (): AppGrantMappingInput => ({ ...form, valid_from: toPayloadDate(validFrom), valid_until: toPayloadDate(validUntil) }) + + const isComplete = Boolean(selectedSource && form.target_id) + + const preview = async () => { + if (!appId || !isComplete) return + try { + setPreviewCount(await appGrantMappingApi.preview(appId, payload())) + } catch (err: any) { + setError(err?.response?.data?.error || t('tenantAppGrantMappings.errors.preview')) + } + } + + const save = async () => { + if (!appId || !isComplete) return + if (validFrom && validUntil && new Date(validUntil) <= new Date(validFrom)) { + setError(t('tenantAppGrantMappings.errors.validity')) + return + } + try { + setSaving(true) + setError('') + if (editing) await appGrantMappingApi.update(appId, editing.id, payload()) + else await appGrantMappingApi.create(appId, payload()) + setShowEditor(false) + await load() + } catch (err: any) { + setError(err?.response?.data?.error || t('tenantAppGrantMappings.errors.save')) + } finally { + setSaving(false) + } + } + + const toggle = async (mapping: AppGrantMapping) => { + if (!appId) return + const input: AppGrantMappingInput = { + source_type: mapping.source.type as GrantSourceType, + source_id: mapping.source.type === 'membership_role' ? 0 : Number(mapping.source.id || 0), + source_code: mapping.source.type === 'membership_role' ? mapping.source.code : '', + target_type: mapping.target.type as GrantTargetType, + target_id: Number(mapping.target.id || 0), + enabled: !mapping.enabled, + valid_from: mapping.valid_from, + valid_until: mapping.valid_until, + } + try { + await appGrantMappingApi.update(appId, mapping.id, input) + await load() + } catch (err: any) { + setError(err?.response?.data?.error || t('tenantAppGrantMappings.errors.save')) + } + } + + const remove = async (mapping: AppGrantMapping) => { + if (!appId || !await uiConfirm(t('tenantAppGrantMappings.confirm.delete', { source: mapping.source.name, target: mapping.target.name }))) return + try { + await appGrantMappingApi.remove(appId, mapping.id) + await load() + } catch (err: any) { + setError(err?.response?.data?.error || t('tenantAppGrantMappings.errors.delete')) + } + } + + return +
+ navigate(`/tenant/apps/${appId}`)} leftIcon={}>{t('tenantAppGrantMappings.back')}}>{t('tenantAppGrantMappings.actions.create')}
} /> + {error && } + + {loading ? : mappings.length === 0 ? + {t('tenantAppGrantMappings.empty')} : +
{mappings.map(mapping => +
+
+
{t(`tenantAppGrantMappings.sourceTypes.${mapping.source.type}`)}
{mapping.source.name}
{mapping.source.code}
+ +
{t(`tenantAppGrantMappings.targetTypes.${mapping.target.type}`)}
{mapping.target.name}
{mapping.target.code}
+
+
+ {mapping.enabled ? t('tenantAppGrantMappings.status.enabled') : t('tenantAppGrantMappings.status.disabled')} + {t('tenantAppGrantMappings.affected', { count: mapping.affected_user_count })} + toggle(mapping)}>{mapping.enabled ? t('tenantAppGrantMappings.actions.disable') : t('tenantAppGrantMappings.actions.enable')} + openEdit(mapping)}>{t('tenantAppGrantMappings.actions.edit')} + remove(mapping)} leftIcon={}>{t('tenantAppGrantMappings.actions.delete')} +
+
+ {(mapping.valid_from || mapping.valid_until) &&
{t('tenantAppGrantMappings.validity', { from: mapping.valid_from ? new Date(mapping.valid_from).toLocaleString(locale) : '—', until: mapping.valid_until ? new Date(mapping.valid_until).toLocaleString(locale) : '—' })}
} +
)}
} +
+ + {showEditor &&
+
+

{editing ? t('tenantAppGrantMappings.editor.editTitle') : t('tenantAppGrantMappings.editor.createTitle')}

+
+ + + + + + +
+ + {previewCount !== null &&
{t('tenantAppGrantMappings.editor.previewResult', { count: previewCount })}
} +
setShowEditor(false)}>{t('tenantAppGrantMappings.actions.cancel')}}>{t('tenantAppGrantMappings.actions.preview')}{t('tenantAppGrantMappings.actions.save')}
+
+
} + +} diff --git a/basaltpass-frontend/src/features/tenant/app/AppUserManagement.tsx b/basaltpass-frontend/src/features/tenant/app/AppUserManagement.tsx index a26899c7..e5574cf2 100644 --- a/basaltpass-frontend/src/features/tenant/app/AppUserManagement.tsx +++ b/basaltpass-frontend/src/features/tenant/app/AppUserManagement.tsx @@ -20,6 +20,7 @@ import TenantLayout from '@features/tenant/components/TenantLayout' import { tenantAppApi } from '@api/tenant/tenantApp' import { appUserApi, type AppUser, type AppUsersResponse } from '@api/tenant/appUser' import { userPermissionsApi, type Permission, type Role, type UserPermission, type UserRole } from '@api/tenant/appPermissions' +import { appGrantMappingApi, type EffectiveGrants, type GrantSource } from '@api/tenant/appGrantMapping' import useDebounce from '@hooks/useDebounce' import useManagedPaginationBar from '@hooks/useManagedPaginationBar' import { PSkeleton, PBadge, PPageHeader, PButton, PManagementFilterCard, PManagedTableSection, PSelect, PManagementPageContainer, PTextarea } from '@ui' @@ -54,6 +55,7 @@ export default function AppUserManagement() { const [roles, setRoles] = useState([]) const [userPermissions, setUserPermissions] = useState([]) const [userRoles, setUserRoles] = useState([]) + const [effectiveGrants, setEffectiveGrants] = useState(null) const [selectedPermissions, setSelectedPermissions] = useState([]) const [selectedRoles, setSelectedRoles] = useState([]) const [permissionExpiry, setPermissionExpiry] = useState('') @@ -123,12 +125,14 @@ export default function AppUserManagement() { try { setLoadingPermissions(true) - const [permissionsRes, rolesRes] = await Promise.all([ + const [permissionsRes, rolesRes, effective] = await Promise.all([ userPermissionsApi.getUserPermissions(appId, userId), - userPermissionsApi.getUserRoles(appId, userId) + userPermissionsApi.getUserRoles(appId, userId), + appGrantMappingApi.effectiveGrants(appId, userId) ]) setUserPermissions(permissionsRes.permissions || []) setUserRoles(rolesRes.roles || []) + setEffectiveGrants(effective) } catch (err: any) { console.error(t('tenantAppUserManagement.logs.fetchUserPermissionsFailed'), err) } finally { @@ -144,6 +148,10 @@ export default function AppUserManagement() { await fetchUserPermissions(user.user_id.toString()) } + const grantSourceLabel = (source: GrantSource) => source.type === 'explicit' + ? t('tenantAppUserManagement.permissionModal.sourceExplicit') + : t('tenantAppUserManagement.permissionModal.sourceMapping', { source: source.source_code || source.source_type || '', mapping: source.mapping_id || 0 }) + // const handleGrantPermissions = async () => { if (!selectedUser || !appId || selectedPermissions.length === 0) return @@ -638,6 +646,17 @@ export default function AppUserManagement() { ) : (
+
+
+

{t('tenantAppUserManagement.permissionModal.effectiveTitle')}

{t('tenantAppUserManagement.permissionModal.effectiveDescription')}

+ navigate(`/tenant/apps/${appId}/rbac-mappings`)}>{t('tenantAppUserManagement.permissionModal.manageMappings')} +
+ {effectiveGrants && !effectiveGrants.eligible ?
{t('tenantAppUserManagement.permissionModal.ineligible', { reason: effectiveGrants.denial_reason || 'unknown' })}
: +
+
{t('tenantAppUserManagement.permissionModal.effectiveRoles', { count: effectiveGrants?.roles.length || 0 })}
{effectiveGrants?.roles.map(role =>
{role.name} {role.code}
{role.sources.map((source, index) => {grantSourceLabel(source)})}
)}{!effectiveGrants?.roles.length && {t('tenantAppUserManagement.permissionModal.noRoles')}}
+
{t('tenantAppUserManagement.permissionModal.effectivePermissions', { count: effectiveGrants?.permissions.length || 0 })}
{effectiveGrants?.permissions.map(permission =>
{permission.name} {permission.code}
{permission.sources.map((source, index) => {grantSourceLabel(source)}{source.via_role_code ? ` · ${source.via_role_code}` : ''})}
)}{!effectiveGrants?.permissions.length && {t('tenantAppUserManagement.permissionModal.noPermissions')}}
+
} +
{/* : */}
{/* */} diff --git a/basaltpass-frontend/src/features/tenant/app/RBACManifests.tsx b/basaltpass-frontend/src/features/tenant/app/RBACManifests.tsx new file mode 100644 index 00000000..c603c5d7 --- /dev/null +++ b/basaltpass-frontend/src/features/tenant/app/RBACManifests.tsx @@ -0,0 +1,294 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { + ArrowLeftIcon, + ArrowPathIcon, + CheckCircleIcon, + ClockIcon, + CodeBracketSquareIcon, + DocumentCheckIcon, + ExclamationTriangleIcon, + ShieldCheckIcon, + XCircleIcon, +} from '@heroicons/react/24/outline' +import TenantLayout from '@features/tenant/components/TenantLayout' +import { tenantAppApi, type TenantApp } from '@api/tenant/tenantApp' +import { + tenantRBACManifestApi, + type RBACManifestDiff, + type RBACManifestRecord, + type RBACManifestStatus, + type RBACRevisionRecord, +} from '@api/tenant/rbacManifest' +import { uiConfirm } from '@contexts/DialogContext' +import { PAlert, PBadge, PButton, PCard, PPageHeader, PSkeleton } from '@ui' +import { useI18n } from '@shared/i18n' + +const emptyDiff: RBACManifestDiff = { + has_changes: false, + permissions_added: [], + permissions_updated: [], + permissions_removed: [], + roles_added: [], + roles_updated: [], + roles_removed: [], + role_permissions_added: [], + role_permissions_removed: [], + assigned_roles_affected: [], + removal_assignment_blocks: [], +} + +function statusVariant(status: RBACManifestStatus) { + if (status === 'approved') return 'success' + if (status === 'pending') return 'warning' + if (status === 'rejected') return 'error' + return 'default' +} + +function DiffGroup({ title, values, tone = 'default' }: { title: string; values: string[]; tone?: 'default' | 'add' | 'remove' | 'warning' }) { + if (!values.length) return null + const styles = { + default: 'border-gray-200 bg-gray-50 text-gray-700', + add: 'border-green-200 bg-green-50 text-green-800', + remove: 'border-red-200 bg-red-50 text-red-800', + warning: 'border-yellow-200 bg-yellow-50 text-yellow-800', + } + return ( +
+
{title} · {values.length}
+
+ {values.map((value) => {value})} +
+
+ ) +} + +function ManifestDiff({ diff = emptyDiff, labels }: { diff?: RBACManifestDiff; labels: Record }) { + return ( +
+ + + + + + + + + + +
+ ) +} + +export default function RBACManifests() { + const { id } = useParams<{ id: string }>() + const { t, locale } = useI18n() + const [app, setApp] = useState(null) + const [manifests, setManifests] = useState([]) + const [revisions, setRevisions] = useState([]) + const [expanded, setExpanded] = useState(null) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(null) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + + const labels = useMemo(() => ({ + permissionsAdded: t('tenantRBACManifests.diff.permissionsAdded'), + permissionsUpdated: t('tenantRBACManifests.diff.permissionsUpdated'), + permissionsRemoved: t('tenantRBACManifests.diff.permissionsRemoved'), + rolesAdded: t('tenantRBACManifests.diff.rolesAdded'), + rolesUpdated: t('tenantRBACManifests.diff.rolesUpdated'), + rolesRemoved: t('tenantRBACManifests.diff.rolesRemoved'), + rolePermissionsAdded: t('tenantRBACManifests.diff.rolePermissionsAdded'), + rolePermissionsRemoved: t('tenantRBACManifests.diff.rolePermissionsRemoved'), + assignedRolesAffected: t('tenantRBACManifests.diff.assignedRolesAffected'), + removalBlocks: t('tenantRBACManifests.diff.removalBlocks'), + }), [t]) + + const load = useCallback(async () => { + if (!id) return + setLoading(true) + setError('') + try { + const [appResponse, manifestResponse, revisionResponse] = await Promise.all([ + tenantAppApi.getTenantApp(id), + tenantRBACManifestApi.list(id), + tenantRBACManifestApi.listRevisions(id), + ]) + setApp(appResponse.data) + setManifests(manifestResponse) + setRevisions(revisionResponse) + setExpanded((current) => current ?? manifestResponse.find((item) => item.status === 'pending')?.id ?? manifestResponse[0]?.id ?? null) + } catch (err: any) { + setError(err.response?.data?.error || t('tenantRBACManifests.errors.load')) + } finally { + setLoading(false) + } + }, [id, t]) + + useEffect(() => { void load() }, [load]) + + const approve = async (manifest: RBACManifestRecord) => { + if (!id) return + if (manifest.diff.removal_assignment_blocks?.length) { + setError(t('tenantRBACManifests.errors.blocked')) + return + } + if (!await uiConfirm(t('tenantRBACManifests.confirm.approve', { revision: manifest.source_revision }))) return + setBusy(`approve-${manifest.id}`) + setError('') + setSuccess('') + try { + await tenantRBACManifestApi.approve(id, manifest.id) + setSuccess(t('tenantRBACManifests.success.approved', { revision: manifest.source_revision })) + await load() + } catch (err: any) { + setError(err.response?.data?.error || t('tenantRBACManifests.errors.approve')) + } finally { + setBusy(null) + } + } + + const reject = async (manifest: RBACManifestRecord) => { + if (!id || !await uiConfirm(t('tenantRBACManifests.confirm.reject', { revision: manifest.source_revision }))) return + setBusy(`reject-${manifest.id}`) + setError('') + try { + await tenantRBACManifestApi.reject(id, manifest.id) + setSuccess(t('tenantRBACManifests.success.rejected', { revision: manifest.source_revision })) + await load() + } catch (err: any) { + setError(err.response?.data?.error || t('tenantRBACManifests.errors.reject')) + } finally { + setBusy(null) + } + } + + const rollback = async (revision: RBACRevisionRecord) => { + if (!id || revision.is_active || !await uiConfirm(t('tenantRBACManifests.confirm.rollback', { revision: revision.revision }))) return + setBusy(`rollback-${revision.id}`) + setError('') + try { + await tenantRBACManifestApi.rollback(id, revision.id) + setSuccess(t('tenantRBACManifests.success.rolledBack', { revision: revision.revision })) + await load() + } catch (err: any) { + setError(err.response?.data?.error || t('tenantRBACManifests.errors.rollback')) + } finally { + setBusy(null) + } + } + + if (loading) { + return
+ } + + return ( + +
+
+
+ + {t('tenantRBACManifests.back')} + + +
+ void load()} leftIcon={}> + {t('tenantRBACManifests.refresh')} + +
+ + {error && setError('')} />} + {success && setSuccess('')} />} + + +
+

{t('tenantRBACManifests.integration.description')}

+
+ POST /api/v1/s2s/rbac/manifests + scope: s2s.rbac.manifest.submit +
+
+
+ +
+
+

{t('tenantRBACManifests.manifests.title')}

+ {manifests.length} +
+ {!manifests.length ? ( + + +

{t('tenantRBACManifests.manifests.emptyTitle')}

+

{t('tenantRBACManifests.manifests.emptyDescription')}

+
+ ) : manifests.map((manifest) => { + const open = expanded === manifest.id + const blocked = Boolean(manifest.diff.removal_assignment_blocks?.length) + return ( + + + {open && ( +
+ {!manifest.diff.has_changes && } + {blocked && } + +
+
{manifest.manifest.permissions.length} {t('tenantRBACManifests.manifests.permissions')} · {manifest.manifest.roles.length} {t('tenantRBACManifests.manifests.roles')}
+ {manifest.status === 'pending' && ( +
+ void reject(manifest)}>{t('tenantRBACManifests.actions.reject')} + } onClick={() => void approve(manifest)}>{t('tenantRBACManifests.actions.approve')} +
+ )} +
+
+ )} +
+ ) + })} +
+ +
+

{t('tenantRBACManifests.revisions.title')}

+ + {!revisions.length ?
{t('tenantRBACManifests.revisions.empty')}
: ( +
+ {revisions.map((revision) => ( +
+
+ {revision.is_active ? : } +
+
#{revision.revision}{revision.is_active ? t('tenantRBACManifests.revisions.active') : t(`tenantRBACManifests.revisions.${revision.action}`)}
+
{new Date(revision.created_at).toLocaleString(locale)} · {revision.digest.slice(0, 12)}
+
+
+ {!revision.is_active && } onClick={() => void rollback(revision)}>{t('tenantRBACManifests.actions.rollback')}} +
+ ))} +
+ )} +
+ + {t('tenantRBACManifests.revisions.safetyDescription')} + +
+
+
+ ) +} diff --git a/basaltpass-frontend/src/features/user/apps/AppRecharge.tsx b/basaltpass-frontend/src/features/user/apps/AppRecharge.tsx new file mode 100644 index 00000000..d4b177b9 --- /dev/null +++ b/basaltpass-frontend/src/features/user/apps/AppRecharge.tsx @@ -0,0 +1,371 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate, useParams, useSearchParams } from 'react-router-dom' +import { getAppRechargeConfig, type AppRechargeConfig, type AppRechargeCurrency } from '@api/user/appRecharge' +import { getCurrencies, getCurrencyRates, type Currency, type CurrencyRate } from '@api/user/currency' +import { paymentAPI } from '@api/subscription/payment/payment' +import Layout from '@features/user/components/Layout' +import { PAlert, PButton, PCard, PInput, PPageHeader, PSkeleton } from '@ui' +import { ROUTES } from '@constants' +import { useConfig } from '@contexts/ConfigContext' +import { useI18n } from '@shared/i18n' +import { ArrowUpIcon, BanknotesIcon } from '@heroicons/react/24/outline' + +const formatAmount = (amount: number, currency: Currency): string => + amount.toFixed(Math.min(currency.decimal_places, 8)) + +const getAmountInputConstraints = (currency: Currency | null) => { + if (!currency) return { min: '0.01', step: '0.01' } + const decimals = Math.max(0, currency.decimal_places || 0) + if (decimals === 0) return { min: '1', step: '1' } + return { + min: (1 / Math.pow(10, decimals)).toFixed(decimals), + step: (1 / Math.pow(10, decimals)).toFixed(decimals), + } +} + +const fromSmallestUnit = (value: number, currency: Currency): number => + value / Math.pow(10, currency.decimal_places) + +const resolveExchangeRate = ( + targetCurrency: Currency, + paymentCurrency: Currency, + rates: CurrencyRate[], +): number | null => { + if (targetCurrency.code === paymentCurrency.code) return 1 + const exact = rates.find((rate) => + rate.base_currency_code === targetCurrency.code && + rate.quote_currency_code === paymentCurrency.code && + rate.is_active !== false && + Number(rate.rate) > 0 + ) + if (exact) return Number(exact.rate) + const inverse = rates.find((rate) => + rate.base_currency_code === paymentCurrency.code && + rate.quote_currency_code === targetCurrency.code && + rate.is_active !== false && + Number(rate.rate) > 0 + ) + if (inverse) return 1 / Number(inverse.rate) + const targetRate = Number(targetCurrency.exchange_rate_usd || 0) + const paymentRate = Number(paymentCurrency.exchange_rate_usd || 0) + if (targetRate > 0 && paymentRate > 0) return targetRate / paymentRate + return null +} + +const calculatePaymentAmount = ( + targetAmount: number, + targetCurrency: Currency | null, + paymentCurrency: Currency | null, + rates: CurrencyRate[], +) => { + if (!targetCurrency || !paymentCurrency || !targetAmount || targetAmount <= 0) return null + const rate = resolveExchangeRate(targetCurrency, paymentCurrency, rates) + if (!rate || rate <= 0) return null + const amount = targetAmount * rate + return { + amount, + smallestUnit: Math.max(1, Math.ceil(amount * Math.pow(10, paymentCurrency.decimal_places) - 1e-9)), + rate, + } +} + +const getQuickAmounts = (currency: Currency): number[] => { + if (currency.type === 'points') return [100000, 500000, 1000000, 5000000, 10000000, 50000000] + if (currency.type === 'crypto') return [1, 10, 50, 100, 500, 1000] + return [50, 100, 200, 500, 1000, 2000] +} + +export default function AppRecharge() { + const { t } = useI18n() + const { walletRechargeWithdrawEnabled } = useConfig() + const navigate = useNavigate() + const params = useParams() + const [searchParams] = useSearchParams() + const [config, setConfig] = useState(null) + const [selectedCurrency, setSelectedCurrency] = useState(null) + const [paymentCurrencies, setPaymentCurrencies] = useState([]) + const [paymentCurrency, setPaymentCurrency] = useState(null) + const [currencyRates, setCurrencyRates] = useState([]) + const [amount, setAmount] = useState('') + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + + const walletOpsDisabled = !walletRechargeWithdrawEnabled + const returnUrl = searchParams.get('return_url') || '' + const targetAmount = Number(amount) + const estimatedPayment = useMemo( + () => calculatePaymentAmount(targetAmount, selectedCurrency, paymentCurrency, currencyRates), + [currencyRates, targetAmount, selectedCurrency, paymentCurrency], + ) + const amountInputConstraints = getAmountInputConstraints(selectedCurrency) + + useEffect(() => { + const load = async () => { + setLoading(true) + setError('') + try { + const clientId = searchParams.get('client_id') || undefined + const appId = params.id || searchParams.get('app_id') || undefined + const tenant = searchParams.get('tenant') || undefined + const [rechargeResponse, currenciesResponse, ratesResponse] = await Promise.all([ + getAppRechargeConfig({ app_id: appId, client_id: clientId, category: 'top_up', tenant }), + getCurrencies(), + getCurrencyRates(), + ]) + const nextConfig = rechargeResponse.data + setConfig(nextConfig) + const defaultCurrency = nextConfig.currencies.find((currency) => currency.is_default) || nextConfig.currencies[0] || null + setSelectedCurrency(defaultCurrency) + const fiatPaymentCurrencies = currenciesResponse.data.filter((currency) => currency.type === 'fiat' && currency.payment_enabled) + setPaymentCurrencies(fiatPaymentCurrencies) + setPaymentCurrency(fiatPaymentCurrencies.find((currency) => currency.code === 'USD') || fiatPaymentCurrencies[0] || null) + setCurrencyRates(ratesResponse.data) + } catch (e: any) { + setError(e.response?.data?.error || t('pages.appRecharge.errors.loadFailed')) + } finally { + setLoading(false) + } + } + void load() + }, [params.id, searchParams, t]) + + useEffect(() => { + if (!selectedCurrency || paymentCurrencies.length === 0) return + if (selectedCurrency.type === 'fiat') { + setPaymentCurrency(paymentCurrencies.find((currency) => currency.code === selectedCurrency.code) || paymentCurrencies[0]) + } else if (!paymentCurrency) { + setPaymentCurrency(paymentCurrencies.find((currency) => currency.code === 'USD') || paymentCurrencies[0]) + } + }, [paymentCurrencies, paymentCurrency, selectedCurrency]) + + const submit = async (event: React.FormEvent) => { + event.preventDefault() + if (walletOpsDisabled) { + setError(t('pages.walletRecharge.errors.disabled')) + return + } + if (!selectedCurrency) { + setError(t('pages.appRecharge.errors.noCurrency')) + return + } + if (!amount || Number(amount) <= 0) { + setError(t('pages.walletRecharge.errors.invalidAmount')) + return + } + if (!paymentCurrency) { + setError(t('pages.walletRecharge.errors.selectPaymentCurrency')) + return + } + if (!estimatedPayment) { + setError(t('pages.walletRecharge.errors.exchangeRateMissing')) + return + } + + setSubmitting(true) + setError('') + try { + const amountInSmallestUnit = Math.round(Number(amount) * Math.pow(10, selectedCurrency.decimal_places)) + const chargeCurrency = paymentCurrency.code + const chargeAmount = estimatedPayment.smallestUnit + const appName = config?.app.name || 'App' + + const intentResponse = await paymentAPI.createPaymentIntent({ + amount: chargeAmount, + currency: chargeCurrency, + description: `${appName} top up ${amount} ${selectedCurrency.code}`, + payment_method_types: ['card'], + confirmation_method: 'automatic', + capture_method: 'automatic', + metadata: { + source: 'wallet_recharge', + checkout_kind: 'top_up', + app_id: config?.app.id ? String(config.app.id) : '', + app_name: appName, + wallet_currency: selectedCurrency.code, + target_wallet_currency: selectedCurrency.code, + target_wallet_amount: String(amountInSmallestUnit), + charge_currency: chargeCurrency, + charge_amount: String(chargeAmount), + }, + }) + + const currentPath = `${window.location.pathname}${window.location.search}` + const successUrl = `${window.location.origin}${ROUTES.user.appRechargeSuccess}?session_id={CHECKOUT_SESSION_ID}${ + returnUrl ? `&return_url=${encodeURIComponent(returnUrl)}` : '' + }` + const sessionResponse = await paymentAPI.createPaymentSession({ + payment_intent_id: intentResponse.payment_intent.ID, + success_url: successUrl, + cancel_url: `${window.location.origin}${currentPath}`, + user_email: '', + }) + + navigate(`/checkout?kind=top_up&session=${encodeURIComponent(sessionResponse.session.StripeSessionID)}`) + } catch (e: any) { + setError(e.response?.data?.error || t('pages.walletRecharge.errors.rechargeFailed')) + } finally { + setSubmitting(false) + } + } + + if (loading) { + return ( + +
+ +
+
+ ) + } + + return ( + +
+ + + {walletOpsDisabled && } + {error && } + + {config && config.currencies.length === 0 ? ( + +
{t('pages.appRecharge.empty')}
+
+ ) : ( +
+ +
+
+ +

{t('pages.appRecharge.form.title')}

+
+ +
+
+ + +
+ + {selectedCurrency && selectedCurrency.type !== 'fiat' && ( +
+ + +
+ )} + + { + setAmount(event.target.value) + setError('') + }} + placeholder={t('pages.walletRecharge.form.amountPlaceholder')} + min={amountInputConstraints.min} + step={amountInputConstraints.step} + /> + + {selectedCurrency && ( +
+ +
+ {getQuickAmounts(selectedCurrency).map((value) => ( + { + setAmount(value.toString()) + setError('') + }} + variant={amount === value.toString() ? 'primary' : 'secondary'} + size="sm" + > + {selectedCurrency.symbol}{formatAmount(value, selectedCurrency)} + + ))} +
+
+ )} + + {selectedCurrency && paymentCurrency && estimatedPayment && ( +
+
{t('pages.walletRecharge.form.paymentEstimateTitle')}
+
+ {t('pages.walletRecharge.form.paymentEstimate', { + target: `${formatAmount(targetAmount, selectedCurrency)} ${selectedCurrency.code}`, + payment: `${paymentCurrency.symbol}${fromSmallestUnit(estimatedPayment.smallestUnit, paymentCurrency).toFixed(paymentCurrency.decimal_places)} ${paymentCurrency.code}`, + })} +
+
+ )} + + + {t('pages.walletRecharge.form.continueToCheckout')} + + +
+
+ + +
+
+ +

{t('pages.appRecharge.guide.title')}

+
+
+

{t('pages.appRecharge.guide.body', { app: config?.app.name || t('pages.appRecharge.header.appFallback') })}

+

{t('pages.appRecharge.guide.currencyCount', { count: config?.currencies.length || 0 })}

+
+
+
+
+ )} +
+
+ ) +} diff --git a/basaltpass-frontend/src/features/user/apps/AppRechargeSuccess.tsx b/basaltpass-frontend/src/features/user/apps/AppRechargeSuccess.tsx new file mode 100644 index 00000000..305a989b --- /dev/null +++ b/basaltpass-frontend/src/features/user/apps/AppRechargeSuccess.tsx @@ -0,0 +1,169 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { CheckCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid' +import Layout from '@features/user/components/Layout' +import { paymentAPI, type PaymentSession } from '@api/subscription/payment/payment' +import { ROUTES } from '@constants' +import { useI18n } from '@shared/i18n' +import { PAlert, PButton, PCard, PSkeleton } from '@ui' + +const REDIRECT_DELAY_SECONDS = 10 + +const getReturnURL = (raw: string | null) => { + if (!raw) return '' + try { + const url = new URL(raw) + if (url.protocol !== 'https:' && url.protocol !== 'http:') return '' + return url.toString() + } catch { + return '' + } +} + +const getReturnHost = (raw: string) => { + try { + return new URL(raw).host + } catch { + return '' + } +} + +const getAppName = (session: PaymentSession | null) => { + const metadata = session?.PaymentIntent?.Metadata + if (!metadata) return '' + try { + const parsed = JSON.parse(metadata) + return typeof parsed.app_name === 'string' ? parsed.app_name : '' + } catch { + return '' + } +} + +export default function AppRechargeSuccess() { + const { t } = useI18n() + const [searchParams] = useSearchParams() + const sessionId = searchParams.get('session_id') || '' + const returnURL = useMemo(() => getReturnURL(searchParams.get('return_url')), [searchParams]) + const returnHost = useMemo(() => getReturnHost(returnURL), [returnURL]) + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [countdown, setCountdown] = useState(REDIRECT_DELAY_SECONDS) + + useEffect(() => { + let active = true + const reconcile = async () => { + if (!sessionId) { + setError(t('pages.appRecharge.success.errors.missingSession')) + setLoading(false) + return + } + setLoading(true) + setError('') + try { + const response = await paymentAPI.reconcileWalletTopUpSession(sessionId) + if (!active) return + setSession(response.session) + if (response.session.Status !== 'complete') { + setError(t('pages.appRecharge.success.errors.notComplete')) + } + } catch (e: any) { + if (active) setError(e.response?.data?.error || t('pages.appRecharge.success.errors.reconcileFailed')) + } finally { + if (active) setLoading(false) + } + } + + void reconcile() + return () => { + active = false + } + }, [sessionId, t]) + + useEffect(() => { + if (loading || error || !returnURL || session?.Status !== 'complete') return + setCountdown(REDIRECT_DELAY_SECONDS) + const interval = window.setInterval(() => { + setCountdown((value) => { + if (value <= 1) { + window.clearInterval(interval) + window.location.href = returnURL + return 0 + } + return value - 1 + }) + }, 1000) + + return () => window.clearInterval(interval) + }, [error, loading, returnURL, session?.Status]) + + const appName = getAppName(session) + + return ( + +
+ + {loading ? ( + + ) : error ? ( +
+
+ +
+
+

{t('pages.appRecharge.success.pendingTitle')}

+

{t('pages.appRecharge.success.pendingDescription')}

+
+ +
+ window.location.reload()}> + {t('pages.appRecharge.success.actions.retry')} + + + + {t('pages.appRecharge.success.actions.wallet')} + + +
+
+ ) : ( +
+
+ +
+
+

{t('pages.appRecharge.success.title')}

+

+ {t('pages.appRecharge.success.description', { app: appName || t('pages.appRecharge.header.appFallback') })} +

+
+ + {returnURL ? ( +
+ {t('pages.appRecharge.success.redirecting', { seconds: countdown, host: returnHost })} +
+ ) : ( +
+ {t('pages.appRecharge.success.noReturnUrl')} +
+ )} + +
+ {returnURL && ( + { window.location.href = returnURL }}> + {t('pages.appRecharge.success.actions.returnNow')} + + )} + + + {t('pages.appRecharge.success.actions.wallet')} + + +
+
+ )} +
+
+
+ ) +} diff --git a/basaltpass-frontend/src/features/user/payment/Cashier.tsx b/basaltpass-frontend/src/features/user/payment/Cashier.tsx new file mode 100644 index 00000000..77338d89 --- /dev/null +++ b/basaltpass-frontend/src/features/user/payment/Cashier.tsx @@ -0,0 +1,118 @@ +import { useEffect, useMemo, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { CreditCardIcon, WalletIcon } from '@heroicons/react/24/outline' +import Layout from '@features/user/components/Layout' +import { paymentAPI, PaymentSession } from '@api/subscription/payment/payment' +import { PAlert, PButton, PCard, PPageHeader } from '@ui' + +const getSessionId = (session: PaymentSession | null) => + session ? (session.StripeSessionID || (session as any).stripe_session_id || '') : '' + +const formatMinor = (amount: number, currency: string) => { + const divisor = ['jpy', 'krw'].includes(currency.toLowerCase()) ? 1 : 100 + return (amount / divisor).toFixed(divisor === 1 ? 0 : 2) +} + +export default function Cashier() { + const [params] = useSearchParams() + const navigate = useNavigate() + const sessionId = params.get('session') || '' + const kind = params.get('kind') || 'checkout' + const [session, setSession] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + let active = true + const load = async () => { + if (!sessionId) { + setError('Missing checkout session') + setLoading(false) + return + } + try { + const data = await paymentAPI.getPaymentSession(sessionId) + if (active) setSession(data) + } catch (e: any) { + if (active) setError(e.response?.data?.error || e.message || 'Unable to load checkout session') + } finally { + if (active) setLoading(false) + } + } + load() + return () => { + active = false + } + }, [sessionId]) + + const title = useMemo(() => { + if (kind === 'top_up') return 'Wallet top-up' + if (kind === 'subscription') return 'Subscription checkout' + return 'Checkout' + }, [kind]) + + const payWithStripe = () => { + const id = getSessionId(session) + if (!id) return + window.location.href = paymentAPI.getPaymentCheckoutUrl(id) + } + + return ( + +
+ + + {error && } + + + {loading ? ( +
Loading checkout...
+ ) : session ? ( +
+
+
+ {kind === 'top_up' ? : } +
+
+
Amount due
+
+ {formatMinor(session.Amount || 0, session.Currency || 'USD')} {session.Currency} +
+
+
+ +
+ + + +
+ +
+ navigate(-1)}>Back +
+
+ ) : null} +
+
+
+ ) +} diff --git a/basaltpass-frontend/src/features/user/payment/Payment.tsx b/basaltpass-frontend/src/features/user/payment/Payment.tsx index 3c7cac11..6cce03cf 100644 --- a/basaltpass-frontend/src/features/user/payment/Payment.tsx +++ b/basaltpass-frontend/src/features/user/payment/Payment.tsx @@ -90,7 +90,7 @@ const Payment: React.FC = () => { try { const sessionRequest = { payment_intent_id: paymentIntent.ID, - success_url: `${window.location.origin}/wallet?payment=success`, + success_url: `${window.location.origin}/wallet?topup=success&session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${window.location.origin}/payment?payment=canceled`, user_email: '' }; diff --git a/basaltpass-frontend/src/features/user/subscription/Checkout.tsx b/basaltpass-frontend/src/features/user/subscription/Checkout.tsx index 12e7d03e..68ee61b7 100644 --- a/basaltpass-frontend/src/features/user/subscription/Checkout.tsx +++ b/basaltpass-frontend/src/features/user/subscription/Checkout.tsx @@ -58,6 +58,8 @@ const SubscriptionCheckout: React.FC = () => { price_id: selectedPrice.id, quantity: quantity, coupon_code: couponCode || undefined, + success_url: `${window.location.origin}${ROUTES.user.subscriptions}?payment=success`, + cancel_url: `${window.location.origin}/subscriptions/checkout?payment=canceled`, }); setCheckoutResponse(response); @@ -65,7 +67,8 @@ const SubscriptionCheckout: React.FC = () => { // , if (response.payment_session) { - window.open(subscriptionAPI.getPaymentCheckoutUrl(response.payment_session.stripe_session_id), '_blank'); + const sessionId = response.payment_session.stripe_session_id || response.payment_session.StripeSessionID; + navigate(`/checkout?kind=subscription&session=${encodeURIComponent(sessionId)}`); } else { // , setStep('success'); @@ -245,7 +248,10 @@ const SubscriptionCheckout: React.FC = () => { {t('pages.userSubscriptionCheckout.result.paymentHint')}

window.open(subscriptionAPI.getPaymentCheckoutUrl(checkoutResponse.payment_session.stripe_session_id), '_blank')} + onClick={() => { + const sessionId = checkoutResponse.payment_session.stripe_session_id || checkoutResponse.payment_session.StripeSessionID; + navigate(`/checkout?kind=subscription&session=${encodeURIComponent(sessionId)}`); + }} variant="secondary" > {t('pages.userSubscriptionCheckout.result.reopenPayment')} diff --git a/basaltpass-frontend/src/features/user/subscription/ProductDetail.tsx b/basaltpass-frontend/src/features/user/subscription/ProductDetail.tsx new file mode 100644 index 00000000..d1122213 --- /dev/null +++ b/basaltpass-frontend/src/features/user/subscription/ProductDetail.tsx @@ -0,0 +1,328 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' +import Layout from '@features/user/components/Layout' +import { PBadge, PButton, PCard, PEmptyState, PPageHeader, PSkeleton } from '@ui' +import { subscriptionAPI } from '@api/subscription/subscription' +import { createOrder, CreateOrderRequest } from '@api/subscription/payment/order' +import client from '@api/client' +import { Product, Plan, Price } from '@types/domain/subscription' +import { ROUTES } from '@constants' +import { useI18n } from '@shared/i18n' +import { + ArrowLeftIcon, + ArrowRightIcon, + CheckIcon, + CreditCardIcon, + CubeIcon, + SparklesIcon, +} from '@heroicons/react/24/outline' + +function unwrapProduct(raw: any): Product | null { + if (!raw) return null + if (raw.ID || raw.id) return raw as Product + if (raw.data?.ID || raw.data?.id) return raw.data as Product + if (raw.data?.data?.ID || raw.data?.data?.id) return raw.data.data as Product + return null +} + +function unwrapProducts(raw: any): Product[] { + if (Array.isArray(raw)) return raw as Product[] + if (Array.isArray(raw?.data)) return raw.data as Product[] + if (Array.isArray(raw?.data?.data)) return raw.data.data as Product[] + if (Array.isArray(raw?.data?.Data)) return raw.data.Data as Product[] + return [] +} + +function priceId(price: Price): number { + return price.ID ?? (price as any).id +} + +function planId(plan: Plan): number { + return plan.ID ?? (plan as any).id +} + +export default function ProductDetailPage() { + const { t } = useI18n() + const { productId = '', productCode = '' } = useParams() + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const [product, setProduct] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selectedPriceId, setSelectedPriceId] = useState(null) + const [subscribingPrice, setSubscribingPrice] = useState(null) + + useEffect(() => { + const loadProduct = async () => { + const numericProductId = Number(productId) + if (!productCode && (!Number.isFinite(numericProductId) || numericProductId <= 0)) { + setError('Invalid product link') + setLoading(false) + return + } + + try { + setLoading(true) + setError(null) + const raw = productCode + ? await client.get('/api/v1/products', { params: { code: productCode, page_size: 50 } }) + : await subscriptionAPI.getProduct(numericProductId) + const nextProduct = productCode + ? unwrapProducts(raw.data).find((item) => item.Code === productCode) || null + : unwrapProduct(raw) + if (!nextProduct) { + setError('Product not found') + return + } + setProduct(nextProduct) + } catch (err: any) { + console.error('failed to load product:', err) + setError(err.response?.data?.error || err.message || 'Failed to load product') + } finally { + setLoading(false) + } + } + + loadProduct() + }, [productCode, productId]) + + const availablePrices = useMemo(() => { + return (product?.Plans || []).flatMap((plan) => + (plan.Prices || []).map((price) => ({ plan, price })), + ) + }, [product]) + + useEffect(() => { + if (availablePrices.length === 0 || selectedPriceId !== null) return + const requestedPriceId = Number(searchParams.get('price_id') || searchParams.get('price')) + const requestedPlanId = Number(searchParams.get('plan_id') || searchParams.get('plan')) + const exactPrice = availablePrices.find(({ price }) => priceId(price) === requestedPriceId) + const planFirstPrice = availablePrices.find(({ plan }) => planId(plan) === requestedPlanId) + const initialPrice = exactPrice || planFirstPrice || availablePrices[0] + setSelectedPriceId(priceId(initialPrice.price)) + }, [availablePrices, searchParams, selectedPriceId]) + + const selectedEntry = availablePrices.find(({ price }) => priceId(price) === selectedPriceId) + + const formatPrice = (price: Price) => { + const amount = (price.AmountCents / 100).toFixed(2) + const period = price.BillingPeriod === 'month' ? t('pages.userSubscriptionProducts.period.month') : + price.BillingPeriod === 'year' ? t('pages.userSubscriptionProducts.period.year') : + price.BillingPeriod === 'week' ? t('pages.userSubscriptionProducts.period.week') : + price.BillingPeriod === 'day' ? t('pages.userSubscriptionProducts.period.day') : price.BillingPeriod + const interval = price.BillingInterval > 1 ? `${price.BillingInterval}` : '' + return `¥${amount}/${interval}${period}` + } + + const usageText = (price: Price) => { + if (price.UsageType === 'license') return t('pages.userSubscriptionProducts.usage.license') + if (price.UsageType === 'metered') return t('pages.userSubscriptionProducts.usage.metered') + return t('pages.userSubscriptionProducts.usage.tiered') + } + + const handleSubscribe = async () => { + if (!selectedEntry) return + + const selectedPrice = selectedEntry.price + const selectedPriceID = priceId(selectedPrice) + try { + setSubscribingPrice(selectedPriceID) + const userResponse = await client.get('/api/v1/user/profile') + const user = userResponse.data + const orderData: CreateOrderRequest = { + user_id: user.id, + price_id: selectedPriceID, + quantity: 1, + } + const order = await createOrder(orderData) + navigate(`/orders/${order.id}/confirm`) + } catch (err: any) { + console.error('failed to create order:', err) + if (err.response?.status === 401) { + navigate(ROUTES.user.login) + return + } + setError(err.response?.data?.error || err.message || t('pages.userSubscriptionProducts.errors.createOrderFailed')) + } finally { + setSubscribingPrice(null) + } + } + + if (loading) { + return ( + +
+ +
+
+ ) + } + + if (error || !product) { + return ( + + } + title={error || 'Product not found'} + description="Please check whether the service link is still valid." + action={{ label: 'Browse products', onClick: () => navigate(ROUTES.user.products) }} + /> + + ) + } + + return ( + +
+
+ +
+ + }> + All products + + + + }> + {t('pages.userSubscriptionProducts.actions.mySubscriptions')} + + +
+
+ + +
+
+
+ +
+
+
+

{product.Name}

+ + {product.IsActive === false ? 'Inactive' : 'Available'} + +
+

+ {t('pages.userSubscriptionProducts.productCode')}: {product.Code} +

+
+
+ {selectedEntry && ( +
+

Selected

+

{formatPrice(selectedEntry.price)}

+
+ )} +
+ + {availablePrices.length > 0 ? ( +
+
+ {(product.Plans || []).map((plan) => ( +
+
+
+

{plan.DisplayName}

+

{t('pages.userSubscriptionProducts.version')} v{plan.PlanVersion}

+
+ +
+ + {plan.Features && plan.Features.length > 0 && ( +
+ {plan.Features.map((feature) => ( +
+ + + {feature.FeatureKey} + {feature.IsUnlimited ? ( + ({t('pages.userSubscriptionProducts.unlimited')}) + ) : ( + <> + {feature.ValueText && : {feature.ValueText}} + {feature.ValueNumeric !== undefined && : {feature.ValueNumeric}} + {feature.Unit && {feature.Unit}} + + )} + +
+ ))} +
+ )} + +
+ {(plan.Prices || []).map((price) => { + const currentPriceId = priceId(price) + const selected = selectedPriceId === currentPriceId + return ( + + ) + })} +
+
+ ))} +
+ +
+ +

Purchase service

+ {selectedEntry ? ( +
+
+

Plan

+

{selectedEntry.plan.DisplayName}

+
+
+

Price

+

{formatPrice(selectedEntry.price)}

+
+ } + > + {t('pages.userSubscriptionProducts.actions.subscribeNow')} + +
+ ) : ( +

{t('pages.userSubscriptionProducts.noPrice')}

+ )} +
+
+
+ ) : ( + } + title={t('pages.userSubscriptionProducts.noPrice')} + description="This product is visible, but no active price is available yet." + /> + )} +
+
+
+ ) +} diff --git a/basaltpass-frontend/src/features/user/subscription/Products.tsx b/basaltpass-frontend/src/features/user/subscription/Products.tsx index 4a39f3ac..608bef21 100644 --- a/basaltpass-frontend/src/features/user/subscription/Products.tsx +++ b/basaltpass-frontend/src/features/user/subscription/Products.tsx @@ -139,9 +139,20 @@ export default function ProductsPage() {
-

{product.Name}

+ + {product.Name} + +

{t('pages.userSubscriptionProducts.productCode')}: {product.Code}

+ + + View service + +
{product.Description && ( diff --git a/basaltpass-frontend/src/features/user/team/Detail.tsx b/basaltpass-frontend/src/features/user/team/Detail.tsx index db4d83ed..bbe8957c 100644 --- a/basaltpass-frontend/src/features/user/team/Detail.tsx +++ b/basaltpass-frontend/src/features/user/team/Detail.tsx @@ -5,7 +5,7 @@ import Layout from '@features/user/components/Layout'; import { PCard, PButton, PSkeleton, PAlert, PBadge, PPageHeader } from '@ui'; import { teamApi, TeamResponse } from '@api/user/team'; import { invitationApi, Invitation } from '@api/user/invitation'; -import { XMarkIcon, ClockIcon } from '@heroicons/react/24/outline'; +import { XMarkIcon, ClockIcon, WalletIcon } from '@heroicons/react/24/outline'; import { ROUTES } from '@constants'; import { useI18n } from '@shared/i18n'; @@ -198,6 +198,12 @@ const TeamDetail: React.FC = () => {
+ + }> + {t('pages.teamDetail.actions.wallet')} + + + {t('pages.teamDetail.actions.manageMembers')} diff --git a/basaltpass-frontend/src/features/user/team/Wallet.tsx b/basaltpass-frontend/src/features/user/team/Wallet.tsx new file mode 100644 index 00000000..1d4cd990 --- /dev/null +++ b/basaltpass-frontend/src/features/user/team/Wallet.tsx @@ -0,0 +1,202 @@ +import { useEffect, useMemo, useState } from 'react' +import { useParams } from 'react-router-dom' +import { + ArrowDownIcon, + ArrowUpIcon, + BanknotesIcon, + WalletIcon, +} from '@heroicons/react/24/outline' +import Layout from '@features/user/components/Layout' +import { + teamApi, + type TeamResponse, + type TeamWalletAccount, + type TeamWalletTransaction, +} from '@api/user/team' +import { PAlert, PBadge, PCard, PPageHeader, PSkeleton } from '@ui' +import { useI18n } from '@shared/i18n' + +const formatAmount = (amount: number, account: TeamWalletAccount | null) => { + const currency = account?.currency + if (!currency) return String(amount) + const places = Math.max(0, currency.decimal_places || 0) + const value = amount / Math.pow(10, places) + const formatted = new Intl.NumberFormat(undefined, { + minimumFractionDigits: Math.min(places, 2), + maximumFractionDigits: Math.min(places, 8), + }).format(value) + return `${currency.symbol || ''}${formatted} ${currency.code}` +} + +const isIncomingTransaction = (tx: TeamWalletTransaction) => { + const type = tx.type.toLowerCase() + if (['withdraw', 'decrease', 'debit', 'usage', 'charge'].some((part) => type.includes(part))) return false + if (['recharge', 'increase', 'deposit', 'refund', 'credit'].some((part) => type.includes(part))) return true + return tx.amount >= 0 +} + +export default function TeamWallet() { + const { id } = useParams<{ id: string }>() + const teamID = Number(id) + const { t, locale } = useI18n() + const [team, setTeam] = useState(null) + const [accounts, setAccounts] = useState([]) + const [selectedID, setSelectedID] = useState(null) + const [transactions, setTransactions] = useState([]) + const [loading, setLoading] = useState(true) + const [historyLoading, setHistoryLoading] = useState(false) + const [error, setError] = useState('') + + const selectedAccount = useMemo( + () => accounts.find((account) => account.id === selectedID) || accounts[0] || null, + [accounts, selectedID], + ) + + useEffect(() => { + if (!Number.isInteger(teamID) || teamID <= 0) { + setError(t('pages.teamWallet.errors.invalidTeam')) + setLoading(false) + return + } + let cancelled = false + Promise.all([teamApi.getTeam(teamID), teamApi.getTeamWallets(teamID)]) + .then(([teamResponse, walletsResponse]) => { + if (cancelled) return + const walletList = walletsResponse.data.data || [] + setTeam(teamResponse.data.data) + setAccounts(walletList) + setSelectedID((walletList.find((wallet) => wallet.balance !== 0) || walletList[0])?.id || null) + }) + .catch((requestError) => { + if (!cancelled) setError(requestError.response?.data?.error || t('pages.teamWallet.errors.loadFailed')) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [teamID, t]) + + useEffect(() => { + const currencyCode = selectedAccount?.currency?.code + if (!currencyCode) { + setTransactions([]) + return + } + let cancelled = false + setHistoryLoading(true) + teamApi.getTeamWalletHistory(teamID, currencyCode) + .then((response) => { + if (!cancelled) setTransactions(response.data.data || []) + }) + .catch((requestError) => { + if (!cancelled) setError(requestError.response?.data?.error || t('pages.teamWallet.errors.historyFailed')) + }) + .finally(() => { + if (!cancelled) setHistoryLoading(false) + }) + return () => { + cancelled = true + } + }, [selectedAccount?.currency?.code, teamID, t]) + + if (loading) { + return
+ } + + return ( + +
+ + + {error ? : null} + +
+ {accounts.map((account) => { + const selected = account.id === selectedAccount?.id + return ( + + ) + })} +
+ + {accounts.length === 0 ? ( +
+ +

{t('pages.teamWallet.empty')}

+
+ ) : null} + + {selectedAccount ? ( + +
+
+

{t('pages.teamWallet.transactions.title')}

+

{selectedAccount.currency?.code}

+
+ {t('pages.teamWallet.readOnly')} +
+
+ {historyLoading ?
: null} + {!historyLoading && transactions.length === 0 ? ( +

{t('pages.teamWallet.transactions.empty')}

+ ) : null} + {!historyLoading && transactions.length > 0 ? ( +
    + {transactions.map((tx) => { + const incoming = isIncomingTransaction(tx) + return ( +
  • +
    + {incoming + ? + : } +
    +
    +

    {tx.type}

    +

    + {new Date(tx.created_at).toLocaleString(locale)}{tx.reference ? ` · ${tx.reference}` : ''} +

    +
    +
    +

    + {incoming ? '+' : '-'}{formatAmount(Math.abs(tx.amount), selectedAccount)} +

    +

    {tx.status}

    +
    +
  • + ) + })} +
+ ) : null} +
+
+ ) : null} +
+
+ ) +} diff --git a/basaltpass-frontend/src/features/user/wallet/History.tsx b/basaltpass-frontend/src/features/user/wallet/History.tsx index 254e606f..7884180f 100644 --- a/basaltpass-frontend/src/features/user/wallet/History.tsx +++ b/basaltpass-frontend/src/features/user/wallet/History.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, type ChangeEvent } from 'react' import { history } from '@api/user/wallet' +import type { Currency } from '@api/user/currency' import { Link } from 'react-router-dom' import Layout from '@features/user/components/Layout' import { PInput, PSelect, PSkeleton, PBadge, PPageHeader, PEmptyState, PCard } from '@ui' @@ -25,6 +26,9 @@ interface Tx { DeletedAt: string | null WalletID: number Reference: string + Wallet?: { + Currency?: Currency | null + } } type TxDirection = 'in' | 'out' @@ -43,11 +47,51 @@ const detectDirection = (tx: Tx): TxDirection => { return tx.Amount >= 0 ? 'in' : 'out' } +const fallbackCurrencyForTx = (tx: Tx): Currency => { + if (tx.Reference?.startsWith('apicred:')) { + return { + id: 0, + code: 'CREDIT', + name: 'Credit', + name_cn: '信用点', + symbol: 'C', + decimal_places: 0, + type: 'points', + is_active: true, + sort_order: 0, + description: '', + } + } + return { + id: 0, + code: 'CNY', + name: 'Chinese Yuan', + name_cn: '人民币', + symbol: '¥', + decimal_places: 2, + type: 'fiat', + is_active: true, + sort_order: 0, + description: '', + } +} + +const formatCurrencyAmount = (amountMinor: number, currency: Currency) => { + const decimalPlaces = Math.max(0, currency.decimal_places || 0) + const displayPlaces = Math.min(decimalPlaces, 8) + const value = amountMinor / Math.pow(10, decimalPlaces) + let text = value.toFixed(displayPlaces) + if (displayPlaces > 2) { + text = text.replace(/0+$/, '').replace(/\.$/, '') + } + return `${currency.symbol || ''}${text} ${currency.code}` +} + const formatAmount = (tx: Tx, direction: TxDirection) => { const absAmount = Math.abs(tx.Amount) - const divisor = tx.Reference?.startsWith('apicred:') ? 1_000_000 : 100 + const currency = tx.Wallet?.Currency || fallbackCurrencyForTx(tx) const sign = direction === 'in' ? '+' : '-' - return `${sign}¥${(absAmount / divisor).toFixed(2)}` + return `${sign}${formatCurrencyAmount(absAmount, currency)}` } export default function History() { diff --git a/basaltpass-frontend/src/features/user/wallet/Index.tsx b/basaltpass-frontend/src/features/user/wallet/Index.tsx index d2a508fc..f1be90a1 100644 --- a/basaltpass-frontend/src/features/user/wallet/Index.tsx +++ b/basaltpass-frontend/src/features/user/wallet/Index.tsx @@ -1,171 +1,200 @@ -import { useEffect, useState } from 'react' -import { getBalance, history as getHistory } from '@api/user/wallet' -import { getCurrencies, Currency } from '@api/user/currency' -import { Link } from 'react-router-dom' +import { useEffect, useMemo, useState } from 'react' +import { getAccounts, history as getHistory, type WalletAccount, type WalletTransaction } from '@api/user/wallet' +import { getCurrencyRates, type Currency, type CurrencyRate } from '@api/user/currency' +import { paymentAPI } from '@api/subscription/payment/payment' +import { Link, useSearchParams } from 'react-router-dom' import Layout from '@features/user/components/Layout' -import CurrencySelector from '@features/user/components/CurrencySelector' import { ROUTES } from '@constants' import { useConfig } from '@contexts/ConfigContext' import { useI18n } from '@shared/i18n' import { PSkeleton, PPageHeader, PBadge, PCard } from '@ui' -import { - WalletIcon, - ArrowUpIcon, - ArrowDownIcon, +import { + WalletIcon, + ArrowUpIcon, + ArrowDownIcon, ClockIcon, GiftIcon, CurrencyDollarIcon, - ChartBarIcon + ChartBarIcon, } from '@heroicons/react/24/outline' +const USD_CODE = 'USD' +const CREDIT_FALLBACK = { + id: 0, + code: 'CREDIT', + name: 'Credit', + name_cn: '信用点', + symbol: 'C', + decimal_places: 0, + type: 'points', + is_active: true, + sort_order: 0, + description: '', +} as const + +const getDirection = (type: string, amount: number): 'in' | 'out' => { + const normalized = (type || '').toLowerCase() + const inKeywords = ['recharge', 'deposit', 'increase', 'refund', 'income'] + const outKeywords = ['withdraw', 'decrease', 'debit', 'consume', 'payment', 'expense'] + if (inKeywords.some((keyword) => normalized.includes(keyword))) return 'in' + if (outKeywords.some((keyword) => normalized.includes(keyword))) return 'out' + return amount >= 0 ? 'in' : 'out' +} + +const resolveExchangeRate = (from: Currency | null | undefined, toCode: string, rates: CurrencyRate[]) => { + if (!from) return null + if (from.code === toCode) return 1 + const exact = rates.find((rate) => + rate.base_currency_code === from.code && + rate.quote_currency_code === toCode && + rate.is_active !== false && + Number(rate.rate) > 0 + ) + if (exact) return Number(exact.rate) + const inverse = rates.find((rate) => + rate.base_currency_code === toCode && + rate.quote_currency_code === from.code && + rate.is_active !== false && + Number(rate.rate) > 0 + ) + if (inverse) return 1 / Number(inverse.rate) + if (toCode === USD_CODE && Number(from.exchange_rate_usd || 0) > 0) return Number(from.exchange_rate_usd) + return null +} + +const amountToUnits = (amountMinor: number, currency?: Currency | null) => { + if (!currency) return amountMinor + return amountMinor / Math.pow(10, Math.max(0, currency.decimal_places || 0)) +} + +const formatCurrency = (amountMinor: number, currency?: Currency | null) => { + if (!currency) return '--' + const places = Math.min(Math.max(0, currency.decimal_places || 0), 8) + let text = amountToUnits(amountMinor, currency).toFixed(places) + if (places > 2) text = text.replace(/0+$/, '').replace(/\.$/, '') + return `${currency.symbol || ''}${text} ${currency.code}` +} + +const formatUSD = (amount: number) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 }).format(amount) + +const getTransactionCurrency = (tx: WalletTransaction, selectedCurrency?: Currency | null) => { + if (tx.Wallet?.Currency) return tx.Wallet.Currency + if (tx.Reference?.startsWith('apicred:')) return CREDIT_FALLBACK + return selectedCurrency || null +} + export default function WalletIndex() { - const { walletRechargeWithdrawEnabled } = useConfig() + const { walletRechargeWithdrawEnabled, walletWithdrawEnabled } = useConfig() const { t, locale } = useI18n() - const [balance, setBalance] = useState(null) - const [selectedCurrency, setSelectedCurrency] = useState(null) + const [searchParams, setSearchParams] = useSearchParams() + const [accounts, setAccounts] = useState([]) + const [rates, setRates] = useState([]) + const [selectedCode, setSelectedCode] = useState('') + const [txs, setTxs] = useState([]) const [isLoading, setIsLoading] = useState(true) - const [txs, setTxs] = useState>([]) - const [monthlyIncome, setMonthlyIncome] = useState(0) // - const [monthlyExpense, setMonthlyExpense] = useState(0) // + const [isDetailLoading, setIsDetailLoading] = useState(false) - const getDirection = (type: string, amount: number): 'in' | 'out' => { - const normalized = (type || '').toLowerCase() - const inKeywords = ['recharge', 'deposit', 'increase', 'refund', 'income'] - const outKeywords = ['withdraw', 'decrease', 'debit', 'consume', 'payment', 'expense'] - if (inKeywords.some((keyword) => normalized.includes(keyword))) return 'in' - if (outKeywords.some((keyword) => normalized.includes(keyword))) return 'out' - return amount >= 0 ? 'in' : 'out' - } - - const getTypeLabel = (type: string) => { - const normalized = (type || '').toLowerCase() - if (normalized === 'recharge') return t('pages.wallet.transactionTypes.recharge') - if (normalized === 'withdraw') return t('pages.wallet.transactionTypes.withdraw') - if (normalized === 'admin_deposit') return t('pages.wallet.transactionTypes.adminDeposit') - if (normalized === 's2s_wallet_increase') return t('pages.wallet.transactionTypes.apiIncrease') - if (normalized === 's2s_wallet_decrease') return t('pages.wallet.transactionTypes.apiDecrease') - return type || t('pages.wallet.transactionTypes.default') - } + const selectedAccount = useMemo( + () => accounts.find((account) => account.Currency?.code === selectedCode) || accounts[0] || null, + [accounts, selectedCode], + ) - useEffect(() => { - // - loadDefaultCurrency() - }, []) + const accountRows = useMemo(() => { + return accounts.map((account) => { + const currency = account.Currency + const units = amountToUnits(account.Balance, currency) + const rate = resolveExchangeRate(currency, USD_CODE, rates) + return { + account, + currency, + usdValue: rate === null ? null : units * rate, + } + }) + }, [accounts, rates]) - useEffect(() => { - if (selectedCurrency) { - // - loadData(selectedCurrency.code) - } - }, [selectedCurrency]) + const totalUSD = accountRows.reduce((sum, row) => sum + (row.usdValue || 0), 0) - const loadDefaultCurrency = async () => { - try { - const response = await getCurrencies() - if (response.data.length > 0) { - // USD, - const defaultCurrency = response.data.find(c => c.code === 'USD') || response.data[0] - setSelectedCurrency(defaultCurrency) - } else { - setIsLoading(false) - } - } catch (error) { - console.error('Failed to load currencies:', error) - setIsLoading(false) + const monthly = useMemo(() => { + if (!selectedAccount?.Currency) return { income: 0, expense: 0 } + const now = new Date() + let income = 0 + let expense = 0 + for (const tx of txs) { + const d = new Date(tx.CreatedAt) + if (d.getFullYear() !== now.getFullYear() || d.getMonth() !== now.getMonth()) continue + if (getDirection(tx.Type, tx.Amount) === 'in') income += Math.abs(tx.Amount) + else expense += Math.abs(tx.Amount) } - } + return { income, expense } + }, [selectedAccount, txs]) - const loadData = async (currencyCode: string) => { - setIsLoading(true) - try { - const [balanceRes, historyRes] = await Promise.all([ - getBalance(currencyCode), - // , - getHistory(currencyCode, 200) - ]) - setBalance(balanceRes.data.balance) - const list = historyRes.data || [] - setTxs(list) - // /() - const now = new Date() - const y = now.getFullYear() - const m = now.getMonth() - let income = 0 - let expense = 0 - for (const t of list) { - const d = new Date(t.CreatedAt) - if (d.getFullYear() === y && d.getMonth() === m) { - const type = (t.Type || '').toLowerCase() - if (getDirection(type, t.Amount) === 'in') { - income += Math.abs(t.Amount) - } else if (getDirection(type, t.Amount) === 'out') { - // , - expense += Math.abs(t.Amount) - } else { - // : - if (t.Amount >= 0) income += t.Amount - else expense += Math.abs(t.Amount) + useEffect(() => { + let cancelled = false + const load = async () => { + setIsLoading(true) + try { + const topupSessionId = searchParams.get('session_id') + if (searchParams.get('topup') === 'success' && topupSessionId) { + try { + await paymentAPI.reconcileWalletTopUpSession(topupSessionId) + } catch (error) { + console.error('Failed to reconcile wallet top-up:', error) + } finally { + const nextParams = new URLSearchParams(searchParams) + nextParams.delete('topup') + nextParams.delete('session_id') + setSearchParams(nextParams, { replace: true }) } } + + const [accountsRes, ratesRes] = await Promise.all([getAccounts(), getCurrencyRates()]) + if (cancelled) return + const list = accountsRes.data || [] + setAccounts(list) + setRates(ratesRes.data || []) + const firstNonZero = list.find((account) => account.Balance !== 0) || list[0] + setSelectedCode(firstNonZero?.Currency?.code || '') + } catch (error) { + if (cancelled) return + console.error('Failed to load wallet accounts:', error) + setAccounts([]) + setRates([]) + } finally { + if (!cancelled) setIsLoading(false) } - setMonthlyIncome(income) - setMonthlyExpense(expense) - } catch (error) { - console.error('Failed to load wallet data:', error) - // :、 - setBalance(null) - setTxs([]) - setMonthlyIncome(0) - setMonthlyExpense(0) - } finally { - setIsLoading(false) } - } + load() + return () => { + cancelled = true + } + }, [searchParams, setSearchParams]) - const formatBalance = (balance: number, currency: Currency) => { - const amount = balance / Math.pow(10, currency.decimal_places) - return `${currency.symbol}${amount.toFixed(Math.min(currency.decimal_places, 8))}` - } + useEffect(() => { + if (!selectedAccount?.Currency?.code) { + setTxs([]) + return + } + setIsDetailLoading(true) + getHistory(selectedAccount.Currency.code, 200) + .then((res) => setTxs(res.data || [])) + .catch((error) => { + console.error('Failed to load wallet transactions:', error) + setTxs([]) + }) + .finally(() => setIsDetailLoading(false)) + }, [selectedAccount?.Currency?.code]) - const formatWithCurrency = (amountMinor: number, currency?: Currency | null) => { - if (!currency) return '--' - const divisor = Math.pow(10, currency.decimal_places) - const amount = amountMinor / divisor - return `${currency.symbol}${amount.toFixed(Math.min(currency.decimal_places, 8))}` + const getTypeLabel = (type: string) => { + const normalized = (type || '').toLowerCase() + if (normalized === 'recharge') return t('pages.wallet.transactionTypes.recharge') + if (normalized === 'withdraw') return t('pages.wallet.transactionTypes.withdraw') + if (normalized === 'admin_deposit') return t('pages.wallet.transactionTypes.adminDeposit') + if (normalized === 's2s_wallet_increase') return t('pages.wallet.transactionTypes.apiIncrease') + if (normalized === 's2s_wallet_decrease') return t('pages.wallet.transactionTypes.apiDecrease') + return type || t('pages.wallet.transactionTypes.default') } - const walletStats = [ - { - name: t('pages.wallet.stats.currentBalance'), - value: selectedCurrency && balance !== null - ? formatBalance(balance, selectedCurrency) - : '--', - icon: WalletIcon, - color: 'text-indigo-600', - bgColor: 'bg-indigo-100' - }, - { - name: t('pages.wallet.stats.monthlyIncome'), - value: selectedCurrency ? `+${formatWithCurrency(monthlyIncome, selectedCurrency)}` : '--', - icon: ChartBarIcon, - color: 'text-green-600', - bgColor: 'bg-green-100' - }, - { - name: t('pages.wallet.stats.monthlyExpense'), - value: selectedCurrency ? `-${formatWithCurrency(monthlyExpense, selectedCurrency)}` : '--', - icon: ChartBarIcon, - color: 'text-red-600', - bgColor: 'bg-red-100' - } - ] - if (isLoading) { return ( @@ -176,177 +205,145 @@ export default function WalletIndex() { ) } + const selectedCurrency = selectedAccount?.Currency || null + return (
- {/* */} -
- - {!isLoading && ( -
- { - setSelectedCurrency(currency) - setIsLoading(true) - }} - /> -
- )} -
+ - {/* */} -
+
-
+
-

- {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) })}

-
- +
+
- {/* */}
- {walletStats.map((stat) => ( - -
-
- -
-

- {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.title')} -

+

{t('pages.wallet.accounts.title')}

+ {accountRows.length === 0 ? ( +
+ {t('pages.wallet.accounts.empty')} +
+ ) : ( +
+ {accountRows.map(({ account, currency, usdValue }) => { + const active = selectedAccount?.ID === account.ID + return ( + + ) + })} +
+ )} +
+
+ + +
+

{t('pages.wallet.quickActions.title')}

{walletRechargeWithdrawEnabled ? ( - -
-
- -
+ +
+
-
+
- ) : ( -
-
-
- -
-
-
-

{t('pages.wallet.quickActions.recharge')}

-

{t('pages.wallet.quickActions.notAvailable')}

-
-
- )} - - {walletRechargeWithdrawEnabled ? ( - -
-
- -
+ ) : null} + {walletWithdrawEnabled ? ( + +
+
-
+
- ) : ( -
-
-
- -
-
-
-

{t('pages.wallet.quickActions.withdraw')}

-

{t('pages.wallet.quickActions.notAvailable')}

-
-
- )} - - -
-
- -
+ ) : null} + +
+
-
+
- - -
-
- -
+ +
+
-
+
- {/* () */}
-
+

- {t('pages.wallet.recentTransactions.title')} + {selectedCurrency + ? t('pages.wallet.recentTransactions.titleWithCode', { code: selectedCurrency.code }) + : t('pages.wallet.recentTransactions.title')}

- + {t('pages.wallet.recentTransactions.viewAll')}
- {txs.length === 0 ? ( -
{t('pages.wallet.recentTransactions.empty')}
+ {isDetailLoading ? ( + + ) : txs.length === 0 ? ( +
{t('pages.wallet.recentTransactions.empty')}
) : (
    {txs.slice(0, 5).map((tx) => { const direction = getDirection(tx.Type, tx.Amount) const isIncoming = direction === 'in' - const iconBg = isIncoming ? 'bg-green-100' : 'bg-red-100' - const amountColor = isIncoming ? 'text-green-600' : 'text-red-600' - const sign = isIncoming ? '+' : '-' const statusLower = (tx.Status || '').toLowerCase() const statusVariant = statusLower === 'success' || statusLower === 'completed' ? 'success' as const : statusLower === 'pending' ? 'warning' as const : 'default' as const - const formatTxAmount = () => { - if (!selectedCurrency) return `${sign}${tx.Amount}` - const divisor = Math.pow(10, selectedCurrency.decimal_places) - const amount = (tx.Amount / divisor).toFixed(Math.min(selectedCurrency.decimal_places, 8)) - return `${sign}${selectedCurrency.symbol}${amount}` - } + const txCurrency = getTransactionCurrency(tx, selectedCurrency) return (
  • -
    - {isIncoming ? ( - - ) : ( - - )} +
    + {isIncoming ? : }
    -
    -

    - {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)} +

    {statusLower === 'success' || statusLower === 'completed' ? t('pages.wallet.recentTransactions.status.completed') @@ -435,4 +418,4 @@ export default function WalletIndex() {
    ) -} +} diff --git a/basaltpass-frontend/src/features/user/wallet/Recharge.tsx b/basaltpass-frontend/src/features/user/wallet/Recharge.tsx index ae7147ec..82908044 100644 --- a/basaltpass-frontend/src/features/user/wallet/Recharge.tsx +++ b/basaltpass-frontend/src/features/user/wallet/Recharge.tsx @@ -1,8 +1,7 @@ -import { useState } from 'react' -import { recharge } from '@api/user/wallet' -import { userGiftCardApi } from '@api/user/giftCard' -import { Currency } from '@api/user/currency' +import { useEffect, useMemo, useState } from 'react' +import { Currency, CurrencyRate, getCurrencies, getCurrencyRates } from '@api/user/currency' import { useNavigate } from 'react-router-dom' +import { paymentAPI } from '@api/subscription/payment/payment' import Layout from '@features/user/components/Layout' import CurrencySelector from '@features/user/components/CurrencySelector' import { PInput, PButton, PPageHeader, PCard, PAlert } from '@ui' @@ -12,38 +11,10 @@ import { useI18n } from '@shared/i18n' import { ArrowUpIcon, CreditCardIcon, - QrCodeIcon, BanknotesIcon, - CheckCircleIcon, + GiftIcon, } from '@heroicons/react/24/outline' -const paymentMethods = [ - { - id: 'alipay', - name: 'Alipay', - icon: QrCodeIcon, - description: 'Scan to pay', - color: 'text-indigo-600', - bgColor: 'bg-indigo-100' - }, - { - id: 'wechat', - name: 'WeChat Pay', - icon: QrCodeIcon, - description: 'Scan to pay', - color: 'text-indigo-600', - bgColor: 'bg-indigo-100' - }, - { - id: 'bank', - name: 'Bank Card', - icon: CreditCardIcon, - description: 'Online payment', - color: 'text-indigo-600', - bgColor: 'bg-indigo-100' - } -] - const quickAmounts = [50, 100, 200, 500, 1000, 2000] // @@ -68,6 +39,62 @@ const formatAmount = (amount: number, currency: Currency): string => { return amount.toFixed(Math.min(currency.decimal_places, 8)) } +const getAmountInputConstraints = (currency: Currency | null) => { + if (!currency) return { min: '0.01', step: '0.01' } + const decimals = Math.max(0, currency.decimal_places || 0) + if (decimals === 0) return { min: '1', step: '1' } + return { + min: (1 / Math.pow(10, decimals)).toFixed(decimals), + step: (1 / Math.pow(10, decimals)).toFixed(decimals), + } +} + +const fromSmallestUnit = (value: number, currency: Currency): number => + value / Math.pow(10, currency.decimal_places) + +const calculatePaymentAmount = ( + targetAmount: number, + targetCurrency: Currency | null, + paymentCurrency: Currency | null, + rates: CurrencyRate[], +) => { + if (!targetCurrency || !paymentCurrency || !targetAmount || targetAmount <= 0) return null + const rate = resolveExchangeRate(targetCurrency, paymentCurrency, rates) + if (!rate || rate <= 0) return null + const amount = targetAmount * rate + return { + amount, + smallestUnit: Math.max(1, Math.ceil(amount * Math.pow(10, paymentCurrency.decimal_places) - 1e-9)), + rate, + } +} + +const resolveExchangeRate = ( + targetCurrency: Currency, + paymentCurrency: Currency, + rates: CurrencyRate[], +): number | null => { + if (targetCurrency.code === paymentCurrency.code) return 1 + const exact = rates.find((rate) => + rate.base_currency_code === targetCurrency.code && + rate.quote_currency_code === paymentCurrency.code && + rate.is_active !== false && + Number(rate.rate) > 0 + ) + if (exact) return Number(exact.rate) + const inverse = rates.find((rate) => + rate.base_currency_code === paymentCurrency.code && + rate.quote_currency_code === targetCurrency.code && + rate.is_active !== false && + Number(rate.rate) > 0 + ) + if (inverse) return 1 / Number(inverse.rate) + const targetRate = Number(targetCurrency.exchange_rate_usd || 0) + const paymentRate = Number(paymentCurrency.exchange_rate_usd || 0) + if (targetRate > 0 && paymentRate > 0) return targetRate / paymentRate + return null +} + export default function Recharge() { const { t } = useI18n() const { walletRechargeWithdrawEnabled } = useConfig() @@ -75,12 +102,43 @@ export default function Recharge() { const navigate = useNavigate() const [amount, setAmount] = useState('') const [selectedCurrency, setSelectedCurrency] = useState(null) - const [selectedMethod, setSelectedMethod] = useState('alipay') + const [paymentCurrencies, setPaymentCurrencies] = useState([]) + const [currencyRates, setCurrencyRates] = useState([]) + const [paymentCurrency, setPaymentCurrency] = useState(null) const [error, setError] = useState('') const [isLoading, setIsLoading] = useState(false) - const [success, setSuccess] = useState(false) - const [giftCode, setGiftCode] = useState('') - const [giftRedeeming, setGiftRedeeming] = useState(false) + const targetAmount = Number(amount) + const requiresPaymentCurrency = !!selectedCurrency && selectedCurrency.type !== 'fiat' + const estimatedPayment = useMemo( + () => calculatePaymentAmount(targetAmount, selectedCurrency, paymentCurrency, currencyRates), + [currencyRates, targetAmount, selectedCurrency, paymentCurrency], + ) + const amountInputConstraints = getAmountInputConstraints(selectedCurrency) + + useEffect(() => { + Promise.all([getCurrencies(), getCurrencyRates()]) + .then(([currenciesResponse, ratesResponse]) => { + const currencies = currenciesResponse.data.filter((currency) => currency.type === 'fiat' && currency.payment_enabled) + setPaymentCurrencies(currencies) + setCurrencyRates(ratesResponse.data) + }) + .catch(() => { + setPaymentCurrencies([]) + setCurrencyRates([]) + }) + }, []) + + useEffect(() => { + if (!selectedCurrency) return + const matchingPaymentCurrency = paymentCurrencies.find((currency) => currency.code === selectedCurrency.code) + if (selectedCurrency.type === 'fiat' && matchingPaymentCurrency) { + setPaymentCurrency(matchingPaymentCurrency) + return + } + if (!paymentCurrency && paymentCurrencies.length > 0) { + setPaymentCurrency(paymentCurrencies.find((currency) => currency.code === 'USD') || paymentCurrencies[0]) + } + }, [paymentCurrencies, paymentCurrency, selectedCurrency]) const submit = async (e: React.FormEvent) => { e.preventDefault() @@ -99,20 +157,50 @@ export default function Recharge() { setError(t('pages.walletRecharge.errors.selectCurrency')) return } + if (!paymentCurrency) { + setError(t('pages.walletRecharge.errors.selectPaymentCurrency')) + return + } + if (!estimatedPayment) { + setError(t('pages.walletRecharge.errors.exchangeRateMissing')) + return + } setIsLoading(true) setError('') try { - // const decimals = selectedCurrency.decimal_places const amountInSmallestUnit = Math.round(Number(amount) * Math.pow(10, decimals)) - - await recharge(selectedCurrency.code, amountInSmallestUnit) - setSuccess(true) - setTimeout(() => { - navigate(ROUTES.user.wallet) - }, 2000) + const chargeCurrency = paymentCurrency.code + const chargeAmount = estimatedPayment.smallestUnit + + const intentResponse = await paymentAPI.createPaymentIntent({ + amount: chargeAmount, + currency: chargeCurrency, + description: `Top up ${amount} ${selectedCurrency.code}`, + payment_method_types: ['card'], + confirmation_method: 'automatic', + capture_method: 'automatic', + metadata: { + source: 'wallet_recharge', + checkout_kind: 'top_up', + wallet_currency: selectedCurrency.code, + target_wallet_currency: selectedCurrency.code, + target_wallet_amount: String(amountInSmallestUnit), + charge_currency: chargeCurrency, + charge_amount: String(chargeAmount), + }, + }) + + const sessionResponse = await paymentAPI.createPaymentSession({ + payment_intent_id: intentResponse.payment_intent.ID, + success_url: `${window.location.origin}${ROUTES.user.wallet}?topup=success&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${window.location.origin}${ROUTES.user.walletRecharge}?topup=canceled`, + user_email: '', + }) + + navigate(`/checkout?kind=top_up&session=${encodeURIComponent(sessionResponse.session.StripeSessionID)}`) } catch (e: any) { setError(e.response?.data?.error || t('pages.walletRecharge.errors.rechargeFailed')) } finally { @@ -130,41 +218,6 @@ export default function Recharge() { setError('') } - const handleRedeemGiftCard = async (e: React.FormEvent) => { - e.preventDefault() - if (!giftCode.trim()) { - setError(t('pages.walletRecharge.errors.giftCodeRequired')) - return - } - setGiftRedeeming(true) - setError('') - try { - await userGiftCardApi.redeem(giftCode.trim()) - setSuccess(true) - setTimeout(() => { - navigate(ROUTES.user.wallet) - }, 2000) - } catch (e: any) { - setError(e.response?.data?.error || t('pages.walletRecharge.errors.giftRedeemFailed')) - } finally { - setGiftRedeeming(false) - } - } - - if (success) { - return ( - -
    -
    - -

    {t('pages.walletRecharge.success.title')}

    -

    {t('pages.walletRecharge.success.redirecting')}

    -
    -
    -
    - ) - } - return (
    @@ -180,26 +233,18 @@ export default function Recharge() { )} - -
    -
    - -

    {t('pages.walletRecharge.giftCard.title')}

    -
    -
    - setGiftCode(e.target.value.toUpperCase())} - placeholder={t('pages.walletRecharge.giftCard.codePlaceholder')} - /> - - {t('pages.walletRecharge.giftCard.submit')} - - -
    -
    +
    + navigate(ROUTES.user.walletGiftCardRedeem)} + > + + + {t('pages.walletRecharge.giftCard.title')} + + +
    {/* */} {error && ( @@ -223,11 +268,38 @@ export default function Recharge() { { + setSelectedCurrency(currency) + setError('') + }} className="w-full" />
    + {requiresPaymentCurrency && ( +
    + + +
    + )} + {/* */}
    handleAmountChange(e.target.value)} placeholder={t('pages.walletRecharge.form.amountPlaceholder')} - min="0.01" - step={selectedCurrency ? `0.${'0'.repeat(Math.max(0, selectedCurrency.decimal_places - 1))}1` : "0.01"} + min={amountInputConstraints.min} + step={amountInputConstraints.step} />
    @@ -264,53 +336,31 @@ export default function Recharge() {
    )} - {/* */} -
    - -
    - {paymentMethods.map((method) => ( -
    setSelectedMethod(method.id)} - > -
    -
    - -
    -
    -

    {t(`pages.walletRecharge.paymentMethods.${method.id}.name`)}

    -

    {t(`pages.walletRecharge.paymentMethods.${method.id}.description`)}

    -
    - {selectedMethod === method.id && ( -
    - -
    - )} -
    + {selectedCurrency && paymentCurrency && estimatedPayment && ( +
    +
    {t('pages.walletRecharge.form.paymentEstimateTitle')}
    +
    + {t('pages.walletRecharge.form.paymentEstimate', { + target: `${formatAmount(targetAmount, selectedCurrency)} ${selectedCurrency.code}`, + payment: `${paymentCurrency.symbol}${fromSmallestUnit(estimatedPayment.smallestUnit, paymentCurrency).toFixed(paymentCurrency.decimal_places)} ${paymentCurrency.code}`, + })} +
    + {requiresPaymentCurrency && ( +
    + {t('pages.walletRecharge.form.exchangeRateHint')}
    - ))} + )}
    -
    + )} {/* */} - {t('pages.walletRecharge.form.submitWithAmount', { - symbol: selectedCurrency?.symbol || '', - amount: amount || '0.00', - code: selectedCurrency?.code || '', - })} + {t('pages.walletRecharge.form.continueToCheckout')}
    diff --git a/basaltpass-frontend/src/features/user/wallet/Withdraw.tsx b/basaltpass-frontend/src/features/user/wallet/Withdraw.tsx index ca5b302a..d9baa8ec 100644 --- a/basaltpass-frontend/src/features/user/wallet/Withdraw.tsx +++ b/basaltpass-frontend/src/features/user/wallet/Withdraw.tsx @@ -47,8 +47,8 @@ const quickAmounts = [50, 100, 200, 500, 1000, 2000] export default function Withdraw() { const { t } = useI18n() - const { walletRechargeWithdrawEnabled } = useConfig() - const walletOpsDisabled = !walletRechargeWithdrawEnabled + const { walletWithdrawEnabled } = useConfig() + const walletOpsDisabled = !walletWithdrawEnabled const navigate = useNavigate() const [amount, setAmount] = useState('') const [selectedCurrency, setSelectedCurrency] = useState(null) diff --git a/basaltpass-frontend/src/routes/tenant/appRoutes.tsx b/basaltpass-frontend/src/routes/tenant/appRoutes.tsx index 286f9d69..d5f764ac 100644 --- a/basaltpass-frontend/src/routes/tenant/appRoutes.tsx +++ b/basaltpass-frontend/src/routes/tenant/appRoutes.tsx @@ -7,6 +7,8 @@ import TenantOAuthClients from '@pages/tenant/app/OAuthClients' import AppUserManagement from '@pages/tenant/app/AppUserManagement' import AppRoleManagement from '@pages/tenant/app/AppRoleManagement' import AppPermissionManagement from '@pages/tenant/app/AppPermissionManagement' +import RBACManifests from '@pages/tenant/app/RBACManifests' +import AppGrantMappings from '@pages/tenant/app/AppGrantMappings' import CreateApp from '@pages/admin/app/CreateApp' import CrossAppTrustManagement from '@pages/tenant/security/CrossAppTrustManagement' import { withTenant } from '@/routes/helpers' @@ -21,6 +23,8 @@ export function TenantAppRoutes() { )} /> )} /> )} /> + )} /> + )} /> )} /> )} /> )} /> diff --git a/basaltpass-frontend/src/routes/user/appRoutes.tsx b/basaltpass-frontend/src/routes/user/appRoutes.tsx index d08bbd18..a9fb50d9 100644 --- a/basaltpass-frontend/src/routes/user/appRoutes.tsx +++ b/basaltpass-frontend/src/routes/user/appRoutes.tsx @@ -1,6 +1,8 @@ import { Route } from 'react-router-dom' import UserAppsIndex from '@pages/user/apps/Index' import UserAppDetail from '@pages/user/apps/Detail' +import AppRecharge from '@pages/user/apps/AppRecharge' +import AppRechargeSuccess from '@pages/user/apps/AppRechargeSuccess' import { withProtected } from '@/routes/helpers' export function UserAppRoutes() { @@ -8,6 +10,9 @@ export function UserAppRoutes() { <> )} /> )} /> + )} /> + )} /> + )} /> ) } diff --git a/basaltpass-frontend/src/routes/user/teamRoutes.tsx b/basaltpass-frontend/src/routes/user/teamRoutes.tsx index 6b5489b1..8bea8ad0 100644 --- a/basaltpass-frontend/src/routes/user/teamRoutes.tsx +++ b/basaltpass-frontend/src/routes/user/teamRoutes.tsx @@ -6,6 +6,7 @@ import TeamMembers from '@pages/user/team/Members' import EditTeam from '@pages/user/team/Edit' import InviteTeam from '@pages/user/team/Invite' import InvitationInbox from '@pages/user/invitations/Inbox' +import TeamWallet from '@pages/user/team/Wallet' import { withProtected } from '@/routes/helpers' export function UserTeamRoutes() { @@ -15,6 +16,7 @@ export function UserTeamRoutes() { )} /> )} /> )} /> + )} /> )} /> )} /> )} /> diff --git a/basaltpass-frontend/src/routes/user/walletRoutes.tsx b/basaltpass-frontend/src/routes/user/walletRoutes.tsx index 2429ab91..49643146 100644 --- a/basaltpass-frontend/src/routes/user/walletRoutes.tsx +++ b/basaltpass-frontend/src/routes/user/walletRoutes.tsx @@ -5,6 +5,7 @@ import Withdraw from '@pages/user/wallet/Withdraw' import History from '@pages/user/wallet/History' import RedeemGiftCard from '@pages/user/wallet/RedeemGiftCard' import Payment from '@pages/user/payment/Payment' +import Cashier from '@pages/user/payment/Cashier' import { withProtected } from '@/routes/helpers' export function UserWalletRoutes() { @@ -16,6 +17,7 @@ export function UserWalletRoutes() { )} /> )} /> )} /> + )} /> ) } diff --git a/basaltpass-frontend/src/shared/api/admin/wallet.ts b/basaltpass-frontend/src/shared/api/admin/wallet.ts index 720f16af..ac1d5028 100644 --- a/basaltpass-frontend/src/shared/api/admin/wallet.ts +++ b/basaltpass-frontend/src/shared/api/admin/wallet.ts @@ -15,6 +15,8 @@ export interface Currency { export interface Wallet { id: number; + owner_type: 'user' | 'app' | 'tenant' | 'team'; + owner_id: number; user_id?: number; team_id?: number; currency_id: number; @@ -58,6 +60,8 @@ export interface WalletStats { } export interface CreateWalletRequest { + owner_type?: 'user' | 'app' | 'tenant' | 'team'; + owner_id?: number; user_id?: number; team_id?: number; currency_code: string; diff --git a/basaltpass-frontend/src/shared/api/config.ts b/basaltpass-frontend/src/shared/api/config.ts index ca1a3a63..61581a4d 100644 --- a/basaltpass-frontend/src/shared/api/config.ts +++ b/basaltpass-frontend/src/shared/api/config.ts @@ -5,6 +5,7 @@ export interface PublicConfig { market_enabled: boolean /** usertranslatedvalue/translated;notbackortranslated true translated */ wallet_recharge_withdraw_enabled?: boolean + wallet_withdraw_enabled?: boolean site_name: string } diff --git a/basaltpass-frontend/src/shared/api/subscription/payment/payment.ts b/basaltpass-frontend/src/shared/api/subscription/payment/payment.ts index bb3a8457..b5770a18 100644 --- a/basaltpass-frontend/src/shared/api/subscription/payment/payment.ts +++ b/basaltpass-frontend/src/shared/api/subscription/payment/payment.ts @@ -88,6 +88,10 @@ export interface SimulatePaymentResponse { stripe_mock_response: MockStripeResponse; } +export interface ReconcileWalletTopUpResponse { + session: PaymentSession; +} + export interface ListPaymentIntentsResponse { payment_intents: PaymentIntent[]; count: number; @@ -118,6 +122,11 @@ class PaymentAPI { return response.data; } + async reconcileWalletTopUpSession(sessionId: string): Promise { + const response = await client.post(`/api/v1/payment/sessions/${sessionId}/reconcile-wallet-top-up`); + return response.data; + } + // getpaymenttranslatedlist async listPaymentIntents(limit?: number): Promise { const params: any = {}; diff --git a/basaltpass-frontend/src/shared/api/subscription/subscription.ts b/basaltpass-frontend/src/shared/api/subscription/subscription.ts index 65f9d5f6..a5380710 100644 --- a/basaltpass-frontend/src/shared/api/subscription/subscription.ts +++ b/basaltpass-frontend/src/shared/api/subscription/subscription.ts @@ -108,6 +108,8 @@ export interface QuickCheckoutRequest { price_id: number; quantity?: number; coupon_code?: string; + success_url?: string; + cancel_url?: string; } export interface CheckoutResponse { @@ -403,4 +405,4 @@ export const cancelSubscription = (id: number, reason?: string) => subscriptionA // translatedmanagementtranslatedsubscriptiontranslated export const adminListSubscriptions = (params?: { tenant_id?: number; page?: number; page_size?: number; user_id?: number; status?: string; price_id?: number; }) => subscriptionAPI.adminListSubscriptions(params); export const adminGetSubscription = (id: number) => subscriptionAPI.adminGetSubscription(id); -export const adminCancelSubscription = (id: number, reason?: string) => subscriptionAPI.adminCancelSubscription(id, reason); \ No newline at end of file +export const adminCancelSubscription = (id: number, reason?: string) => subscriptionAPI.adminCancelSubscription(id, reason); diff --git a/basaltpass-frontend/src/shared/api/tenant/appGrantMapping.ts b/basaltpass-frontend/src/shared/api/tenant/appGrantMapping.ts new file mode 100644 index 00000000..8b7707f2 --- /dev/null +++ b/basaltpass-frontend/src/shared/api/tenant/appGrantMapping.ts @@ -0,0 +1,119 @@ +import client from '../client' + +export type GrantSourceType = 'membership_role' | 'tenant_role' | 'tenant_permission' +export type GrantTargetType = 'app_role' | 'app_permission' + +export interface GrantEndpoint { + type: GrantSourceType | GrantTargetType + id?: number + code: string + name: string +} + +export interface AppGrantMapping { + id: number + tenant_id: number + app_id: number + source: GrantEndpoint + target: GrantEndpoint + enabled: boolean + valid_from?: string + valid_until?: string + affected_user_count: number + created_by: number + updated_by: number + created_at: string + updated_at: string +} + +export interface AppGrantMappingInput { + source_type: GrantSourceType + source_id: number + source_code: string + target_type: GrantTargetType + target_id: number + enabled: boolean + valid_from?: string + valid_until?: string +} + +export interface AppGrantMappingOptions { + membership_roles: GrantEndpoint[] + tenant_roles: GrantEndpoint[] + tenant_permissions: GrantEndpoint[] + app_roles: GrantEndpoint[] + app_permissions: GrantEndpoint[] +} + +export interface GrantSource { + type: 'explicit' | 'tenant_mapping' + assignment_id?: number + mapping_id?: number + source_type?: GrantSourceType + source_id?: number + source_code?: string + via_role_code?: string +} + +export interface EffectiveRole { + id: number + code: string + name: string + description: string + app_id: number + permissions: Array<{ id: number; code: string; name: string }> + sources: GrantSource[] +} + +export interface EffectivePermission { + id: number + code: string + name: string + description: string + category: string + app_id: number + sources: GrantSource[] +} + +export interface EffectiveGrants { + eligible: boolean + denial_reason?: string + roles: EffectiveRole[] + permissions: EffectivePermission[] +} + +export const appGrantMappingApi = { + async list(appId: string) { + const response = await client.get(`/api/v1/tenant/apps/${appId}/rbac/mappings`) + return (response.data?.data?.mappings || []) as AppGrantMapping[] + }, + + async options(appId: string) { + const response = await client.get(`/api/v1/tenant/apps/${appId}/rbac/mappings/options`) + return response.data.data as AppGrantMappingOptions + }, + + async preview(appId: string, input: AppGrantMappingInput) { + const response = await client.post(`/api/v1/tenant/apps/${appId}/rbac/mappings/preview`, input) + return Number(response.data?.data?.affected_user_count || 0) + }, + + async create(appId: string, input: AppGrantMappingInput) { + const response = await client.post(`/api/v1/tenant/apps/${appId}/rbac/mappings`, input) + return response.data.data as AppGrantMapping + }, + + async update(appId: string, mappingId: number, input: AppGrantMappingInput) { + const response = await client.put(`/api/v1/tenant/apps/${appId}/rbac/mappings/${mappingId}`, input) + return response.data.data as AppGrantMapping + }, + + async remove(appId: string, mappingId: number) { + await client.delete(`/api/v1/tenant/apps/${appId}/rbac/mappings/${mappingId}`) + }, + + async effectiveGrants(appId: string, userId: string) { + const response = await client.get(`/api/v1/tenant/apps/${appId}/users/${userId}/effective-grants`) + return response.data.data as EffectiveGrants + }, +} diff --git a/basaltpass-frontend/src/shared/api/tenant/rbacManifest.ts b/basaltpass-frontend/src/shared/api/tenant/rbacManifest.ts new file mode 100644 index 00000000..00127d71 --- /dev/null +++ b/basaltpass-frontend/src/shared/api/tenant/rbacManifest.ts @@ -0,0 +1,105 @@ +import client from '../client' + +export type RBACManifestStatus = 'pending' | 'approved' | 'rejected' | 'superseded' + +export interface RBACManifestDiff { + has_changes: boolean + permissions_added: string[] + permissions_updated: string[] + permissions_removed: string[] + roles_added: string[] + roles_updated: string[] + roles_removed: string[] + role_permissions_added: string[] + role_permissions_removed: string[] + assigned_roles_affected: string[] + removal_assignment_blocks: string[] +} + +export interface RBACManifestPermission { + permission_key: string + display_name: string + resource: string + action: string + scope: string + description: string + status: string +} + +export interface RBACManifestRole { + role_key: string + display_name: string + description: string + assignable: boolean + priority: number + status: string +} + +export interface RBACManifestPayload { + schema_version: string + type: 'basalt_rbac_bundle' + revision: number + permissions: RBACManifestPermission[] + roles: RBACManifestRole[] + role_permissions: Array<{ role_key: string; permission_key: string; effect: 'allow' }> +} + +export interface RBACManifestRecord { + id: number + tenant_id: number + app_id: number + source_client_id: string + schema_version: string + source_revision: number + digest: string + base_digest: string + status: RBACManifestStatus + diff: RBACManifestDiff + manifest: RBACManifestPayload + submitted_at: string + reviewed_at?: string + reviewed_by?: number + review_note?: string + active_revision_id?: number +} + +export interface RBACRevisionRecord { + id: number + tenant_id: number + app_id: number + revision: number + digest: string + manifest_id?: number + action: 'baseline' | 'manifest' | 'rollback' + target_revision_id?: number + is_active: boolean + created_by: number + created_at: string +} + +export const tenantRBACManifestApi = { + async list(appId: string) { + const response = await client.get(`/api/v1/tenant/apps/${appId}/rbac/manifests`) + return response.data.data.manifests as RBACManifestRecord[] + }, + + async approve(appId: string, manifestId: number) { + const response = await client.post(`/api/v1/tenant/apps/${appId}/rbac/manifests/${manifestId}/approve`) + return response.data.data as RBACManifestRecord + }, + + async reject(appId: string, manifestId: number, note = '') { + const response = await client.post(`/api/v1/tenant/apps/${appId}/rbac/manifests/${manifestId}/reject`, { note }) + return response.data.data as RBACManifestRecord + }, + + async listRevisions(appId: string) { + const response = await client.get(`/api/v1/tenant/apps/${appId}/rbac/revisions`) + return response.data.data.revisions as RBACRevisionRecord[] + }, + + async rollback(appId: string, revisionId: number) { + const response = await client.post(`/api/v1/tenant/apps/${appId}/rbac/revisions/${revisionId}/rollback`) + return response.data.data as RBACRevisionRecord + }, +} diff --git a/basaltpass-frontend/src/shared/api/user/appRecharge.ts b/basaltpass-frontend/src/shared/api/user/appRecharge.ts new file mode 100644 index 00000000..163dac17 --- /dev/null +++ b/basaltpass-frontend/src/shared/api/user/appRecharge.ts @@ -0,0 +1,26 @@ +import client from '../client' +import type { Currency } from './currency' + +export interface AppRechargeApp { + id: number + tenant_id: number + name: string + description?: string + icon_url?: string + logo_url?: string + homepage_url?: string +} + +export interface AppRechargeCurrency extends Currency { + wallet_category?: string + is_default?: boolean +} + +export interface AppRechargeConfig { + app: AppRechargeApp + wallet_category: string + currencies: AppRechargeCurrency[] +} + +export const getAppRechargeConfig = (params: { app_id?: string | number; client_id?: string; category?: string; tenant?: string }) => + client.get('/api/v1/user/apps/recharge-config', { params }) diff --git a/basaltpass-frontend/src/shared/api/user/currency.ts b/basaltpass-frontend/src/shared/api/user/currency.ts index 874f9639..aeb7bc26 100644 --- a/basaltpass-frontend/src/shared/api/user/currency.ts +++ b/basaltpass-frontend/src/shared/api/user/currency.ts @@ -8,11 +8,24 @@ export interface Currency { symbol: string decimal_places: number type: string + exchange_rate_usd?: number + payment_enabled?: boolean is_active: boolean sort_order: number description: string icon_url?: string } +export interface CurrencyRate { + id: number + base_currency_code: string + quote_currency_code: string + rate: number + source: string + is_active: boolean + description?: string +} + export const getCurrencies = () => client.get('/api/v1/currencies') export const getCurrency = (code: string) => client.get(`/api/v1/currencies/${code}`) +export const getCurrencyRates = () => client.get('/api/v1/currencies/rates') diff --git a/basaltpass-frontend/src/shared/api/user/team.ts b/basaltpass-frontend/src/shared/api/user/team.ts index 7b8d113b..6439e115 100644 --- a/basaltpass-frontend/src/shared/api/user/team.ts +++ b/basaltpass-frontend/src/shared/api/user/team.ts @@ -56,6 +56,38 @@ export interface UserTeamResponse { joined_at: string; } +export interface TeamWalletCurrency { + code: string; + name: string; + name_cn?: string; + symbol?: string; + decimal_places: number; + type: string; +} + +export interface TeamWalletAccount { + id: number; + tenant_id: number; + owner_type: 'team'; + owner_id: number; + currency_id: number; + balance: number; + freeze: number; + currency?: TeamWalletCurrency | null; + created_at: string; + updated_at: string; +} + +export interface TeamWalletTransaction { + id: number; + wallet_id: number; + type: string; + amount: number; + status: string; + reference?: string; + created_at: string; +} + // teamAPI export const teamApi = { // createteam @@ -82,6 +114,14 @@ export const teamApi = { getTeamMembers: (id: number) => client.get<{ data: TeamMemberResponse[] }>(`/api/v1/teams/${id}/members`), + getTeamWallets: (id: number) => + client.get<{ data: TeamWalletAccount[] }>(`/api/v1/teams/${id}/wallets`), + + getTeamWalletHistory: (id: number, currency: string, limit = 100) => + client.get<{ data: TeamWalletTransaction[] }>(`/api/v1/teams/${id}/wallets/history`, { + params: { currency, limit }, + }), + // translatedteamtranslated addMember: (id: number, data: AddMemberRequest) => client.post<{ message: string }>(`/api/v1/teams/${id}/members`, data), @@ -97,4 +137,4 @@ export const teamApi = { // translatedteam leaveTeam: (id: number) => client.post<{ message: string }>(`/api/v1/teams/${id}/leave`), -}; \ No newline at end of file +}; diff --git a/basaltpass-frontend/src/shared/api/user/wallet.ts b/basaltpass-frontend/src/shared/api/user/wallet.ts index 0b89fad5..2a41b9ec 100644 --- a/basaltpass-frontend/src/shared/api/user/wallet.ts +++ b/basaltpass-frontend/src/shared/api/user/wallet.ts @@ -1,10 +1,32 @@ import client from '../client' +import type { Currency } from './currency' + +export interface WalletAccount { + ID: number + TenantID: number + UserID?: number + CurrencyID?: number + Balance: number + Freeze: number + Currency?: Currency | null +} + +export interface WalletTransaction { + ID: number + Type: string + Amount: number + Status: string + CreatedAt: string + Reference?: string + Wallet?: WalletAccount | null +} export const getBalance = (currency: string) => client.get('/api/v1/wallet/balance', { params: { currency } }) +export const getAccounts = () => client.get('/api/v1/wallet/accounts') export const recharge = (currency: string, amount: number) => client.post('/api/v1/wallet/recharge', { currency, amount }) export const withdraw = (currency: string, amount: number) => client.post('/api/v1/wallet/withdraw', { currency, amount }) export const history = (currency?: string, limit = 20) => - client.get('/api/v1/wallet/history', { + client.get('/api/v1/wallet/history', { params: { ...(currency ? { currency } : {}), limit, diff --git a/basaltpass-frontend/src/shared/constants/routes/user.ts b/basaltpass-frontend/src/shared/constants/routes/user.ts index 2fb26d45..84406e04 100644 --- a/basaltpass-frontend/src/shared/constants/routes/user.ts +++ b/basaltpass-frontend/src/shared/constants/routes/user.ts @@ -16,6 +16,8 @@ export const USER_ROUTES = { products: '/products', orders: '/orders', myApps: '/my-apps', + appRecharge: '/apps/recharge', + appRechargeSuccess: '/apps/recharge/success', security: '/security', securityTwoFA: '/security/2fa', securityPasskey: '/security/passkey', diff --git a/basaltpass-frontend/src/shared/contexts/ConfigContext.tsx b/basaltpass-frontend/src/shared/contexts/ConfigContext.tsx index 75d5b4c4..805fb5dd 100644 --- a/basaltpass-frontend/src/shared/contexts/ConfigContext.tsx +++ b/basaltpass-frontend/src/shared/contexts/ConfigContext.tsx @@ -7,6 +7,7 @@ interface ConfigContextType { marketEnabled: boolean /** andtranslated features.wallet_recharge_withdraw_enabled translated,defaulttranslated */ walletRechargeWithdrawEnabled: boolean + walletWithdrawEnabled: boolean siteName: string siteInitial: string setPageTitle: (pageTitle?: string) => void @@ -19,6 +20,7 @@ export const ConfigProvider: React.FC<{ children: React.ReactNode }> = ({ childr const [config, setConfig] = useState({ market_enabled: true, wallet_recharge_withdraw_enabled: false, + wallet_withdraw_enabled: false, site_name: DEFAULT_SITE_NAME, }) const [loading, setLoading] = useState(false) @@ -39,6 +41,7 @@ export const ConfigProvider: React.FC<{ children: React.ReactNode }> = ({ childr setConfig({ market_enabled: true, wallet_recharge_withdraw_enabled: false, + wallet_withdraw_enabled: false, site_name: DEFAULT_SITE_NAME, }) } finally { @@ -51,6 +54,7 @@ export const ConfigProvider: React.FC<{ children: React.ReactNode }> = ({ childr const marketEnabled = config?.market_enabled ?? true const walletRechargeWithdrawEnabled = config?.wallet_recharge_withdraw_enabled === true + const walletWithdrawEnabled = config?.wallet_withdraw_enabled === true const siteName = config?.site_name?.trim() || DEFAULT_SITE_NAME const siteInitial = siteName.charAt(0).toUpperCase() || 'B' @@ -77,7 +81,7 @@ export const ConfigProvider: React.FC<{ children: React.ReactNode }> = ({ childr }, [setPageTitle]) return ( - + {children} ) diff --git a/basaltpass-frontend/src/shared/i18n/messages/en.ts b/basaltpass-frontend/src/shared/i18n/messages/en.ts index db8fe23e..7ad9530b 100644 --- a/basaltpass-frontend/src/shared/i18n/messages/en.ts +++ b/basaltpass-frontend/src/shared/i18n/messages/en.ts @@ -532,13 +532,22 @@ const en = { description: 'Manage your funds and transactions', stats: { currentBalance: 'Current balance', + totalUsd: 'Total assets', monthlyIncome: 'Monthly income', monthlyExpense: 'Monthly expense', }, overview: { + totalUsd: 'Total assets in USD', + accountCount: '{{count}} wallet accounts', currentBalanceWithCode: 'Current balance ({{code}})', lastUpdated: 'Last updated: {{time}}', }, + accounts: { + title: 'Accounts', + empty: 'No wallet accounts yet', + noRate: 'No USD rate configured', + unknown: 'Unknown account', + }, quickActions: { title: 'Quick Actions', recharge: 'Recharge', @@ -555,6 +564,7 @@ const en = { }, recentTransactions: { title: 'Recent Transactions', + titleWithCode: 'Recent Transactions ({{code}})', viewAll: 'View all', empty: 'No recent transactions', status: { @@ -659,51 +669,35 @@ const en = { }, giftCard: { title: 'Gift Card Redeem', - codeLabel: 'Card Code', - codePlaceholder: 'e.g. GC-XXXXX-XXXXX', - submit: 'Redeem Now', }, errors: { title: 'Recharge failed', disabled: 'Wallet recharge is not available yet', invalidAmount: 'Please enter a valid recharge amount', selectCurrency: 'Please select recharge currency', + selectPaymentCurrency: 'Please select payment currency', + exchangeRateMissing: 'Exchange rate is not configured for this recharge', rechargeFailed: 'Recharge failed, please retry', - giftCodeRequired: 'Please enter Gift Card code', - giftRedeemFailed: 'Gift Card redemption failed', - }, - success: { - title: 'Recharge successful!', - redirecting: 'Redirecting to wallet page...', }, form: { title: 'Recharge Information', currencyLabel: 'Select currency', + paymentCurrencyLabel: 'Payment currency', + paymentCurrencyPlaceholder: 'Select payment currency', amountLabel: 'Recharge Amount {{currency}}', amountPlaceholder: '0.00', quickAmountLabel: 'Quick amount', - methodLabel: 'Payment method', + paymentEstimateTitle: 'Payment amount', + paymentEstimate: '{{target}} will cost {{payment}}', + exchangeRateHint: 'Calculated from the configured internal exchange rate.', submitWithAmount: 'Recharge {{symbol}}{{amount}} {{code}}', - }, - paymentMethods: { - alipay: { - name: 'Alipay', - description: 'QR payment', - }, - wechat: { - name: 'WeChat Pay', - description: 'QR payment', - }, - bank: { - name: 'Bank Card', - description: 'Online payment', - }, + continueToCheckout: 'Continue to checkout', }, guide: { title: 'Recharge Notes', items: { realtime: 'Recharge amount is credited to wallet balance in real time', - methods: 'Supports Alipay, WeChat Pay, bank card and more', + methods: 'Choose a payment method in checkout', limit: 'Single recharge limit: ¥50 - ¥50,000', fee: 'Recharge is free of charge', }, @@ -717,6 +711,45 @@ const en = { }, }, }, + appRecharge: { + header: { + title: '{{app}} Top-up', + appFallback: 'App', + description: 'Top up a wallet currency used by this app', + }, + form: { + title: 'App wallet top-up', + currencyLabel: 'App currency', + }, + guide: { + title: 'App-specific checkout', + body: 'This page only shows wallet currencies linked to {{app}}.', + currencyCount: '{{count}} currencies are available for this app.', + }, + success: { + title: 'Payment successful', + description: '{{app}} top-up has been credited.', + redirecting: 'Returning to {{host}} in {{seconds}} seconds.', + noReturnUrl: 'No app return URL was configured. You can view the updated balance in your wallet.', + pendingTitle: 'Confirming payment', + pendingDescription: 'We are confirming this top-up with the payment provider. Please refresh shortly.', + actions: { + retry: 'Confirm again', + returnNow: 'Return to app now', + wallet: 'View wallet', + }, + errors: { + missingSession: 'Payment session is missing.', + notComplete: 'The payment provider has not confirmed this payment yet.', + reconcileFailed: 'Failed to confirm this top-up. Please retry shortly.', + }, + }, + empty: 'This app has no wallet currency configured yet.', + errors: { + loadFailed: 'Failed to load app recharge configuration', + noCurrency: 'Please select an app currency', + }, + }, walletWithdraw: { header: { title: 'Withdraw Funds', @@ -1298,6 +1331,7 @@ const en = { noDescription: 'No description', }, actions: { + wallet: 'Team wallet', manageMembers: 'Manage members', inviteMembers: 'Invite members', manageInvitations: 'Manage invitations', @@ -1320,6 +1354,23 @@ const en = { confirmLeave: 'Confirm leave', }, }, + teamWallet: { + title: '{{team}} wallet', + description: 'View balances and transaction details owned by this team.', + backToTeam: 'Back to team', + readOnly: 'Read only', + empty: 'This team does not have any wallet accounts yet.', + errors: { + title: 'Wallet unavailable', + invalidTeam: 'Invalid team ID', + loadFailed: 'Failed to load team wallets', + historyFailed: 'Failed to load wallet transactions', + }, + transactions: { + title: 'Transactions', + empty: 'No transactions for this account.', + }, + }, teamIndex: { title: 'My Teams', description: 'Manage teams you created and joined', @@ -2226,6 +2277,14 @@ const en = { grantSelectedPermissions: 'Grant Selected Permissions ({{count}})', assignRoles: 'Assign Roles', assignSelectedRoles: 'Assign Selected Roles ({{count}})', + effectiveTitle: 'Effective authorization', + effectiveDescription: 'Read-only union of explicit assignments and current tenant mappings.', + effectiveRoles: 'Effective roles ({{count}})', + effectivePermissions: 'Effective permissions ({{count}})', + sourceExplicit: 'Explicit assignment', + sourceMapping: '{{source}} · mapping #{{mapping}}', + manageMappings: 'Manage mappings', + ineligible: 'No runtime grants are returned because the user is not eligible: {{reason}}', }, success: { permissionGranted: 'Permission granted successfully', @@ -3602,6 +3661,16 @@ const en = { description: 'Define and categorize permissions', badge: 'Permission Setup', }, + manifestManagement: { + title: 'Automatic RBAC Import', + description: 'Review app-submitted RBAC manifests', + badge: 'Review & Rollback', + }, + grantMapping: { + title: 'Tenant → App Mapping', + description: 'Map tenant roles and permissions to app grants', + badge: 'Dynamic grants', + }, }, }, quickStart: { @@ -3643,6 +3712,75 @@ const en = { copyFailed: 'Copy failed:', }, }, + tenantAppGrantMappings: { + title: 'Tenant → App Grant Mappings', + description: 'Automatically derive app roles and permissions from current tenant access without creating user assignment records.', + back: 'Back to app', + dynamicNotice: 'Mappings are evaluated on every authorization query. Tenant role changes, expiry, disabling, and deletion take effect immediately.', + empty: 'No mapping policy has been configured for this app.', + affected: '{{count}} authorized users affected', + validity: 'Valid from {{from}} until {{until}}', + sourceTypes: { membership_role: 'Tenant membership role', tenant_role: 'Tenant RBAC role', tenant_permission: 'Effective tenant permission' }, + targetTypes: { app_role: 'App role', app_permission: 'App permission' }, + status: { enabled: 'Enabled', disabled: 'Disabled' }, + actions: { create: 'Create mapping', edit: 'Edit', delete: 'Delete', enable: 'Enable', disable: 'Disable', preview: 'Preview impact', save: 'Save', cancel: 'Cancel' }, + editor: { + createTitle: 'Create grant mapping', editTitle: 'Edit grant mapping', sourceType: 'Tenant source type', source: 'Tenant source', + targetType: 'App target type', target: 'App target', select: 'Select…', validFrom: 'Valid from (optional)', validUntil: 'Valid until (optional)', + enabled: 'Enable this mapping', previewResult: '{{count}} currently authorized app users match this policy.', + }, + confirm: { delete: 'Delete the mapping from {{source}} to {{target}}? Inherited access will disappear immediately.' }, + errors: { load: 'Failed to load grant mappings', preview: 'Failed to preview mapping impact', save: 'Failed to save grant mapping', delete: 'Failed to delete grant mapping', validity: 'Valid until must be later than valid from.' }, + }, + tenantRBACManifests: { + title: 'Automatic RBAC Import', + description: 'Review and publish RBAC-only manifests submitted by {{app}}', + back: 'Back to app', + refresh: 'Refresh', + integration: { + title: 'App submission endpoint', + description: 'The authenticated app can only submit a draft with the dedicated scope. OAuth settings and user assignments are rejected.', + }, + manifests: { + title: 'Submitted manifests', + emptyTitle: 'No manifest submitted', + emptyDescription: 'After the app submits its RBAC manifest, it will appear here for review.', + revision: 'App revision {{revision}}', + blocked: 'Blocked', + blockedTitle: 'This manifest cannot be published yet', + blockedDescription: 'It removes a role or permission referenced by user assignments or Tenant → App mappings. Remove the reference or submit a compatible manifest.', + noChanges: 'This manifest does not change the effective RBAC configuration.', + permissions: 'permissions', + roles: 'roles', + }, + status: { pending: 'Pending review', approved: 'Published', rejected: 'Rejected', superseded: 'Superseded' }, + diff: { + permissionsAdded: 'Permissions added', permissionsUpdated: 'Permissions updated', permissionsRemoved: 'Permissions removed', + rolesAdded: 'Roles added', rolesUpdated: 'Roles updated', rolesRemoved: 'Roles removed', + rolePermissionsAdded: 'Role grants added', rolePermissionsRemoved: 'Role grants removed', + assignedRolesAffected: 'Assigned or mapped roles affected', removalBlocks: 'Removal blockers', + }, + revisions: { + title: 'Published revision history', empty: 'No published revision yet', active: 'Active', + baseline: 'Baseline', manifest: 'Manifest', rollback: 'Rollback', + safetyTitle: 'Rollback safety', + safetyDescription: 'Rollback creates a new immutable revision. It is refused if it would delete a role or permission referenced by user assignments or Tenant → App mappings.', + }, + actions: { approve: 'Approve and publish', reject: 'Reject', rollback: 'Rollback to this revision' }, + confirm: { + approve: 'Approve app revision {{revision}} and atomically publish this RBAC configuration?', + reject: 'Reject app revision {{revision}}?', + rollback: 'Create a new active revision from revision #{{revision}}?', + }, + success: { + approved: 'App revision {{revision}} was published.', rejected: 'App revision {{revision}} was rejected.', + rolledBack: 'Rolled back to the snapshot from revision #{{revision}}.', + }, + errors: { + load: 'Failed to load RBAC manifests', approve: 'Failed to approve the manifest', reject: 'Failed to reject the manifest', + rollback: 'Failed to roll back the revision', blocked: 'This manifest would remove referenced RBAC entities and cannot be published.', + }, + }, adminDashboard: { layoutTitle: 'Admin Console', title: 'Admin Dashboard', diff --git a/basaltpass-frontend/src/shared/i18n/messages/zh.ts b/basaltpass-frontend/src/shared/i18n/messages/zh.ts index 1befca80..ffe04f25 100644 --- a/basaltpass-frontend/src/shared/i18n/messages/zh.ts +++ b/basaltpass-frontend/src/shared/i18n/messages/zh.ts @@ -532,13 +532,22 @@ const en = { description: 'Manage your funds and transactions', stats: { currentBalance: 'Current balance', + totalUsd: '总资产', monthlyIncome: 'Monthly income', monthlyExpense: 'Monthly expense', }, overview: { + totalUsd: '按美元计价的总资产', + accountCount: '{{count}} 个钱包账户', currentBalanceWithCode: 'Current balance ({{code}})', lastUpdated: 'Last updated: {{time}}', }, + accounts: { + title: '账户', + empty: '暂无钱包账户', + noRate: '未配置美元汇率', + unknown: '未知账户', + }, quickActions: { title: '快捷操作', recharge: 'Recharge', @@ -555,6 +564,7 @@ const en = { }, recentTransactions: { title: 'Recent Transactions', + titleWithCode: '最近交易({{code}})', viewAll: 'View all', empty: 'No recent transactions', status: { @@ -659,51 +669,35 @@ const en = { }, giftCard: { title: 'Gift Card Redeem', - codeLabel: 'Card Code', - codePlaceholder: 'e.g. GC-XXXXX-XXXXX', - submit: 'Redeem Now', }, errors: { title: 'Recharge failed', disabled: 'Wallet recharge is not available yet', invalidAmount: 'Please enter a valid recharge amount', selectCurrency: 'Please select recharge currency', + selectPaymentCurrency: '请选择付款货币', + exchangeRateMissing: '当前充值币种没有配置汇率', rechargeFailed: 'Recharge failed, please retry', - giftCodeRequired: 'Please enter Gift Card code', - giftRedeemFailed: 'Gift Card redemption failed', - }, - success: { - title: 'Recharge successful!', - redirecting: 'Redirecting to wallet page...', }, form: { title: 'Recharge Information', currencyLabel: 'Select currency', + paymentCurrencyLabel: '付款货币', + paymentCurrencyPlaceholder: '请选择付款货币', amountLabel: 'Recharge Amount {{currency}}', amountPlaceholder: '0.00', quickAmountLabel: 'Quick amount', - methodLabel: 'Payment method', + paymentEstimateTitle: '付款金额', + paymentEstimate: '{{target}} 需要支付 {{payment}}', + exchangeRateHint: '金额根据系统配置的内部汇率计算。', submitWithAmount: 'Recharge {{symbol}}{{amount}} {{code}}', - }, - paymentMethods: { - alipay: { - name: 'Alipay', - description: 'QR payment', - }, - wechat: { - name: 'WeChat Pay', - description: 'QR payment', - }, - bank: { - name: 'Bank Card', - description: 'Online payment', - }, + continueToCheckout: '进入收银台', }, guide: { title: 'Recharge Notes', items: { realtime: 'Recharge amount is credited to wallet balance in real time', - methods: 'Supports Alipay, WeChat Pay, bank card and more', + methods: '在收银台选择付款方式', limit: 'Single recharge limit: ¥50 - ¥50,000', fee: 'Recharge is free of charge', }, @@ -717,6 +711,45 @@ const en = { }, }, }, + appRecharge: { + header: { + title: '{{app}} 充值', + appFallback: '应用', + description: '为这个应用使用的钱包币种充值', + }, + form: { + title: '应用钱包充值', + currencyLabel: '应用币种', + }, + guide: { + title: '应用专属收银台', + body: '此页面只显示 {{app}} 关联的钱包币种。', + currencyCount: '此应用当前可充值 {{count}} 个币种。', + }, + success: { + title: '付款成功', + description: '{{app}} 的充值已经到账。', + redirecting: '{{seconds}} 秒后返回 {{host}}。', + noReturnUrl: '没有配置应用返回地址,你可以返回钱包查看余额。', + pendingTitle: '正在确认付款', + pendingDescription: '我们正在向支付渠道确认这笔充值,请稍后刷新。', + actions: { + retry: '重新确认', + returnNow: '立即返回应用', + wallet: '查看钱包', + }, + errors: { + missingSession: '缺少支付会话。', + notComplete: '支付渠道还没有确认这笔付款。', + reconcileFailed: '确认充值失败,请稍后重试。', + }, + }, + empty: '这个应用还没有配置钱包币种。', + errors: { + loadFailed: '加载应用充值配置失败', + noCurrency: '请选择应用币种', + }, + }, walletWithdraw: { header: { title: 'Withdraw Funds', @@ -1298,6 +1331,7 @@ const en = { noDescription: 'No description', }, actions: { + wallet: '团队钱包', manageMembers: 'Manage members', inviteMembers: 'Invite members', manageInvitations: 'Manage invitations', @@ -1320,6 +1354,23 @@ const en = { confirmLeave: 'Confirm leave', }, }, + teamWallet: { + title: '{{team}}的钱包', + description: '查看归属于该团队的资产余额和交易明细。', + backToTeam: '返回团队', + readOnly: '只读', + empty: '该团队还没有钱包账户。', + errors: { + title: '钱包不可用', + invalidTeam: '团队 ID 无效', + loadFailed: '无法加载团队钱包', + historyFailed: '无法加载钱包交易记录', + }, + transactions: { + title: '交易记录', + empty: '该账户暂无交易记录。', + }, + }, teamIndex: { title: 'My Teams', description: 'Manage teams you created and joined', @@ -1446,16 +1497,17 @@ const en = { userLayout: { pageTitle: '用户中心', nav: { - dashboard: 'Dashboard', - profile: 'Profile', - teams: 'Teams', - wallet: 'Wallet', - subscriptions: 'My Subscriptions', - products: 'Products & Plans', + dashboard: '仪表盘', + profile: '个人资料', + teams: '团队', + wallet: '钱包', + subscriptions: '我的订阅', + products: '产品与套餐', orders: '我的订单', - myApps: 'My Apps', - security: 'Security', - settings: 'Settings', + myApps: '我的应用', + security: '安全', + settings: '设置', + help: '帮助', }, theme: { openMenu: '主题', @@ -1463,17 +1515,17 @@ const en = { light: '亮色', dark: '暗色', }, - tenantManagement: 'Tenant Management', - adminPanel: 'Admin Panel', - switchToTenantTitle: 'Switch to Tenant Console (on-demand authorization)', - switchToAdminTitle: 'Switch to Global Admin Console (on-demand authorization)', - tenantSwitchFailed: 'This account has no tenant management permission, or tenant console authorization failed.', - tenantSwitchFailedTitle: 'Unable to Open Tenant Management', - noTenantPerspectiveAvailable: 'No tenant perspective is available for this account.', - selectTenantPerspectiveTitle: 'Select Tenant Perspective', - selectTenantPerspectiveDescription: 'Choose a tenant to enter its dashboard view.', - switchToTenantFor: 'Enter {{tenant}}', - tenantUnknown: 'Unknown tenant', + tenantManagement: '租户管理', + adminPanel: '管理后台', + switchToTenantTitle: '切换到租户控制台(按需授权)', + switchToAdminTitle: '切换到全局管理控制台(按需授权)', + tenantSwitchFailed: '该账户没有租户管理权限,或租户控制台授权失败。', + tenantSwitchFailedTitle: '无法打开租户管理', + noTenantPerspectiveAvailable: '该账户没有可用的租户视角。', + selectTenantPerspectiveTitle: '选择租户视角', + selectTenantPerspectiveDescription: '选择一个租户以进入其仪表盘视图。', + switchToTenantFor: '进入 {{tenant}}', + tenantUnknown: '未知租户', }, tenantLayout: { consoleTitle: 'Tenant Console', @@ -2226,6 +2278,14 @@ const en = { grantSelectedPermissions: 'Grant Selected Permissions ({{count}})', assignRoles: 'Assign Roles', assignSelectedRoles: 'Assign Selected Roles ({{count}})', + effectiveTitle: '当前有效授权', + effectiveDescription: '只读展示显式分配与 Tenant 动态映射合并后的结果。', + effectiveRoles: '有效角色({{count}})', + effectivePermissions: '有效权限({{count}})', + sourceExplicit: '显式分配', + sourceMapping: '{{source}} · 映射 #{{mapping}}', + manageMappings: '管理映射', + ineligible: '用户当前不符合运行时授权条件,因此不返回任何权限:{{reason}}', }, success: { permissionGranted: 'Permission granted successfully', @@ -3602,6 +3662,16 @@ const en = { description: 'Define and categorize permissions', badge: 'Permission Setup', }, + manifestManagement: { + title: 'RBAC 自动导入', + description: '审核应用自动提交的 RBAC 配置', + badge: '审批与回滚', + }, + grantMapping: { + title: 'Tenant → App 映射', + description: '将租户角色和权限映射为应用授权', + badge: '动态授权', + }, }, }, quickStart: { @@ -3643,6 +3713,75 @@ const en = { copyFailed: 'Copy failed:', }, }, + tenantAppGrantMappings: { + title: 'Tenant → App 授权映射', + description: '根据用户当前的租户角色和权限动态获得应用角色或权限,不创建用户继承分配记录。', + back: '返回应用', + dynamicNotice: '映射会在每次授权查询时动态计算;租户角色变更、过期、停用或删除会立即生效。', + empty: '该应用尚未配置任何授权映射。', + affected: '当前影响 {{count}} 个已授权用户', + validity: '生效时间:{{from}} 至 {{until}}', + sourceTypes: { membership_role: '租户成员身份', tenant_role: 'Tenant RBAC 角色', tenant_permission: '有效 Tenant 权限' }, + targetTypes: { app_role: '应用角色', app_permission: '应用权限' }, + status: { enabled: '已启用', disabled: '已停用' }, + actions: { create: '新建映射', edit: '编辑', delete: '删除', enable: '启用', disable: '停用', preview: '预览影响', save: '保存', cancel: '取消' }, + editor: { + createTitle: '新建授权映射', editTitle: '编辑授权映射', sourceType: '租户来源类型', source: '租户来源', + targetType: '应用目标类型', target: '应用目标', select: '请选择…', validFrom: '开始时间(可选)', validUntil: '结束时间(可选)', + enabled: '启用该映射', previewResult: '当前有 {{count}} 个已授权应用用户符合该策略。', + }, + confirm: { delete: '确定删除 {{source}} 到 {{target}} 的映射吗?继承授权将立即消失。' }, + errors: { load: '加载授权映射失败', preview: '预览映射影响失败', save: '保存授权映射失败', delete: '删除授权映射失败', validity: '结束时间必须晚于开始时间。' }, + }, + tenantRBACManifests: { + title: 'RBAC 自动导入', + description: '审核并发布 {{app}} 自动提交的纯 RBAC 配置', + back: '返回应用', + refresh: '刷新', + integration: { + title: '应用提交接口', + description: '已认证应用只能通过专用 scope 提交草案;OAuth 配置和用户授权字段会被拒绝。', + }, + manifests: { + title: '配置提交记录', + emptyTitle: '尚未收到配置', + emptyDescription: '应用提交 RBAC manifest 后,会在这里等待管理员审核。', + revision: '应用版本 {{revision}}', + blocked: '存在阻断', + blockedTitle: '当前配置暂时无法发布', + blockedDescription: '它尝试删除被用户授权或 Tenant → App 映射引用的角色或权限。请先移除引用,或让应用提交兼容配置。', + noChanges: '该配置不会改变当前生效的 RBAC。', + permissions: '个权限', + roles: '个角色', + }, + status: { pending: '待审核', approved: '已发布', rejected: '已拒绝', superseded: '已被新版本替代' }, + diff: { + permissionsAdded: '新增权限', permissionsUpdated: '更新权限', permissionsRemoved: '删除权限', + rolesAdded: '新增角色', rolesUpdated: '更新角色', rolesRemoved: '删除角色', + rolePermissionsAdded: '新增角色授权', rolePermissionsRemoved: '移除角色授权', + assignedRolesAffected: '受影响的已分配或映射角色', removalBlocks: '删除阻断项', + }, + revisions: { + title: '已发布版本历史', empty: '尚无已发布版本', active: '当前生效', + baseline: '初始基线', manifest: '配置发布', rollback: '回滚版本', + safetyTitle: '回滚安全保护', + safetyDescription: '回滚会创建新的不可变版本;如果需要删除被用户授权或 Tenant → App 映射引用的角色或权限,回滚将被拒绝。', + }, + actions: { approve: '批准并发布', reject: '拒绝', rollback: '回滚到此版本' }, + confirm: { + approve: '确认批准应用版本 {{revision}} 并原子发布此 RBAC 配置吗?', + reject: '确认拒绝应用版本 {{revision}} 吗?', + rollback: '确认根据版本 #{{revision}} 创建一个新的生效版本吗?', + }, + success: { + approved: '应用版本 {{revision}} 已成功发布。', rejected: '应用版本 {{revision}} 已拒绝。', + rolledBack: '已回滚到版本 #{{revision}} 的配置快照。', + }, + errors: { + load: '加载 RBAC 配置失败', approve: '批准配置失败', reject: '拒绝配置失败', + rollback: '回滚版本失败', blocked: '该配置会删除仍被引用的角色或权限,因此不能发布。', + }, + }, adminDashboard: { layoutTitle: 'Admin Console', title: 'Admin Dashboard', @@ -4052,90 +4191,90 @@ const en = { }, }, userOrderSuccess: { - loading: 'Loading...', - title: 'Payment Successful!', - subtitle: 'Thanks for your subscription. Your order has been paid successfully.', + loading: '加载中……', + title: '支付成功!', + subtitle: '感谢你的订阅,订单已支付成功。', orderDetail: { - title: 'Order Details', - paidStatus: 'Paid', - orderNumber: 'Order Number', - product: 'Product', - amount: 'Paid Amount', - paidAt: 'Paid At', + title: '订单详情', + paidStatus: '已支付', + orderNumber: '订单号', + product: '产品', + amount: '支付金额', + paidAt: '支付时间', }, nextStep: { - title: 'Subscription Activated', - description: 'Your subscription is now active. You can start using our services right away!', + title: '订阅已激活', + description: '你的订阅现已生效,可以立即开始使用我们的服务!', }, actions: { - viewSubscriptions: 'View My Subscriptions', - backToConsole: 'Back to Console', - viewMoreProducts: 'View More Products', + viewSubscriptions: '查看我的订阅', + backToConsole: '返回控制台', + viewMoreProducts: '查看更多产品', }, logs: { - fetchOrderFailed: 'Failed to fetch order:', + fetchOrderFailed: '获取订单失败:', }, }, userOrderConfirm: { - loading: 'Loading...', - title: 'Order Confirmation', + loading: '加载中……', + title: '订单确认', breadcrumb: { - dashboard: 'Dashboard', - products: 'Products & Plans', - confirm: 'Order Confirmation', + dashboard: '仪表盘', + products: '产品与套餐', + confirm: '订单确认', }, status: { - pending: 'Pending Payment', - paid: 'Paid', - expired: 'Expired', - cancelled: 'Cancelled', + pending: '待支付', + paid: '已支付', + expired: '已过期', + cancelled: '已取消', }, sections: { - orderInfo: 'Order Information', - productDetail: 'Product Details', - priceDetail: 'Price Details', + orderInfo: '订单信息', + productDetail: '产品详情', + priceDetail: '价格明细', }, fields: { - orderNumber: 'Order Number', - createdAt: 'Created At', - expiresAt: 'Expires At', - timeLeft: 'Time Left', + orderNumber: '订单号', + createdAt: '创建时间', + expiresAt: '过期时间', + timeLeft: '剩余时间', }, timeLeft: { - minutesSeconds: '{{minutes}}m {{seconds}}s', - expired: 'Expired', + minutesSeconds: '{{minutes}}分{{seconds}}秒', + expired: '已过期', }, - quantity: 'Quantity: {{count}}', - billing: '{{count}}{{unit}} billing', + quantity: '数量:{{count}}', + billing: '{{count}}{{unit}} 计费', billingUnits: { - month: 'month', - year: 'year', - week: 'week', - day: 'day', + month: '个月', + year: '年', + week: '周', + day: '天', }, price: { - base: 'Base Price', - discount: 'Discount', - coupon: 'Coupon', - total: 'Total', + base: '基础价格', + discount: '折扣', + coupon: '优惠券', + total: '合计', }, actions: { - payNow: 'Pay Now ¥{{amount}}', - paid: 'Order Paid', - expired: 'Order Expired', - cancelled: 'Order Cancelled', - backToProducts: 'Back to Products', + payNow: '立即支付 ¥{{amount}}', + paid: '订单已支付', + expired: '订单已过期', + cancelled: '订单已取消', + backToProducts: '返回产品', }, empty: { - title: 'Order Not Found', - description: 'Please check whether the order link is correct.', + title: '未找到订单', + description: '请检查订单链接是否正确。', }, errors: { - createPaymentFailed: 'Failed to create payment, please try again', + createPaymentFailed: '创建支付失败,请重试', }, logs: { - fetchOrderFailed: 'Failed to fetch order:', - createPaymentFailed: 'Failed to create payment:', + fetchOrderFailed: '获取订单失败:', + createPaymentFailed: '创建支付失败:', }, }, userOrderList: { @@ -4175,182 +4314,182 @@ const en = { }, }, userNotifications: { - title: 'Notification Center', - description: 'Manage all your notifications', + title: '通知中心', + description: '管理你所有的通知', badges: { - unread: 'Unread', + unread: '未读', }, types: { - success: 'Success', - warning: 'Warning', - error: 'Error', - info: 'Info', + success: '成功', + warning: '警告', + error: '错误', + info: '信息', }, actions: { - markAllRead: 'Mark All as Read', - markRead: 'Mark as Read', - delete: 'Delete Notification', + markAllRead: '全部标为已读', + markRead: '标为已读', + delete: '删除通知', }, filters: { - all: 'All ({{count}})', - unread: 'Unread ({{count}})', - read: 'Read ({{count}})', + all: '全部({{count}})', + unread: '未读({{count}})', + read: '已读({{count}})', }, empty: { - title: 'No notifications', - all: 'You have not received any notifications yet', - unread: 'No unread notifications', - read: 'No read notifications', + title: '暂无通知', + all: '你还没有收到任何通知', + unread: '没有未读通知', + read: '没有已读通知', }, logs: { - loadFailed: 'Failed to load notifications:', + loadFailed: '加载通知失败:', }, }, userAppsIndex: { - title: 'My Apps', - description: 'View and manage your authorized applications', - confirmRevoke: 'Are you sure you want to revoke authorization for "{{appName}}"?', - firstAuthorizedAt: 'First authorized: {{date}}', - lastActiveAt: 'Last active: {{date}}', - scopes: 'Scopes', + title: '我的应用', + description: '查看并管理你已授权的应用', + confirmRevoke: '确定要取消对“{{appName}}”的授权吗?', + firstAuthorizedAt: '首次授权:{{date}}', + lastActiveAt: '最近活跃:{{date}}', + scopes: '授权范围', dash: '—', actions: { - retry: 'Retry', - viewApp: 'View App', - revoke: 'Revoke Authorization', + retry: '重试', + viewApp: '查看应用', + revoke: '取消授权', }, empty: { - title: 'No authorized apps', - description: 'You have not authorized any applications yet', + title: '暂无已授权应用', + description: '你还没有授权任何应用', }, messages: { - revoked: 'Authorization revoked', + revoked: '授权已取消', }, errors: { - title: 'Load failed', - loadFailed: 'Load failed', - revokeFailed: 'Revoke failed', + title: '加载失败', + loadFailed: '加载失败', + revokeFailed: '取消授权失败', }, }, userAppDetail: { - noDescription: 'No description', + noDescription: '暂无描述', dash: '—', - confirmRevoke: 'Are you sure you want to revoke authorization for "{{appName}}"?', + confirmRevoke: '确定要取消对“{{appName}}”的授权吗?', sections: { - authorizationInfo: 'Authorization Information', - scopes: 'Scopes', + authorizationInfo: '授权信息', + scopes: '授权范围', }, fields: { - firstAuthorizedAt: 'First Authorization Time', - lastActiveAt: 'Last Active Time', - lastAuthorizedAt: 'Last Authorization Time', + firstAuthorizedAt: '首次授权时间', + lastActiveAt: '最近活跃时间', + lastAuthorizedAt: '最近授权时间', }, actions: { - back: 'Back', - backToMyApps: 'Back to My Apps', - retry: 'Retry', - revoke: 'Revoke Authorization', + back: '返回', + backToMyApps: '返回我的应用', + retry: '重试', + revoke: '取消授权', }, messages: { - revoked: 'Authorization revoked', + revoked: '授权已取消', }, errors: { - title: 'Load failed', - loadFailed: 'Load failed', - revokeFailed: 'Revoke failed', - notFound: 'App not found', - notFoundOrUnauthorized: 'App does not exist or is not authorized', + title: '加载失败', + loadFailed: '加载失败', + revokeFailed: '取消授权失败', + notFound: '未找到应用', + notFoundOrUnauthorized: '应用不存在或未被授权', }, - scopeHint: 'This app has been authorized to access the data and features within the scopes above. If you no longer trust this app, you can revoke authorization at any time.', + scopeHint: '该应用已被授权访问上述范围内的数据和功能。如果你不再信任该应用,可以随时取消授权。', }, userProfilePage: { - unset: 'Not set', - unsetNickname: 'No nickname set', - userId: 'User ID: {{id}}', + unset: '未设置', + unsetNickname: '未设置昵称', + userId: '用户 ID:{{id}}', badges: { - verified: 'Verified', - activeUser: 'Active User', + verified: '已认证', + activeUser: '活跃用户', }, accountStats: { - title: 'Account Statistics', - registeredAt: 'Registered At', - updatedAt: 'Last Updated', - status: 'Account Status', - normal: 'Normal', + title: '账户统计', + registeredAt: '注册时间', + updatedAt: '最近更新', + status: '账户状态', + normal: '正常', }, basicInfo: { - title: 'Basic Information', - nickname: 'Nickname', - email: 'Email Address', - phone: 'Phone Number', + title: '基本信息', + nickname: '昵称', + email: '邮箱地址', + phone: '手机号码', }, placeholders: { - nickname: 'Please enter nickname', - phone: 'Please enter phone number', + nickname: '请输入昵称', + phone: '请输入手机号', }, emailEditHint: { - prefix: 'Email change requires security verification. Please go to', - securitySettings: 'Security Settings', - suffix: 'to continue.', + prefix: '修改邮箱需要进行安全验证,请前往', + securitySettings: '安全设置', + suffix: '继续。', }, details: { - title: 'Detailed Profile', - gender: 'Gender', - birthDate: 'Birth Date', - language: 'Language', - timezone: 'Time Zone', - primaryCurrency: 'Primary Currency', - location: 'Location', - company: 'Company', - jobTitle: 'Job Title', - website: 'Website', - bio: 'Bio', - editInSettings: 'Edit detailed profile in settings →', + title: '详细资料', + gender: '性别', + birthDate: '出生日期', + language: '语言', + timezone: '时区', + primaryCurrency: '主货币', + location: '所在地', + company: '公司', + jobTitle: '职位', + website: '网站', + bio: '个人简介', + editInSettings: '在设置中编辑详细资料 →', }, actions: { - retry: 'Retry', - editProfile: 'Edit Profile', - cancel: 'Cancel', - saveChanges: 'Save Changes', + retry: '重试', + editProfile: '编辑资料', + cancel: '取消', + saveChanges: '保存修改', }, errors: { - loadFailed: 'Failed to load profile, please check your network and try again', - saveFailed: 'Save failed, please try again later', + loadFailed: '加载个人资料失败,请检查网络后重试', + saveFailed: '保存失败,请稍后重试', }, }, userInvitationsInbox: { - title: 'Invitation Inbox', - description: 'View and manage team invitations you received', - totalCount: '{{count}} invitations', - inviteFromTeam: 'Invitation from team "{{teamName}}"', - inviter: 'Inviter: {{name}}', + title: '邀请收件箱', + description: '查看并管理你收到的团队邀请', + totalCount: '{{count}} 条邀请', + inviteFromTeam: '来自团队“{{teamName}}”的邀请', + inviter: '邀请人:{{name}}', status: { - pending: 'Pending', - accepted: 'Accepted', - rejected: 'Rejected', - revoked: 'Revoked', + pending: '待处理', + accepted: '已接受', + rejected: '已拒绝', + revoked: '已撤销', }, time: { - today: 'Today {{time}}', - yesterday: 'Yesterday {{time}}', - daysAgo: '{{days}} days ago', + today: '今天 {{time}}', + yesterday: '昨天 {{time}}', + daysAgo: '{{days}} 天前', }, empty: { - title: 'No invitations', - description: 'You currently have no team invitations. New invitations will appear here.', + title: '暂无邀请', + description: '你目前没有团队邀请,新的邀请会显示在这里。', }, actions: { - backToTeams: 'Back to team list', - accept: 'Accept Invitation', - reject: 'Reject', + backToTeams: '返回团队列表', + accept: '接受邀请', + reject: '拒绝', }, errors: { - acceptFailed: 'Failed to accept invitation', - rejectFailed: 'Failed to reject invitation', + acceptFailed: '接受邀请失败', + rejectFailed: '拒绝邀请失败', }, logs: { - loadFailed: 'Failed to load invitations:', - loadFailedDetail: 'Invitation loading failed:', + loadFailed: '加载邀请失败:', + loadFailedDetail: '邀请加载失败:', }, }, adminCreateApp: { diff --git a/basaltpass-frontend/src/shared/styles/components.css b/basaltpass-frontend/src/shared/styles/components.css index c283316f..a216174f 100644 --- a/basaltpass-frontend/src/shared/styles/components.css +++ b/basaltpass-frontend/src/shared/styles/components.css @@ -128,3 +128,112 @@ .bp-button-gradient { @apply rounded-lg border-transparent bg-blue-600 text-white hover:bg-indigo-700 focus:ring-blue-500 shadow-sm; } + +.user-console-shell { + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + background: var(--bp-white); +} + +.user-console-shell :is(.shadow, .shadow-sm, .shadow-md, .shadow-lg, .shadow-xl, .shadow-2xl) { + --tw-shadow: 0 0 #0000 !important; + --tw-shadow-colored: 0 0 #0000 !important; + box-shadow: none !important; +} + +.user-console-shell :is(.rounded-xl, .rounded-2xl, .rounded-3xl) { + border-radius: 0.5rem !important; +} + +.user-console-shell :is(.bg-gray-100, .bg-gray-50) { + background-color: #ffffff; +} + +.user-console-shell :is(.hover\:bg-gray-50:hover, .hover\:bg-gray-100:hover) { + background-color: rgb(17 24 39 / 0.035); +} + +.user-console-shell :is(.border-gray-200, .border-gray-300) { + border-color: rgb(17 24 39 / 0.12); +} + +.user-console-shell :is(.ring-black, .ring-opacity-5) { + --tw-ring-color: rgb(17 24 39 / 0.12); +} + +.user-console-shell main > div { + padding-block: clamp(2rem, 4vw, 3.5rem); +} + +.user-console-shell main > div > div { + width: 100%; + max-width: 72rem; + padding-inline: clamp(1.5rem, 4vw, 3.5rem); +} + +.user-console-shell main .space-y-6 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(2rem * var(--tw-space-y-reverse)); +} + +.user-console-shell main :is(h1, h2, h3) { + letter-spacing: 0; +} + +.user-console-shell main h1 { + font-size: clamp(2rem, 4vw, 3.5rem); + line-height: 1.05; + font-weight: 750; +} + +.user-console-shell main [class*="grid-cols"] > .bg-white[class*="border"], +.user-console-shell main [class*="grid-cols"] > .rounded-lg.bg-white, +.user-console-shell main [class*="grid-cols"] > .rounded-xl.bg-white, +.user-console-shell main [class*="grid-cols"] > .rounded-2xl.bg-white { + background-color: transparent; +} + +.user-console-shell main label.rounded-xl.border, +.user-console-shell main label.rounded-lg.border { + border-radius: 0; + border-width: 0 0 1px; + padding-inline: 0; +} + +.user-console-shell main input, +.user-console-shell main textarea, +.user-console-shell main button[aria-haspopup="listbox"] { + box-shadow: none !important; +} + +.user-console-shell main input:not([type="checkbox"]):not([type="radio"]), +.user-console-shell main textarea, +.user-console-shell main button[aria-haspopup="listbox"] { + border-radius: 9999px; + min-height: 3rem; +} + +.user-console-shell main textarea { + border-radius: 0.5rem; +} + +.user-console-shell main table thead { + background-color: rgb(17 24 39 / 0.035); +} + +.user-console-shell main table th { + font-weight: 650; +} + +.dark .user-console-shell { + background: var(--bp-black); +} + +.dark .user-console-shell :is(.bg-gray-100, .bg-gray-50) { + background-color: rgb(17 24 39); +} + +.dark .user-console-shell :is(.border-gray-200, .border-gray-300) { + border-color: rgb(255 255 255 / 0.12); +} diff --git a/config/settings.yaml b/config/settings.yaml index 192104a6..4fb98c55 100644 --- a/config/settings.yaml +++ b/config/settings.yaml @@ -186,9 +186,13 @@ settings: category: features description: 仅限邀请注册 features.wallet_recharge_withdraw_enabled: + value: true + category: features + description: 是否开放用户钱包充值;充值必须通过统一收银台完成 + features.wallet_withdraw_enabled: value: false category: features - description: 是否开放用户钱包充值与提现(关闭时相关 API 拒绝,前端灰显入口) + description: 是否开放用户钱包提现 general.date_format: value: YYYY-MM-DD category: general diff --git a/docs/BEANCS_SYSTEM_DEPLOY.md b/docs/BEANCS_SYSTEM_DEPLOY.md index 22158fe4..27f16e26 100644 --- a/docs/BEANCS_SYSTEM_DEPLOY.md +++ b/docs/BEANCS_SYSTEM_DEPLOY.md @@ -65,7 +65,7 @@ When Harbor credentials are configured, it also publishes: git checkout deploy git pull --ff-only origin deploy git tag beancs-v1.0.0 -git push origin deploy --tags +git push origin refs/heads/deploy:refs/heads/deploy --tags ``` The workflow creates: diff --git a/scripts/test-re-services.py b/scripts/test-re-services.py new file mode 100644 index 00000000..c547ada2 --- /dev/null +++ b/scripts/test-re-services.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Live OAuth/RBAC/isolation checks for Relock, Recal, and Renote. + +Required environment variables: + BP_ADMIN_EMAIL, BP_ADMIN_PASSWORD, BP_USER_EMAIL, BP_USER_PASSWORD +""" + +import http.cookiejar +import json +import os +import urllib.error +import urllib.parse +import urllib.request + + +BP_BASE = os.getenv("BP_BASE_URL", "http://localhost:8101") + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def no_redirect_open(opener, request): + try: + return opener.open(request) + except urllib.error.HTTPError as exc: + if exc.code in {301, 302, 303, 307, 308}: + return exc + raise + + +def json_request(url, *, method="GET", data=None, token=None, expected=200, headers=None): + body = None if data is None else json.dumps(data).encode() + request_headers = {"Accept": "application/json"} + if data is not None: + request_headers["Content-Type"] = "application/json" + if token: + request_headers["Authorization"] = f"Bearer {token}" + if headers: + request_headers.update(headers) + request = urllib.request.Request(url, data=body, headers=request_headers, method=method) + try: + response = urllib.request.urlopen(request, timeout=20) + status = response.status + payload = json.loads(response.read() or b"{}") + except urllib.error.HTTPError as exc: + status = exc.code + payload = json.loads(exc.read() or b"{}") + if status != expected: + raise AssertionError(f"{method} {url}: expected {expected}, got {status}: {payload}") + return payload + + +def bp_login(email, password): + payload = json_request(f"{BP_BASE}/api/v1/auth/login", method="POST", data={"identifier": email, "password": password}) + token = (payload.get("data") or {}).get("token") or payload.get("access_token") + if not token: + raise AssertionError(f"BasaltPass login returned no token: {payload}") + return token + + +def oauth_login(backend, bp_token): + jar = http.cookiejar.CookieJar() + opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar), NoRedirect()) + start = no_redirect_open(opener, urllib.request.Request(f"{backend}/v1/auth/basalt/login")) + authorize_url = start.headers["Location"] + + authorize_request = urllib.request.Request(authorize_url, headers={"Cookie": f"access_token_user={bp_token}"}) + authorize = no_redirect_open(opener, authorize_request) + next_url = authorize.headers["Location"] + + if urllib.parse.urlsplit(next_url).path == "/oauth-consent": + query = urllib.parse.parse_qs(urllib.parse.urlsplit(next_url).query) + form = {key: values[0] for key, values in query.items() if values} + form["action"] = "allow" + consent_request = urllib.request.Request( + f"{BP_BASE}/api/v1/oauth/consent", + data=urllib.parse.urlencode(form).encode(), + headers={"Content-Type": "application/x-www-form-urlencoded", "Cookie": f"access_token_user={bp_token}"}, + method="POST", + ) + consent = no_redirect_open(opener, consent_request) + callback_url = consent.headers["Location"] + else: + callback_url = next_url + + callback = no_redirect_open(opener, urllib.request.Request(callback_url)) + final_url = callback.headers["Location"] + token = urllib.parse.parse_qs(urllib.parse.urlsplit(final_url).query).get("token", [None])[0] + if not token: + raise AssertionError(f"Application callback returned no local token: {final_url}") + return token + + +def ids(items): + return {item["id"] for item in items} + + +def check_service(name, backend, admin_bp_token, user_bp_token): + admin_token = oauth_login(backend, admin_bp_token) + user_token = oauth_login(backend, user_bp_token) + me = json_request(f"{backend}/v1/auth/me", token=user_token) + json_request( + f"{backend}/v1/auth/login", + method="POST", + data={"email": "blocked@example.com", "password": "Blocked@123"}, + expected=403, + ) + json_request(f"{backend}/v1/admin/overview", token=user_token, expected=403) + overview = json_request(f"{backend}/v1/admin/overview", token=admin_token) + manifest = json_request(f"{backend}/v1/integrations/basalt/status") + if manifest.get("state") != "approved": + raise AssertionError(f"{name} manifest is not approved: {manifest}") + + if name == "relock": + user_item = json_request(f"{backend}/v1/clock/alarms", method="POST", token=user_token, data={"label": "RBAC user alarm", "hour": 9, "minute": 15, "timezone": "Asia/Shanghai", "repeat_days": [], "enabled": True}) + admin_item = json_request(f"{backend}/v1/clock/alarms", method="POST", token=admin_token, data={"label": "RBAC admin alarm", "hour": 10, "minute": 30, "timezone": "Asia/Shanghai", "repeat_days": [], "enabled": True}) + user_items = json_request(f"{backend}/v1/clock/alarms", token=user_token) + admin_items = json_request(f"{backend}/v1/clock/alarms", token=admin_token) + json_request(f"{backend}/v1/clock/alarms/{user_item['id']}", method="PUT", token=admin_token, data={"label": "forbidden", "hour": 0, "minute": 0, "timezone": "UTC", "repeat_days": [], "enabled": False}, expected=404) + elif name == "recal": + user_item = json_request(f"{backend}/v1/calendar/events", method="POST", token=user_token, data={"title": "RBAC user event", "description": "private", "event_date": "2026-07-15"}) + admin_item = json_request(f"{backend}/v1/calendar/events", method="POST", token=admin_token, data={"title": "RBAC admin event", "description": "private", "event_date": "2026-07-15"}) + user_items = json_request(f"{backend}/v1/calendar/events", token=user_token) + admin_items = json_request(f"{backend}/v1/calendar/events", token=admin_token) + json_request(f"{backend}/v1/calendar/events/{user_item['id']}", method="DELETE", token=admin_token, expected=404) + else: + user_item = json_request(f"{backend}/v1/notes", method="POST", token=user_token, data={"title": "RBAC user note", "content_markdown": "private user text", "tags": ["rbac"], "is_favorite": False}) + admin_item = json_request(f"{backend}/v1/notes", method="POST", token=admin_token, data={"title": "RBAC admin note", "content_markdown": "private admin text", "tags": ["rbac"], "is_favorite": False}) + user_items = json_request(f"{backend}/v1/notes", token=user_token) + admin_items = json_request(f"{backend}/v1/notes", token=admin_token) + json_request(f"{backend}/v1/notes/{user_item['id']}/render", token=admin_token, expected=404) + + if user_item["id"] not in ids(user_items) or admin_item["id"] in ids(user_items): + raise AssertionError(f"{name}: normal user list is not isolated") + if admin_item["id"] not in ids(admin_items) or user_item["id"] in ids(admin_items): + raise AssertionError(f"{name}: admin workspace list is not isolated") + + # Leave the running demo databases free of repeatable E2E resource fixtures. + if name == "relock": + for item in user_items: + if item.get("label") == "RBAC user alarm": + json_request(f"{backend}/v1/clock/alarms/{item['id']}", method="DELETE", token=user_token) + for item in admin_items: + if item.get("label") == "RBAC admin alarm": + json_request(f"{backend}/v1/clock/alarms/{item['id']}", method="DELETE", token=admin_token) + elif name == "recal": + for item in user_items: + if item.get("title") == "RBAC user event": + json_request(f"{backend}/v1/calendar/events/{item['id']}", method="DELETE", token=user_token) + for item in admin_items: + if item.get("title") == "RBAC admin event": + json_request(f"{backend}/v1/calendar/events/{item['id']}", method="DELETE", token=admin_token) + else: + for item in user_items: + if item.get("title") == "RBAC user note": + json_request(f"{backend}/v1/notes/{item['id']}", method="DELETE", token=user_token) + for item in admin_items: + if item.get("title") == "RBAC admin note": + json_request(f"{backend}/v1/notes/{item['id']}", method="DELETE", token=admin_token) + + return {"oauth_user": me["email"], "manifest": manifest["state"], "admin_users": overview["user_count"], "own_data_isolated": True, "normal_admin_denied": True} + + +def required(name): + value = os.getenv(name) + if not value: + raise SystemExit(f"missing required environment variable: {name}") + return value + + +def main(): + admin_token = bp_login(required("BP_ADMIN_EMAIL"), required("BP_ADMIN_PASSWORD")) + user_token = bp_login(required("BP_USER_EMAIL"), required("BP_USER_PASSWORD")) + results = { + "relock": check_service("relock", "http://localhost:8108", admin_token, user_token), + "recal": check_service("recal", "http://localhost:8110", admin_token, user_token), + "renote": check_service("renote", "http://localhost:8109", admin_token, user_token), + } + print(json.dumps(results, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/test-tenant-app-mappings.py b/scripts/test-tenant-app-mappings.py new file mode 100644 index 00000000..3438e9e1 --- /dev/null +++ b/scripts/test-tenant-app-mappings.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Live tenant-to-app grant mapping checks against the three demo services. + +The test temporarily removes the normal user's explicit app role, grants the +same role through a tenant membership mapping, exercises the real OAuth/S2S +path, proves revocation, and restores the original assignments in ``finally``. + +Required environment variables: + BP_ADMIN_EMAIL, BP_ADMIN_PASSWORD, BP_USER_EMAIL, BP_USER_PASSWORD + +Optional environment variables: + BP_TENANT_ID (default 1), BP_USER_ID (default 2), BP_BASE_URL +""" + +import json +import os +import runpy +from pathlib import Path + + +HELPERS = runpy.run_path(str(Path(__file__).with_name("test-re-services.py"))) +BP_BASE = HELPERS["BP_BASE"] +bp_login = HELPERS["bp_login"] +check_service = HELPERS["check_service"] +json_request = HELPERS["json_request"] +oauth_login = HELPERS["oauth_login"] +required = HELPERS["required"] + +TENANT_ID = int(os.getenv("BP_TENANT_ID", "1")) +USER_ID = int(os.getenv("BP_USER_ID", "2")) +SERVICES = ( + {"name": "relock", "app_id": int(os.getenv("BP_RELOCK_APP_ID", "8")), "role": "relock.user", "backend": "http://localhost:8108", "protected": "/v1/clock/alarms"}, + {"name": "recal", "app_id": int(os.getenv("BP_RECAL_APP_ID", "9")), "role": "recal.user", "backend": "http://localhost:8110", "protected": "/v1/calendar/events"}, + {"name": "renote", "app_id": int(os.getenv("BP_RENOTE_APP_ID", "10")), "role": "renote.user", "backend": "http://localhost:8109", "protected": "/v1/notes"}, +) + + +def tenant_console_token(user_token): + authorized = json_request( + f"{BP_BASE}/api/v1/auth/console/authorize", + method="POST", + data={"target": "tenant", "tenant_id": TENANT_ID}, + token=user_token, + headers={"X-Auth-Scope": "user"}, + ) + exchanged = json_request( + f"{BP_BASE}/api/v1/auth/console/exchange", + method="POST", + data={"code": authorized["code"]}, + ) + if exchanged.get("scope") != "tenant" or not exchanged.get("access_token"): + raise AssertionError(f"invalid tenant console exchange: {exchanged}") + return exchanged["access_token"] + + +def tenant_request(token, path, **kwargs): + headers = dict(kwargs.pop("headers", {}) or {}) + headers["X-Auth-Scope"] = "tenant" + return json_request(f"{BP_BASE}/api/v1/tenant{path}", token=token, headers=headers, **kwargs) + + +def effective_role(grants, code): + return next((role for role in grants.get("roles", []) if role.get("code") == code), None) + + +def prepare_mapping(token, service): + app_id = service["app_id"] + role_code = service["role"] + roles = tenant_request(token, f"/apps/{app_id}/roles").get("roles", []) + role = next((item for item in roles if item.get("code") == role_code), None) + if not role: + raise AssertionError(f"{service['name']}: app role {role_code} is missing") + + mappings = tenant_request(token, f"/apps/{app_id}/rbac/mappings").get("data", {}).get("mappings", []) + duplicate = next( + ( + item for item in mappings + if item.get("source", {}).get("type") == "membership_role" + and item.get("source", {}).get("code") == "member" + and item.get("target", {}).get("type") == "app_role" + and item.get("target", {}).get("id") == role["id"] + ), + None, + ) + if duplicate: + raise AssertionError( + f"{service['name']}: refusing to mutate pre-existing mapping {duplicate['id']}" + ) + + direct = tenant_request(token, f"/apps/{app_id}/users/{USER_ID}/roles") + was_explicit = any(item.get("id") == role["id"] for item in direct.get("roles", [])) + mapping_input = { + "source_type": "membership_role", + "source_id": 0, + "source_code": "member", + "target_type": "app_role", + "target_id": role["id"], + "enabled": True, + } + preview = tenant_request( + token, + f"/apps/{app_id}/rbac/mappings/preview", + method="POST", + data=mapping_input, + ) + if preview.get("data", {}).get("affected_user_count", 0) < 1: + raise AssertionError(f"{service['name']}: preview did not include the normal user: {preview}") + + created = tenant_request( + token, + f"/apps/{app_id}/rbac/mappings", + method="POST", + data=mapping_input, + expected=201, + )["data"] + state = {**service, "role_id": role["id"], "mapping_id": created["id"], "was_explicit": was_explicit, "explicit_removed": False, "mapping_deleted": False} + + if was_explicit: + tenant_request(token, f"/apps/{app_id}/users/{USER_ID}/roles/{role['id']}", method="DELETE") + state["explicit_removed"] = True + + direct = tenant_request(token, f"/apps/{app_id}/users/{USER_ID}/roles") + if any(item.get("id") == role["id"] for item in direct.get("roles", [])): + raise AssertionError(f"{service['name']}: mapped role was materialized as an explicit assignment") + mapped = effective_role(direct, role_code) or effective_role( + tenant_request(token, f"/apps/{app_id}/users/{USER_ID}/effective-grants").get("data", {}), + role_code, + ) + if not mapped or not any( + source.get("type") == "tenant_mapping" and source.get("mapping_id") == created["id"] + for source in mapped.get("sources", []) + ): + raise AssertionError(f"{service['name']}: effective role has no mapping provenance: {mapped}") + + # A referenced target must not be deletable, including while the mapping is active. + tenant_request(token, f"/apps/{app_id}/roles/{role['id']}", method="DELETE", expected=409) + return state + + +def prove_revocation(token, user_bp_token, state): + # Keep the same application session across the policy change. The demo apps + # ask BasaltPass over S2S on every protected request, so this verifies live + # revocation rather than merely proving that a newly issued token is denied. + active_app_token = oauth_login(state["backend"], user_bp_token) + json_request(f"{state['backend']}{state['protected']}", token=active_app_token) + tenant_request( + token, + f"/apps/{state['app_id']}/rbac/mappings/{state['mapping_id']}", + method="DELETE", + expected=204, + ) + state["mapping_deleted"] = True + grants = tenant_request( + token, + f"/apps/{state['app_id']}/users/{USER_ID}/effective-grants", + )["data"] + if effective_role(grants, state["role"]): + raise AssertionError(f"{state['name']}: deleted mapping still grants {state['role']}") + + json_request( + f"{state['backend']}{state['protected']}", + token=active_app_token, + expected=403, + ) + + +def restore(token, states): + errors = [] + for state in reversed(states): + try: + if state.get("was_explicit") and state.get("explicit_removed"): + tenant_request( + token, + f"/apps/{state['app_id']}/users/{USER_ID}/roles", + method="POST", + data={"role_ids": [state["role_id"]]}, + ) + state["explicit_removed"] = False + except Exception as exc: # cleanup must continue for the remaining apps + errors.append(f"restore {state['name']} explicit role: {exc}") + try: + if state.get("mapping_id") and not state.get("mapping_deleted"): + tenant_request( + token, + f"/apps/{state['app_id']}/rbac/mappings/{state['mapping_id']}", + method="DELETE", + expected=204, + ) + state["mapping_deleted"] = True + except Exception as exc: + errors.append(f"delete {state['name']} mapping: {exc}") + if errors: + raise AssertionError("; ".join(errors)) + + +def main(): + admin_bp_token = bp_login(required("BP_ADMIN_EMAIL"), required("BP_ADMIN_PASSWORD")) + user_bp_token = bp_login(required("BP_USER_EMAIL"), required("BP_USER_PASSWORD")) + json_request( + f"{BP_BASE}/api/v1/auth/console/authorize", + method="POST", + data={"target": "tenant", "tenant_id": TENANT_ID}, + token=user_bp_token, + headers={"X-Auth-Scope": "user"}, + expected=403, + ) + tenant_token = tenant_console_token(admin_bp_token) + states = [] + results = {} + test_error = None + try: + for service in SERVICES: + states.append(prepare_mapping(tenant_token, service)) + + # These calls enter through each application's OAuth callback and use its + # own S2S credentials to fetch the dynamically resolved role from BP. + for service in SERVICES: + results[service["name"]] = check_service( + service["name"], service["backend"], admin_bp_token, user_bp_token + ) + + for state in states: + prove_revocation(tenant_token, user_bp_token, state) + results[state["name"]]["mapping_revoked_immediately"] = True + results[state["name"]]["no_inherited_assignment_row"] = True + except Exception as exc: + test_error = exc + finally: + try: + restore(tenant_token, states) + except Exception as cleanup_exc: + if test_error: + raise AssertionError(f"test failed: {test_error}; cleanup failed: {cleanup_exc}") from cleanup_exc + raise + if test_error: + raise test_error + print(json.dumps(results, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 00000000..4b4a97cf --- /dev/null +++ b/tools/README.md @@ -0,0 +1,11 @@ +# BasaltPass Tools + +This directory is for local development, test data, and one-off helper scripts. + +- `dev/`: manual development helpers that are not part of the stable startup flow. +- `conformance/`: OpenID conformance helper scripts. + +Stable full-stack entrypoints stay in `../scripts/`, such as `dev.sh` and `dev.ps1`. + +Backend tools that import `basaltpass-backend/internal` packages stay under +`../basaltpass-backend/tools/` so Go internal package visibility still works. diff --git a/basaltpass-backend/scripts/seed_conformance_sqlite.py b/tools/conformance/seed_conformance_sqlite.py similarity index 100% rename from basaltpass-backend/scripts/seed_conformance_sqlite.py rename to tools/conformance/seed_conformance_sqlite.py diff --git a/scripts/create-admin.ps1 b/tools/dev/create-admin.ps1 similarity index 97% rename from scripts/create-admin.ps1 rename to tools/dev/create-admin.ps1 index 26f82534..abbf4861 100644 --- a/scripts/create-admin.ps1 +++ b/tools/dev/create-admin.ps1 @@ -10,7 +10,8 @@ if (-not $Email) { $Email = 'admin@basalt.local' } if (-not $Password) { $Password = 'Admin@12345' } if (-not $Nickname) { $Nickname = 'AdminUser' } -$BaseUrl = 'http://localhost:8080' +$BaseUrl = $env:BASALTPASS_API_BASE_URL +if (-not $BaseUrl) { $BaseUrl = 'http://localhost:8101' } function Invoke-Json { param( diff --git a/scripts/debug_run.ps1 b/tools/dev/debug_run.ps1 similarity index 100% rename from scripts/debug_run.ps1 rename to tools/dev/debug_run.ps1