Files
fastopencn 93eef5feac refactor(protocol): centralize tenants.* RPC method names as constants (#1371)
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.
2026-07-07 12:54:08 +07:00

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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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},
})
}