mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 14:16:08 +00:00
The tenants.* methods were the only RPC group without MethodXxx
constants in pkg/protocol/methods.go. Their wire names were hardcoded
as raw string literals across three call sites — router registration,
and the read/admin allowlists in the permission policy — which is
inconsistent with the other 147 methods and risks silent drift: a
typo in any allowlist string is not caught by the compiler and would
fail-closed (deny) at runtime, which is hard to diagnose.
Add MethodTenants{List,Get,Create,Update,UsersList,UsersAdd,
UsersRemove,Mine} and replace the method-identifier literals in
internal/permissions/policy.go and internal/gateway/methods/tenants.go
with the constants.
No behavior change — string values are identical. Human-facing error
labels and log prefixes in internal/http/tenants.go are intentionally
left as free-form text, matching the sibling HTTP handler convention.
421 lines
15 KiB
Go
421 lines
15 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
var slugRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
|
|
|
// TenantsMethods handles tenant management RPC methods.
|
|
type TenantsMethods struct {
|
|
tenantStore store.TenantStore
|
|
msgBus *bus.MessageBus
|
|
workspace string // base workspace directory for tenant dirs
|
|
}
|
|
|
|
// NewTenantsMethods creates a new TenantsMethods handler.
|
|
func NewTenantsMethods(tenantStore store.TenantStore, msgBus *bus.MessageBus, workspace string) *TenantsMethods {
|
|
return &TenantsMethods{tenantStore: tenantStore, msgBus: msgBus, workspace: workspace}
|
|
}
|
|
|
|
// Register registers tenant management RPC methods.
|
|
func (m *TenantsMethods) Register(router *gateway.MethodRouter) {
|
|
router.Register(protocol.MethodTenantsList, m.handleList)
|
|
router.Register(protocol.MethodTenantsGet, m.handleGet)
|
|
router.Register(protocol.MethodTenantsCreate, m.handleCreate)
|
|
router.Register(protocol.MethodTenantsUpdate, m.handleUpdate)
|
|
router.Register(protocol.MethodTenantsUsersList, m.handleUsersList)
|
|
router.Register(protocol.MethodTenantsUsersAdd, m.handleUsersAdd)
|
|
router.Register(protocol.MethodTenantsUsersRemove, m.handleUsersRemove)
|
|
router.Register(protocol.MethodTenantsMine, m.handleMine)
|
|
}
|
|
|
|
func (m *TenantsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
if !client.IsOwner() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.list")))
|
|
return
|
|
}
|
|
|
|
tenants, err := m.tenantStore.ListTenants(ctx)
|
|
if err != nil {
|
|
slog.Error("tenants.list failed", "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants")))
|
|
return
|
|
}
|
|
if tenants == nil {
|
|
tenants = []store.TenantData{}
|
|
}
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": tenants}))
|
|
}
|
|
|
|
func (m *TenantsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
if !client.IsOwner() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.get")))
|
|
return
|
|
}
|
|
|
|
var params struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if req.Params != nil {
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
}
|
|
|
|
id, err := uuid.Parse(params.ID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant")))
|
|
return
|
|
}
|
|
|
|
tenant, err := m.tenantStore.GetTenant(ctx, id)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "tenant", params.ID)))
|
|
return
|
|
}
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, tenant))
|
|
}
|
|
|
|
func (m *TenantsMethods) handleCreate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
if !client.IsOwner() && !client.HasScope(permissions.ScopeProvision) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.create")))
|
|
return
|
|
}
|
|
|
|
var params struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Settings any `json:"settings"`
|
|
}
|
|
if req.Params != nil {
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
}
|
|
|
|
if params.Name == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
|
|
return
|
|
}
|
|
if params.Slug == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "slug")))
|
|
return
|
|
}
|
|
if !slugRe.MatchString(params.Slug) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidSlug, "slug")))
|
|
return
|
|
}
|
|
|
|
tenant := &store.TenantData{
|
|
ID: store.GenNewID(),
|
|
Name: params.Name,
|
|
Slug: params.Slug,
|
|
Status: store.TenantStatusActive,
|
|
}
|
|
|
|
if err := m.tenantStore.CreateTenant(ctx, tenant); err != nil {
|
|
slog.Error("tenants.create failed", "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "tenant", err.Error())))
|
|
return
|
|
}
|
|
|
|
// Create workspace directory for the tenant.
|
|
if m.workspace != "" {
|
|
tenantDir := filepath.Join(m.workspace, "tenants", tenant.Slug)
|
|
if err := os.MkdirAll(tenantDir, 0755); err != nil {
|
|
slog.Warn("tenants.create: failed to create workspace dir", "dir", tenantDir, "error", err)
|
|
}
|
|
}
|
|
|
|
m.emitCacheInvalidate(bus.CacheKindTenantUsers, tenant.ID.String())
|
|
m.emitCacheInvalidate(bus.CacheKindTenants, "")
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, tenant))
|
|
}
|
|
|
|
func (m *TenantsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
if !client.IsOwner() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.update")))
|
|
return
|
|
}
|
|
|
|
var params struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Settings map[string]any `json:"settings"`
|
|
}
|
|
if req.Params != nil {
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
}
|
|
|
|
id, err := uuid.Parse(params.ID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant")))
|
|
return
|
|
}
|
|
|
|
updates := make(map[string]any)
|
|
if params.Name != "" {
|
|
updates["name"] = params.Name
|
|
}
|
|
if params.Status != "" {
|
|
updates["status"] = params.Status
|
|
}
|
|
if params.Settings != nil {
|
|
updates["settings"] = params.Settings
|
|
}
|
|
|
|
if len(updates) == 0 {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidUpdates)))
|
|
return
|
|
}
|
|
|
|
if err := m.tenantStore.UpdateTenant(ctx, id, updates); err != nil {
|
|
slog.Error("tenants.update failed", "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "tenant", err.Error())))
|
|
return
|
|
}
|
|
|
|
m.emitCacheInvalidate(bus.CacheKindTenantUsers, id.String())
|
|
m.emitCacheInvalidate(bus.CacheKindTenants, "")
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"}))
|
|
}
|
|
|
|
func (m *TenantsMethods) handleUsersList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
if !client.IsOwner() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.list")))
|
|
return
|
|
}
|
|
|
|
var params struct {
|
|
TenantID string `json:"tenant_id"`
|
|
}
|
|
if req.Params != nil {
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
}
|
|
|
|
tid, err := uuid.Parse(params.TenantID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
|
|
return
|
|
}
|
|
|
|
users, err := m.tenantStore.ListUsers(ctx, tid)
|
|
if err != nil {
|
|
slog.Error("tenants.users.list failed", "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenant users")))
|
|
return
|
|
}
|
|
if users == nil {
|
|
users = []store.TenantUserData{}
|
|
}
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"users": users}))
|
|
}
|
|
|
|
func (m *TenantsMethods) handleUsersAdd(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
if !client.IsOwner() && !client.HasScope(permissions.ScopeProvision) {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.add")))
|
|
return
|
|
}
|
|
|
|
var params struct {
|
|
TenantID string `json:"tenant_id"`
|
|
UserID string `json:"user_id"`
|
|
Role string `json:"role"`
|
|
}
|
|
if req.Params != nil {
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
}
|
|
|
|
if params.UserID == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "user_id")))
|
|
return
|
|
}
|
|
if params.Role == "" {
|
|
params.Role = store.TenantRoleMember
|
|
}
|
|
validRoles := map[string]bool{
|
|
store.TenantRoleOwner: true, store.TenantRoleAdmin: true,
|
|
store.TenantRoleOperator: true, store.TenantRoleMember: true, store.TenantRoleViewer: true,
|
|
}
|
|
if !validRoles[params.Role] {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRole)))
|
|
return
|
|
}
|
|
|
|
tid, err := uuid.Parse(params.TenantID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
|
|
return
|
|
}
|
|
|
|
if err := m.tenantStore.AddUser(ctx, tid, params.UserID, params.Role); err != nil {
|
|
slog.Error("tenants.users.add failed", "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "tenant user", err.Error())))
|
|
return
|
|
}
|
|
|
|
m.emitCacheInvalidate(bus.CacheKindTenantUsers, params.UserID)
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"}))
|
|
}
|
|
|
|
func (m *TenantsMethods) handleUsersRemove(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
if !client.IsOwner() {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.remove")))
|
|
return
|
|
}
|
|
|
|
var params struct {
|
|
TenantID string `json:"tenant_id"`
|
|
UserID string `json:"user_id"`
|
|
}
|
|
if req.Params != nil {
|
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
|
return
|
|
}
|
|
}
|
|
|
|
if params.UserID == "" {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "user_id")))
|
|
return
|
|
}
|
|
|
|
tid, err := uuid.Parse(params.TenantID)
|
|
if err != nil {
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "tenant_id")))
|
|
return
|
|
}
|
|
|
|
if err := m.tenantStore.RemoveUser(ctx, tid, params.UserID); err != nil {
|
|
slog.Error("tenants.users.remove failed", "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToDelete, "tenant user", err.Error())))
|
|
return
|
|
}
|
|
|
|
m.emitCacheInvalidate(bus.CacheKindTenantUsers, params.UserID)
|
|
|
|
// Notify affected user's WS sessions to force logout
|
|
m.msgBus.Broadcast(bus.Event{
|
|
Name: protocol.EventTenantAccessRevoked,
|
|
Payload: map[string]string{"user_id": params.UserID, "tenant_id": tid.String()},
|
|
})
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]string{"ok": "true"}))
|
|
}
|
|
|
|
// handleMine returns the current user's tenant memberships.
|
|
// Unlike other tenant methods, this does NOT require cross-tenant access.
|
|
// Cross-tenant admins receive all tenants instead.
|
|
func (m *TenantsMethods) handleMine(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
|
locale := store.LocaleFromContext(ctx)
|
|
|
|
type tenantEntry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Role string `json:"role"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// Owner: return all tenants with "owner" role
|
|
if client.IsOwner() {
|
|
tenants, err := m.tenantStore.ListTenants(ctx)
|
|
if err != nil {
|
|
slog.Error("tenants.mine failed (cross-tenant)", "error", err)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants")))
|
|
return
|
|
}
|
|
entries := make([]tenantEntry, len(tenants))
|
|
for i, t := range tenants {
|
|
entries[i] = tenantEntry{ID: t.ID.String(), Name: t.Name, Slug: t.Slug, Role: "owner", Status: t.Status}
|
|
}
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": entries}))
|
|
return
|
|
}
|
|
|
|
// Regular user: return their tenant memberships enriched with name/slug
|
|
userID := client.UserID()
|
|
if userID == "" {
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": []tenantEntry{}}))
|
|
return
|
|
}
|
|
|
|
memberships, err := m.tenantStore.ListUserTenants(ctx, userID)
|
|
if err != nil {
|
|
slog.Error("tenants.mine failed", "error", err, "user_id", userID)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants")))
|
|
return
|
|
}
|
|
|
|
// Batch-fetch all tenant data in a single query instead of per-membership.
|
|
ids := make([]uuid.UUID, 0, len(memberships))
|
|
for _, mem := range memberships {
|
|
ids = append(ids, mem.TenantID)
|
|
}
|
|
tenants, tErr := m.tenantStore.GetTenantsByIDs(ctx, ids)
|
|
if tErr != nil {
|
|
slog.Error("tenants.mine: batch fetch failed", "error", tErr, "user_id", userID)
|
|
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "tenants")))
|
|
return
|
|
}
|
|
tenantMap := make(map[uuid.UUID]*store.TenantData, len(tenants))
|
|
for i := range tenants {
|
|
tenantMap[tenants[i].ID] = &tenants[i]
|
|
}
|
|
|
|
entries := make([]tenantEntry, 0, len(memberships))
|
|
for _, mem := range memberships {
|
|
t := tenantMap[mem.TenantID]
|
|
if t == nil || t.Status != store.TenantStatusActive {
|
|
continue
|
|
}
|
|
entries = append(entries, tenantEntry{ID: t.ID.String(), Name: t.Name, Slug: t.Slug, Role: mem.Role, Status: t.Status})
|
|
}
|
|
|
|
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{"tenants": entries}))
|
|
}
|
|
|
|
func (m *TenantsMethods) emitCacheInvalidate(kind, key string) {
|
|
if m.msgBus == nil {
|
|
return
|
|
}
|
|
m.msgBus.Broadcast(bus.Event{
|
|
Name: protocol.EventCacheInvalidate,
|
|
Payload: bus.CacheInvalidatePayload{Kind: kind, Key: key},
|
|
})
|
|
}
|