From cd022699f695fdf6241fb8b976958099408306cc Mon Sep 17 00:00:00 2001 From: Viet Tran Date: Mon, 23 Mar 2026 08:08:23 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20multi-tenant=20isolation=20=E2=80=94=20?= =?UTF-8?q?complete=20implementation=20(#359)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(security): multi-tenant user data isolation (Plan 1) Comprehensive user data isolation for non-owner system users: - API key identity binding: owner_id column forces user_id on auth, prevents spoofing via X-GoClaw-User-Id header - Sessions: ownership checks on list/preview/patch/delete/reset, non-admin users see only their own sessions - Cron: user_id filtering on list, ownership checks on mutations - Server-side WS event filtering: agent/chat/session/cron/team events scoped per-user instead of broadcast to all clients - Web UI role guards: RequireAdmin on 15 admin-only pages, role propagated from WS connect response to auth store - Tracing/activity: user_id enforcement for non-admin HTTP callers - Teams: HasTeamAccess membership checks on get/delete/list - Skills: fail-closed ownership check (deny non-admin if store doesn't support owner lookup) - HTTP auth: requireAuthBearer now enforces owner_id + user context for file/media downloads (was missing) - Dead code: removed delegation_history, handoff_routes tables and all related handlers/store code - New: team_user_grants table for user-to-team access control Migration 000026: api_keys.owner_id + team_user_grants + DROP legacy tables * feat(security): multi-tenant foundation — tenants table, tenant_id propagation, permission cache (Plan 2) Add tenant isolation infrastructure across the entire gateway: Schema (migration 000027): - Create tenants + tenant_users tables with master tenant seed - Add tenant_id column to 30 user-scoped tables (NOT NULL DEFAULT master) - api_keys.tenant_id nullable (NULL = system-level cross-tenant key) - Create builtin_tool_tenant_configs + skill_tenant_configs for per-tenant overrides - Drop custom_tools table (agent loop integration never wired) Store layer: - TenantStore interface + PGTenantStore (CRUD tenants + tenant_users) - TenantID field on AgentData + APIKeyData - tenant_id in agents/api_keys/skills SQL (Create, Get, List) Context propagation: - WithTenantID/TenantIDFromContext (uuid.Nil = fail-closed) - WithCrossTenant/IsCrossTenant (owner/system admin flag) Auth tenant resolution: - HTTP: resolveAuthBearer sets TenantID/CrossTenant on all 5 auth paths - WS: handleConnect sets tenantID/crossTenant on Client - API key 2-tier: NULL = cross-tenant (system), set = tenant-scoped Runtime isolation: - Event bus: TenantID field on Event, fail-closed filter in event_filter.go - Cron: tenant context injected in RunJob handler - Subagent: tenant validation prevents cross-tenant spawn - Security logging: tenant_id in auth resolution logs Tenant management: - WS RPC: 7 methods (tenants.list/get/create/update, tenants.users.*) - HTTP: 7 endpoints (/v1/tenants/*) - Slug validation + path traversal prevention - Role validation (owner/admin/operator/member/viewer) Infrastructure: - PermissionCache: 4 sub-caches (tenant resolve, role, agent access, team access) - tenant_paths.go: filesystem path helpers with master-tenant backward compat - i18n: MsgInvalidRole key + translations (en/vi/zh) Dead code removed: custom_tools store, HTTP handler, DynamicToolLoader (-828 lines) * feat(security): tenant query filtering + workspace isolation (Plan 3) Add WHERE tenant_id filtering to all 30+ tenant-scoped store queries, wire workspace filesystem isolation, and harden restrict_to_workspace. Store query filtering: - Add tenantClauseN/tenantIDForInsert/requireTenantID helpers - Filter all SELECT/INSERT/UPDATE/DELETE by tenant_id for non-cross-tenant - Refactor SessionStore.GetOrCreate and CronStore.AddJob/ListJobs to accept context.Context for tenant propagation - System skills (is_system=true) bypass tenant filter for all tenants - Special cases: GetByKey (channels), GetByHash (auth) skip filter Workspace isolation: - Resolver computes tenant-scoped workspace + dataDir for non-master tenants - Add WithTenantSlug/TenantSlugFromContext to context propagation - Add TenantStore + Workspace to ResolverDeps - Force effectiveRestrict() to always return true (multi-tenant security) - Remove restrict_to_workspace from agentAllowedFields UI cleanup: - Remove custom-tools pages, types, routes, constants (backend removed in Plan 2) - Clean tool-name-select component of custom tools references * feat(security): session ctx propagation + execMapUpdate tenant guard (Plan 4) Session store: - Add ctx to AddMessage, SetSessionMetadata, SetAgentInfo, List, Save - List now filters by tenant_id for non-cross-tenant callers - Save uses ExecContext for cancellation support - All ~15 callers updated to pass ctx execMapUpdate tenant guard: - Remove deleted_at IS NULL from execMapUpdateWhereTenant (only agents has soft-delete) - Migrate 8 callers to execMapUpdateWhereTenant: agent_links, channel_instances, mcp_servers, secure_cli, tracing, teams, skills_crud, cron_update - Add ctx to UpdateSkill, UpdateJob interfaces + all callers Deferred: cron scheduler global cache (correct by design — system process), browser per-tenant isolation (separate plan). * refactor(store): add context.Context to all SessionStore interface methods Complete ctx propagation across all 24 SessionStore methods for: - Future tenant-aware DB operations - Request cancellation/timeout support - Distributed tracing capability Updated ~15 files including all callers in agent loop, gateway methods, heartbeat ticker, tools, and CLI commands. * fix(security): remove context.Background() shadowing in gateway handlers Critical fix from code review: gateway agent handlers (create, update, delete, identity, files, links, teams) were creating ctx := context.Background() which shadowed the handler's ctx that carries tenant_id. This breaks tenant-scoped agent queries for non-master tenants. - Remove ctx shadowing in 7 agent handler files - Add ctx param to resolveAgentUUID/resolveAgentInfo helpers - Use store.WithCrossTenant in resolver (system-level operation) * feat(security): tenant-scoped UNIQUE constraints for multi-tenant isolation Update UNIQUE indexes to include tenant_id, allowing same names across tenants: - agents: (agent_key) → (tenant_id, agent_key) WHERE deleted_at IS NULL - sessions: (session_key) → (tenant_id, session_key) - skills: (slug) → (tenant_id, slug) - mcp_servers: (name) → (tenant_id, name) - channel_contacts: (channel_type, sender_id) → (tenant_id, channel_type, sender_id) Code changes: - GetByKey now filters by tenant_id (same pattern as GetByID) - ON CONFLICT clauses updated for sessions and skills - Channel consumer uses WithCrossTenant for agent resolution - Down migration restores original constraints * fix(security): close remaining tenant isolation gaps from final audit Critical fixes: - gateway_setup: WithCrossTenant for default agent lookup at startup (C6) - channel_contacts: ON CONFLICT updated to (tenant_id, channel_type, sender_id) (Q15) - agents.Delete: tenant filter on DELETE (Q1) High priority fixes: - agents: List, GetDefault, ShareAgent, RevokeShare, ListShares, CanAccess, ListAccessible, Update unset-default — all now tenant-scoped - skills_crud: DeleteSkill now takes ctx, verifies tenant ownership - mcp_servers, channel_instances, secure_cli: Delete methods tenant-scoped - WithCrossTenant added to: gateway team notifications, team_tool_cache, pending_messages GetDefault * fix(migration): add tenant_id to usage_snapshots unique index Update idx_usage_snapshots_unique to include tenant_id, preventing cross-tenant upsert collisions when different tenants have agents with same provider/model/channel combination. * feat(security): cron tenant guard + browser per-tenant isolation Phase 3 — Cron API tenant guard: - Add ctx to 5 CronStore methods (GetJob, RemoveJob, EnableJob, RunJob, GetRunLog) - All API-facing cron ops now filter by tenant_id (prevents cross-tenant CRUD) - RemoveJob/EnableJob return "not found" on tenant mismatch (no enumeration) - GetRunLog JOINs cron_jobs for tenant filtering - UpdateJob internal reads scoped by tenant (defense-in-depth) - Scheduler-internal methods (GetDueJobs, refreshJobCache) unchanged (system-level) Phase 4 — Browser per-tenant isolation: - Per-tenant incognito browser contexts via rod Incognito() (separate cookie jars) - All page access (Snapshot, Screenshot, Navigate, Click, Type, etc.) validated via getPageForTenant — blocks cross-tenant access by targetID - OpenTab creates pages in tenant's incognito context - ListTabs scoped to tenant's incognito context - ConsoleMessages validates page ownership - Stop/reconnect properly cleans up incognito contexts * feat(security): isolation gaps + per-tenant config (Plan 5) Part A — Isolation Gap Fixes: - Merge migration 028 into 027: add tenant_id to llm_providers + config_secrets, fix UNIQUE constraints for paired_devices + channel_instances - providers.go: tenant filtering on all CRUD queries - config_secrets.go: ON CONFLICT (key, tenant_id) - pairing_store: add ctx to all 7 interface methods, remove hardcoded MasterTenantID, update ~15 channel caller files - Session cache: prefix keys with tenantID to prevent cross-tenant collision. DB queries (loadFromDB, Save, Delete, LastUsedChannel) add tenant filter - config_permissions cache: prefix keys with tenantID - Cron ListJobs: fail-closed when tenant context missing Part B — Per-Tenant Configuration: - Provider Registry: compound key tenantID/name with fallback to master tenant. GetForTenant/ListForTenant/RegisterForTenant - Resolver: uses tenant-aware provider lookup + disabled tools query - Agent loop: filter disabled tools from LLM tool definitions - Builtin tool tenant configs: store interface + PG implementation + PUT/DELETE HTTP endpoints - Skill tenant configs: store interface + PG + ListAccessible LEFT JOIN to exclude disabled skills per tenant - OAuth: DBTokenSource with tenantID field for tenant-scoped token refresh - All HTTP provider handlers use RegisterForTenant/UnregisterForTenant * feat(security): channel tenant propagation + MCP per-user credentials (Plan 6) - Propagate tenant_id from channel_instances through BaseChannel → InboundMessage → agent loop context (fixes 5-point break in tenant flow) - Inject tenant context in WS router dispatch for all gateway methods - Add MCP per-user credential overrides (api_key, headers, env) with AES-256-GCM encryption and HTTP API endpoints - Rewrite MCP pool with tenant-scoped keys, slot semaphore, idle eviction, and credential rotation support (Evict per tenant+server) - Bypass pool for users with custom credentials (separate connections) - Fix MCP APIKey never passed to connections (inject as Authorization header) * fix(security): close remaining tenant isolation gaps from Plan 1-6 audit - Add tenant_id to 6 missing tables: agent_context_files, skill_agent_grants, mcp_agent_grants, team_tasks, spans, embedding_cache (migration 027) - Fix tid==uuid.Nil fallback to fail-closed (return error) in 8 update methods: agent_links, teams, skills, channel_instances, secure_cli, cron, mcp_servers, tracing - Add tenant filter to bare DELETEs: DeleteLink, DeleteTeam - Add tenant filter to queries: ListChildTraces, GetMonthlyAgentCost, CountAgentGrantsByServer, ListAccessible (MCP), ReviewRequest, ResolveGroupTitles, buildTraceWhere - Fix missing tenant_id in INSERTs: CreateSkill, GrantToUser, ReviewRequest grant INSERTs - Add tenant filter to api_keys: List, Revoke, Delete - Fix cron scanJob/RemoveJob/EnableJob fallthrough patterns * fix(security): inject tenant context into channel handler entry points Channel handlers used context.Background() which lost tenant context, causing store operations to either fail-closed or default to master tenant. Now all 10 handler entry points inject tenant from BaseChannel. * fix(security): tenant filters for teams, tasks, skills (Plan 6b audit) - Teams: add tenant filter to GetTeamForAgent, ListMembers, ListIdleMembers, KnownUserIDs (JOIN agent_teams for tenant check) - Teams: add tenant_id to GrantTeamAccess INSERT, tenant filter to RevokeTeamAccess, ListTeamGrants, HasTeamAccess - Team tasks: add tenant_id to CreateTask INSERT, fail-closed UpdateTask, tenant filter on all 7 query/delete methods - Skills: add tenant filter to RevokeFromAgent, ListAgentGrants - Skills: add ctx param + tenant filter to ToggleSkill - History: annotate context.Background() locations with TODOs for future tenant injection (requires PendingHistory struct refactor) * fix(security): add tenant_id to 4 missing team tables + fix INSERTs Add tenant_id column to: agent_team_members, team_task_comments, team_task_events, team_task_attachments (migration 027). Fix INSERT statements to include tenant_id: AddMember, AddTaskComment, RecordTaskEvent, AttachFileToTask. * fix(migration): cast UUID literals in tenant_users seed + usage_snapshots index PostgreSQL doesn't auto-cast string to UUID in SELECT and expression index contexts. Add explicit ::uuid casts to prevent migration failure. * docs: add multi-tenant architecture guide for integrators Comprehensive solution doc covering auth model, WS protocol, event system, data isolation, API reference, and integration patterns. Target audience: developers building custom frontends or SaaS on GoClaw. * feat(ui): multi-tenant awareness + tenant admin page (Plan 7) Backend: - Enrich WS connect response with tenant_name, tenant_slug, cross_tenant - Add tenants.mine WS method (any user, returns own memberships) - Parse tenant_hint in connect params for browser pairing multi-tenant - Wire tenantStore to MethodRouter for connect-time tenant lookup Frontend: - Auth store: tenantId, tenantName, tenantSlug, isCrossTenant, availableTenants - WS client: capture tenant fields from connect, send tenant_hint - WS provider: auto-fetch tenants.mine on connect - useTenants() shared hook for all tenant-aware components - Tenant indicator in sidebar connection status - Tenant admin page (/admin/tenants) with list + create dialog - Tenants nav in sidebar (cross-tenant admin only) - i18n: tenants namespace (en/vi/zh) - Type updates: tenant_id on AgentData, ApiKeyData * refactor(ui): move tenant selector into user menu dropdown in topbar Replace simple logout button with a Radix Popover user menu showing: - User ID display - Tenant selector (when multi-tenant: list all tenants with check mark) - Logout button Remove tenant indicator from connection-status.tsx (now in topbar). Tenant switch saves slug to localStorage and reloads for reconnect. * feat(ui): add logout confirmation dialog Show destructive confirm dialog before logout via ConfirmDialog component. Added logoutConfirm i18n key for en/vi/zh. * fix(ui): security hardening — hide admin nav, fix route guard, fix refresh - Hide System nav group for non-admin roles in sidebar (was visible to all) - Replace RequireAdmin with RequireCrossTenant guard on /admin/tenants route - Add RequireCrossTenant component to require-role.tsx - Fix refresh button animation: use isFetching instead of isLoading - Clean up connection-status.tsx (remove tenant indicator, now in topbar) * feat: cross-tenant admin tenant scope selector Backend: add tenant_scope connect param. Cross-tenant clients can narrow their scope to a specific tenant (slug). applyTenantScope() sets client.tenantID and clears crossTenant flag. UI: user menu shows "All Tenants" option for cross-tenant admins. Selecting a tenant saves slug to localStorage as tenant_scope, reload reconnects with narrowed scope. "All Tenants" clears scope. * feat: provisioning API key scope + tenant detail page (Plan 8) Backend: - Add operator.provision scope for limited tenant management - Add HasScope() method to gateway Client - Allow provision-scoped keys to create tenants + add users - Allow provision-scoped keys to create tenant-bound API keys Frontend: - Tenant detail page with user management (list, add, remove) - Clickable tenant list rows navigate to detail - i18n: tenant detail keys (en/vi/zh) - Route /admin/tenants/:id with RequireCrossTenant guard * fix: tenant scope keeps admin privileges + UI pattern fixes Backend: - applyTenantScope keeps crossTenant=true (retains admin features) - Router: scoped cross-tenant injects WithTenantID (filters data) while keeping admin role for method access UI: - Fix "All Tenants" check mark (compare against nil UUID string) - Fix tenant label when scope active (show selected tenant name) - Use ConfirmDialog for user removal (was hand-rolled) - Add DialogDescription to add-user dialog (Radix a11y) - Fix table min-w-[600px] consistency - Fix column header mismatch (was "role", should be "created") * fix(ui): clean up tenant detail header — remove redundant info panel Remove duplicate slug/status/created panel. Info now shown in PageHeader description (slug + date). Status badge removed (redundant with description). Cleaner, consistent with other admin pages. * fix(ui): redesign tenant detail with info cards + user cards * feat(ui): tenant selection gate — require tenant before app access - Add tenantSelected flag to auth store (persisted via localStorage) - WS provider auto-selects: single-tenant user auto, cross-tenant admin defaults to "All Tenants", zero-tenant user blocked - RequireAuth gate: redirect to /select-tenant when connected but no tenant selected - New TenantSelectorPage: centered card layout matching login page, "All Tenants" amber card for cross-tenant admin, per-tenant cards with role badges, no-access state with logout button - i18n: selectTenant, noAccess keys (en/vi/zh) * fix(security): scope events for cross-tenant admin with tenant_scope Event filter was checking !crossTenant before filtering — scoped cross-tenant admins (crossTenant=true + tenantID set) bypassed tenant event filtering. Now checks tenantID != Nil regardless of crossTenant flag, ensuring scoped admins only see their chosen tenant's events. * fix(security): HTTP API now respects tenant_scope for gateway token Root cause: UI uses HTTP API (/v1/agents, /v1/mcp/servers, etc.) for data fetching. HTTP auth middleware with gateway token always set CrossTenant=true with no tenant filtering. tenant_scope only worked for WS connection, not HTTP requests. Fix: - HTTP client sends X-GoClaw-Tenant-Scope header from localStorage - HTTP auth resolves header slug → tenant UUID via tenantStore - requireAuth: CrossTenant + TenantID → WithTenantID (scoped) - Wire InitTenantStore(pgStores.Tenants) in gateway startup * feat(security): tenant-aware provider registry, event filter, and membership validation - Refactor providers.Registry: Get(ctx, name) / List(ctx) extract tenant from context via injected TenantFromCtx func (avoids circular import) - Event filter: fail-closed 3-mode tenant filtering Mode 1: unscoped admin sees all Mode 2: scoped admin sees tenant events + system events Mode 3: regular user sees only own tenant (fail-closed) - WS connect: resolveTenantHint validates membership via GetUserRole with PermissionCache (30s TTL, bus invalidation) - BroadcastForTenant helper for tenant-scoped event emission - Session list: add TenantID to SessionListOpts from context - Cron handleRun: preserve tenant in background goroutine context - GOCLAW_LOG_LEVEL env var (debug|info|warn|error) for Docker/K8s - Cache debug logging: tenant_cache, permission_cache, api_key_cache - Friendly verify error: timeout → user-readable message - Verify timeout: 15s → 30s * feat(ui): setup wizard improvements + agent preset enrichment - Setup: skip link with confirm dialog, language selector (en/vi/zh) - Setup: card padding fix (py-0 gap-0 on Card, py-5 on CardContent) - Setup: remove duplicate skip link from layout - Step Model: verify countdown timer (30s), stops on result - Step Agent: default Fox Spirit preset, selected state styling, hide agent key/name inputs, auto-derive from preset, emoji in config - Summoning modal: elapsed timer (m:ss format) - Agent presets: enriched prompts with human-like quirks Fox Spirit: playful personality, care reminders Artisan: portrait/banner/ads/logo expertise Astrologer: reference sites (astro.com, cafeastrology, labyrinthos) - i18n: "triệu hồi linh hồn" fix, all 3 locales updated * feat(ui): API Key tenant support + card layout + provider chain fix - API Key create: tenant selector for cross-tenant admin, provision scope - API Key create: redesigned dialog with scope cards, Radix Select, icons - API Key list: card layout with badges (status, tenant, scopes) - API Key: shortcut in user menu (topbar) - API Key: keep "API Key" untranslated across all locales - Provider chain: empty state fix — skip legacy entry when provider not found in current tenant - i18n: form.cancel key added to all 3 locales * fix(ui): add bottom padding to all page layouts + misc improvements - Add pb-10 to all 24 page containers to prevent content touching bottom edge of viewport - Various UI polish from user modifications (summoning colors, layout icon, agent cards, sidebar adjustments) * feat(ui): MCP user credentials dialog + builtin tool tenant toggle - MCP: per-user credentials dialog (api_key, headers, env KV editor) with status badges, delete all, save - MCP: "My Credentials" button on each server row - Builtin Tools: per-tenant enable/disable override toggle with "Using default" / "Enabled/Disabled for tenant" badges and reset-to-default button - Setup: larger logo (h-16) and bolder title (text-4xl font-bold) - i18n: all keys added to en/vi/zh for both features * fix(ui): API key card spacing + remove pagination border - Card padding: px-4 py-3.5 (was px-3 py-2), rows spaced with gap-2 - Scopes on separate row from dates for readability - Card gap: space-y-2.5 between cards - Pagination: add className prop, remove border-t on API keys page - Badge/icon sizes bumped to text-xs / h-3.5 (was text-[10px] / h-3) * fix(security): comprehensive tenant isolation audit — SQL, events, cache, skills, files Defense-in-depth hardening across 12 audit phases: - SQL: add tenant_id WHERE to teams_tasks lifecycle/activity/followup/progress/embedding (~30 functions) - Events: broadcastTeamEvent + task_ticker + subagent announce now carry TenantID - Cache: agentKeyCache scoped by tenant (agent keys per-tenant, not globally unique) - Skills: SkillStore interface accepts ctx, SQL filter (is_system OR tenant_id=$N), per-tenant list cache, GrantToAgent includes tenant_id, tenant-scoped file storage - Files: StorageHandler/FilesHandler/TeamAttachments/teamWorkspaceDir use config.TenantDataDir/TenantTeamDir - Security: HMAC signed file tokens (file_token.go) replace gateway token in URLs - Audit: AuditEventPayload carries TenantID for async subscriber tenant scoping - InboundMessage: subagent/dispatch/validation/session_send propagate TenantID - Pending messages: DeleteStale scoped by tenant * fix(security): skip gateway token in URLs with signed file tokens toFileUrl() now skips appending ?token=GATEWAY_TOKEN when the URL already contains ?ft= (HMAC signed file token). Prevents gateway token exposure via browser history, logs, and referrer headers. * fix(security): stop persisting auth tokens in session media URLs mediaToMarkdown() now stores clean paths (/v1/files/path) without any auth tokens. Previously embedded ?token=GATEWAY_TOKEN (or ?ft=) into markdown which gets persisted in session messages DB. Frontend toFileUrl() adds auth at render time — tokens never stored. * fix(security): migration 027 strips leaked gateway tokens from session URLs Adds cleanup step to tenant foundation migration: removes ?token=xxx from persisted media URLs in session messages. Old code embedded the gateway token; new code stores clean paths only. * fix(security): sign file URLs at delivery time, not persist time Add SignFileURLs() utility that finds /v1/files/ and /v1/media/ URLs in content and appends HMAC signed ?ft= tokens before delivery. Applied at 4 delivery points: - WS agent events (OnEvent callback in gateway_managed.go) - WS chat.history response - WS sessions.preview response - HTTP /v1/chat/completions response Sessions store clean paths only. Tokens are generated per-delivery with 1h TTL — never persisted in DB. Frontend toFileUrl() skips appending gateway token when ?ft= is already present. * fix: file token verify path must match signed path (/v1/files/ prefix) SignFileURLs() signs the full URL path "/v1/files/{path}" but the verify in files.go auth() was using "/{path}" (without prefix). HMAC mismatch caused all signed file tokens to return 401. * fix(security): scope storage size cache per-tenant sizeCache was a single global entry — all tenants shared one cached size. Changed to sync.Map keyed by tenantBaseDir so each tenant gets its own cached size calculation. * feat(ui): redesign API keys page — table layout + code snippet dialog Replace card-based API keys list with table layout matching MCP Servers pattern. Add "API Key Usage" dialog with tabbed code snippets (cURL, TypeScript, Go) showing gateway connection examples with syntax highlighting and copy-to-clipboard. * fix(builtin-tools): seed media tools disabled, fix tenant toggle, add unconfigured warning - Seed media tools with Enabled=false and no default provider settings (user must configure provider chain before enabling) - Fix provider chain form ghost entries: validate provider exists in tenant before showing (parseInitialEntries new-format path) - Fix double toggle: show only tenant override OR global toggle, not both - Fix list API: merge tenant_enabled from builtin_tool_tenant_configs into response when tenant-scoped (was always null) - Add ListAll() to BuiltinToolTenantConfigStore for full override map - Add amber warning banner for enabled media tools missing provider config * feat(mcp): require_user_credentials setting + KeyValueEditor for user creds - Add require_user_credentials setting in mcp_servers.settings JSONB - Backend: skip MCP server in LoadForAgent when user lacks credentials - Frontend: toggle in MCP form dialog, persisted in settings field - Redesign MCP user credentials dialog: replace raw Textarea with KeyValueEditor (sensitive key masking for auth/token/secret fields) - Add settings to mcpServerAllowedFields for HTTP update * fix(security): restrict cross-tenant to owner IDs, config to owners only - Gateway token + non-owner user ID: admin role but tenant-scoped (no cross-tenant access). Fallback: only "system" is owner when GOCLAW_OWNER_IDS not configured (fail-closed). - Config page (WS config.* methods): wrapped with requireCrossTenant middleware — non-owner admins get permission denied - Config sidebar link: hidden for non-cross-tenant users - Logout: clear tenant_id and tenant_hint from localStorage (prevents tenant scope leak to next user session) - Refactor: LOCAL_STORAGE_KEYS.TENANT_ID/TENANT_HINT constants * fix(ui): chat bubble contrast, login logo, tenant no-access UX - Chat bubble: use --chat-bubble-user CSS var (darker orange, L=0.50/0.52) with text-white for WCAG AA contrast (~5.5:1) - Login page: logo h-20 w-20, title text-3xl font-bold - Tenant selector no-access: shield icon + hint text explaining user needs admin to add them to a tenant - Sidebar: GoClaw text uses text-sidebar-primary (brand color) * feat(contacts): merge/unmerge contacts to tenant users Add API and UI for linking channel contacts to tenant_users identity, enabling cross-channel user identification within a tenant. Backend: - POST /v1/contacts/merge — link contacts to existing or new tenant_user - POST /v1/contacts/unmerge — remove merged_id from contacts - GET /v1/contacts/merged/{id} — list contacts by tenant_user - GET /v1/tenant-users — list users for current tenant - Add display_name + metadata columns to tenant_users (migration 27) - All endpoints enforce tenant isolation via context tenant_id Frontend: - Checkbox multi-select on contacts table - Selection toolbar with Merge/Unmerge buttons - Merge dialog: link to existing user or create new - Link2 icon indicator for merged contacts - i18n: en/vi/zh translations for merge section * fix(security): add tenant_id to span and embedding_cache inserts SpanData struct was missing TenantID field — all span inserts failed with NOT NULL constraint violation after migration 027 dropped defaults. Fix captures tenant_id from context at emit time (6 call sites in loop_tracing.go + subagent_tracing.go), then includes it in both CreateSpan() and BatchCreateSpans() SQL (25→26 columns). Also fixes embedding_cache writeEmbeddingCache() which was missing tenant_id in its batch INSERT — same class of bug. Both use MasterTenantID fallback for backward compatibility. * feat: Introduce tenant switcher UI and enhance multi-tenant architecture documentation. * fix(security): enforce tenant scoping, fix session isolation and UI cleanup - Force cross-tenant admins to always have a concrete tenant_id (default MasterTenantID) instead of unscoped WithCrossTenant — prevents mismatch between session listing (no filter) and writes (MasterTenantID fallback) - Make agent router tenant-aware: Get(ctx, agentID) resolves agent for the caller's tenant, preventing cross-tenant agent cache collisions - Fix context.Background() in title goroutine and summarization — now uses tenant-aware context (WithoutCancel) so titles and compaction persist to the correct tenant - Add read-only SessionStore.Get() method; replace GetOrCreate in auth checks (preview/patch/delete/reset) to prevent phantom session creation - Inject tenant from channel instance into inbound message processing - Remove "All Tenants" option from tenant selector, topbar switcher, and ws-provider auto-select — admin must always operate within a tenant - Fix contacts page selection toolbar layout shift (always rendered) - Widen MCP credentials sensitive header regex to catch API_KEY etc. * fix(security): propagate tenant_id in consumer handlers and background ops - InjectTeamDispatch: use context.WithoutCancel instead of context.Background to preserve tenant_id while avoiding cancel propagation from HTTP/WS handlers - handleTeammateMessage/handleSubagentAnnounce: inject tenant_id from msg - Add nil guard for outcome.Result to prevent panic on agent-not-found - Use BroadcastForTenant for EventTeamTaskFailed/Completed/LeaderProcessing - Remove unnecessary WithCrossTenant in autoSetFollowup (ctx already scoped) - resolveAgentByKey: accept ctx param for tenant-scoped agent lookup - pending_messages: use request ctx instead of cross-tenant for GetDefault * fix(security): tenant-scope EnsureContact and PendingHistory DB operations - All channel EnsureContact calls now use tenant-scoped ctx instead of context.Background (whatsapp, slack, discord, telegram, feishu, zalo) - PendingHistory: add tenantID field, thread through constructors - All PendingHistory DB ops (load, flush, compact, delete) use tenantCtx() - Normalize timeouts: 10s for simple queries, 15s for batch writes * feat(teams): auto-attach media, retry completed tasks, improve tool messages - Auto-attach workspace media from any tool (create_image/audio/video) to team tasks via loop-level hook, not just write_file interceptor - Store absolute paths in team_task_attachments instead of relative - Extend retry action to support completed tasks (reopen for follow-up) - Context-aware comment result messages with next-action guidance for leader vs member roles and task status - All tool results include task_id for agent follow-up actions - Use #N "subject" format instead of raw UUIDs in tool messages * feat(multi-tenant): tenant isolation for media, events, providers and UI - Tenant-scoped media store, event filter, provider registry - Tenant header propagation in WS/HTTP clients - UI: tenant-aware chat messages, markdown renderer improvements - Protocol: tenant error codes and event definitions * docs: add multi-tenant architecture documentation * fix(teams): store absolute paths in team_task_attachments - AutoAttachWorkspaceFile: use cleanPath consistently instead of raw absPath - executeAttach: resolve relative paths to absolute via team workspace - AfterWrite interceptor already uses filepath.Clean (verified) * fix(teams): attachment download handles both absolute and relative paths filepath.Join with an absolute att.Path discards the teamBase prefix, causing path traversal check to fail and download to serve wrong file. Now checks IsAbs first — uses path directly for new absolute entries, falls back to legacy join for old relative entries. * fix(teams): attachment download validates against workspace root not tenant dir Absolute paths stored in DB don't match TenantTeamDir structure (master tenant has no tenants/ prefix). Now validates absolute paths against dataDir (workspace root) instead. Legacy relative paths still resolve via TenantTeamDir as before. IDOR check on att.TeamID ensures cross-team isolation. * fix(teams): attachment download uses workspace root, not data dir Files are stored under GOCLAW_WORKSPACE/teams/ but handler was passed dataDir (GOCLAW_DATA_DIR) — completely different directory. Now passes workspace. Legacy relative paths resolve via {workspace}/teams/{teamID}/{chatID}/{path}. * fix(security): use HMAC-signed file tokens for attachment downloads Replace gateway token exposure (?token=) with HMAC-signed short-lived file tokens (?ft=) for team task attachment downloads — same mechanism used by chat file URLs. Backend: - team_attachments auth: accept ?ft= signed token (priority 1), Bearer (priority 2) - teams_tasks RPC: sign download_url with HMAC at delivery time - Add fileTokenSecret to TeamsMethods, thread through wireChannelRPCMethods Frontend: - Use server-signed download_url from attachment data instead of ?token= - Remove useAuthStore dependency from task-detail-dialog * fix(security): decouple file token signing from gateway token - Generate random 256-bit HMAC key at startup (crypto/rand, memory-only) - All file signing/verification uses FileSigningKey() instead of gateway token - Remove ?token= query param fallback from /v1/files/, /v1/media/, attachments - Only ?ft= signed tokens and Bearer header accepted for file access - Reduce file token TTL from 1h to 5min - Frontend: remove gateway token from all file URLs and imports - Note: tokens invalidate on restart (acceptable for 5min TTL + WS reconnect) * fix(ui): use signed download_url for task attachments - Add download_url to TeamTaskAttachment type - Use a.download_url (server-signed ?ft=) instead of bare URL * fix(security): tenant-scope team workspace paths + show user/tenant in topbar - WorkspaceDir callers now use config.TenantWorkspace() to resolve tenant-scoped base dir (non-master tenants get workspace/tenants/{slug}/) - Fixes: all tenants previously wrote to global /app/workspace/teams/ without filesystem-level isolation - Affected: loop.go (agent run), team_tasks_mutations.go (task creation) - teams_workspace.go already correct (uses TenantTeamDir) - UI topbar: show "userId (tenantName)" in user menu * feat(ui): redesign task detail dialog with improved UX - Split monolithic 343-line component into 5 focused files - New header: subject as title, identifier + status badges above - Metadata grid with soft bg-muted/30 background, priority icons - Attachments: card-style with mime-type icons + proper download Button - Description/Result: markdown rendering via MarkdownRenderer - Comments: avatar circles + markdown rendering for content - All sections collapsible with chevron + count badge - Timeline: vertical dot-line pattern, collapsed by default - Fix kanban card hover layout shift (opacity instead of display toggle) * fix(security): tenant-scoped workspace paths and tool cache isolation - Scope team workspace paths to tenant directory - Add tenant isolation to tool cache and task reads - Shell deny pattern improvements - Agent resolver and context file tenant scoping - Sidebar tenant/user display fix - Add tests for workspace, boundary, and context file interceptor * fix(ui): tenant visibility fallback, merge coming-soon, task detail tweaks - Tenants page: try tenants.list (owner), fall back to tenants.mine for regular users; hide create button for non-owners - Merge contacts dialog: add coming-soon banner (i18n en/vi/zh), disable form and submit button - Task detail: collapse attachments/comments by default, guard download_url before rendering --- README.md | 6 +- _statics/goclaw-icon.svg | 74 ++++ _statics/goclaw-logo.svg | 104 +++++ cmd/gateway.go | 90 ++-- cmd/gateway_agents.go | 9 +- cmd/gateway_builtin_tools.go | 23 +- cmd/gateway_channels_setup.go | 6 +- cmd/gateway_consumer.go | 1 + cmd/gateway_consumer_handlers.go | 70 +-- cmd/gateway_consumer_helpers.go | 9 +- cmd/gateway_consumer_normal.go | 17 +- cmd/gateway_consumer_process.go | 2 +- cmd/gateway_cron.go | 5 +- cmd/gateway_http_handlers.go | 21 +- cmd/gateway_managed.go | 41 +- cmd/gateway_methods.go | 9 +- cmd/gateway_providers.go | 28 +- cmd/gateway_setup.go | 33 +- cmd/skills_cmd.go | 7 +- docs/18-http-api.md | 1 + docs/23-multi-tenant-architecture.md | 387 +++++++++++++++++ internal/agent/loop.go | 73 +++- internal/agent/loop_history.go | 36 +- internal/agent/loop_run.go | 3 +- internal/agent/loop_tracing.go | 12 + internal/agent/loop_types.go | 17 +- internal/agent/media.go | 111 +++-- internal/agent/media_sanitize.go | 12 +- internal/agent/media_tool_routing.go | 7 +- internal/agent/memoryflush.go | 14 +- internal/agent/resolver.go | 85 +++- internal/agent/router.go | 32 +- internal/bootstrap/seed_store_test.go | 6 + internal/bus/broadcast_helpers.go | 10 + internal/bus/types.go | 15 +- internal/cache/permission_cache.go | 121 ++++++ internal/channels/channel.go | 10 + internal/channels/discord/discord.go | 2 +- internal/channels/discord/handler.go | 27 +- internal/channels/events.go | 6 + internal/channels/feishu/bot.go | 4 +- internal/channels/feishu/bot_policy.go | 16 +- internal/channels/feishu/feishu.go | 8 +- internal/channels/history.go | 24 +- internal/channels/history_compaction.go | 4 +- internal/channels/history_flush.go | 2 +- internal/channels/instance_loader.go | 13 +- internal/channels/quota.go | 38 +- internal/channels/slack/channel.go | 2 +- internal/channels/slack/handlers.go | 9 +- internal/channels/slack/handlers_mention.go | 22 +- internal/channels/slack/utils.go | 4 +- internal/channels/telegram/channel.go | 2 +- .../channels/telegram/commands_pairing.go | 4 +- internal/channels/telegram/handlers.go | 8 +- internal/channels/whatsapp/whatsapp.go | 24 +- internal/channels/zalo/personal/channel.go | 2 +- internal/channels/zalo/personal/handlers.go | 15 +- internal/channels/zalo/personal/policy.go | 16 +- .../channels/zalo/personal/zalomethods/qr.go | 2 +- internal/channels/zalo/zalo.go | 18 +- internal/config/tenant_paths.go | 58 +++ internal/gateway/client.go | 45 ++ internal/gateway/event_filter.go | 208 +++++++++ internal/gateway/methods/access.go | 18 + internal/gateway/methods/agent_links.go | 18 +- internal/gateway/methods/agents.go | 15 +- internal/gateway/methods/agents_create.go | 22 +- .../methods/agents_create_owner_test.go | 6 + internal/gateway/methods/agents_delete.go | 1 - internal/gateway/methods/agents_files.go | 6 +- internal/gateway/methods/agents_identity.go | 3 +- internal/gateway/methods/agents_update.go | 1 - internal/gateway/methods/api_keys.go | 53 ++- internal/gateway/methods/chat.go | 39 +- internal/gateway/methods/config.go | 23 +- internal/gateway/methods/cron.go | 64 ++- internal/gateway/methods/delegations.go | 137 ------ internal/gateway/methods/pairing.go | 18 +- internal/gateway/methods/sessions.go | 96 ++++- internal/gateway/methods/skills.go | 30 +- internal/gateway/methods/teams.go | 16 +- internal/gateway/methods/teams_crud.go | 19 + internal/gateway/methods/teams_members.go | 2 +- internal/gateway/methods/teams_tasks.go | 8 + internal/gateway/methods/teams_workspace.go | 78 +++- .../gateway/methods/teams_workspace_test.go | 124 ++++++ internal/gateway/methods/tenants.go | 404 ++++++++++++++++++ internal/gateway/methods/usage.go | 12 +- internal/gateway/router.go | 254 ++++++++++- internal/gateway/server.go | 39 +- internal/heartbeat/ticker.go | 21 +- internal/http/activity.go | 8 + internal/http/agents.go | 22 +- internal/http/agents_sharing.go | 4 +- internal/http/api_key_cache.go | 7 + internal/http/api_key_cache_test.go | 8 +- internal/http/api_keys.go | 24 +- internal/http/audit.go | 1 + internal/http/auth.go | 168 +++++++- internal/http/builtin_tools.go | 88 +++- internal/http/channel_instances.go | 11 +- internal/http/chat_completions.go | 6 +- internal/http/contact_merge_handlers.go | 198 +++++++++ internal/http/custom_tools.go | 214 ---------- internal/http/delegations.go | 98 ----- internal/http/file_token.go | 89 ++++ internal/http/files.go | 63 ++- internal/http/knowledge_graph.go | 5 +- internal/http/knowledge_graph_handlers.go | 2 +- internal/http/mcp.go | 34 +- internal/http/mcp_user_credentials.go | 108 +++++ internal/http/media_serve.go | 22 +- internal/http/oauth.go | 25 +- internal/http/openapi_spec.json | 2 +- internal/http/pending_messages.go | 14 +- internal/http/provider_verify.go | 15 +- internal/http/providers.go | 24 +- internal/http/responses.go | 2 +- internal/http/skills.go | 171 ++++++-- internal/http/skills_grants.go | 8 +- internal/http/skills_upload.go | 7 +- internal/http/skills_versions.go | 6 +- internal/http/storage.go | 87 ++-- internal/http/summoner.go | 38 +- internal/http/summoner_regenerate.go | 35 +- internal/http/team_attachments.go | 44 +- internal/http/tenant_cache.go | 95 ++++ internal/http/tenants.go | 312 ++++++++++++++ internal/http/traces.go | 33 ++ internal/http/usage.go | 11 + internal/http/validate.go | 4 +- internal/http/wake.go | 2 +- internal/i18n/catalog_en.go | 11 +- internal/i18n/catalog_vi.go | 11 +- internal/i18n/catalog_zh.go | 11 +- internal/i18n/keys.go | 13 +- internal/mcp/manager.go | 78 +++- internal/mcp/manager_connect.go | 13 +- internal/mcp/manager_tools.go | 4 +- internal/mcp/pool.go | 289 +++++++++++-- internal/media/store.go | 6 +- internal/oauth/token.go | 10 +- internal/permissions/policy.go | 2 + internal/providers/registry.go | 138 ++++-- internal/providers/types.go | 7 +- internal/sessions/manager.go | 49 +-- internal/skills/dep_scanner.go | 7 + internal/skills/loader.go | 23 +- internal/skills/seeder.go | 16 +- internal/store/agent_store.go | 5 +- internal/store/api_key_store.go | 22 +- internal/store/channel_instance_store.go | 1 + internal/store/contact_store.go | 17 +- internal/store/context.go | 45 ++ internal/store/cron_store.go | 25 +- internal/store/custom_tool_store.go | 44 -- internal/store/mcp_store.go | 12 + internal/store/pairing_store.go | 16 +- internal/store/pg/activity_store.go | 27 +- internal/store/pg/agent_links.go | 67 ++- internal/store/pg/agents.go | 260 +++++++++-- internal/store/pg/agents_batch.go | 76 ++++ internal/store/pg/agents_context.go | 87 ++-- internal/store/pg/api_keys.go | 117 ++++- internal/store/pg/channel_contacts.go | 127 ++++-- internal/store/pg/channel_instances.go | 101 ++++- internal/store/pg/config_permissions.go | 53 ++- internal/store/pg/config_secrets.go | 16 +- internal/store/pg/cron_crud.go | 70 ++- internal/store/pg/cron_exec.go | 48 ++- internal/store/pg/cron_scan.go | 32 +- internal/store/pg/cron_scheduler.go | 4 +- internal/store/pg/cron_update.go | 34 +- internal/store/pg/custom_tools.go | 236 ---------- internal/store/pg/factory.go | 6 +- internal/store/pg/helpers.go | 41 +- internal/store/pg/knowledge_graph.go | 175 ++++++-- .../store/pg/knowledge_graph_embedding.go | 19 +- .../store/pg/knowledge_graph_traversal.go | 35 +- internal/store/pg/mcp_servers.go | 71 ++- internal/store/pg/mcp_servers_access.go | 93 ++-- internal/store/pg/mcp_user_credentials.go | 100 +++++ internal/store/pg/memory_admin.go | 54 ++- internal/store/pg/memory_docs.go | 84 ++-- internal/store/pg/memory_embedding_cache.go | 13 +- internal/store/pg/memory_search.go | 67 ++- internal/store/pg/pairing.go | 95 ++-- internal/store/pg/pending_message_store.go | 94 ++-- internal/store/pg/providers.go | 64 ++- internal/store/pg/secure_cli.go | 124 +++++- internal/store/pg/sessions.go | 97 +++-- internal/store/pg/sessions_list.go | 95 ++-- internal/store/pg/sessions_ops.go | 20 +- internal/store/pg/sessions_tokens.go | 45 +- internal/store/pg/skills.go | 58 ++- internal/store/pg/skills_content.go | 98 +++-- internal/store/pg/skills_crud.go | 96 +++-- internal/store/pg/skills_embedding.go | 30 +- internal/store/pg/skills_grants.go | 76 +++- internal/store/pg/snapshot.go | 30 +- internal/store/pg/teams.go | 248 +++++++++-- internal/store/pg/teams_delegation.go | 172 -------- internal/store/pg/teams_tasks.go | 126 +++++- internal/store/pg/teams_tasks_activity.go | 57 +-- internal/store/pg/teams_tasks_embedding.go | 4 +- internal/store/pg/teams_tasks_followup.go | 28 +- internal/store/pg/teams_tasks_lifecycle.go | 51 ++- internal/store/pg/teams_tasks_progress.go | 27 +- internal/store/pg/tenant_configs.go | 126 ++++++ internal/store/pg/tenant_store.go | 229 ++++++++++ internal/store/pg/tracing.go | 115 +++-- internal/store/provider_store.go | 1 + internal/store/session_store.go | 74 ++-- internal/store/skill_store.go | 12 +- internal/store/stores.go | 6 +- internal/store/team_store.go | 53 +-- internal/store/tenant_config_store.go | 43 ++ internal/store/tenant_store.go | 81 ++++ internal/store/tracing_store.go | 1 + internal/tasks/task_ticker.go | 25 +- internal/tools/announce_queue.go | 3 + internal/tools/boundary_test.go | 60 +++ .../tools/context_file_interceptor_test.go | 6 + internal/tools/context_keys.go | 12 +- internal/tools/cron.go | 46 +- internal/tools/dynamic_loader.go | 95 ---- internal/tools/dynamic_tool.go | 133 ------ internal/tools/media_provider_chain.go | 7 +- internal/tools/read_audio_resolve.go | 16 +- internal/tools/read_document_resolve.go | 20 +- internal/tools/read_image.go | 2 +- internal/tools/read_video_resolve.go | 16 +- internal/tools/sessions.go | 4 +- internal/tools/sessions_history.go | 2 +- internal/tools/sessions_send.go | 5 +- internal/tools/shell.go | 13 +- internal/tools/skill_manage.go | 42 +- internal/tools/skill_search.go | 12 +- internal/tools/subagent.go | 2 + internal/tools/subagent_exec.go | 4 +- internal/tools/subagent_spawn_tool.go | 12 + internal/tools/subagent_tracing.go | 12 + internal/tools/team_tasks_followup.go | 12 +- internal/tools/team_tasks_lifecycle.go | 12 +- internal/tools/team_tasks_mutations.go | 47 +- internal/tools/team_tasks_read.go | 19 + internal/tools/team_tool_cache.go | 105 ++++- internal/tools/team_tool_dispatch.go | 3 +- internal/tools/team_tool_helpers.go | 10 +- internal/tools/team_tool_validation.go | 7 +- internal/tools/workspace_dir.go | 9 +- internal/tools/workspace_interceptor.go | 73 +++- internal/upgrade/version.go | 2 +- .../000026_api_key_user_binding.down.sql | 4 + migrations/000026_api_key_user_binding.up.sql | 21 + migrations/000027_tenant_foundation.down.sql | 112 +++++ migrations/000027_tenant_foundation.up.sql | 358 ++++++++++++++++ pkg/browser/actions.go | 15 +- pkg/browser/browser.go | 72 +++- pkg/browser/browser_page.go | 9 +- pkg/browser/browser_remote.go | 33 +- pkg/browser/browser_tabs.go | 51 ++- pkg/browser/browser_tenant.go | 20 + pkg/browser/tool.go | 12 +- pkg/protocol/errors.go | 5 +- pkg/protocol/events.go | 3 + pkg/protocol/methods.go | 6 - ui/web/index.html | 3 + ui/web/public/favicon.svg | 46 ++ ui/web/public/goclaw-icon.svg | 74 ++++ ui/web/src/api/http-client.ts | 7 +- ui/web/src/api/protocol.ts | 10 + ui/web/src/api/ws-client.ts | 31 +- .../src/components/chat/active-run-zone.tsx | 5 +- .../components/chat/activity-indicator.tsx | 2 +- ui/web/src/components/chat/message-bubble.tsx | 4 +- ui/web/src/components/chat/rich-content.tsx | 4 +- ui/web/src/components/chat/streaming-text.tsx | 5 +- ui/web/src/components/layout/sidebar.tsx | 28 +- ui/web/src/components/layout/topbar.tsx | 147 ++++++- .../src/components/providers/ws-provider.tsx | 65 ++- ui/web/src/components/shared/file-tree.tsx | 4 +- .../components/shared/markdown-renderer.tsx | 250 +++++------ ui/web/src/components/shared/pagination.tsx | 4 +- ui/web/src/components/shared/require-auth.tsx | 16 + ui/web/src/components/shared/require-role.tsx | 30 ++ .../components/shared/tool-name-select.tsx | 40 +- ui/web/src/hooks/use-tenants.ts | 19 + ui/web/src/i18n/index.ts | 8 +- ui/web/src/i18n/locales/en/agents.json | 8 +- ui/web/src/i18n/locales/en/api-keys.json | 28 +- ui/web/src/i18n/locales/en/contacts.json | 24 +- ui/web/src/i18n/locales/en/login.json | 10 +- ui/web/src/i18n/locales/en/mcp.json | 27 +- ui/web/src/i18n/locales/en/setup.json | 6 +- ui/web/src/i18n/locales/en/sidebar.json | 3 +- ui/web/src/i18n/locales/en/skills.json | 5 +- ui/web/src/i18n/locales/en/tenants.json | 33 ++ ui/web/src/i18n/locales/en/tools.json | 7 + ui/web/src/i18n/locales/en/topbar.json | 2 + ui/web/src/i18n/locales/vi/agents.json | 10 +- ui/web/src/i18n/locales/vi/api-keys.json | 28 +- ui/web/src/i18n/locales/vi/contacts.json | 24 +- ui/web/src/i18n/locales/vi/login.json | 10 +- ui/web/src/i18n/locales/vi/mcp.json | 27 +- ui/web/src/i18n/locales/vi/setup.json | 6 +- ui/web/src/i18n/locales/vi/sidebar.json | 5 +- ui/web/src/i18n/locales/vi/skills.json | 5 +- ui/web/src/i18n/locales/vi/tenants.json | 33 ++ ui/web/src/i18n/locales/vi/tools.json | 7 + ui/web/src/i18n/locales/vi/topbar.json | 2 + ui/web/src/i18n/locales/zh/agents.json | 10 +- ui/web/src/i18n/locales/zh/api-keys.json | 52 ++- ui/web/src/i18n/locales/zh/contacts.json | 24 +- ui/web/src/i18n/locales/zh/login.json | 10 +- ui/web/src/i18n/locales/zh/mcp.json | 27 +- ui/web/src/i18n/locales/zh/setup.json | 6 +- ui/web/src/i18n/locales/zh/sidebar.json | 5 +- ui/web/src/i18n/locales/zh/skills.json | 5 +- ui/web/src/i18n/locales/zh/tenants.json | 33 ++ ui/web/src/i18n/locales/zh/tools.json | 7 + ui/web/src/i18n/locales/zh/topbar.json | 2 + ui/web/src/index.css | 149 +++---- ui/web/src/lib/constants.ts | 6 +- ui/web/src/lib/file-helpers.ts | 18 +- ui/web/src/lib/query-keys.ts | 12 +- ui/web/src/pages/activity/activity-page.tsx | 2 +- ui/web/src/pages/agents/agent-card.tsx | 4 +- .../agents/agent-detail/agent-header.tsx | 2 +- .../workspace-sharing-section.tsx | 6 +- .../agent-detail/heartbeat-config-dialog.tsx | 2 +- .../overview-sections/evolution-section.tsx | 4 +- .../overview-sections/heartbeat-card.tsx | 2 +- ui/web/src/pages/agents/agent-list-row.tsx | 4 +- ui/web/src/pages/agents/agent-presets.ts | 4 + ui/web/src/pages/agents/agents-page.tsx | 2 +- ui/web/src/pages/agents/summoning-modal.tsx | 35 +- .../pages/api-keys/api-key-code-dialog.tsx | 254 +++++++++++ .../pages/api-keys/api-key-create-dialog.tsx | 180 +++++--- ui/web/src/pages/api-keys/api-keys-page.tsx | 149 ++++--- ui/web/src/pages/approvals/approvals-page.tsx | 2 +- .../builtin-tools/builtin-tools-page.tsx | 170 +++++++- .../builtin-tools/hooks/use-builtin-tools.ts | 31 +- .../media-provider-chain-form.tsx | 61 ++- ui/web/src/pages/channels/channels-page.tsx | 2 +- .../pages/channels/channels-status-view.tsx | 2 +- .../src/pages/chat/hooks/use-chat-messages.ts | 39 +- .../cli-credentials/cli-credentials-page.tsx | 2 +- ui/web/src/pages/config/config-page.tsx | 6 +- .../behavior-pending-compaction-card.tsx | 2 +- .../config/sections/behavior-ux-card.tsx | 4 +- ui/web/src/pages/contacts/contacts-page.tsx | 116 ++++- .../pages/contacts/hooks/use-contact-merge.ts | 33 ++ .../contacts/hooks/use-tenant-users-list.ts | 16 + .../pages/contacts/merge-contacts-dialog.tsx | 197 +++++++++ ui/web/src/pages/cron/cron-page.tsx | 2 +- .../custom-tools/custom-tool-form-dialog.tsx | 171 -------- .../pages/custom-tools/custom-tools-page.tsx | 198 --------- .../custom-tools/hooks/use-custom-tools.ts | 89 ---- .../events/event-sections/event-categories.ts | 4 +- .../event-sections/event-detail-dialog.tsx | 2 +- ui/web/src/pages/events/events-page.tsx | 2 +- ui/web/src/pages/login/login-layout.tsx | 3 +- ui/web/src/pages/login/tenant-selector.tsx | 80 ++++ ui/web/src/pages/mcp/hooks/use-mcp.ts | 28 +- ui/web/src/pages/mcp/mcp-form-dialog.tsx | 12 + ui/web/src/pages/mcp/mcp-page.tsx | 27 +- .../pages/mcp/mcp-user-credentials-dialog.tsx | 201 +++++++++ ui/web/src/pages/memory/kg-graph-view.tsx | 4 +- ui/web/src/pages/memory/memory-page.tsx | 2 +- ui/web/src/pages/nodes/nodes-page.tsx | 2 +- .../pending-messages-page.tsx | 2 +- ui/web/src/pages/providers/providers-page.tsx | 2 +- ui/web/src/pages/sessions/sessions-page.tsx | 2 +- ui/web/src/pages/setup/setup-layout.tsx | 18 +- ui/web/src/pages/setup/setup-page.tsx | 79 +++- ui/web/src/pages/setup/step-agent.tsx | 126 +++--- ui/web/src/pages/setup/step-channel.tsx | 4 +- ui/web/src/pages/setup/step-model.tsx | 40 +- ui/web/src/pages/setup/step-provider.tsx | 4 +- ui/web/src/pages/skills/hooks/use-skills.ts | 22 +- ui/web/src/pages/skills/skills-page.tsx | 2 +- ui/web/src/pages/teams/board/board-header.tsx | 2 +- ui/web/src/pages/teams/board/kanban-card.tsx | 2 +- .../pages/teams/board/team-info-dialog.tsx | 2 +- .../pages/teams/board/team-members-dialog.tsx | 2 +- .../task-sections/task-detail-attachments.tsx | 76 ++++ .../task-sections/task-detail-comments.tsx | 94 ++++ .../task-sections/task-detail-content.tsx | 69 +++ .../task-sections/task-detail-dialog.tsx | 286 +++++-------- .../task-sections/task-detail-timeline.tsx | 50 +++ ui/web/src/pages/teams/team-settings-tab.tsx | 2 +- ui/web/src/pages/teams/team-version-modal.tsx | 6 +- ui/web/src/pages/teams/teams-page.tsx | 2 +- .../tenants-admin/hooks/use-tenant-detail.ts | 77 ++++ .../tenants-admin/hooks/use-tenants-admin.ts | 67 +++ .../tenants-admin/tenant-detail-page.tsx | 243 +++++++++++ .../tenants-admin/tenants-admin-page.tsx | 166 +++++++ .../src/pages/traces/trace-detail-dialog.tsx | 4 +- ui/web/src/pages/traces/traces-page.tsx | 2 +- ui/web/src/pages/tts/tts-page.tsx | 4 +- .../usage/components/distribution-donut.tsx | 6 +- .../pages/usage/components/duration-chart.tsx | 2 +- .../usage/components/knowledge-chart.tsx | 8 +- .../usage/components/request-volume-chart.tsx | 2 +- .../usage/components/token-area-chart.tsx | 8 +- ui/web/src/routes.tsx | 50 ++- ui/web/src/stores/use-auth-store.ts | 45 +- ui/web/src/types/agent.ts | 1 + ui/web/src/types/api-key.ts | 2 + ui/web/src/types/contact.ts | 11 + ui/web/src/types/custom-tool.ts | 25 -- ui/web/src/types/mcp.ts | 15 + ui/web/src/types/session.ts | 2 +- ui/web/src/types/team.ts | 1 + ui/web/src/types/tenant.ts | 28 ++ 417 files changed, 13582 insertions(+), 4484 deletions(-) create mode 100644 _statics/goclaw-icon.svg create mode 100644 _statics/goclaw-logo.svg create mode 100644 docs/23-multi-tenant-architecture.md create mode 100644 internal/bus/broadcast_helpers.go create mode 100644 internal/cache/permission_cache.go create mode 100644 internal/config/tenant_paths.go create mode 100644 internal/gateway/event_filter.go create mode 100644 internal/gateway/methods/access.go delete mode 100644 internal/gateway/methods/delegations.go create mode 100644 internal/gateway/methods/teams_workspace_test.go create mode 100644 internal/gateway/methods/tenants.go create mode 100644 internal/http/contact_merge_handlers.go delete mode 100644 internal/http/custom_tools.go delete mode 100644 internal/http/delegations.go create mode 100644 internal/http/file_token.go create mode 100644 internal/http/mcp_user_credentials.go create mode 100644 internal/http/tenant_cache.go create mode 100644 internal/http/tenants.go delete mode 100644 internal/store/custom_tool_store.go create mode 100644 internal/store/pg/agents_batch.go delete mode 100644 internal/store/pg/custom_tools.go create mode 100644 internal/store/pg/mcp_user_credentials.go delete mode 100644 internal/store/pg/teams_delegation.go create mode 100644 internal/store/pg/tenant_configs.go create mode 100644 internal/store/pg/tenant_store.go create mode 100644 internal/store/tenant_config_store.go create mode 100644 internal/store/tenant_store.go delete mode 100644 internal/tools/dynamic_loader.go delete mode 100644 internal/tools/dynamic_tool.go create mode 100644 migrations/000026_api_key_user_binding.down.sql create mode 100644 migrations/000026_api_key_user_binding.up.sql create mode 100644 migrations/000027_tenant_foundation.down.sql create mode 100644 migrations/000027_tenant_foundation.up.sql create mode 100644 pkg/browser/browser_tenant.go create mode 100644 ui/web/public/favicon.svg create mode 100644 ui/web/public/goclaw-icon.svg create mode 100644 ui/web/src/components/shared/require-role.tsx create mode 100644 ui/web/src/hooks/use-tenants.ts create mode 100644 ui/web/src/i18n/locales/en/tenants.json create mode 100644 ui/web/src/i18n/locales/vi/tenants.json create mode 100644 ui/web/src/i18n/locales/zh/tenants.json create mode 100644 ui/web/src/pages/api-keys/api-key-code-dialog.tsx create mode 100644 ui/web/src/pages/contacts/hooks/use-contact-merge.ts create mode 100644 ui/web/src/pages/contacts/hooks/use-tenant-users-list.ts create mode 100644 ui/web/src/pages/contacts/merge-contacts-dialog.tsx delete mode 100644 ui/web/src/pages/custom-tools/custom-tool-form-dialog.tsx delete mode 100644 ui/web/src/pages/custom-tools/custom-tools-page.tsx delete mode 100644 ui/web/src/pages/custom-tools/hooks/use-custom-tools.ts create mode 100644 ui/web/src/pages/login/tenant-selector.tsx create mode 100644 ui/web/src/pages/mcp/mcp-user-credentials-dialog.tsx create mode 100644 ui/web/src/pages/teams/task-sections/task-detail-attachments.tsx create mode 100644 ui/web/src/pages/teams/task-sections/task-detail-comments.tsx create mode 100644 ui/web/src/pages/teams/task-sections/task-detail-content.tsx create mode 100644 ui/web/src/pages/teams/task-sections/task-detail-timeline.tsx create mode 100644 ui/web/src/pages/tenants-admin/hooks/use-tenant-detail.ts create mode 100644 ui/web/src/pages/tenants-admin/hooks/use-tenants-admin.ts create mode 100644 ui/web/src/pages/tenants-admin/tenant-detail-page.tsx create mode 100644 ui/web/src/pages/tenants-admin/tenants-admin-page.tsx delete mode 100644 ui/web/src/types/custom-tool.ts create mode 100644 ui/web/src/types/tenant.ts diff --git a/README.md b/README.md index 3e877496..1d3081e5 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@

- GoClaw + GoClaw

-

GoClaw

- -

Enterprise AI Agent Platform

+

Multi-Tenant AI Agent Platform

Multi-agent AI gateway built in Go. 20+ LLM providers. 7 channels. Multi-tenant PostgreSQL.
diff --git a/_statics/goclaw-icon.svg b/_statics/goclaw-icon.svg new file mode 100644 index 00000000..797941f5 --- /dev/null +++ b/_statics/goclaw-icon.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/_statics/goclaw-logo.svg b/_statics/goclaw-logo.svg new file mode 100644 index 00000000..a12bce9b --- /dev/null +++ b/_statics/goclaw-logo.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cmd/gateway.go b/cmd/gateway.go index 78a5b766..754d9592 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -7,6 +7,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "github.com/google/uuid" @@ -45,6 +46,21 @@ func runGateway() { if verbose { logLevel = slog.LevelDebug } + // Env override (docker/K8s friendly, default: info): GOCLAW_LOG_LEVEL=debug|info|warn|error + if lvl := os.Getenv("GOCLAW_LOG_LEVEL"); lvl != "" { + switch strings.ToLower(lvl) { + case "debug": + logLevel = slog.LevelDebug + case "info": + logLevel = slog.LevelInfo + case "warn": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + default: + fmt.Fprintf(os.Stderr, "warning: unknown GOCLAW_LOG_LEVEL=%q, using info\n", lvl) + } + } textHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: logLevel, }) @@ -64,7 +80,7 @@ func runGateway() { msgBus := bus.New() // Create provider registry - providerRegistry := providers.NewRegistry() + providerRegistry := providers.NewRegistry(store.TenantIDFromContext) registerProviders(providerRegistry, cfg) // Resolve workspace (must be absolute for system prompt + file tool path resolution) @@ -145,12 +161,9 @@ func runGateway() { } // Notify clients that leader is processing team results // (bridges UI gap between last task.completed and announce run.started). - msgBus.Broadcast(bus.Event{ - Name: protocol.EventTeamLeaderProcessing, - Payload: map[string]any{ - "agentId": meta.ParentAgent, - "tasks": len(items), - }, + bus.BroadcastForTenant(msgBus, protocol.EventTeamLeaderProcessing, meta.OriginTenantID, map[string]any{ + "agentId": meta.ParentAgent, + "tasks": len(items), }) msgBus.PublishInbound(bus.InboundMessage{ @@ -159,6 +172,7 @@ func runGateway() { ChatID: meta.OriginChatID, Content: content, UserID: meta.OriginUserID, + TenantID: meta.OriginTenantID, Metadata: batchMeta, Media: batchMedia, }) @@ -170,7 +184,7 @@ func runGateway() { slog.Info("subagent system enabled", "tools", []string{"spawn"}) } - skillsLoader, skillSearchTool, globalSkillsDir := setupSkillsSystem(cfg, workspace, dataDir, pgStores, toolsReg, providerRegistry, msgBus) + skillsLoader, skillSearchTool, globalSkillsDir, bundledSkillsDir := setupSkillsSystem(cfg, workspace, dataDir, pgStores, toolsReg, providerRegistry, msgBus) _ = skillSearchTool // used via wireExtras → skillsLoader; kept for type clarity // DateTime tool (precise time for cron scheduling, memory timestamps, etc.) @@ -282,19 +296,10 @@ func runGateway() { server.SetAgentStore(pgStores.Agents) } - // Dynamic custom tools: load global tools from DB before resolver - var dynamicLoader *tools.DynamicToolLoader - if pgStores.CustomTools != nil { - dynamicLoader = tools.NewDynamicToolLoader(pgStores.CustomTools, workspace) - if err := dynamicLoader.LoadGlobal(context.Background(), toolsReg); err != nil { - slog.Warn("failed to load global custom tools", "error", err) - } - } - var mcpPool *mcpbridge.Pool var mediaStore *media.Store var postTurn tools.PostTurnProcessor - contextFileInterceptor, mcpPool, mediaStore, postTurn = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, dynamicLoader, redisClient) + contextFileInterceptor, mcpPool, mediaStore, postTurn = wireExtras(pgStores, agentRouter, providerRegistry, msgBus, pgStores.Sessions, toolsReg, toolPE, skillsLoader, hasMemory, traceCollector, workspace, cfg.Gateway.InjectionAction, cfg, sandboxMgr, redisClient) if mcpPool != nil { defer mcpPool.Stop() } @@ -303,7 +308,7 @@ func runGateway() { if mcpMgr != nil { mcpToolLister = mcpMgr } - agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH := wireHTTP(pgStores, cfg.Gateway.Token, cfg.Agents.Defaults.Workspace, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister) + agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, mcpUserCredsH := wireHTTP(pgStores, cfg.Gateway.Token, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister) if providersH != nil { providersH.SetAPIBaseFallback(cfg.Providers.APIBaseForType) } @@ -323,10 +328,13 @@ func runGateway() { } server.SetWakeHandler(wakeH) if mcpH != nil { + if mcpPool != nil { + mcpH.SetPoolEvictor(mcpPool) + } server.SetMCPHandler(mcpH) } - if customToolsH != nil { - server.SetCustomToolsHandler(customToolsH) + if mcpUserCredsH != nil { + server.SetMCPUserCredentialsHandler(mcpUserCredsH) } if channelInstancesH != nil { server.SetChannelInstancesHandler(channelInstancesH) @@ -334,14 +342,11 @@ func runGateway() { if providersH != nil { server.SetProvidersHandler(providersH) } - if delegationsH != nil { - server.SetDelegationsHandler(delegationsH) - } if teamEventsH != nil { server.SetTeamEventsHandler(teamEventsH) } if pgStores != nil && pgStores.Teams != nil { - server.SetTeamAttachmentsHandler(httpapi.NewTeamAttachmentsHandler(pgStores.Teams, cfg.Gateway.Token, dataDir)) + server.SetTeamAttachmentsHandler(httpapi.NewTeamAttachmentsHandler(pgStores.Teams, cfg.Gateway.Token, workspace)) } if builtinToolsH != nil { server.SetBuiltinToolsHandler(builtinToolsH) @@ -399,7 +404,7 @@ func runGateway() { // Workspace file serving endpoint — serves files by absolute path, auth-token protected. // Supports media from any agent workspace (each agent has its own workspace from DB). - server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token, workspace)) + server.SetFilesHandler(httpapi.NewFilesHandler(cfg.Gateway.Token, workspace, dataDir)) // Storage file management — browse/delete files under the resolved workspace directory. // Uses GOCLAW_WORKSPACE (or default ~/.goclaw/workspace) so it works correctly @@ -472,7 +477,7 @@ func runGateway() { instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages)) instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.Factory) instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages)) - if err := instanceLoader.LoadAll(context.Background()); err != nil { + if err := instanceLoader.LoadAll(store.WithCrossTenant(context.Background())); err != nil { slog.Error("failed to load channel instances from DB", "error", err) } } @@ -507,7 +512,8 @@ func runGateway() { }) go func() { for payload := range auditCh { - if err := pgStores.Activity.Log(context.Background(), &store.ActivityLog{ + auditCtx := store.WithTenantID(context.Background(), payload.TenantID) + if err := pgStores.Activity.Log(auditCtx, &store.ActivityLog{ ActorType: payload.ActorType, ActorID: payload.ActorID, Action: payload.Action, @@ -612,7 +618,7 @@ func runGateway() { if err != nil { return } - team, err := notifyTeamStore.GetTeam(context.Background(), teamUUID) + team, err := notifyTeamStore.GetTeam(store.WithCrossTenant(context.Background()), teamUUID) if err != nil || team == nil { return } @@ -654,7 +660,7 @@ func runGateway() { // Resolve lead agent key (needed for leader mode routing + completed-by-leader skip). var leadAgentKey string if notifyAgentStore != nil { - if la, err := notifyAgentStore.GetByID(context.Background(), team.LeadAgentID); err == nil { + if la, err := notifyAgentStore.GetByID(store.WithCrossTenant(context.Background()), team.LeadAgentID); err == nil { leadAgentKey = la.AgentKey } } @@ -793,10 +799,11 @@ func runGateway() { // Uses calibrated token estimation (actual prompt tokens from last LLM call) // and the agent's real context window (cached on session by the Loop). sched.SetTokenEstimateFunc(func(sessionKey string) (int, int) { - history := pgStores.Sessions.GetHistory(sessionKey) - lastPT, lastMC := pgStores.Sessions.GetLastPromptTokens(sessionKey) + bctx := context.Background() + history := pgStores.Sessions.GetHistory(bctx, sessionKey) + lastPT, lastMC := pgStores.Sessions.GetLastPromptTokens(bctx, sessionKey) tokens := agent.EstimateTokensWithCalibration(history, lastPT, lastMC) - cw := pgStores.Sessions.GetContextWindow(sessionKey) + cw := pgStores.Sessions.GetContextWindow(bctx, sessionKey) if cw <= 0 { cw = config.DefaultContextWindow } @@ -870,6 +877,23 @@ func runGateway() { methods.NewAPIKeysMethods(pgStores.APIKeys).Register(server.Router()) } + // Tenant management RPC + HTTP + if pgStores.Tenants != nil { + methods.NewTenantsMethods(pgStores.Tenants, msgBus, workspace).Register(server.Router()) + server.SetTenantsHandler(httpapi.NewTenantsHandler(pgStores.Tenants, cfg.Gateway.Token, msgBus, workspace)) + server.Router().SetTenantStore(pgStores.Tenants) + // Permission cache for tenant membership checks (tenant role, agent access, etc.) + permCache := cache.NewPermissionCache() + msgBus.Subscribe("permission-cache", func(e bus.Event) { + if p, ok := e.Payload.(bus.CacheInvalidatePayload); ok { + permCache.HandleInvalidation(p) + } + }) + server.Router().SetPermissionCache(permCache) + httpapi.InitTenantStore(pgStores.Tenants, msgBus) + httpapi.InitOwnerIDs(cfg.Gateway.OwnerIDs) + } + // Reload quota config on config changes via pub/sub. if quotaChecker != nil { msgBus.Subscribe("quota-config-reload", func(evt bus.Event) { diff --git a/cmd/gateway_agents.go b/cmd/gateway_agents.go index 05654f72..37dce7d7 100644 --- a/cmd/gateway_agents.go +++ b/cmd/gateway_agents.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "log/slog" "strings" @@ -86,7 +87,7 @@ func createEmbeddingProvider(name string, cfg *config.Config, memCfg *config.Mem // Fallback: resolve from provider registry (DB-stored providers like "openai-embedding"). // Any OpenAI-compatible provider in the registry can serve embeddings. if providerReg != nil { - if regProv, err := providerReg.Get(name); err == nil { + if regProv, err := providerReg.Get(context.Background(), name); err == nil { if op, ok := regProv.(*providers.OpenAIProvider); ok { embBase := op.APIBase() if apiBase != "" { @@ -102,15 +103,15 @@ func createEmbeddingProvider(name string, cfg *config.Config, memCfg *config.Mem } func setupSubagents(providerReg *providers.Registry, cfg *config.Config, msgBus *bus.MessageBus, toolsReg *tools.Registry, workspace string, sandboxMgr sandbox.Manager) *tools.SubagentManager { - names := providerReg.List() + names := providerReg.List(context.Background()) if len(names) == 0 { return nil } agentCfg := cfg.ResolveAgent("default") - provider, err := providerReg.Get(agentCfg.Provider) + provider, err := providerReg.Get(context.Background(), agentCfg.Provider) if err != nil { - provider, _ = providerReg.Get(names[0]) + provider, _ = providerReg.Get(context.Background(), names[0]) } if provider == nil { return nil diff --git a/cmd/gateway_builtin_tools.go b/cmd/gateway_builtin_tools.go index fa98b727..71f6f209 100644 --- a/cmd/gateway_builtin_tools.go +++ b/cmd/gateway_builtin_tools.go @@ -44,33 +44,26 @@ func builtinToolSeedData() []store.BuiltinToolDef { Requires: []string{"knowledge_graph"}, }, - // media — seed uses chain format: {"providers":[...]} - {Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"openrouter","model":"google/gemini-2.5-flash-image","enabled":true,"timeout":120,"max_retries":2}]}`), + // media — user must configure provider chain via UI before use + {Name: "read_image", DisplayName: "Read Image", Description: "Analyze images using a vision-capable LLM provider", Category: "media", Enabled: false, Requires: []string{"vision_provider"}, }, - {Name: "read_document", DisplayName: "Read Document", Description: "Analyze documents (PDF, Word, Excel, PowerPoint, CSV, etc.) using a document-capable LLM provider", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"gemini-2.5-flash","enabled":true,"timeout":120,"max_retries":2}]}`), + {Name: "read_document", DisplayName: "Read Document", Description: "Analyze documents (PDF, Word, Excel, PowerPoint, CSV, etc.) using a document-capable LLM provider", Category: "media", Enabled: false, Requires: []string{"document_provider"}, }, - {Name: "create_image", DisplayName: "Create Image", Description: "Generate images from text prompts using an image generation provider", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"openrouter","model":"google/gemini-2.5-flash-image","enabled":true,"timeout":120,"max_retries":2}]}`), + {Name: "create_image", DisplayName: "Create Image", Description: "Generate images from text prompts using an image generation provider", Category: "media", Enabled: false, Requires: []string{"image_gen_provider"}, }, - {Name: "read_audio", DisplayName: "Read Audio", Description: "Analyze audio files (speech, music, sounds) using an audio-capable LLM provider", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"gemini-2.5-flash","enabled":true,"timeout":120,"max_retries":2}]}`), + {Name: "read_audio", DisplayName: "Read Audio", Description: "Analyze audio files (speech, music, sounds) using an audio-capable LLM provider", Category: "media", Enabled: false, Requires: []string{"audio_provider"}, }, - {Name: "read_video", DisplayName: "Read Video", Description: "Analyze video files using a video-capable LLM provider", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"gemini-2.5-flash","enabled":true,"timeout":120,"max_retries":2}]}`), + {Name: "read_video", DisplayName: "Read Video", Description: "Analyze video files using a video-capable LLM provider", Category: "media", Enabled: false, Requires: []string{"video_provider"}, }, - {Name: "create_video", DisplayName: "Create Video", Description: "Generate videos from text descriptions using AI", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"gemini","model":"veo-3.0-generate-preview","enabled":true,"timeout":120,"max_retries":2}]}`), + {Name: "create_video", DisplayName: "Create Video", Description: "Generate videos from text descriptions using AI", Category: "media", Enabled: false, Requires: []string{"video_gen_provider"}, }, - {Name: "create_audio", DisplayName: "Create Audio", Description: "Generate music or sound effects from text descriptions using AI", Category: "media", Enabled: true, - Settings: json.RawMessage(`{"providers":[{"provider":"minimax","model":"music-2.5+","enabled":true,"timeout":120,"max_retries":2}]}`), + {Name: "create_audio", DisplayName: "Create Audio", Description: "Generate music or sound effects from text descriptions using AI", Category: "media", Enabled: false, Requires: []string{"audio_gen_provider"}, }, {Name: "tts", DisplayName: "Text to Speech", Description: "Convert text to natural-sounding speech audio", Category: "media", Enabled: true, diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index 2c0edffd..fc055513 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -143,7 +143,7 @@ func wireChannelEventSubscribers( if !ok || payload.Kind != bus.CacheKindChannelInstances { return } - go instanceLoader.Reload(context.Background()) + go instanceLoader.Reload(store.WithCrossTenant(context.Background())) }) } @@ -197,7 +197,7 @@ func wireChannelEventSubscribers( if err != nil { return } - all, err := ciStore.ListAll(context.Background()) + all, err := ciStore.ListAll(store.WithCrossTenant(context.Background())) if err != nil { slog.Warn("cascade disable: failed to list channel instances", "error", err) return @@ -205,7 +205,7 @@ func wireChannelEventSubscribers( disabled := 0 for _, inst := range all { if inst.AgentID == agentID && inst.Enabled { - if err := ciStore.Update(context.Background(), inst.ID, map[string]any{"enabled": false}); err != nil { + if err := ciStore.Update(store.WithCrossTenant(context.Background()), inst.ID, map[string]any{"enabled": false}); err != nil { slog.Warn("cascade disable: failed to disable channel instance", "name", inst.Name, "error", err) } else { disabled++ diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 53804167..0de0526a 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -120,6 +120,7 @@ func autoSetFollowup(ctx context.Context, teamStore store.TeamStore, agentStore if agentStore == nil { return } + // Caller (processNormalMessage) already injected tenant_id into ctx. // agentKey may be a slug ("default") or a UUID string (from WS clients). var ag *store.AgentData var err error diff --git a/cmd/gateway_consumer_handlers.go b/cmd/gateway_consumer_handlers.go index 9b7a2506..310e668a 100644 --- a/cmd/gateway_consumer_handlers.go +++ b/cmd/gateway_consumer_handlers.go @@ -39,6 +39,13 @@ func handleSubagentAnnounce( return false } + // Inject tenant scope — same as processNormalMessage. + if msg.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, msg.TenantID) + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + } + origChannel := msg.Metadata["origin_channel"] origPeerKind := msg.Metadata["origin_peer_kind"] origLocalKey := msg.Metadata["origin_local_key"] @@ -190,6 +197,13 @@ func handleTeammateMessage( return false } + // Inject tenant scope — same as processNormalMessage. + if msg.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, msg.TenantID) + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + } + origChannel := msg.Metadata["origin_channel"] origPeerKind := msg.Metadata["origin_peer_kind"] origLocalKey := msg.Metadata["origin_local_key"] @@ -346,24 +360,21 @@ func handleTeammateMessage( if err := teamStore.FailTask(ctx, teamTaskID, teamID, outcome.Err.Error()); err != nil { slog.Warn("auto-complete: FailTask error", "task_id", teamTaskID, "error", err) } else { - msgBus.Broadcast(bus.Event{ - Name: protocol.EventTeamTaskFailed, - Payload: protocol.TeamTaskEventPayload{ - TeamID: teamID.String(), - TaskID: teamTaskID.String(), - TaskNumber: taskNumber, - Subject: taskSubject, - Status: store.TeamTaskStatusFailed, - Reason: outcome.Err.Error(), - Channel: taskChannel, - ChatID: taskChatID, - Timestamp: now, - ActorType: "agent", - ActorID: toAgent, - }, + bus.BroadcastForTenant(msgBus, protocol.EventTeamTaskFailed, store.TenantIDFromContext(ctx), protocol.TeamTaskEventPayload{ + TeamID: teamID.String(), + TaskID: teamTaskID.String(), + TaskNumber: taskNumber, + Subject: taskSubject, + Status: store.TeamTaskStatusFailed, + Reason: outcome.Err.Error(), + Channel: taskChannel, + ChatID: taskChatID, + Timestamp: now, + ActorType: "agent", + ActorID: toAgent, }) } - } else { + } else if outcome.Result != nil { result := outcome.Result.Content if len(outcome.Result.Deliverables) > 0 { result = strings.Join(outcome.Result.Deliverables, "\n\n---\n\n") @@ -374,21 +385,18 @@ func handleTeammateMessage( if err := teamStore.CompleteTask(ctx, teamTaskID, teamID, result); err != nil { slog.Warn("auto-complete: CompleteTask error", "task_id", teamTaskID, "error", err) } else { - msgBus.Broadcast(bus.Event{ - Name: protocol.EventTeamTaskCompleted, - Payload: protocol.TeamTaskEventPayload{ - TeamID: teamID.String(), - TaskID: teamTaskID.String(), - TaskNumber: taskNumber, - Subject: taskSubject, - Status: store.TeamTaskStatusCompleted, - OwnerAgentKey: toAgent, - Channel: taskChannel, + bus.BroadcastForTenant(msgBus, protocol.EventTeamTaskCompleted, store.TenantIDFromContext(ctx), protocol.TeamTaskEventPayload{ + TeamID: teamID.String(), + TaskID: teamTaskID.String(), + TaskNumber: taskNumber, + Subject: taskSubject, + Status: store.TeamTaskStatusCompleted, + OwnerAgentKey: toAgent, + Channel: taskChannel, ChatID: taskChatID, Timestamp: now, ActorType: "agent", ActorID: toAgent, - }, }) } } @@ -413,6 +421,9 @@ func handleTeammateMessage( errMsg = errMsg[:500] + "..." } announceContent = fmt.Sprintf("[FAILED] %s", errMsg) + } else if outcome.Result == nil { + slog.Warn("teammate message: nil result without error", "from", senderID) + return } else if (outcome.Result.Content == "" && len(outcome.Result.Media) == 0) || agent.IsSilentReply(outcome.Result.Content) { slog.Info("teammate message: suppressed silent/empty reply", "from", senderID) return @@ -560,8 +571,9 @@ func handleResetCommand( sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID) } } - sessStore.Reset(sessionKey) - sessStore.Save(sessionKey) + ctx := store.WithTenantID(context.Background(), msg.TenantID) + sessStore.Reset(ctx, sessionKey) + sessStore.Save(ctx, sessionKey) providers.ResetCLISession("", sessionKey) slog.Info("inbound: /reset command", "session", sessionKey) diff --git a/cmd/gateway_consumer_helpers.go b/cmd/gateway_consumer_helpers.go index 904cc674..420650a9 100644 --- a/cmd/gateway_consumer_helpers.go +++ b/cmd/gateway_consumer_helpers.go @@ -116,11 +116,6 @@ func mediaToMarkdown(media []agent.MediaResult, cfg *config.Config) string { return "" } - tokenQuery := "" - if cfg.Gateway.Token != "" { - tokenQuery = "?token=" + cfg.Gateway.Token - } - var parts []string for _, mr := range media { cleanPath := filepath.Clean(mr.Path) @@ -129,7 +124,9 @@ func mediaToMarkdown(media []agent.MediaResult, cfg *config.Config) string { if urlPath == "" { continue } - fileURL := "/v1/files/" + urlPath + tokenQuery + // Store clean path only — no auth tokens in persisted session messages. + // Frontend adds auth (Bearer header or ?ft= signed token) at render time. + fileURL := "/v1/files/" + urlPath if strings.HasPrefix(mr.ContentType, "image/") { parts = append(parts, fmt.Sprintf("![image](%s)", fileURL)) } else { diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index 9035caa0..4a380628 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -38,13 +38,21 @@ func processNormalMessage( postTurn tools.PostTurnProcessor, msgBus *bus.MessageBus, ) { + // Inject tenant from channel instance into context so all store operations + // (agent lookup, session creation, etc.) are tenant-scoped. + if msg.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, msg.TenantID) + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + } + // Determine target agent via bindings or explicit AgentID agentID := msg.AgentID if agentID == "" { agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind) } - agentLoop, err := agents.Get(agentID) + agentLoop, err := agents.Get(ctx, agentID) if err != nil { slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel) return @@ -96,7 +104,7 @@ func processNormalMessage( // Persist friendly names from channel metadata into session + user profile. sessionMeta := extractSessionMetadata(msg, peerKind) if len(sessionMeta) > 0 { - sessStore.SetSessionMetadata(sessionKey, sessionMeta) + sessStore.SetSessionMetadata(ctx, sessionKey, sessionMeta) if agentStore != nil { if agentUUID, err := uuid.Parse(agentID); err == nil && agentUUID != uuid.Nil { _ = agentStore.UpdateUserProfileMetadata(ctx, agentUUID, userID, sessionMeta) @@ -298,6 +306,11 @@ func processNormalMessage( } } + // Inject tenant context from channel instance so all store queries are tenant-scoped. + if msg.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, msg.TenantID) + } + // Inject post-turn dispatch tracker so team task creates are deferred. ptd := tools.NewPendingTeamDispatch() schedCtx := tools.WithPendingTeamDispatch(ctx, ptd) diff --git a/cmd/gateway_consumer_process.go b/cmd/gateway_consumer_process.go index 9f75bc17..08be6a06 100644 --- a/cmd/gateway_consumer_process.go +++ b/cmd/gateway_consumer_process.go @@ -30,7 +30,7 @@ func makeSchedulerRunFunc(agents *agent.Router, cfg *config.Config) scheduler.Ru } } - loop, err := agents.Get(agentID) + loop, err := agents.Get(ctx, agentID) if err != nil { return nil, fmt.Errorf("agent %s not found: %w", agentID, err) } diff --git a/cmd/gateway_cron.go b/cmd/gateway_cron.go index 2075f1f6..1abb565b 100644 --- a/cmd/gateway_cron.go +++ b/cmd/gateway_cron.go @@ -60,8 +60,11 @@ func makeCronJobHandler(sched *scheduler.Scheduler, msgBus *bus.MessageBus, cfg ) } + // Build context with tenant scope so agent loop events are scoped correctly. + cronCtx := store.WithTenantID(context.Background(), job.TenantID) + // Schedule through cron lane — scheduler handles agent resolution and concurrency - outCh := sched.Schedule(context.Background(), scheduler.LaneCron, agent.RunRequest{ + outCh := sched.Schedule(cronCtx, scheduler.LaneCron, agent.RunRequest{ SessionKey: sessionKey, Message: job.Payload.Message, Channel: channel, diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 66f9ce24..483df227 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -9,16 +9,14 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/tools" ) -// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers + delegations + builtin tools + pending messages). -func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler) { +// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + channel instances + providers + builtin tools + pending messages). +func wireHTTP(stores *store.Stores, token, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.MCPUserCredentialsHandler) { var agentsH *httpapi.AgentsHandler var skillsH *httpapi.SkillsHandler var tracesH *httpapi.TracesHandler var mcpH *httpapi.MCPHandler - var customToolsH *httpapi.CustomToolsHandler var channelInstancesH *httpapi.ChannelInstancesHandler var providersH *httpapi.ProvidersHandler - var delegationsH *httpapi.DelegationsHandler var builtinToolsH *httpapi.BuiltinToolsHandler var pendingMessagesH *httpapi.PendingMessagesHandler var secureCLIH *httpapi.SecureCLIHandler @@ -35,7 +33,7 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus. if pgSkills, ok := stores.Skills.(*pg.PGSkillStore); ok { dirs := pgSkills.Dirs() if len(dirs) > 0 { - skillsH = httpapi.NewSkillsHandler(pgSkills, dirs[0], token, msgBus) + skillsH = httpapi.NewSkillsHandler(pgSkills, dirs[0], dataDir, bundledSkillsDir, token, msgBus) } } } @@ -47,13 +45,13 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus. if stores != nil && stores.MCP != nil { mcpH = httpapi.NewMCPHandler(stores.MCP, token, msgBus, mcpToolLister) } - - if stores != nil && stores.CustomTools != nil { - customToolsH = httpapi.NewCustomToolsHandler(stores.CustomTools, token, msgBus, toolsReg) + var mcpUserCredsH *httpapi.MCPUserCredentialsHandler + if stores != nil && stores.MCP != nil { + mcpUserCredsH = httpapi.NewMCPUserCredentialsHandler(stores.MCP, token) } if stores != nil && stores.ChannelInstances != nil { - channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, token, msgBus) + channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, stores.Tenants, token, msgBus) } if stores != nil && stores.Providers != nil { @@ -67,12 +65,11 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus. var teamEventsH *httpapi.TeamEventsHandler if stores != nil && stores.Teams != nil { - delegationsH = httpapi.NewDelegationsHandler(stores.Teams, token) teamEventsH = httpapi.NewTeamEventsHandler(stores.Teams, token) } if stores != nil && stores.BuiltinTools != nil { - builtinToolsH = httpapi.NewBuiltinToolsHandler(stores.BuiltinTools, token, msgBus) + builtinToolsH = httpapi.NewBuiltinToolsHandler(stores.BuiltinTools, stores.BuiltinToolTenantCfgs, token, msgBus) } if stores != nil && stores.PendingMessages != nil { @@ -83,5 +80,5 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus. secureCLIH = httpapi.NewSecureCLIHandler(stores.SecureCLI, token, msgBus) } - return agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH + return agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, mcpUserCredsH } diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 84508a14..5c6db2f7 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -8,9 +8,12 @@ import ( "github.com/google/uuid" + "strings" + "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" + httpapi "github.com/nextlevelbuilder/goclaw/internal/http" kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph" mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" "github.com/nextlevelbuilder/goclaw/internal/media" @@ -45,7 +48,6 @@ func wireExtras( injectionAction string, appCfg *config.Config, sandboxMgr sandbox.Manager, - dynamicLoader *tools.DynamicToolLoader, redisClient any, // nil when built without -tags redis or when Redis is unconfigured ) (*tools.ContextFileInterceptor, *mcpbridge.Pool, *media.Store, tools.PostTurnProcessor) { // 1. Build cache instances (in-memory or Redis depending on build tags) @@ -115,7 +117,7 @@ func wireExtras( // 5. Shared MCP connection pool (eliminates duplicate connections across agents) var mcpPool *mcpbridge.Pool if stores.MCP != nil { - mcpPool = mcpbridge.NewPool() + mcpPool = mcpbridge.NewPool(mcpbridge.DefaultPoolConfig()) } // 6. Set up agent resolver: lazy-creates Loops from DB @@ -145,7 +147,6 @@ func wireExtras( SandboxEnabled: sandboxEnabled, SandboxContainerDir: sandboxContainerDir, SandboxWorkspaceAccess: sandboxWorkspaceAccess, - DynamicLoader: dynamicLoader, AgentLinkStore: stores.AgentLinks, TeamStore: stores.Teams, DataDir: workspace, @@ -158,10 +159,22 @@ func wireExtras( ModelPricing: appCfg.Telemetry.ModelPricing, TracingStore: stores.Tracing, MemoryStore: stores.Memory, + TenantStore: stores.Tenants, + BuiltinToolTenantCfgs: stores.BuiltinToolTenantCfgs, + SkillTenantCfgs: stores.SkillTenantCfgs, + Workspace: workspace, OnEvent: func(event agent.AgentEvent) { + // Sign /v1/files/ and /v1/media/ URLs in content before delivery. + // Sessions store clean paths; signing happens only at delivery time. + if m, ok := event.Payload.(map[string]string); ok { + if c, has := m["content"]; has && strings.Contains(c, "/v1/") { + m["content"] = httpapi.SignFileURLs(c, httpapi.FileSigningKey()) + } + } msgBus.Broadcast(bus.Event{ - Name: protocol.EventAgent, - Payload: event, + Name: protocol.EventAgent, + Payload: event, + TenantID: event.TenantID, }) }, }) @@ -381,22 +394,6 @@ func wireExtras( }) } - // Custom tools cache: reload global tools on create/update/delete - if dynamicLoader != nil { - msgBus.Subscribe(bus.TopicCacheCustomTools, func(event bus.Event) { - if event.Name != protocol.EventCacheInvalidate { - return - } - payload, ok := event.Payload.(bus.CacheInvalidatePayload) - if !ok || payload.Kind != bus.CacheKindCustomTools { - return - } - dynamicLoader.ReloadGlobal(context.Background(), toolsReg) - // Invalidate all agent caches so they re-resolve with updated tools - agentRouter.InvalidateAll() - }) - } - // Builtin tools cache: re-apply disables on settings/enabled changes if stores.BuiltinTools != nil { msgBus.Subscribe(bus.TopicCacheBuiltinTools, func(event bus.Event) { @@ -530,7 +527,7 @@ func buildKGExtractFunc(kgStore store.KnowledgeGraphStore, bts store.BuiltinTool return } - p, err := providerReg.Get(settings.ExtractionProvider) + p, err := providerReg.Get(ctx, settings.ExtractionProvider) if err != nil { slog.Warn("kg extract: provider not found", "provider", settings.ExtractionProvider, "error", err) return diff --git a/cmd/gateway_methods.go b/cmd/gateway_methods.go index 9749a114..d4544961 100644 --- a/cmd/gateway_methods.go +++ b/cmd/gateway_methods.go @@ -19,14 +19,14 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore chatMethods := methods.NewChatMethods(agents, sessStore, server.RateLimiter(), msgBus) chatMethods.Register(router) methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router) - methods.NewSessionsMethods(sessStore, msgBus).Register(router) + methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router) methods.NewConfigMethods(cfg, cfgPath, configSecretsStore, msgBus).Register(router) // Phase 2: Skills (uses SkillStore interface — PG or File) methods.NewSkillsMethods(skillStore).Register(router) // Phase 2: Cron (store created externally, shared with gateway) - methods.NewCronMethods(cronStore, msgBus).Register(router) + methods.NewCronMethods(cronStore, msgBus, cfg).Register(router) // Phase 2: Heartbeat heartbeatMethods := methods.NewHeartbeatMethods(heartbeatStore, msgBus) @@ -52,11 +52,6 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore // Phase 3: Live log tailing methods.NewLogsMethods(logTee).Register(router) - // Phase 4: Delegation history - if teamStore != nil { - methods.NewDelegationsMethods(teamStore).Register(router) - } - slog.Info("registered all RPC methods", "phase1", []string{"chat", "agents", "sessions", "config"}, "phase2", []string{"skills", "cron", "heartbeat", "pairing", "usage", "exec_approval", "send"}, diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index b505c68a..6b4ed746 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -233,7 +233,7 @@ func jsonToStringMap(data json.RawMessage) map[string]string { // mcpStore is optional; when provided, per-agent MCP servers are injected into CLI config. // cfg provides fallback api_base values from config/env when DB providers have none set. func registerProvidersFromDB(registry *providers.Registry, provStore store.ProviderStore, secretStore store.ConfigSecretsStore, gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore, cfg *config.Config) { - ctx := context.Background() + ctx := store.WithCrossTenant(context.Background()) dbProviders, err := provStore.ListProviders(ctx) if err != nil { slog.Warn("failed to load providers from DB", "error", err) @@ -265,7 +265,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi mcpData.AgentMCPLookup = buildMCPServerLookup(mcpStore) cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData)) } - registry.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...)) + registry.RegisterForTenant(p.TenantID, providers.NewClaudeCLIProvider(cliPath, cliOpts...)) slog.Info("registered provider from DB", "name", p.Name) continue } @@ -280,7 +280,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi if host == "" { host = "http://localhost:11434" } - registry.Register(providers.NewOpenAIProvider(p.Name, "ollama", host+"/v1", "llama3.3")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, "ollama", host+"/v1", "llama3.3")) slog.Info("registered provider from DB", "name", p.Name) continue } @@ -297,37 +297,37 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi } switch p.ProviderType { case store.ProviderChatGPTOAuth: - ts := oauth.NewDBTokenSource(provStore, secretStore, p.Name) - registry.Register(providers.NewCodexProvider(p.Name, ts, p.APIBase, "")) + ts := oauth.NewDBTokenSource(provStore, secretStore, p.Name).WithTenantID(p.TenantID) + registry.RegisterForTenant(p.TenantID, providers.NewCodexProvider(p.Name, ts, p.APIBase, "")) case store.ProviderAnthropicNative: - registry.Register(providers.NewAnthropicProvider(p.APIKey, + registry.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicBaseURL(p.APIBase))) case store.ProviderDashScope: - registry.Register(providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, "")) + registry.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, p.APIBase, "")) case store.ProviderBailian: base := p.APIBase if base == "" { base = "https://coding-intl.dashscope.aliyuncs.com/v1" } - registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus")) case store.ProviderZai: base := p.APIBase if base == "" { base = "https://api.z.ai/api/paas/v4" } - registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5")) case store.ProviderZaiCoding: base := p.APIBase if base == "" { base = "https://api.z.ai/api/coding/paas/v4" } - registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "glm-5")) case store.ProviderOllamaCloud: base := p.APIBase if base == "" { base = "https://ollama.com/v1" } - registry.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "llama3.3")) + registry.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "llama3.3")) case store.ProviderSuno: // Suno is a media-only provider (music gen). Register as OpenAI-compat // so credentialProvider interface works for API key/base extraction. @@ -337,14 +337,14 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi } prov := providers.NewOpenAIProvider(p.Name, p.APIKey, base, "") prov.WithProviderType(p.ProviderType) - registry.Register(prov) + registry.RegisterForTenant(p.TenantID, prov) default: prov := providers.NewOpenAIProvider(p.Name, p.APIKey, p.APIBase, "") prov.WithProviderType(p.ProviderType) if p.ProviderType == store.ProviderMiniMax { prov.WithChatPath("/text/chatcompletion_v2") } - registry.Register(prov) + registry.RegisterForTenant(p.TenantID, prov) } slog.Info("registered provider from DB", "name", p.Name) } @@ -416,7 +416,7 @@ func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData) { if workDir == "" { workDir = defaultACPWorkDir() } - registry.Register(providers.NewACPProvider( + registry.RegisterForTenant(p.TenantID, providers.NewACPProvider( binary, settings.Args, workDir, idleTTL, tools.DefaultDenyPatterns(), providers.WithACPModel(p.Name), )) diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go index e2543f47..f32d290e 100644 --- a/cmd/gateway_setup.go +++ b/cmd/gateway_setup.go @@ -201,6 +201,15 @@ func setupToolRegistry( if et, ok := execTool.(*tools.ExecTool); ok { et.DenyPaths(dataDir, ".goclaw/") et.AllowPathExemptions(".goclaw/skills-store/") + // Harden: block access to internal workspace files via shell commands. + // Prevents `cat ../config.json`, `cat memory.db` etc. from user workspaces. + et.DenyPaths( + filepath.Join(workspace, "memory.db"), + filepath.Join(workspace, "memory.db-wal"), + filepath.Join(workspace, "memory.db-shm"), + filepath.Join(workspace, "config.json"), + filepath.Join(workspace, "delegate"), + ) if cfgPath := os.Getenv("GOCLAW_CONFIG"); cfgPath != "" { et.DenyPaths(cfgPath) } @@ -214,11 +223,17 @@ func setupToolRegistry( // deny paths add defense-in-depth. internalDenyPaths := []string{ "config.json", "memory.db", "memory.db-wal", "memory.db-shm", - "memory/", ".media/", "delegate/", + "memory/", ".media/", ".uploads/", "delegate/", + } + // read_file: allow .media/ access (uploaded documents accessed via AllowPaths + // for backward compat; new uploads go to per-user .uploads/ within workspace). + readFileDenyPaths := []string{ + "config.json", "memory.db", "memory.db-wal", "memory.db-shm", + "memory/", "delegate/", } if rf, ok := toolsReg.Get("read_file"); ok { if t, ok := rf.(*tools.ReadFileTool); ok { - t.DenyPaths(internalDenyPaths...) + t.DenyPaths(readFileDenyPaths...) } } if wf, ok := toolsReg.Get("write_file"); ok { @@ -338,7 +353,7 @@ func setupMemoryEmbeddings( if pgStores.Memory != nil { memCfg := cfg.Agents.Defaults.Memory if pgStores.Agents != nil { - if defaultAgent, agErr := pgStores.Agents.GetByKey(context.Background(), "default"); agErr == nil { + if defaultAgent, agErr := pgStores.Agents.GetByKey(store.WithCrossTenant(context.Background()), "default"); agErr == nil { if agentMemCfg := defaultAgent.ParseMemoryConfig(); agentMemCfg != nil { memCfg = agentMemCfg slog.Debug("using per-agent memory config from DB", "agent", defaultAgent.AgentKey) @@ -452,7 +467,9 @@ func setupSkillsSystem( toolsReg *tools.Registry, providerRegistry *providers.Registry, msgBus *bus.MessageBus, -) (*skills.Loader, *tools.SkillSearchTool, string) { +) (*skills.Loader, *tools.SkillSearchTool, string, string) { + var bundledSkillsDir string // resolved later; returned for HTTP handler fallback + // Skills loader + search tool // Global skills live under ~/.goclaw/skills/ (user-managed), not data/skills/. globalSkillsDir := os.Getenv("GOCLAW_SKILLS_DIR") @@ -469,7 +486,7 @@ func setupSkillsSystem( skillSearchTool := tools.NewSkillSearchTool(skillsLoader) toolsReg.Register(skillSearchTool) toolsReg.Register(tools.NewUseSkillTool()) - slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills())) + slog.Info("skill_search tool registered", "skills", len(skillsLoader.ListSkills(store.WithCrossTenant(context.Background())))) // Wire skills-store directory into filesystem loader so agents // can discover uploaded skills in their system prompt and BM25 search index. @@ -480,7 +497,7 @@ func setupSkillsSystem( slog.Info("skills-store directory wired into loader", "dir", storeDirs[0]) // Seed system/bundled skills into DB - bundledSkillsDir := os.Getenv("GOCLAW_BUNDLED_SKILLS_DIR") + bundledSkillsDir = os.Getenv("GOCLAW_BUNDLED_SKILLS_DIR") if bundledSkillsDir == "" { // Check common locations: Docker default, then local dev for _, candidate := range []string{"bundled-skills", "/app/bundled-skills", "skills"} { @@ -518,7 +535,7 @@ func setupSkillsSystem( if len(storeDirs) > 0 { toolsReg.Register(tools.NewPublishSkillTool(pgSkills, storeDirs[0], skillsLoader)) slog.Info("publish_skill tool registered") - toolsReg.Register(tools.NewSkillManageTool(pgSkills, storeDirs[0], skillsLoader)) + toolsReg.Register(tools.NewSkillManageTool(pgSkills, storeDirs[0], dataDir, skillsLoader)) slog.Info("skill_manage tool registered") } } @@ -549,6 +566,6 @@ func setupSkillsSystem( } } - return skillsLoader, skillSearchTool, globalSkillsDir + return skillsLoader, skillSearchTool, globalSkillsDir, bundledSkillsDir } diff --git a/cmd/skills_cmd.go b/cmd/skills_cmd.go index 542d4691..49dc1459 100644 --- a/cmd/skills_cmd.go +++ b/cmd/skills_cmd.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "encoding/json" "fmt" "os" @@ -30,7 +31,7 @@ func skillsListCmd() *cobra.Command { Short: "List all available skills", Run: func(cmd *cobra.Command, args []string) { loader := loadSkillsLoader() - allSkills := loader.ListSkills() + allSkills := loader.ListSkills(context.Background()) if jsonOutput { data, _ := json.MarshalIndent(allSkills, "", " ") @@ -66,7 +67,7 @@ func skillsShowCmd() *cobra.Command { Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { loader := loadSkillsLoader() - info, ok := loader.GetSkill(args[0]) + info, ok := loader.GetSkill(context.Background(), args[0]) if !ok { fmt.Fprintf(os.Stderr, "Skill not found: %s\n", args[0]) os.Exit(1) @@ -77,7 +78,7 @@ func skillsShowCmd() *cobra.Command { fmt.Printf("Location: %s\n", info.Path) fmt.Println() - content, ok := loader.LoadSkill(args[0]) + content, ok := loader.LoadSkill(context.Background(), args[0]) if ok { fmt.Println("--- Content ---") fmt.Println(content) diff --git a/docs/18-http-api.md b/docs/18-http-api.md index ee4d95bc..4cae6e79 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -32,6 +32,7 @@ API keys are hashed with SHA-256 before lookup — the raw key is never stored. | `Authorization` | Bearer token for authentication | | `X-GoClaw-User-Id` | External user ID for multi-tenant context | | `X-GoClaw-Agent-Id` | Agent identifier for scoped operations | +| `X-GoClaw-Tenant-Id` | Tenant scope — UUID or slug (gateway token / cross-tenant API keys) | | `Accept-Language` | Locale (`en`, `vi`, `zh`) for i18n error messages | | `Content-Type` | `application/json` for request bodies | diff --git a/docs/23-multi-tenant-architecture.md b/docs/23-multi-tenant-architecture.md new file mode 100644 index 00000000..ac801a84 --- /dev/null +++ b/docs/23-multi-tenant-architecture.md @@ -0,0 +1,387 @@ +# Multi-Tenant Integration Guide + +GoClaw is an **AI agent gateway** — it handles agents, chat, sessions, tools, MCP servers, and memory. It supports two deployment modes: + +1. **Personal / Single-tenant** — Use GoClaw directly as your AI backend. Built-in dashboard included. +2. **SaaS / Multi-tenant** — Integrate GoClaw behind your application. API keys bridge the two systems. + +--- + +## Deployment Modes + +### Mode 1: Personal Use (Single-Tenant) + +Use GoClaw as a standalone AI backend with its built-in web dashboard. No separate frontend or backend needed. + +```mermaid +graph LR + U[You] -->|browser| GC[GoClaw Dashboard
+ Gateway] + GC --> AG[Agents / Chat / Tools] + AG --> DB[(PostgreSQL)] + AG -->|LLM calls| LLM[Anthropic / OpenAI / Gemini / ...] +``` + +**How it works:** +- Log in with the gateway token via the built-in web dashboard +- Create agents, configure LLM providers, chat — all from the dashboard +- Connect chat channels (Telegram, Discord, etc.) for messaging +- All data lives under the default "master" tenant — no tenant config needed + +**Setup:** + +```bash +# 1. Build and onboard +go build -o goclaw . && ./goclaw onboard + +# 2. Start the gateway +source .env.local && ./goclaw + +# 3. Open dashboard at http://localhost:3777 +# Log in with your gateway token + user ID "system" +``` + +**When to use:** Personal AI assistant, small team, self-hosted AI tools, development/testing. + +**Scaling up:** When you need multiple isolated environments (clients, departments, projects), create additional tenants. Multi-tenant features activate automatically — no migration needed. + +--- + +### Mode 2: SaaS Integration (Multi-Tenant) + +Integrate GoClaw as the AI engine behind your SaaS application. Your app handles auth, billing, and UI. GoClaw handles AI. Each tenant is fully isolated — agents, sessions, memory, teams, providers, and files. + +```mermaid +graph TB + subgraph "Tenant A" + FEa[Frontend A] + BEa[Backend A] + TGa[Telegram Bot A] + end + + subgraph "Tenant B" + FEb[Frontend B] + BEb[Backend B] + DCb[Discord Bot B] + end + + subgraph "GoClaw Gateway" + subgraph "Entry Points" + HTTP[HTTP API] + WS[WebSocket] + CH[Channel Manager] + end + + TI{Tenant Isolation
Layer} + + subgraph "Tenant-Scoped Engine" + AG[Agent Loop] + TOOLS[Tools / Skills / MCP] + MEM[Memory / KG] + end + + DB[(PostgreSQL
WHERE tenant_id = $N)] + end + + FEa -->|authenticated| BEa + BEa -->|API Key A + user_id| HTTP + TGa -->|webhook| CH + + FEb -->|authenticated| BEb + BEb -->|API Key B + user_id| HTTP + DCb -->|webhook| CH + + HTTP --> TI + WS --> TI + CH --> TI + + TI -->|ctx with tenant_id| AG + AG --> TOOLS + AG --> MEM + AG --> DB + TOOLS --> DB + MEM --> DB +``` + +**How it works:** +- Each tenant's **backend connects via a tenant-bound API key** — GoClaw auto-scopes all data +- **Chat channels** (Telegram, Discord, etc.) connect directly — tenant resolved from channel instance config +- The **Tenant Isolation Layer** resolves tenant_id from credentials and injects it into Go context +- Every SQL query enforces `WHERE tenant_id = $N` — fail-closed, no cross-tenant leakage + +**When to use:** SaaS products with AI features, multi-client platforms, white-label AI solutions. + +### Connection Types Summary + +All connections go through the **Tenant Isolation Layer** before reaching the agent engine: + +| Connection | Auth Method | Tenant Resolution | Isolation | +|------------|-------------|-------------------|-----------| +| **HTTP API** | `Bearer` token (API key or gateway token) | Auto from API key's `tenant_id` | Per-request | +| **WebSocket** | Token on `connect` (API key or gateway token) | Auto from API key's `tenant_id` | Per-session | +| **Chat Channels** | None (direct webhook/WS) | Baked into channel instance DB config | Per-instance | +| **Dashboard** | Gateway token or browser pairing | User's tenant membership | Per-session | + +**Tenant Isolation Layer** — resolves credentials → injects `tenant_id` into Go `context.Context` → all downstream SQL queries enforce `WHERE tenant_id = $N`. Fail-closed: missing tenant = error, never unfiltered data. + +--- + +## Tenant Setup (Multi-Tenant Only) + +```mermaid +sequenceDiagram + participant Admin as System Admin + participant GC as GoClaw API + + Admin->>GC: tenants.create {name: "Acme Corp", slug: "acme"} + GC-->>Admin: {id: "tenant-uuid", slug: "acme"} + + Admin->>GC: tenants.users.add {tenant_id, user_id: "user-123", role: "admin"} + + Admin->>GC: api_keys.create {tenant_id, scopes: ["operator.read", "operator.write"]} + GC-->>Admin: {key: "goclaw_sk_abc123..."} + + Note over Admin: Store API key in your backend's config/secrets +``` + +Each tenant gets isolated: **agents, sessions, teams, memory, LLM providers, MCP servers, skills**. A tenant-bound API key automatically scopes every request — no extra headers needed. + +--- + +## Tenant Resolution + +GoClaw determines the tenant from the credentials used to connect: + +| Credential | Tenant Resolution | Use Case | +|------------|-------------------|----------| +| **Gateway token** + owner user ID | All tenants (cross-tenant) | System administration | +| **Gateway token** + non-owner user ID | User's tenant membership | Dashboard users | +| **API key** (tenant-bound) | Auto from key's `tenant_id` | Normal SaaS integration | +| **API key** (system-level) + `X-GoClaw-Tenant-Id` | Header value (UUID or slug) | Cross-tenant admin tools | +| **Browser pairing** | Paired tenant | Dashboard operators | +| **No credentials** | Master tenant | Dev/single-user mode | + +**Owner IDs:** Configured via `GOCLAW_OWNER_IDS` env var (comma-separated). Only owners get cross-tenant access with the gateway token. Default: `system`. + +**Recommended for SaaS**: Use **tenant-bound API keys**. The tenant is resolved automatically from the key — your backend doesn't need to send any tenant header. + +--- + +## HTTP API + +All HTTP endpoints accept standard headers: + +| Header | Required | Description | +|--------|:---:|-------------| +| `Authorization` | Yes | `Bearer ` | +| `X-GoClaw-User-Id` | Yes | Your app's user ID (max 255 chars). Scopes sessions and per-user data | +| `X-GoClaw-Tenant-Id` | No | Tenant UUID or slug. Only needed for system-level keys | +| `X-GoClaw-Agent-Id` | No | Target agent ID (alternative to `model` field) | +| `Accept-Language` | No | Locale for error messages: `en`, `vi`, `zh` | + +### Chat (OpenAI-Compatible) + +```bash +curl -X POST https://goclaw.example.com/v1/chat/completions \ + -H "Authorization: Bearer goclaw_sk_abc123..." \ + -H "X-GoClaw-User-Id: user-456" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "agent:my-agent", + "messages": [{"role": "user", "content": "Hello"}] + }' +``` + +The `model` field uses `agent:` format. The API key is bound to tenant "Acme Corp" — the response only includes data from that tenant. + +### List Resources + +```bash +# List agents +curl https://goclaw.example.com/v1/agents \ + -H "Authorization: Bearer goclaw_sk_abc123..." \ + -H "X-GoClaw-User-Id: user-456" + +# List sessions +curl https://goclaw.example.com/v1/sessions \ + -H "Authorization: Bearer goclaw_sk_abc123..." \ + -H "X-GoClaw-User-Id: user-456" +``` + +### System Admin (Cross-Tenant) + +```bash +# List agents for a specific tenant (requires gateway token + owner user ID) +curl https://goclaw.example.com/v1/agents \ + -H "Authorization: Bearer $GATEWAY_TOKEN" \ + -H "X-GoClaw-Tenant-Id: acme" \ + -H "X-GoClaw-User-Id: system" +``` + +--- + +## WebSocket Integration + +For real-time features (streaming chat, live events), connect via WebSocket: + +```mermaid +sequenceDiagram + participant FE as Your Frontend + participant BE as Your Backend + participant GC as GoClaw Gateway + + FE->>BE: User sends message (authenticated) + BE->>GC: WS connect {token: "goclaw_sk_abc...", user_id: "user-456"} + GC-->>BE: {role: "operator", tenant_id, tenant_name, tenant_slug} + + BE->>GC: chat.send {agent_key: "my-agent", message: "Hello"} + GC-->>BE: event: agent {type: "chunk", content: "Hi..."} + BE-->>FE: Stream response to user + GC-->>BE: event: agent {type: "run.completed"} +``` + +After `connect`, **all methods are auto-scoped** to the API key's tenant. Events are server-side filtered — your backend only receives events belonging to its tenant. + +**Protocol**: Frame types `req` (client→server), `res` (server→client), `event` (async push). Protocol version 3. + +--- + +## Chat Channels + +Chat channels (Telegram, Discord, Zalo, Slack, WhatsApp, Feishu) connect **directly** to GoClaw — no API key needed. Tenant isolation is baked into the channel instance at registration time. + +```mermaid +sequenceDiagram + participant TG as Telegram + participant CH as Channel Manager + participant TI as Tenant Isolation + participant AG as Agent Loop + participant DB as PostgreSQL + + Note over CH: Channel instance loaded from DB
with tenant_id baked in + + TG->>CH: Incoming message (webhook) + CH->>TI: Resolve tenant from instance config + TI->>AG: ctx with tenant_id + user_id + AG->>DB: Query with WHERE tenant_id = $N + AG-->>CH: Agent response + CH-->>TG: Reply to user +``` + +Each channel instance stores its `tenant_id` in the `channel_instances` table. When a message arrives, the Channel Manager looks up the instance config and injects the tenant context — no headers or tokens required. + +| Channel | Protocol | Tenant Source | +|---------|----------|---------------| +| Telegram | Webhook | Instance config | +| Discord | WebSocket | Instance config | +| Zalo | Webhook | Instance config | +| Slack | Events API | Instance config | +| WhatsApp | Webhook | Instance config | +| Feishu/Lark | Webhook | Instance config | + +--- + +## API Key Scopes + +API keys use scopes to control access level: + +| Scope | Role | Permissions | +|-------|------|-------------| +| `operator.admin` | admin | Full access — agents, config, API keys, tenants | +| `operator.read` | viewer | Read-only — list agents, sessions, configs | +| `operator.write` | operator | Read + write — chat, create sessions, manage agents | +| `operator.approvals` | operator | Approve/reject execution requests | +| `operator.provision` | operator | Create tenants + manage tenant users | +| `operator.pairing` | operator | Manage device pairing | + +A key with `["operator.read", "operator.write"]` gets `operator` role. A key with `["operator.admin"]` gets `admin` role. + +--- + +## Per-Tenant Overrides + +Tenants can customize their environment without affecting other tenants: + +| Feature | Scope | How | +|---------|-------|-----| +| **LLM Providers** | Per-tenant provider configs | Each tenant registers own API keys + models | +| **Builtin Tools** | Enable/disable per tenant | `builtin_tool_tenant_configs` table | +| **Skills** | Enable/disable per tenant | `skill_tenant_configs` table | +| **MCP Servers** | Per-tenant + per-user credentials | Server-level shared, user-level overrides | +| **MCP Require User Credentials** | Per-server setting | `settings.require_user_credentials` — forces per-user API keys | + +MCP servers support two credential tiers: +- **Server-level** (shared): configured in the MCP server form, used by all users +- **User-level** (overrides): configured via "My Credentials", per-user API keys merged at runtime (user wins on key collision) + +When `require_user_credentials` is enabled, users without personal credentials cannot use that MCP server. + +--- + +## Security + +| Concern | How GoClaw Handles It | +|---------|-----------------------| +| API key exposure | Keys stay in your backend — never sent to browser | +| Cross-tenant data access | All SQL queries include `WHERE tenant_id = $N` (fail-closed) | +| Event leakage | Server-side 3-mode filter: unscoped admin, scoped admin, regular user | +| Missing tenant context | Fail-closed: returns error, never unfiltered data | +| API key storage | Keys hashed with SHA-256 at rest; only prefix shown in UI | +| Tenant impersonation | Tenant resolved from API key binding, not client headers | +| Privilege escalation | Role derived from key scopes, not client claims | +| Gateway token abuse | Only configured owner IDs get cross-tenant; others are tenant-scoped | +| System config access | Config page restricted to cross-tenant owners only | +| Logout isolation | Tenant scope cleared from localStorage on logout | +| Tenant access revocation | Proactive WS event + `TENANT_ACCESS_REVOKED` error forces immediate UI logout | +| File URL security | HMAC-signed file tokens (`?ft=`) — no gateway token in URLs | + +--- + +## Tenant Data Model + +```mermaid +erDiagram + TENANTS ||--o{ TENANT_USERS : "members" + TENANTS ||--o{ API_KEYS : "keys" + TENANTS ||--o{ AGENTS : "owns" + TENANTS ||--o{ SESSIONS : "owns" + TENANTS ||--o{ TEAMS : "owns" + TENANTS ||--o{ LLM_PROVIDERS : "configures" + TENANTS ||--o{ MCP_SERVERS : "registers" + TENANTS ||--o{ SKILLS : "manages" + + TENANTS { + uuid id PK + string name + string slug UK + string status + jsonb settings + } + + TENANT_USERS { + uuid tenant_id FK + string user_id + string role + } + + API_KEYS { + uuid id PK + uuid tenant_id FK "NULL = system key" + string owner_id + text[] scopes + boolean revoked + } +``` + +40+ tables carry `tenant_id` with NOT NULL constraint. Exception: `api_keys.tenant_id` is nullable — NULL means system-level cross-tenant key. + +**Master tenant** (UUID `0193a5b0-7000-7000-8000-000000000001`): All legacy/default data. Single-tenant deployments use this exclusively. + +--- + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `GOCLAW_OWNER_IDS` | `system` | Comma-separated user IDs with cross-tenant access | +| `GOCLAW_LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` | +| `GOCLAW_CONFIG` | `config.json5` | Path to gateway config file | diff --git a/internal/agent/loop.go b/internal/agent/loop.go index f05b0e1d..111e5d7a 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -43,6 +43,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if l.agentUUID != uuid.Nil { ctx = store.WithAgentID(ctx, l.agentUUID) } + // Inject tenant into context for tool-level tenant scoping (spawn, MCP, etc.) + if l.tenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, l.tenantID) + } // Inject user ID into context for per-user scoping (memory, context files, etc.) if req.UserID != "" { ctx = store.WithUserID(ctx, req.UserID) @@ -182,7 +186,8 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if tools.IsSharedWorkspace(team.Settings) { wsChat = "" } - if wsDir, err := tools.WorkspaceDir(l.dataDir, team.ID, wsChat); err == nil { + tenantBase := config.TenantWorkspace(l.dataDir, store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)) + if wsDir, err := tools.WorkspaceDir(tenantBase, team.ID, wsChat); err == nil { ctx = tools.WithToolTeamWorkspace(ctx, wsDir) if team.LeadAgentID == l.agentUUID { ctx = tools.WithToolWorkspace(ctx, wsDir) @@ -196,7 +201,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // Persist agent UUID + user ID on the session (for querying/tracing) if l.agentUUID != uuid.Nil || req.UserID != "" { - l.sessions.SetAgentInfo(req.SessionKey, l.agentUUID, req.UserID) + l.sessions.SetAgentInfo(ctx, req.SessionKey, l.agentUUID, req.UserID) } // Security: scan user message for injection patterns. @@ -247,19 +252,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // 0. Cache agent's context window on the session (first run only). // Enables scheduler's adaptive throttle to use the real value instead of hardcoded 200K. - if l.sessions.GetContextWindow(req.SessionKey) <= 0 { - l.sessions.SetContextWindow(req.SessionKey, l.contextWindow) + if l.sessions.GetContextWindow(ctx, req.SessionKey) <= 0 { + l.sessions.SetContextWindow(ctx, req.SessionKey, l.contextWindow) } // 0b. Load adaptive tool timing from session metadata. - toolTiming := ParseToolTiming(l.sessions.GetSessionMetadata(req.SessionKey)) + toolTiming := ParseToolTiming(l.sessions.GetSessionMetadata(ctx, req.SessionKey)) // Resolve slow_tool notification config from already-loaded team settings (no extra DB query). slowToolEnabled := tools.ParseTeamNotifyConfig(resolvedTeamSettings).SlowTool // 1. Build messages from session history - history := l.sessions.GetHistory(req.SessionKey) - summary := l.sessions.GetSummary(req.SessionKey) + history := l.sessions.GetHistory(ctx, req.SessionKey) + summary := l.sessions.GetSummary(ctx, req.SessionKey) // buildMessages resolves context files once and also detects BOOTSTRAP.md presence // (hadBootstrap) — no extra DB roundtrip needed for bootstrap detection. @@ -279,14 +284,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // 2. Process media: sanitize images, persist to media store. var mediaRefs []providers.MediaRef if len(req.Media) > 0 { - mediaRefs = l.persistMedia(req.SessionKey, req.Media) - // Load current-turn images from persisted refs. + mediaRefs = l.persistMedia(req.SessionKey, req.Media, tools.ToolWorkspaceFromCtx(ctx)) + // Load current-turn images from persisted refs (Path is always set for new uploads). var imageFiles []bus.MediaFile for _, ref := range mediaRefs { - if ref.Kind == "image" { - if p, err := l.mediaStore.LoadPath(ref.ID); err == nil { - imageFiles = append(imageFiles, bus.MediaFile{Path: p, MimeType: ref.MimeType}) - } + if ref.Kind == "image" && ref.Path != "" { + imageFiles = append(imageFiles, bus.MediaFile{Path: ref.Path, MimeType: ref.MimeType}) } } if images := loadImages(imageFiles); len(images) > 0 { @@ -384,7 +387,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if len(mediaRefs) > 0 && l.mediaStore != nil { var mediaPaths []string for _, ref := range mediaRefs { - if p, err := l.mediaStore.LoadPath(ref.ID); err == nil { + // Prefer workspace-local path (.uploads/) over canonical .media/ path. + if ref.Path != "" { + mediaPaths = append(mediaPaths, ref.Path) + } else if p, err := l.mediaStore.LoadPath(ref.ID); err == nil { mediaPaths = append(mediaPaths, p) } } @@ -636,6 +642,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) toolDefs = l.tools.ProviderDefs() } + // Per-tenant tool exclusions: remove tools disabled for this agent's tenant. + if len(l.disabledTools) > 0 { + filtered := toolDefs[:0] + for _, td := range toolDefs { + if !l.disabledTools[td.Function.Name] { + filtered = append(filtered, td) + } else { + delete(allowedTools, td.Function.Name) + } + } + toolDefs = filtered + } + // Bootstrap mode: restrict API tool definitions to write_file only (open agents). // Predefined agents keep all tools — BOOTSTRAP.md guides behavior. if hadBootstrap && l.agentType != store.AgentTypePredefined { @@ -874,7 +893,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) if sanitized != "" && !IsSilentReply(sanitized) { blockReplies++ lastBlockReply = sanitized - l.emit(AgentEvent{ + emitRun(AgentEvent{ Type: protocol.AgentEventBlockReply, AgentID: l.id, RunID: req.RunID, @@ -1032,6 +1051,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } else if mr := parseMediaResult(result.ForLLM); mr != nil { mediaResults = append(mediaResults, *mr) } + // Auto-attach workspace media to task (covers create_image/audio/video). + if teamWs := tools.ToolTeamWorkspaceFromCtx(ctx); teamWs != "" { + for _, mf := range result.Media { + tools.AutoAttachWorkspaceFile(ctx, l.teamStore, teamWs, mf.Path) + } + } if result.Deliverable != "" { deliverables = append(deliverables, result.Deliverable) } @@ -1229,6 +1254,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } else if mr := parseMediaResult(r.result.ForLLM); mr != nil { mediaResults = append(mediaResults, *mr) } + // Auto-attach workspace media to task (covers create_image/audio/video). + if teamWs := tools.ToolTeamWorkspaceFromCtx(ctx); teamWs != "" { + for _, mf := range r.result.Media { + tools.AutoAttachWorkspaceFile(ctx, l.teamStore, teamWs, mf.Path) + } + } if r.result.Deliverable != "" { deliverables = append(deliverables, r.result.Deliverable) } @@ -1403,27 +1434,27 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // Flush all buffered messages to session atomically. // This ensures concurrent runs never see each other's in-progress messages. for _, msg := range pendingMsgs { - l.sessions.AddMessage(req.SessionKey, msg) + l.sessions.AddMessage(ctx, req.SessionKey, msg) } // Persist adaptive tool timing to session metadata. if serialized := toolTiming.Serialize(); serialized != "" { - l.sessions.SetSessionMetadata(req.SessionKey, map[string]string{"tool_timing": serialized}) + l.sessions.SetSessionMetadata(ctx, req.SessionKey, map[string]string{"tool_timing": serialized}) } // Write session metadata (matching TS session entry updates) - l.sessions.UpdateMetadata(req.SessionKey, l.model, l.provider.Name(), req.Channel) - l.sessions.AccumulateTokens(req.SessionKey, int64(totalUsage.PromptTokens), int64(totalUsage.CompletionTokens)) + l.sessions.UpdateMetadata(ctx, req.SessionKey, l.model, l.provider.Name(), req.Channel) + l.sessions.AccumulateTokens(ctx, req.SessionKey, int64(totalUsage.PromptTokens), int64(totalUsage.CompletionTokens)) // Calibrate token estimation: store actual prompt tokens + message count. // Next time EstimateTokensWithCalibration() is called, it uses this as a base // instead of the chars/3 heuristic (more accurate for multilingual content). if totalUsage.PromptTokens > 0 { msgCount := len(history) + len(pendingMsgs) - l.sessions.SetLastPromptTokens(req.SessionKey, totalUsage.PromptTokens, msgCount) + l.sessions.SetLastPromptTokens(ctx, req.SessionKey, totalUsage.PromptTokens, msgCount) } - l.sessions.Save(req.SessionKey) + l.sessions.Save(ctx, req.SessionKey) // Bootstrap auto-cleanup: after enough conversation turns, remove BOOTSTRAP.md // as a safety net in case the LLM didn't clear it itself. diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index 7e67d883..bbbdd3c2 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -179,7 +179,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s OwnerIDs: l.ownerIDs, Mode: mode, ToolNames: toolNames, - SkillsSummary: l.resolveSkillsSummary(skillFilter), + SkillsSummary: l.resolveSkillsSummary(ctx, skillFilter), HasMemory: l.hasMemory, HasSpawn: l.tools != nil && hasSpawn, HasTeam: hasTeamTools, @@ -231,8 +231,8 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s slog.Info("sanitizeHistory: cleaned session history", "session", sessionKey, "dropped", droppedCount) cleanedHistory, _ := sanitizeHistory(history) - l.sessions.SetHistory(sessionKey, cleanedHistory) - l.sessions.Save(sessionKey) + l.sessions.SetHistory(ctx, sessionKey, cleanedHistory) + l.sessions.Save(ctx, sessionKey) } // Current user message @@ -305,7 +305,7 @@ const ( // Returns (summary XML, useInline) — useInline=true means skills are inlined and // the system prompt should use TS-style "scan " instructions // instead of "use skill_search". -func (l *Loop) resolveSkillsSummary(skillFilter []string) string { +func (l *Loop) resolveSkillsSummary(ctx context.Context, skillFilter []string) string { if l.skillsLoader == nil { return "" } @@ -316,7 +316,7 @@ func (l *Loop) resolveSkillsSummary(skillFilter []string) string { allowList = skillFilter } - filtered := l.skillsLoader.FilterSkills(allowList) + filtered := l.skillsLoader.FilterSkills(ctx, allowList) if len(filtered) == 0 { return "" } @@ -330,7 +330,7 @@ func (l *Loop) resolveSkillsSummary(skillFilter []string) string { if len(filtered) <= skillInlineMaxCount && estimatedTokens <= skillInlineMaxTokens { // Inline mode: build full XML summary - return l.skillsLoader.BuildSummary(allowList) + return l.skillsLoader.BuildSummary(ctx, allowList) } // Search mode: no XML in prompt, agent uses skill_search tool @@ -471,10 +471,10 @@ func sanitizeHistory(msgs []providers.Message) ([]providers.Message, int) { } func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { - history := l.sessions.GetHistory(sessionKey) + history := l.sessions.GetHistory(ctx, sessionKey) // Use calibrated token estimation when available. - lastPT, lastMC := l.sessions.GetLastPromptTokens(sessionKey) + lastPT, lastMC := l.sessions.GetLastPromptTokens(ctx, sessionKey) tokenEstimate := EstimateTokensWithCalibration(history, lastPT, lastMC) // Resolve compaction thresholds from config with sensible defaults. @@ -505,7 +505,7 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { // Memory flush runs synchronously INSIDE the guard // (so concurrent runs don't both trigger flush for the same compaction cycle). flushSettings := ResolveMemoryFlushSettings(l.compactionCfg) - if l.shouldRunMemoryFlush(sessionKey, tokenEstimate, flushSettings) { + if l.shouldRunMemoryFlush(ctx, sessionKey, tokenEstimate, flushSettings) { l.runMemoryFlush(ctx, sessionKey, flushSettings) } @@ -521,15 +521,15 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { // Re-check: history may have been truncated by a concurrent summarize // that finished between our threshold check and acquiring the lock. - history := l.sessions.GetHistory(sessionKey) + sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 120*time.Second) + defer cancel() + + history := l.sessions.GetHistory(sctx, sessionKey) if len(history) <= keepLast { return } - sctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - summary := l.sessions.GetSummary(sessionKey) + summary := l.sessions.GetSummary(sctx, sessionKey) toSummarize := history[:len(history)-keepLast] var sb strings.Builder @@ -579,10 +579,10 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) { return } - l.sessions.SetSummary(sessionKey, SanitizeAssistantContent(resp.Content)) - l.sessions.TruncateHistory(sessionKey, keepLast) - l.sessions.IncrementCompaction(sessionKey) - l.sessions.Save(sessionKey) + l.sessions.SetSummary(sctx, sessionKey, SanitizeAssistantContent(resp.Content)) + l.sessions.TruncateHistory(sctx, sessionKey, keepLast) + l.sessions.IncrementCompaction(sctx, sessionKey) + l.sessions.Save(sctx, sessionKey) }() } diff --git a/internal/agent/loop_run.go b/internal/agent/loop_run.go index 8b329335..051774f2 100644 --- a/internal/agent/loop_run.go +++ b/internal/agent/loop_run.go @@ -29,6 +29,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { event.UserID = req.UserID event.Channel = req.Channel event.ChatID = req.ChatID + event.TenantID = store.TenantIDFromContext(ctx) l.emit(event) } @@ -160,7 +161,7 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) { traceCtx := ctx traceStatus := store.TraceStatusError if ctx.Err() != nil { - traceCtx = context.Background() + traceCtx = context.WithoutCancel(ctx) traceStatus = store.TraceStatusCancelled } l.traceCollector.FinishTrace(traceCtx, traceID, traceStatus, err.Error(), "") diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go index f7805a5b..caabdbf3 100644 --- a/internal/agent/loop_tracing.go +++ b/internal/agent/loop_tracing.go @@ -65,6 +65,10 @@ func (l *Loop) emitLLMSpanStart(ctx context.Context, start time.Time, iteration span.AgentID = &l.agentUUID } span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx) + span.TenantID = store.TenantIDFromContext(ctx) + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } // Verbose mode: include input messages (same stripping as emitLLMSpan). if collector.Verbose() && len(messages) > 0 { @@ -195,6 +199,10 @@ func (l *Loop) emitToolSpanStart(ctx context.Context, start time.Time, toolName, span.AgentID = &l.agentUUID } span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx) + span.TenantID = store.TenantIDFromContext(ctx) + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } collector.EmitSpan(span) return spanID @@ -302,6 +310,10 @@ func (l *Loop) emitAgentSpanStart(ctx context.Context, agentSpanID uuid.UUID, st span.AgentID = &l.agentUUID } span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx) + span.TenantID = store.TenantIDFromContext(ctx) + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } collector.EmitSpan(span) } diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index 00c509bf..9d8840a6 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -40,6 +40,7 @@ type BootstrapCleanupFunc func(ctx context.Context, agentID uuid.UUID, userID st type Loop struct { id string agentUUID uuid.UUID // set for context propagation + tenantID uuid.UUID // agent's owning tenant agentType string // "open" or "predefined" provider providers.Provider model string @@ -108,6 +109,9 @@ type Loop struct { // Global builtin tool settings (from builtin_tools table) builtinToolSettings tools.BuiltinToolSettings + // Per-tenant disabled tools (tool name → true means excluded from LLM) + disabledTools map[string]bool + // Thinking level for extended thinking support thinkingLevel string @@ -160,6 +164,9 @@ type AgentEvent struct { UserID string `json:"userId,omitempty"` Channel string `json:"channel,omitempty"` ChatID string `json:"chatId,omitempty"` + + // TenantID scopes this event to a specific tenant for filtering (not serialized). + TenantID uuid.UUID `json:"-"` } // LoopConfig configures a new Loop. @@ -209,9 +216,10 @@ type LoopConfig struct { // Shell deny group overrides (nil = all defaults) ShellDenyGroups map[string]bool - // Agent UUID for context propagation to tools + // Agent UUID + tenant for context propagation to tools AgentUUID uuid.UUID - AgentType string // "open" or "predefined" + TenantID uuid.UUID // agent's owning tenant — injected into execution context + AgentType string // "open" or "predefined" // Per-user file seeding + dynamic context loading EnsureUserFiles EnsureUserFilesFunc @@ -229,6 +237,9 @@ type LoopConfig struct { // Global builtin tool settings (from builtin_tools table) BuiltinToolSettings tools.BuiltinToolSettings + // Per-tenant disabled tools (tool name → true means excluded) + DisabledTools map[string]bool + // Thinking level: "off", "low", "medium", "high" (from agent other_config) ThinkingLevel string @@ -298,6 +309,7 @@ func NewLoop(cfg LoopConfig) *Loop { return &Loop{ id: cfg.ID, agentUUID: cfg.AgentUUID, + tenantID: cfg.TenantID, agentType: cfg.AgentType, provider: cfg.Provider, model: cfg.Model, @@ -337,6 +349,7 @@ func NewLoop(cfg LoopConfig) *Loop { injectionAction: action, maxMessageChars: cfg.MaxMessageChars, builtinToolSettings: cfg.BuiltinToolSettings, + disabledTools: cfg.DisabledTools, thinkingLevel: cfg.ThinkingLevel, selfEvolve: cfg.SelfEvolve, skillEvolve: cfg.SkillEvolve, diff --git a/internal/agent/media.go b/internal/agent/media.go index 6223d66e..3895a531 100644 --- a/internal/agent/media.go +++ b/internal/agent/media.go @@ -3,12 +3,16 @@ package agent import ( "encoding/base64" "fmt" + "io" "log/slog" "os" "path/filepath" "strings" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/media" "github.com/nextlevelbuilder/goclaw/internal/providers" ) @@ -50,19 +54,20 @@ func loadImages(files []bus.MediaFile) []providers.ImageContent { return images } -// persistMedia sanitizes images, saves all media files to persistent storage, -// and returns lightweight MediaRefs. Non-image files are saved as-is. -// If mediaStore is nil, falls back to legacy behavior (no persistence, delete temp files). -func (l *Loop) persistMedia(sessionKey string, files []bus.MediaFile) []providers.MediaRef { - if l.mediaStore == nil { - // Fallback: no persistent storage configured. - // slog.Warn("media: no media store configured, temp files will be deleted", "agent", l.id) - // Keep workspace files — don't delete originals. - // defer func() { - // for _, f := range files { - // _ = os.Remove(f.Path) - // } - // }() +// persistMedia sanitizes images, saves all media files to the per-user workspace +// .uploads/ directory, and returns lightweight MediaRefs with persisted paths. +// All media types (images, documents, audio, video) are stored within the user's +// workspace for filesystem-level tenant isolation. +// workspace is the per-user workspace path from ToolWorkspaceFromCtx(ctx). +func (l *Loop) persistMedia(sessionKey string, files []bus.MediaFile, workspace string) []providers.MediaRef { + if workspace == "" { + slog.Warn("media: no workspace, cannot persist media") + return nil + } + + uploadsDir := filepath.Join(workspace, ".uploads") + if err := os.MkdirAll(uploadsDir, 0755); err != nil { + slog.Warn("media: failed to create .uploads dir", "dir", uploadsDir, "error", err) return nil } @@ -70,49 +75,80 @@ func (l *Loop) persistMedia(sessionKey string, files []bus.MediaFile) []provider for _, f := range files { mime := f.MimeType if mime == "" { - mime = mimeFromExt(filepath.Ext(f.Path)) // fallback for legacy callers + mime = mimeFromExt(filepath.Ext(f.Path)) } kind := mediaKindFromMime(mime) // Sanitize images before persistent storage. srcPath := f.Path + var sanitizedTemp string // track temp file for cleanup if kind == "image" { sanitized, err := SanitizeImage(f.Path) if err != nil { slog.Warn("media: sanitize image failed, using original", "path", f.Path, "error", err) } else { srcPath = sanitized + sanitizedTemp = sanitized mime = "image/jpeg" // sanitized output is always JPEG - // Keep workspace files — don't delete originals after sanitization. - // if sanitized != f.Path { - // _ = os.Remove(f.Path) - // } } } - id, _, err := l.mediaStore.SaveFile(sessionKey, srcPath, mime) - if err != nil { + id := uuid.New().String() + ext := media.ExtFromMime(mime) + if ext == "" { + ext = filepath.Ext(srcPath) // fallback to source extension + } + dstPath := filepath.Join(uploadsDir, id+ext) + + if err := copyMediaFile(srcPath, dstPath); err != nil { slog.Warn("media: failed to persist file", "path", f.Path, "error", err) - // Keep workspace files — don't delete on persist failure. - // _ = os.Remove(srcPath) + if sanitizedTemp != "" { + os.Remove(sanitizedTemp) + } continue } + if sanitizedTemp != "" { + os.Remove(sanitizedTemp) // cleanup sanitized temp file + } refs = append(refs, providers.MediaRef{ ID: id, MimeType: mime, Kind: kind, + Path: dstPath, }) - slog.Debug("media: persisted file", "id", id, "kind", kind, "mime", mime, "agent", l.id) + slog.Debug("media: persisted file", "id", id, "kind", kind, "path", dstPath, "agent", l.id) } return refs } +// copyMediaFile copies src to dst using buffered I/O. +// Removes partial dst file on failure. +func copyMediaFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + + if _, err := io.Copy(out, in); err != nil { + out.Close() + os.Remove(dst) // cleanup partial file + return err + } + return out.Close() +} + // enrichDocumentPaths updates the last user message to include persisted file paths // in tags. This allows skills (e.g. pdf skill via exec) to access // the file directly, matching how Claude Code skills work with file paths. func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []providers.MediaRef) { - if l.mediaStore == nil || len(messages) == 0 { + if len(messages) == 0 { return } // Find last user message @@ -132,8 +168,16 @@ func (l *Loop) enrichDocumentPaths(messages []providers.Message, refs []provider if ref.Kind != "document" { continue } - p, err := l.mediaStore.LoadPath(ref.ID) - if err != nil { + // Use persisted workspace path; fall back to legacy .media/ lookup. + p := ref.Path + if p == "" && l.mediaStore != nil { + var err error + p, err = l.mediaStore.LoadPath(ref.ID) + if err != nil { + continue + } + } + if p == "" { continue } // Replace or with version that includes path. @@ -307,7 +351,7 @@ const maxMediaReloadMessages = 5 // reloadMediaForMessages populates Images on historical messages that have image MediaRefs. // Only reloads the last maxMessages messages with image refs (newest first) to limit context usage. func (l *Loop) reloadMediaForMessages(msgs []providers.Message, maxMessages int) { - if l.mediaStore == nil || maxMessages <= 0 { + if maxMessages <= 0 { return } @@ -324,9 +368,16 @@ func (l *Loop) reloadMediaForMessages(msgs []providers.Message, maxMessages int) continue } hasImageRef = true - p, err := l.mediaStore.LoadPath(ref.ID) - if err != nil { - slog.Debug("media: reload skip missing file", "id", ref.ID, "error", err) + p := ref.Path + if p == "" && l.mediaStore != nil { + var err error + p, err = l.mediaStore.LoadPath(ref.ID) + if err != nil { + slog.Debug("media: reload skip missing file", "id", ref.ID, "error", err) + continue + } + } + if p == "" { continue } imageFiles = append(imageFiles, bus.MediaFile{Path: p, MimeType: ref.MimeType}) diff --git a/internal/agent/media_sanitize.go b/internal/agent/media_sanitize.go index a06ead50..813f80df 100644 --- a/internal/agent/media_sanitize.go +++ b/internal/agent/media_sanitize.go @@ -7,7 +7,6 @@ import ( "image/jpeg" "image/png" "os" - "path/filepath" "github.com/disintegration/imaging" ) @@ -51,10 +50,17 @@ func SanitizeImage(inputPath string) (string, error) { return "", fmt.Errorf("encode jpeg (q=%d): %w", quality, err) } if buf.Len() <= imageSanitizeMaxBytes { - outPath := filepath.Join(os.TempDir(), fmt.Sprintf("goclaw_sanitized_%d.jpg", os.Getpid())) - if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil { + f, err := os.CreateTemp("", "goclaw_sanitized_*.jpg") + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + outPath := f.Name() + if _, err := f.Write(buf.Bytes()); err != nil { + f.Close() + os.Remove(outPath) return "", fmt.Errorf("write sanitized image: %w", err) } + f.Close() return outPath, nil } } diff --git a/internal/agent/media_tool_routing.go b/internal/agent/media_tool_routing.go index e2231837..61369346 100644 --- a/internal/agent/media_tool_routing.go +++ b/internal/agent/media_tool_routing.go @@ -69,8 +69,11 @@ func (l *Loop) loadHistoricalImagesForTool(ctx context.Context, currentRefs []pr continue } hasImage = true - p, err := l.mediaStore.LoadPath(ref.ID) - if err != nil { + p := ref.Path + if p == "" && l.mediaStore != nil { + p, _ = l.mediaStore.LoadPath(ref.ID) + } + if p == "" { continue } histPaths = append(histPaths, bus.MediaFile{Path: p, MimeType: ref.MimeType}) diff --git a/internal/agent/memoryflush.go b/internal/agent/memoryflush.go index 1abed466..223cda57 100644 --- a/internal/agent/memoryflush.go +++ b/internal/agent/memoryflush.go @@ -67,7 +67,7 @@ func ResolveMemoryFlushSettings(compaction *config.CompactionConfig) *MemoryFlus // shouldRunMemoryFlush checks whether a memory flush should run before compaction. // Flush always runs when compaction triggers (called inside maybeSummarize), // gated only by enabled/memory checks and a dedup guard per compaction cycle. -func (l *Loop) shouldRunMemoryFlush(sessionKey string, totalTokens int, settings *MemoryFlushSettings) bool { +func (l *Loop) shouldRunMemoryFlush(ctx context.Context, sessionKey string, totalTokens int, settings *MemoryFlushSettings) bool { if settings == nil || !settings.Enabled || !l.hasMemory { return false } @@ -77,8 +77,8 @@ func (l *Loop) shouldRunMemoryFlush(sessionKey string, totalTokens int, settings } // Deduplication: skip if already flushed in this compaction cycle. - compactionCount := l.sessions.GetCompactionCount(sessionKey) - lastFlushAt := l.sessions.GetMemoryFlushCompactionCount(sessionKey) + compactionCount := l.sessions.GetCompactionCount(ctx, sessionKey) + lastFlushAt := l.sessions.GetMemoryFlushCompactionCount(ctx, sessionKey) if lastFlushAt >= 0 && lastFlushAt == compactionCount { return false } @@ -95,8 +95,8 @@ func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings * defer cancel() // Build messages: system prompt + history summary + flush prompt - history := l.sessions.GetHistory(sessionKey) - summary := l.sessions.GetSummary(sessionKey) + history := l.sessions.GetHistory(ctx, sessionKey) + summary := l.sessions.GetSummary(ctx, sessionKey) var messages []providers.Message @@ -203,8 +203,8 @@ func (l *Loop) runMemoryFlush(ctx context.Context, sessionKey string, settings * } // Mark flush as done - l.sessions.SetMemoryFlushDone(sessionKey) - l.sessions.Save(sessionKey) + l.sessions.SetMemoryFlushDone(ctx, sessionKey) + l.sessions.Save(ctx, sessionKey) slog.Info("memory flush: completed", "session", sessionKey) } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 137fde47..a6e0a03f 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -50,9 +50,6 @@ type ResolverDeps struct { SandboxContainerDir string SandboxWorkspaceAccess string - // Dynamic custom tools - DynamicLoader *tools.DynamicToolLoader - // Inter-agent delegation AgentLinkStore store.AgentLinkStore @@ -89,13 +86,22 @@ type ResolverDeps struct { // Memory store for extractive memory fallback MemoryStore store.MemoryStore + + // Tenant store for workspace path resolution + TenantStore store.TenantStore + + // Per-tenant tool/skill config overrides + BuiltinToolTenantCfgs store.BuiltinToolTenantConfigStore + SkillTenantCfgs store.SkillTenantConfigStore + + // Global workspace root (GOCLAW_WORKSPACE) + Workspace string } // NewManagedResolver creates a ResolverFunc that builds Loops from DB agent data. // Agents are defined in Postgres, not config.json. func NewManagedResolver(deps ResolverDeps) ResolverFunc { - return func(agentKey string) (Agent, error) { - ctx := context.Background() + return func(ctx context.Context, agentKey string) (Agent, error) { // Support lookup by UUID (e.g. from cron jobs that store agent_id as UUID) var ag *store.AgentData @@ -113,15 +119,15 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { return nil, fmt.Errorf("agent %s is inactive", agentKey) } - // Resolve provider - provider, err := deps.ProviderReg.Get(ag.Provider) + // Resolve provider (tenant-aware: tries tenant-specific first, falls back to master) + provider, err := deps.ProviderReg.GetForTenant(ag.TenantID, ag.Provider) if err != nil { - // Fallback to any available provider - names := deps.ProviderReg.List() + // Fallback to any available provider for this tenant + names := deps.ProviderReg.ListForTenant(ag.TenantID) if len(names) == 0 { return nil, fmt.Errorf("no providers configured for agent %s", agentKey) } - provider, _ = deps.ProviderReg.Get(names[0]) + provider, _ = deps.ProviderReg.GetForTenant(ag.TenantID, names[0]) slog.Warn("agent provider not found, using fallback", "agent", agentKey, "wanted", ag.Provider, "using", names[0]) if tl := ag.ParseThinkingLevel(); tl != "" && tl != "off" { @@ -210,27 +216,33 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { sandboxCfgOverride = &resolved } - // Expand ~ in workspace path and ensure directory exists + // Resolve tenant slug once for workspace + dataDir scoping. + var tenantSlug string + if ag.TenantID != store.MasterTenantID && ag.TenantID != uuid.Nil { + tenantSlug = resolveTenantSlug(deps.TenantStore, ag.TenantID) + } + + // Expand ~ in workspace path and ensure directory exists. + // For non-master tenants, prefix workspace with tenant slug directory. workspace := ag.Workspace if workspace != "" { workspace = config.ExpandHome(workspace) if !filepath.IsAbs(workspace) { workspace, _ = filepath.Abs(workspace) } + } + if tenantSlug != "" { + if deps.Workspace != "" { + workspace = config.TenantWorkspace(deps.Workspace, ag.TenantID, tenantSlug) + } + } + if workspace != "" { if err := os.MkdirAll(workspace, 0755); err != nil { slog.Warn("failed to create agent workspace directory", "workspace", workspace, "agent", agentKey, "error", err) } } - // Per-agent custom tools (clone registry if agent has custom tools) toolsReg := deps.Tools - if deps.DynamicLoader != nil { - if agentReg, err := deps.DynamicLoader.LoadForAgent(ctx, deps.Tools, ag.ID); err != nil { - slog.Warn("failed to load custom tools", "agent", agentKey, "error", err) - } else if agentReg != nil { - toolsReg = agentReg - } - } // Per-agent MCP servers: connect to granted MCP servers and register their tools. // Uses a per-agent MCP Manager that queries the MCPServerStore for accessible servers. @@ -292,6 +304,18 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { } } + // Load per-tenant tool exclusions (disabled tools for this agent's tenant) + var disabledTools map[string]bool + if deps.BuiltinToolTenantCfgs != nil && ag.TenantID != uuid.Nil { + if disabled, err := deps.BuiltinToolTenantCfgs.ListDisabled(ctx, ag.TenantID); err == nil && len(disabled) > 0 { + disabledTools = make(map[string]bool, len(disabled)) + for _, name := range disabled { + disabledTools[name] = true + } + slog.Debug("tenant tool exclusions", "agent", agentKey, "tenant", ag.TenantID, "disabled", len(disabled)) + } + } + // Filter skills by visibility + agent grants. // Only public skills and explicitly granted internal skills appear in the system prompt. var skillAllowList []string @@ -308,10 +332,17 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { } } + // Resolve tenant-scoped DataDir for team workspace resolution. + dataDir := deps.DataDir + if tenantSlug != "" { + dataDir = config.TenantDataDir(deps.DataDir, ag.TenantID, tenantSlug) + } + restrictVal := true // always restrict agents to their workspace loop := NewLoop(LoopConfig{ ID: ag.AgentKey, AgentUUID: ag.ID, + TenantID: ag.TenantID, AgentType: ag.AgentType, Provider: provider, Model: ag.Model, @@ -319,7 +350,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { MaxTokens: ag.ParseMaxTokens(), MaxIterations: maxIter, Workspace: workspace, - DataDir: deps.DataDir, + DataDir: dataDir, RestrictToWs: &restrictVal, SubagentsCfg: ag.ParseSubagentsConfig(), MemoryCfg: ag.ParseMemoryConfig(), @@ -346,6 +377,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { SandboxContainerDir: sandboxContainerDir, SandboxWorkspaceAccess: sandboxWorkspaceAccess, BuiltinToolSettings: builtinSettings, + DisabledTools: disabledTools, ThinkingLevel: ag.ParseThinkingLevel(), SelfEvolve: ag.ParseSelfEvolve(), SkillEvolve: ag.AgentType == store.AgentTypePredefined && ag.ParseSkillEvolve(), @@ -385,6 +417,19 @@ func (r *Router) InvalidateAll() { slog.Debug("invalidated all agent caches") } +// resolveTenantSlug looks up the tenant slug for workspace path resolution. +// Returns the tenant ID string as fallback if lookup fails. +func resolveTenantSlug(ts store.TenantStore, tenantID uuid.UUID) string { + if ts == nil { + return tenantID.String() + } + tenant, err := ts.GetTenant(store.WithCrossTenant(context.Background()), tenantID) + if err != nil || tenant == nil { + return tenantID.String() + } + return tenant.Slug +} + func derefInt(p *int) int { if p == nil { return 0 diff --git a/internal/agent/router.go b/internal/agent/router.go index f3e251b7..486624e2 100644 --- a/internal/agent/router.go +++ b/internal/agent/router.go @@ -5,11 +5,15 @@ import ( "fmt" "sync" "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" ) // ResolverFunc is called when an agent isn't found in the cache. -// Used to lazy-create agents from DB. -type ResolverFunc func(agentKey string) (Agent, error) +// Used to lazy-create agents from DB. Context carries tenant scope. +type ResolverFunc func(ctx context.Context, agentKey string) (Agent, error) const defaultRouterTTL = 10 * time.Minute @@ -64,9 +68,12 @@ func (r *Router) Register(ag Agent) { // Get returns an agent by ID. Lazy-creates from DB via resolver if needed. // Cached entries expire after TTL as a safety net for multi-instance deployments. -func (r *Router) Get(agentID string) (Agent, error) { +// Cache key includes tenant so the same agent_key in different tenants resolves independently. +func (r *Router) Get(ctx context.Context, agentID string) (Agent, error) { + cacheKey := agentCacheKey(ctx, agentID) + r.mu.RLock() - entry, ok := r.agents[agentID] + entry, ok := r.agents[cacheKey] resolver := r.resolver r.mu.RUnlock() @@ -77,23 +84,23 @@ func (r *Router) Get(agentID string) (Agent, error) { // TTL expired → remove stale entry so resolver re-creates if ok { r.mu.Lock() - delete(r.agents, agentID) + delete(r.agents, cacheKey) r.mu.Unlock() } // Try resolver (create from DB) if resolver != nil { - ag, err := resolver(agentID) + ag, err := resolver(ctx, agentID) if err != nil { return nil, err } r.mu.Lock() // Double-check: another goroutine might have created it - if existing, ok := r.agents[agentID]; ok { + if existing, ok := r.agents[cacheKey]; ok { r.mu.Unlock() return existing.agent, nil } - r.agents[agentID] = &agentEntry{agent: ag, cachedAt: time.Now()} + r.agents[cacheKey] = &agentEntry{agent: ag, cachedAt: time.Now()} r.mu.Unlock() return ag, nil } @@ -101,6 +108,15 @@ func (r *Router) Get(agentID string) (Agent, error) { return nil, fmt.Errorf("agent not found: %s", agentID) } +// agentCacheKey builds a tenant-scoped cache key for the agent router. +func agentCacheKey(ctx context.Context, agentID string) string { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return agentID + } + return tid.String() + ":" + agentID +} + // Remove removes an agent from the router. func (r *Router) Remove(agentID string) { r.mu.Lock() diff --git a/internal/bootstrap/seed_store_test.go b/internal/bootstrap/seed_store_test.go index 61f32378..f260e766 100644 --- a/internal/bootstrap/seed_store_test.go +++ b/internal/bootstrap/seed_store_test.go @@ -63,6 +63,12 @@ func (s *seedStubStore) GetByKey(_ context.Context, _ string) (*store.AgentData, func (s *seedStubStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { return nil, nil } +func (s *seedStubStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) { + return nil, nil +} +func (s *seedStubStore) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.AgentData, error) { + return nil, nil +} func (s *seedStubStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil } func (s *seedStubStore) Delete(_ context.Context, _ uuid.UUID) error { return nil } func (s *seedStubStore) List(_ context.Context, _ string) ([]store.AgentData, error) { return nil, nil } diff --git a/internal/bus/broadcast_helpers.go b/internal/bus/broadcast_helpers.go new file mode 100644 index 00000000..5ca181d7 --- /dev/null +++ b/internal/bus/broadcast_helpers.go @@ -0,0 +1,10 @@ +package bus + +import "github.com/google/uuid" + +// BroadcastForTenant broadcasts an event with explicit tenant scoping. +// Use this for events that carry tenant-specific data to ensure proper +// isolation in the WS event filter (event_filter.go). +func BroadcastForTenant(pub EventPublisher, name string, tenantID uuid.UUID, payload any) { + pub.Broadcast(Event{Name: name, TenantID: tenantID, Payload: payload}) +} diff --git a/internal/bus/types.go b/internal/bus/types.go index 6cf66205..095df11c 100644 --- a/internal/bus/types.go +++ b/internal/bus/types.go @@ -3,6 +3,8 @@ package bus import ( "context" "encoding/json" + + "github.com/google/uuid" ) // MediaFile represents an inbound media file with its MIME type. @@ -21,6 +23,7 @@ type InboundMessage struct { Media []MediaFile `json:"media,omitempty"` SessionKey string `json:"session_key"` // deprecated: gateway builds canonical key PeerKind string `json:"peer_kind,omitempty"` // "direct" or "group" (used for session key) + TenantID uuid.UUID `json:"tenant_id,omitempty"` // tenant scope from channel instance AgentID string `json:"agent_id,omitempty"` // target agent (for multi-agent routing) UserID string `json:"user_id,omitempty"` // external user ID for per-user scoping (memory, bootstrap) HistoryLimit int `json:"history_limit,omitempty"` // max turns to keep in context (0=unlimited, from channel config) @@ -46,8 +49,9 @@ type MediaAttachment struct { // Event represents a server-side event to broadcast to WebSocket clients. type Event struct { - Name string `json:"name"` // event name (e.g. "agent", "chat", "health") - Payload any `json:"payload,omitempty"` + Name string `json:"name"` // event name (e.g. "agent", "chat", "health") + Payload any `json:"payload,omitempty"` + TenantID uuid.UUID `json:"-"` // tenant scope for event filtering (not serialized to clients) } // Cache invalidation kind constants. @@ -56,7 +60,6 @@ const ( CacheKindBootstrap = "bootstrap" CacheKindSkills = "skills" CacheKindCron = "cron" - CacheKindCustomTools = "custom_tools" CacheKindChannelInstances = "channel_instances" CacheKindBuiltinTools = "builtin_tools" CacheKindTeam = "team" @@ -67,6 +70,10 @@ const ( CacheKindAPIKeys = "api_keys" CacheKindHeartbeat = "heartbeat" CacheKindConfigPerms = "config_perms" + CacheKindTenantUsers = "tenant_users" + CacheKindAgentAccess = "agent_access" + CacheKindTeamAccess = "team_access" + CacheKindTenants = "tenants" ) // Topic constants for msgBus.Subscribe() / Broadcast(). @@ -75,7 +82,6 @@ const ( TopicCacheAgent = "cache:agent" TopicCacheSkills = "cache:skills" TopicCacheCron = "cache:cron" - TopicCacheCustomTools = "cache:custom_tools" TopicCacheBuiltinTools = "cache:builtin_tools" TopicCacheTeam = "cache:team" TopicCacheUserWorkspace = "cache:user_workspace" @@ -122,6 +128,7 @@ type AuditEventPayload struct { EntityID string `json:"entity_id"` IPAddress string `json:"ip_address,omitempty"` Details json.RawMessage `json:"details,omitempty"` + TenantID uuid.UUID `json:"tenant_id,omitempty"` // for async subscriber tenant scoping } // CacheInvalidatePayload signals cache layers to evict stale entries. diff --git a/internal/cache/permission_cache.go b/internal/cache/permission_cache.go new file mode 100644 index 00000000..744387a8 --- /dev/null +++ b/internal/cache/permission_cache.go @@ -0,0 +1,121 @@ +package cache + +import ( + "context" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +// agentAccessEntry caches agent access check results. +type agentAccessEntry struct { + Allowed bool + Role string +} + +// PermissionCache provides short-TTL caching for hot permission lookups. +// Uses InMemoryCache[V] caches with pubsub invalidation. +type PermissionCache struct { + tenantResolve *InMemoryCache[uuid.UUID] + tenantRole *InMemoryCache[string] + agentAccess *InMemoryCache[agentAccessEntry] + teamAccess *InMemoryCache[bool] +} + +// NewPermissionCache creates a new permission cache. +func NewPermissionCache() *PermissionCache { + return &PermissionCache{ + tenantResolve: NewInMemoryCache[uuid.UUID](), + tenantRole: NewInMemoryCache[string](), + agentAccess: NewInMemoryCache[agentAccessEntry](), + teamAccess: NewInMemoryCache[bool](), + } +} + +const ( + tenantResolveTTL = 60 * time.Second + tenantRoleTTL = 30 * time.Second + agentAccessTTL = 30 * time.Second + teamAccessTTL = 30 * time.Second +) + +// --- Tenant Resolution --- + +func (pc *PermissionCache) GetTenantResolve(ctx context.Context, userID string) (uuid.UUID, bool) { + return pc.tenantResolve.Get(ctx, userID) +} + +func (pc *PermissionCache) SetTenantResolve(ctx context.Context, userID string, tenantID uuid.UUID) { + pc.tenantResolve.Set(ctx, userID, tenantID, tenantResolveTTL) +} + +// --- Tenant Role --- + +func (pc *PermissionCache) GetTenantRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, bool) { + return pc.tenantRole.Get(ctx, tenantID.String()+":"+userID) +} + +func (pc *PermissionCache) SetTenantRole(ctx context.Context, tenantID uuid.UUID, userID, role string) { + pc.tenantRole.Set(ctx, tenantID.String()+":"+userID, role, tenantRoleTTL) +} + +// --- Agent Access --- + +func (pc *PermissionCache) GetAgentAccess(ctx context.Context, agentID uuid.UUID, userID string) (bool, string, bool) { + entry, ok := pc.agentAccess.Get(ctx, agentID.String()+":"+userID) + if !ok { + return false, "", false + } + return entry.Allowed, entry.Role, true +} + +func (pc *PermissionCache) SetAgentAccess(ctx context.Context, agentID uuid.UUID, userID string, allowed bool, role string) { + pc.agentAccess.Set(ctx, agentID.String()+":"+userID, agentAccessEntry{Allowed: allowed, Role: role}, agentAccessTTL) +} + +// --- Team Access --- + +func (pc *PermissionCache) GetTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, bool) { + return pc.teamAccess.Get(ctx, teamID.String()+":"+userID) +} + +func (pc *PermissionCache) SetTeamAccess(ctx context.Context, teamID uuid.UUID, userID string, allowed bool) { + pc.teamAccess.Set(ctx, teamID.String()+":"+userID, allowed, teamAccessTTL) +} + +// --- Invalidation --- + +// HandleInvalidation processes a cache invalidation event from the bus. +func (pc *PermissionCache) HandleInvalidation(p bus.CacheInvalidatePayload) { + slog.Debug("perm_cache.invalidated", "kind", string(p.Kind), "key", p.Key) + ctx := context.Background() + switch p.Kind { + case bus.CacheKindTenantUsers: + // Key is userID — invalidate resolve + all tenant roles for this user. + // Can't efficiently delete all tenantRole entries for a user by prefix, + // so clear all tenant roles (short TTL makes this acceptable). + if p.Key != "" { + pc.tenantResolve.Delete(ctx, p.Key) + pc.tenantRole.Clear(ctx) + } else { + pc.tenantResolve.Clear(ctx) + pc.tenantRole.Clear(ctx) + } + case bus.CacheKindAgentAccess: + // Key is agentID — delete all access entries for this agent. + if p.Key != "" { + pc.agentAccess.DeleteByPrefix(ctx, p.Key+":") + } else { + pc.agentAccess.Clear(ctx) + } + case bus.CacheKindTeamAccess: + // Key is teamID — delete all access entries for this team. + if p.Key != "" { + pc.teamAccess.DeleteByPrefix(ctx, p.Key+":") + } else { + pc.teamAccess.Clear(ctx) + } + } +} diff --git a/internal/channels/channel.go b/internal/channels/channel.go index 5d4d26e8..c4bf4570 100644 --- a/internal/channels/channel.go +++ b/internal/channels/channel.go @@ -14,6 +14,8 @@ import ( "net/http" "strings" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -156,6 +158,7 @@ type BaseChannel struct { running bool allowList []string agentID string // for DB instances: routes to specific agent (empty = use resolveAgentRoute) + tenantID uuid.UUID // for DB instances: tenant scope (zero = master tenant fallback) contactCollector *store.ContactCollector // optional: auto-collect contacts from channel messages } @@ -191,6 +194,12 @@ func (c *BaseChannel) AgentID() string { return c.agentID } // SetAgentID sets the explicit agent ID for routing (used by InstanceLoader for DB instances). func (c *BaseChannel) SetAgentID(id string) { c.agentID = id } +// TenantID returns the tenant UUID for this channel (zero = master tenant fallback). +func (c *BaseChannel) TenantID() uuid.UUID { return c.tenantID } + +// SetTenantID sets the tenant scope (used by InstanceLoader for DB instances). +func (c *BaseChannel) SetTenantID(id uuid.UUID) { c.tenantID = id } + // SetContactCollector sets the contact collector for auto-collecting contacts from messages. func (c *BaseChannel) SetContactCollector(cc *store.ContactCollector) { c.contactCollector = cc } @@ -325,6 +334,7 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st PeerKind: peerKind, UserID: userID, Metadata: metadata, + TenantID: c.tenantID, AgentID: c.agentID, } diff --git a/internal/channels/discord/discord.go b/internal/channels/discord/discord.go index 80f96a62..89f90dbd 100644 --- a/internal/channels/discord/discord.go +++ b/internal/channels/discord/discord.go @@ -70,7 +70,7 @@ func New(cfg config.DiscordConfig, msgBus *bus.MessageBus, pairingSvc store.Pair config: cfg, requireMention: requireMention, pairingService: pairingSvc, - groupHistory: channels.MakeHistory(channels.TypeDiscord, pendingStore), + groupHistory: channels.MakeHistory(channels.TypeDiscord, pendingStore, base.TenantID()), historyLimit: historyLimit, agentStore: agentStore, configPermStore: configPermStore, diff --git a/internal/channels/discord/handler.go b/internal/channels/discord/handler.go index db665c5d..77c74d72 100644 --- a/internal/channels/discord/handler.go +++ b/internal/channels/discord/handler.go @@ -13,10 +13,13 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/channels/typing" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // handleMessage processes incoming Discord messages. func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) // Ignore bot's own messages if m.Author == nil || m.Author.ID == c.botUserID { return @@ -40,11 +43,11 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate } if isDM { - if !c.checkDMPolicy(senderID, channelID) { + if !c.checkDMPolicy(ctx, senderID, channelID) { return } } else { - if !c.checkGroupPolicy(senderID, channelID) { + if !c.checkGroupPolicy(ctx, senderID, channelID) { slog.Debug("discord group message rejected by policy", "user_id", senderID, "username", senderName, @@ -192,7 +195,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate // Collect contact even when bot is not mentioned (cache prevents DB spam). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, "group") } slog.Debug("discord group message recorded (no mention)", @@ -284,7 +287,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate // Collect contact for processed messages (DM + group-mentioned). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, peerKind) + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, peerKind) } // Publish directly to bus (to preserve MediaFile MIME types) @@ -307,7 +310,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate } // checkGroupPolicy evaluates the group policy for a sender, with pairing support. -func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, channelID string) bool { groupPolicy := c.config.GroupPolicy if groupPolicy == "" { groupPolicy = "open" @@ -327,7 +330,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { } groupSenderID := fmt.Sprintf("group:%s", channelID) if c.pairingService != nil { - paired, err := c.pairingService.IsPaired(groupSenderID, c.Name()) + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", err) @@ -338,7 +341,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { return true } } - c.sendPairingReply(groupSenderID, channelID) + c.sendPairingReply(ctx, groupSenderID, channelID) return false default: // "open" return true @@ -346,7 +349,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { } // checkDMPolicy evaluates the DM policy for a sender, handling pairing flow. -func (c *Channel) checkDMPolicy(senderID, channelID string) bool { +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, channelID string) bool { dmPolicy := c.config.DMPolicy if dmPolicy == "" { dmPolicy = "pairing" @@ -367,7 +370,7 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool { default: // "pairing" paired := false if c.pairingService != nil { - p, err := c.pairingService.IsPaired(senderID, c.Name()) + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "sender_id", senderID, "channel", c.Name(), "error", err) @@ -382,13 +385,13 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool { return true } - c.sendPairingReply(senderID, channelID) + c.sendPairingReply(ctx, senderID, channelID) return false } } // sendPairingReply sends a pairing code to the user via DM. -func (c *Channel) sendPairingReply(senderID, channelID string) { +func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID string) { if c.pairingService == nil { return } @@ -400,7 +403,7 @@ func (c *Channel) sendPairingReply(senderID, channelID string) { } } - code, err := c.pairingService.RequestPairing(senderID, c.Name(), channelID, "default", nil) + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), channelID, "default", nil) if err != nil { slog.Debug("discord pairing request failed", "sender_id", senderID, "error", err) return diff --git a/internal/channels/events.go b/internal/channels/events.go index af552f7e..b261423f 100644 --- a/internal/channels/events.go +++ b/internal/channels/events.go @@ -6,7 +6,10 @@ import ( "log/slog" "strings" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -28,6 +31,9 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) { } ctx := context.Background() + if ta, ok := ch.(interface{ TenantID() uuid.UUID }); ok { + ctx = store.WithTenantID(ctx, ta.TenantID()) + } // Forward to StreamingChannel if sc, ok := ch.(StreamingChannel); ok { diff --git a/internal/channels/feishu/bot.go b/internal/channels/feishu/bot.go index 9d01195d..e03829b0 100644 --- a/internal/channels/feishu/bot.go +++ b/internal/channels/feishu/bot.go @@ -62,7 +62,7 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { // 4. Group policy if mc.ChatType == "group" { - if !c.checkGroupPolicy(mc.SenderID, mc.ChatID) { + if !c.checkGroupPolicy(ctx, mc.SenderID, mc.ChatID) { slog.Debug("feishu group message rejected by policy", "sender_id", mc.SenderID, "chat_id", mc.ChatID) return } @@ -99,7 +99,7 @@ func (c *Channel) handleMessageEvent(ctx context.Context, event *MessageEvent) { // 6. DM policy (pairing flow) if mc.ChatType == "p2p" { - if !c.checkDMPolicy(mc.SenderID, mc.ChatID) { + if !c.checkDMPolicy(ctx, mc.SenderID, mc.ChatID) { return } } diff --git a/internal/channels/feishu/bot_policy.go b/internal/channels/feishu/bot_policy.go index f91724da..470c3a19 100644 --- a/internal/channels/feishu/bot_policy.go +++ b/internal/channels/feishu/bot_policy.go @@ -46,7 +46,7 @@ func (c *Channel) fetchSenderName(ctx context.Context, openID string) string { // --- Policy checks --- -func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool { groupPolicy := c.cfg.GroupPolicy if groupPolicy == "" { groupPolicy = "open" @@ -85,7 +85,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { } groupSenderID := fmt.Sprintf("group:%s", chatID) if c.pairingService != nil { - paired, err := c.pairingService.IsPaired(groupSenderID, c.Name()) + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", err) @@ -96,14 +96,14 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { return true } } - c.sendPairingReply(groupSenderID, chatID) + c.sendPairingReply(ctx, groupSenderID, chatID) return false default: // "open" return true } } -func (c *Channel) checkDMPolicy(senderID, chatID string) bool { +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { dmPolicy := c.cfg.DMPolicy if dmPolicy == "" { dmPolicy = "pairing" @@ -124,7 +124,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { default: // "pairing" paired := false if c.pairingService != nil { - p, err := c.pairingService.IsPaired(senderID, c.Name()) + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "sender_id", senderID, "channel", c.Name(), "error", err) @@ -139,12 +139,12 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { return true } - c.sendPairingReply(senderID, chatID) + c.sendPairingReply(ctx, senderID, chatID) return false } } -func (c *Channel) sendPairingReply(senderID, chatID string) { +func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { if c.pairingService == nil { return } @@ -156,7 +156,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) { } } - code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil) + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) if err != nil { slog.Debug("feishu pairing request failed", "sender_id", senderID, "error", err) return diff --git a/internal/channels/feishu/feishu.go b/internal/channels/feishu/feishu.go index da87c40e..7db0c75d 100644 --- a/internal/channels/feishu/feishu.go +++ b/internal/channels/feishu/feishu.go @@ -86,7 +86,7 @@ func New(cfg config.FeishuConfig, msgBus *bus.MessageBus, pairingSvc store.Pairi client: client, pairingService: pairingSvc, groupAllowList: cfg.GroupAllowFrom, - groupHistory: channels.MakeHistory(channels.TypeFeishu, pendingStore), + groupHistory: channels.MakeHistory(channels.TypeFeishu, pendingStore, base.TenantID()), historyLimit: historyLimit, stopCh: make(chan struct{}), }, nil @@ -255,7 +255,8 @@ func (c *Channel) WebhookHandler() (string, http.Handler) { } handler := NewWebhookHandler(c.cfg.VerificationToken, c.cfg.EncryptKey, func(event *MessageEvent) { - c.handleMessageEvent(context.Background(), event) + ctx := store.WithTenantID(context.Background(), c.TenantID()) + c.handleMessageEvent(ctx, event) }) return path, http.HandlerFunc(handler) @@ -275,7 +276,8 @@ func (c *Channel) startWebhook(ctx context.Context) error { slog.Info("feishu: starting Webhook server", "port", port, "path", path) handler := NewWebhookHandler(c.cfg.VerificationToken, c.cfg.EncryptKey, func(event *MessageEvent) { - c.handleMessageEvent(context.Background(), event) + ctx := store.WithTenantID(context.Background(), c.TenantID()) + c.handleMessageEvent(ctx, event) }) mux := http.NewServeMux() diff --git a/internal/channels/history.go b/internal/channels/history.go index a5470e30..fb077ac6 100644 --- a/internal/channels/history.go +++ b/internal/channels/history.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -56,6 +57,9 @@ type PendingHistory struct { stopCh chan struct{} stopped chan struct{} + // Tenant isolation for DB operations. + tenantID uuid.UUID + // Compaction (optional — nil means no auto-compaction) compactionCfg *CompactionConfig @@ -70,11 +74,12 @@ func NewPendingHistory() *PendingHistory { // NewPersistentHistory creates a persistent history tracker with batched DB flush. // Call StartFlusher() after creation and StopFlusher() on shutdown. -func NewPersistentHistory(channelName string, s store.PendingMessageStore) *PendingHistory { +func NewPersistentHistory(channelName string, s store.PendingMessageStore, tenantID uuid.UUID) *PendingHistory { return &PendingHistory{ entries: make(map[string][]HistoryEntry), channelName: channelName, store: s, + tenantID: tenantID, flushSignal: make(chan struct{}, 1), stopCh: make(chan struct{}), stopped: make(chan struct{}), @@ -103,13 +108,22 @@ func (ph *PendingHistory) LoadFromDB(ctx context.Context) { } // MakeHistory creates a PendingHistory — persistent if store is non-nil, RAM-only otherwise. -func MakeHistory(channelName string, s store.PendingMessageStore) *PendingHistory { +func MakeHistory(channelName string, s store.PendingMessageStore, tenantID uuid.UUID) *PendingHistory { if s != nil { - return NewPersistentHistory(channelName, s) + return NewPersistentHistory(channelName, s, tenantID) } return NewPendingHistory() } +// tenantCtx returns a context with the tenant ID set for DB operations. +func (ph *PendingHistory) tenantCtx() context.Context { + ctx := context.Background() + if ph.tenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, ph.tenantID) + } + return ctx +} + // Record adds a message to the pending history for a group. // If limit ≤ 0, recording is disabled. func (ph *PendingHistory) Record(historyKey string, entry HistoryEntry, limit int) { @@ -155,7 +169,7 @@ func (ph *PendingHistory) Record(historyKey string, entry HistoryEntry, limit in // populates RAM cache, and returns converted entries. // Called when RAM has no entries but DB store is available. func (ph *PendingHistory) loadFromDB(historyKey string) []HistoryEntry { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + ctx, cancel := context.WithTimeout(ph.tenantCtx(), 10*time.Second) defer cancel() msgs, err := ph.store.ListByKey(ctx, ph.channelName, historyKey) @@ -270,7 +284,7 @@ func (ph *PendingHistory) Clear(historyKey string) { // Remove pending flushes for this key ph.removeFromFlushBuf(historyKey) // Delete from DB - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(ph.tenantCtx(), 10*time.Second) defer cancel() if err := ph.store.DeleteByKey(ctx, ph.channelName, historyKey); err != nil { slog.Warn("pending_history.clear_db_failed", "channel", ph.channelName, "key", historyKey, "error", err) diff --git a/internal/channels/history_compaction.go b/internal/channels/history_compaction.go index 62349794..5306d499 100644 --- a/internal/channels/history_compaction.go +++ b/internal/channels/history_compaction.go @@ -37,7 +37,7 @@ func (ph *PendingHistory) MaybeCompact(historyKey string, currentCount int, cfg // RAM count may be stale after restart (LoadFromDB doesn't warm full history). // If RAM says below threshold, ask DB for the real count. if currentCount <= threshold { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(ph.tenantCtx(), 10*time.Second) defer cancel() dbCount, err := ph.store.CountByKey(ctx, ph.channelName, historyKey) if err != nil || dbCount <= threshold { @@ -137,7 +137,7 @@ func (ph *PendingHistory) runCompaction(historyKey string, cfg *CompactionConfig // Force-flush buffer to ensure DB is consistent ph.flushNow() - ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second) + ctx, cancel := context.WithTimeout(ph.tenantCtx(), 180*time.Second) defer cancel() // Check threshold from DB (may have been cleared since trigger) diff --git a/internal/channels/history_flush.go b/internal/channels/history_flush.go index bd5501a9..b19a5cf5 100644 --- a/internal/channels/history_flush.go +++ b/internal/channels/history_flush.go @@ -93,7 +93,7 @@ func (ph *PendingHistory) flushLoop() { // writeBatch writes a batch of messages to the DB store. func (ph *PendingHistory) writeBatch(batch []store.PendingMessage) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(ph.tenantCtx(), 15*time.Second) defer cancel() if err := ph.store.AppendBatch(ctx, batch); err != nil { diff --git a/internal/channels/instance_loader.go b/internal/channels/instance_loader.go index 79f38109..07a00d2c 100644 --- a/internal/channels/instance_loader.go +++ b/internal/channels/instance_loader.go @@ -9,6 +9,8 @@ import ( "sync" "time" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/providers" @@ -23,7 +25,7 @@ type ChannelFactory func(name string, creds json.RawMessage, cfg json.RawMessage msgBus *bus.MessageBus, pairingSvc store.PairingStore) (Channel, error) // InstanceLoader loads channel instances from the database and registers them with the Manager. -// Follows the DynamicToolLoader pattern: LoadAll at startup, Reload on cache invalidation. +// Follows a load-all-at-startup pattern with cache invalidation for reload. type InstanceLoader struct { store store.ChannelInstanceStore agentStore store.AgentStore @@ -234,6 +236,10 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns if base, ok := ch.(interface{ SetType(string) }); ok { base.SetType(inst.ChannelType) } + // Propagate tenant_id from DB instance to channel for tenant-scoped message handling. + if base, ok := ch.(interface{ SetTenantID(uuid.UUID) }); ok { + base.SetTenantID(inst.TenantID) + } // Wire pending message auto-compaction. // Priority: config provider/model > agent's provider/model > fallback. @@ -242,8 +248,9 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns var model string // Try config-level provider/model first. + tctx := store.WithTenantID(ctx, inst.TenantID) if l.pendingCompactCfg != nil && l.pendingCompactCfg.Provider != "" { - if cp, err := l.providerReg.Get(l.pendingCompactCfg.Provider); err == nil { + if cp, err := l.providerReg.Get(tctx, l.pendingCompactCfg.Provider); err == nil { p = cp model = l.pendingCompactCfg.Model if model == "" { @@ -253,7 +260,7 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns } // Fallback: agent's provider/model. if p == nil && ag != nil && ag.Provider != "" { - if ap, err := l.providerReg.Get(ag.Provider); err == nil { + if ap, err := l.providerReg.Get(tctx, ag.Provider); err == nil { p = ap model = ag.Model if model == "" { diff --git a/internal/channels/quota.go b/internal/channels/quota.go index 8a9bcef6..6c9a9684 100644 --- a/internal/channels/quota.go +++ b/internal/channels/quota.go @@ -3,11 +3,14 @@ package channels import ( "context" "database/sql" + "fmt" "log/slog" "sync" "time" + "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // QuotaResult is returned by QuotaChecker.Check. @@ -222,20 +225,23 @@ func (qc *QuotaChecker) Usage(ctx context.Context) QuotaUsageResult { dayAgo := now.Add(-24 * time.Hour) weekAgo := now.Add(-7 * 24 * time.Hour) - // Query per-user counts for the past week - rows, err := qc.db.QueryContext(ctx, ` + // Query per-user counts for the past week (tenant-scoped) + tenantFilter, tenantArgs, nextIdx := tenantWhereClause(ctx, 4) + args := append([]any{hourAgo, dayAgo, weekAgo}, tenantArgs...) + rows, err := qc.db.QueryContext(ctx, fmt.Sprintf(` SELECT user_id, COUNT(*) FILTER (WHERE created_at >= $1) AS hour_count, COUNT(*) FILTER (WHERE created_at >= $2) AS day_count, COUNT(*) AS week_count FROM traces - WHERE parent_trace_id IS NULL AND created_at >= $3 + WHERE parent_trace_id IS NULL AND created_at >= $3%s GROUP BY user_id ORDER BY week_count DESC - LIMIT 50`, - hourAgo, dayAgo, weekAgo, + LIMIT 50`, tenantFilter), + args..., ) + _ = nextIdx if err != nil { slog.Warn("quota.usage: failed to query user counts", "error", err) QueryTodaySummary(ctx, qc.db, &result) @@ -272,7 +278,9 @@ func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult now := time.Now().UTC() startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) - err := db.QueryRowContext(ctx, ` + tenantFilter, tenantArgs, _ := tenantWhereClause(ctx, 2) + args := append([]any{startOfDay}, tenantArgs...) + err := db.QueryRowContext(ctx, fmt.Sprintf(` SELECT COUNT(*), COALESCE(SUM(total_input_tokens), 0), @@ -280,14 +288,28 @@ func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult COALESCE(SUM(total_cost), 0), COUNT(DISTINCT user_id) FROM traces - WHERE parent_trace_id IS NULL AND created_at >= $1`, - startOfDay, + WHERE parent_trace_id IS NULL AND created_at >= $1%s`, tenantFilter), + args..., ).Scan(&result.RequestsToday, &result.InputTokensToday, &result.OutputTokensToday, &result.CostToday, &result.UniqueUsersToday) if err != nil { slog.Warn("quota.usage: failed to query today summary", "error", err) } } +// tenantWhereClause returns a SQL fragment " AND tenant_id = $N" with the tenant UUID arg, +// or empty string if the caller has cross-tenant access. startIdx is the next $N placeholder. +func tenantWhereClause(ctx context.Context, startIdx int) (string, []any, int) { + if store.IsCrossTenant(ctx) { + return "", nil, startIdx + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + // Fail-closed: no tenant = filter to impossible value + return " AND tenant_id = '00000000-0000-0000-0000-000000000000'", nil, startIdx + } + return fmt.Sprintf(" AND tenant_id = $%d", startIdx), []any{tid}, startIdx + 1 +} + // cleanupLoop periodically evicts stale cache entries. func (qc *QuotaChecker) cleanupLoop() { ticker := time.NewTicker(2 * time.Minute) diff --git a/internal/channels/slack/channel.go b/internal/channels/slack/channel.go index 8e965847..4c6954f3 100644 --- a/internal/channels/slack/channel.go +++ b/internal/channels/slack/channel.go @@ -122,7 +122,7 @@ func New(cfg config.SlackConfig, msgBus *bus.MessageBus, pairingSvc store.Pairin config: cfg, requireMention: requireMention, pairingService: pairingSvc, - groupHistory: channels.MakeHistory(channels.TypeSlack, pendingStore), + groupHistory: channels.MakeHistory(channels.TypeSlack, pendingStore, base.TenantID()), historyLimit: historyLimit, debounceDelay: debounceDelay, threadTTL: threadTTL, diff --git a/internal/channels/slack/handlers.go b/internal/channels/slack/handlers.go index 92cbc5a6..fa165abb 100644 --- a/internal/channels/slack/handlers.go +++ b/internal/channels/slack/handlers.go @@ -12,6 +12,7 @@ import ( "github.com/slack-go/slack/socketmode" "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/store" ) func (c *Channel) handleEventsAPI(evt socketmode.Event) { @@ -32,6 +33,8 @@ func (c *Channel) handleEventsAPI(evt socketmode.Event) { } func (c *Channel) handleMessage(ev *slackevents.MessageEvent) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) // For message_changed: extract user/text from the nested Message field. // Only process if the edit introduces a new @bot mention. if ev.SubType == "message_changed" { @@ -89,11 +92,11 @@ func (c *Channel) handleMessage(ev *slackevents.MessageEvent) { // Policy check if isDM { - if !c.checkDMPolicy(senderID, channelID) { + if !c.checkDMPolicy(ctx, senderID, channelID) { return } } else { - if !c.checkGroupPolicy(senderID, channelID) { + if !c.checkGroupPolicy(ctx, senderID, channelID) { return } } @@ -196,7 +199,7 @@ func (c *Channel) handleMessage(ev *slackevents.MessageEvent) { // Collect contact even when bot is not mentioned (cache prevents DB spam). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, displayName, "", "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, displayName, "", "group") } slog.Debug("slack group message recorded (no mention)", diff --git a/internal/channels/slack/handlers_mention.go b/internal/channels/slack/handlers_mention.go index 2a0ba6f7..62b6ecd3 100644 --- a/internal/channels/slack/handlers_mention.go +++ b/internal/channels/slack/handlers_mention.go @@ -1,6 +1,7 @@ package slack import ( + "context" "fmt" "log/slog" "strings" @@ -10,9 +11,12 @@ import ( "github.com/slack-go/slack/slackevents" "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/store" ) func (c *Channel) handleAppMention(ev *slackevents.AppMentionEvent) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) if ev.User == c.botUserID || ev.User == "" { return } @@ -35,7 +39,7 @@ func (c *Channel) handleAppMention(ev *slackevents.AppMentionEvent) { displayName := strings.ReplaceAll(c.resolveDisplayName(senderID), "|", "_") compoundSenderID := fmt.Sprintf("%s|%s", senderID, displayName) - if !c.checkGroupPolicy(senderID, channelID) { + if !c.checkGroupPolicy(ctx, senderID, channelID) { return } @@ -115,7 +119,7 @@ func (c *Channel) stripBotMention(text string) string { // --- Policy checks --- -func (c *Channel) checkDMPolicy(senderID, channelID string) bool { +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, channelID string) bool { dmPolicy := c.config.DMPolicy if dmPolicy == "" { dmPolicy = "pairing" @@ -130,7 +134,7 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool { return c.HasAllowList() && c.IsAllowed(senderID) default: // "pairing" if c.pairingService != nil { - paired, err := c.pairingService.IsPaired(senderID, c.Name()) + paired, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "sender_id", senderID, "channel", c.Name(), "error", err) @@ -143,12 +147,12 @@ func (c *Channel) checkDMPolicy(senderID, channelID string) bool { if c.HasAllowList() && c.IsAllowed(senderID) { return true } - c.sendPairingReply(senderID, channelID) + c.sendPairingReply(ctx, senderID, channelID) return false } } -func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, channelID string) bool { groupPolicy := c.config.GroupPolicy if groupPolicy == "" { groupPolicy = "open" @@ -172,7 +176,7 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { } groupSenderID := fmt.Sprintf("group:%s", channelID) if c.pairingService != nil { - paired, err := c.pairingService.IsPaired(groupSenderID, c.Name()) + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", err) @@ -183,14 +187,14 @@ func (c *Channel) checkGroupPolicy(senderID, channelID string) bool { return true } } - c.sendPairingReply(groupSenderID, channelID) + c.sendPairingReply(ctx, groupSenderID, channelID) return false default: // "open" return true } } -func (c *Channel) sendPairingReply(senderID, channelID string) { +func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID string) { if c.pairingService == nil { return } @@ -201,7 +205,7 @@ func (c *Channel) sendPairingReply(senderID, channelID string) { } } - code, err := c.pairingService.RequestPairing(senderID, c.Name(), channelID, "default", nil) + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), channelID, "default", nil) if err != nil { slog.Warn("slack: failed to request pairing code", "error", err) return diff --git a/internal/channels/slack/utils.go b/internal/channels/slack/utils.go index 307095c0..da639a61 100644 --- a/internal/channels/slack/utils.go +++ b/internal/channels/slack/utils.go @@ -9,6 +9,7 @@ import ( "time" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // HandleMessage overrides BaseChannel to allow messages when the chatID (Slack channel) @@ -32,7 +33,8 @@ func (c *Channel) HandleMessage(senderID, chatID, content string, mediaPaths []s // Collect contact for processed messages (DM + group-mentioned). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), userID, userID, metadata["username"], "", peerKind) + ctx := store.WithTenantID(context.Background(), c.TenantID()) + cc.EnsureContact(ctx, c.Type(), c.Name(), userID, userID, metadata["username"], "", peerKind) } c.Bus().PublishInbound(bus.InboundMessage{ diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index ea6b61ad..4c328f90 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -122,7 +122,7 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai agentStore: agentStore, configPermStore: configPermStore, teamStore: teamStore, - groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore), + groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore, base.TenantID()), historyLimit: historyLimit, requireMention: requireMention, }, nil diff --git a/internal/channels/telegram/commands_pairing.go b/internal/channels/telegram/commands_pairing.go index 9ff8446a..9a003495 100644 --- a/internal/channels/telegram/commands_pairing.go +++ b/internal/channels/telegram/commands_pairing.go @@ -36,7 +36,7 @@ func (c *Channel) sendPairingReply(ctx context.Context, chatID int64, userID, us } meta := map[string]string{"username": username} - code, err := c.pairingService.RequestPairing(userID, c.Name(), fmt.Sprintf("%d", chatID), "default", meta) + code, err := c.pairingService.RequestPairing(ctx, userID, c.Name(), fmt.Sprintf("%d", chatID), "default", meta) if err != nil { slog.Debug("pairing request failed", "user_id", userID, "error", err) return @@ -70,7 +70,7 @@ func (c *Channel) sendGroupPairingReply(ctx context.Context, chatID int64, chatI if chatTitle != "" { meta = map[string]string{"chat_title": chatTitle} } - code, err := c.pairingService.RequestPairing(groupSenderID, c.Name(), localKey, "default", meta) + code, err := c.pairingService.RequestPairing(ctx, groupSenderID, c.Name(), localKey, "default", meta) if err != nil { slog.Debug("group pairing request failed", "chat_id", chatIDStr, "error", err) return diff --git a/internal/channels/telegram/handlers.go b/internal/channels/telegram/handlers.go index 1d9f06d7..4e796635 100644 --- a/internal/channels/telegram/handlers.go +++ b/internal/channels/telegram/handlers.go @@ -148,8 +148,8 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { default: // "pairing" or unknown → secure default paired := false if c.pairingService != nil { - p1, err1 := c.pairingService.IsPaired(userID, c.Name()) - p2, err2 := c.pairingService.IsPaired(senderID, c.Name()) + p1, err1 := c.pairingService.IsPaired(ctx, userID, c.Name()) + p2, err2 := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err1 != nil || err2 != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "user_id", userID, "channel", c.Name(), "err1", err1, "err2", err2) @@ -256,7 +256,7 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { if topicCfg.groupPolicy == "pairing" && c.pairingService != nil { if _, cached := c.approvedGroups.Load(chatIDStr); !cached { groupSenderID := fmt.Sprintf("group:%d", chatID) - paired, pairErr := c.pairingService.IsPaired(groupSenderID, c.Name()) + paired, pairErr := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if pairErr != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", pairErr) @@ -295,7 +295,7 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) { if isGroup && topicCfg.groupPolicy == "pairing" && c.pairingService != nil { if _, cached := c.approvedGroups.Load(chatIDStr); !cached { groupSenderID := fmt.Sprintf("group:%d", chatID) - paired, err := c.pairingService.IsPaired(groupSenderID, c.Name()) + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", err) diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 66e10e10..582bb5d2 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -204,6 +204,8 @@ func (c *Channel) listenLoop() { // handleIncomingMessage processes a message received from the bridge. // Expected format: {"type":"message","from":"...","chat":"...","content":"...","id":"...","from_name":"...","media":[...]} func (c *Channel) handleIncomingMessage(msg map[string]any) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) senderID, ok := msg["from"].(string) if !ok || senderID == "" { return @@ -222,11 +224,11 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { // DM/Group policy check if peerKind == "direct" { - if !c.checkDMPolicy(senderID, chatID) { + if !c.checkDMPolicy(ctx, senderID, chatID) { return } } else { - if !c.checkGroupPolicy(senderID, chatID) { + if !c.checkGroupPolicy(ctx, senderID, chatID) { slog.Debug("whatsapp group message rejected by policy", "sender_id", senderID) return } @@ -269,14 +271,14 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { // Collect contact for processed messages. if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind) + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind) } c.HandleMessage(senderID, chatID, content, media, metadata, peerKind) } // checkGroupPolicy evaluates the group policy for a sender, with pairing support. -func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool { groupPolicy := c.config.GroupPolicy if groupPolicy == "" { groupPolicy = "open" @@ -296,7 +298,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { } groupSenderID := fmt.Sprintf("group:%s", chatID) if c.pairingService != nil { - paired, err := c.pairingService.IsPaired(groupSenderID, c.Name()) + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", err) @@ -307,7 +309,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { return true } } - c.sendPairingReply(groupSenderID, chatID) + c.sendPairingReply(ctx, groupSenderID, chatID) return false default: // "open" return true @@ -315,7 +317,7 @@ func (c *Channel) checkGroupPolicy(senderID, chatID string) bool { } // checkDMPolicy evaluates the DM policy for a sender, handling pairing flow. -func (c *Channel) checkDMPolicy(senderID, chatID string) bool { +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { dmPolicy := c.config.DMPolicy if dmPolicy == "" { dmPolicy = "pairing" @@ -336,7 +338,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { default: // "pairing" paired := false if c.pairingService != nil { - p, err := c.pairingService.IsPaired(senderID, c.Name()) + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "sender_id", senderID, "channel", c.Name(), "error", err) @@ -351,13 +353,13 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { return true } - c.sendPairingReply(senderID, chatID) + c.sendPairingReply(ctx, senderID, chatID) return false } } // sendPairingReply sends a pairing code to the user via the WS bridge. -func (c *Channel) sendPairingReply(senderID, chatID string) { +func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { if c.pairingService == nil { return } @@ -369,7 +371,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) { } } - code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil) + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) if err != nil { slog.Debug("whatsapp pairing request failed", "sender_id", senderID, "error", err) return diff --git a/internal/channels/zalo/personal/channel.go b/internal/channels/zalo/personal/channel.go index 98d39d9b..07dd74ed 100644 --- a/internal/channels/zalo/personal/channel.go +++ b/internal/channels/zalo/personal/channel.go @@ -64,7 +64,7 @@ func New(cfg config.ZaloPersonalConfig, msgBus *bus.MessageBus, pairingSvc store BaseChannel: base, config: cfg, pairingService: pairingSvc, - groupHistory: channels.MakeHistory(channels.TypeZaloPersonal, pendingStore), + groupHistory: channels.MakeHistory(channels.TypeZaloPersonal, pendingStore, base.TenantID()), historyLimit: historyLimit, requireMention: requireMention, stopCh: make(chan struct{}), diff --git a/internal/channels/zalo/personal/handlers.go b/internal/channels/zalo/personal/handlers.go index 5ecb1614..4cefe8ac 100644 --- a/internal/channels/zalo/personal/handlers.go +++ b/internal/channels/zalo/personal/handlers.go @@ -15,6 +15,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/channels/typing" "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" ) @@ -32,6 +33,8 @@ func (c *Channel) handleMessage(msg protocol.Message) { } func (c *Channel) handleDM(msg protocol.UserMessage) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) senderID := msg.Data.UIDFrom threadID := msg.ThreadID() @@ -40,7 +43,7 @@ func (c *Channel) handleDM(msg protocol.UserMessage) { return } - if !c.checkDMPolicy(senderID, threadID) { + if !c.checkDMPolicy(ctx, senderID, threadID) { return } @@ -61,7 +64,7 @@ func (c *Channel) handleDM(msg protocol.UserMessage) { // Collect contact for DM messages. if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, "", "direct") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "direct") } metadata := map[string]string{ @@ -72,6 +75,8 @@ func (c *Channel) handleDM(msg protocol.UserMessage) { } func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) senderID := msg.Data.UIDFrom threadID := msg.ThreadID() @@ -81,7 +86,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) { } // Step 1: enforce access policy (allowlist/pairing). Hard reject — don't record history. - if !c.checkGroupPolicy(senderID, threadID) { + if !c.checkGroupPolicy(ctx, senderID, threadID) { return } @@ -105,7 +110,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) { // Collect contact even when bot is not mentioned (cache prevents DB spam). if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, "", "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "group") } slog.Debug("zalo_personal group message recorded (no mention)", @@ -138,7 +143,7 @@ func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) { // Collect contact for group-mentioned messages. if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(context.Background(), c.Type(), c.Name(), senderID, senderID, senderName, "", "group") + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, "", "group") } metadata := map[string]string{ diff --git a/internal/channels/zalo/personal/policy.go b/internal/channels/zalo/personal/policy.go index 6dcf31db..04d4c5fb 100644 --- a/internal/channels/zalo/personal/policy.go +++ b/internal/channels/zalo/personal/policy.go @@ -13,7 +13,7 @@ import ( const pairingDebounce = 60 * time.Second // checkDMPolicy enforces DM policy for incoming messages. -func (c *Channel) checkDMPolicy(senderID, chatID string) bool { +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { dmPolicy := c.config.DMPolicy if dmPolicy == "" { dmPolicy = "allowlist" @@ -37,7 +37,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { default: // "pairing" paired := false if c.pairingService != nil { - p, err := c.pairingService.IsPaired(senderID, c.Name()) + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "sender_id", senderID, "channel", c.Name(), "error", err) @@ -52,12 +52,12 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { return true } - c.sendPairingReply(senderID, chatID) + c.sendPairingReply(ctx, senderID, chatID) return false } } -func (c *Channel) sendPairingReply(senderID, chatID string) { +func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { sess := c.session() if c.pairingService == nil || sess == nil { return @@ -70,7 +70,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) { } } - code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil) + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) if err != nil { slog.Debug("zalo_personal pairing request failed", "sender_id", senderID, "error", err) return @@ -98,7 +98,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) { // checkGroupPolicy enforces group access policy (allowlist/pairing). // Returns false if the group is blocked by policy; does NOT check @mention gating. -func (c *Channel) checkGroupPolicy(senderID, groupID string) bool { +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, groupID string) bool { groupPolicy := c.config.GroupPolicy if groupPolicy == "" { groupPolicy = "allowlist" @@ -124,7 +124,7 @@ func (c *Channel) checkGroupPolicy(senderID, groupID string) bool { groupSenderID := fmt.Sprintf("group:%s", groupID) paired := false if c.pairingService != nil { - p, err := c.pairingService.IsPaired(groupSenderID, c.Name()) + p, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "group_sender", groupSenderID, "channel", c.Name(), "error", err) @@ -135,7 +135,7 @@ func (c *Channel) checkGroupPolicy(senderID, groupID string) bool { if paired { c.approvedGroups.Store(groupID, true) } else { - c.sendPairingReply(groupSenderID, groupID) + c.sendPairingReply(ctx, groupSenderID, groupID) return false } } diff --git a/internal/channels/zalo/personal/zalomethods/qr.go b/internal/channels/zalo/personal/zalomethods/qr.go index 27a66f54..9fe5266c 100644 --- a/internal/channels/zalo/personal/zalomethods/qr.go +++ b/internal/channels/zalo/personal/zalomethods/qr.go @@ -110,7 +110,7 @@ func (m *QRMethods) runQRFlow(ctx context.Context, client *gateway.Client, insta return } - if err := m.instanceStore.Update(context.Background(), instanceID, map[string]any{ + if err := m.instanceStore.Update(ctx, instanceID, map[string]any{ "credentials": string(credsJSON), }); err != nil { slog.Error("Zalo Personal QR: save credentials failed", "instance", instanceIDStr, "error", err) diff --git a/internal/channels/zalo/zalo.go b/internal/channels/zalo/zalo.go index 88279a4b..2357f117 100644 --- a/internal/channels/zalo/zalo.go +++ b/internal/channels/zalo/zalo.go @@ -186,6 +186,8 @@ func (c *Channel) processUpdate(update zaloUpdate) { } func (c *Channel) handleTextMessage(msg *zaloMessage) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) senderID := msg.From.ID if senderID == "" { slog.Warn("zalo: dropping text message with empty sender ID", "message_id", msg.MessageID) @@ -197,7 +199,7 @@ func (c *Channel) handleTextMessage(msg *zaloMessage) { } // DM policy enforcement (Zalo is DM-only) - if !c.checkDMPolicy(senderID, chatID) { + if !c.checkDMPolicy(ctx, senderID, chatID) { return } @@ -221,6 +223,8 @@ func (c *Channel) handleTextMessage(msg *zaloMessage) { } func (c *Channel) handleImageMessage(msg *zaloMessage) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) senderID := msg.From.ID if senderID == "" { slog.Warn("zalo: dropping image message with empty sender ID", "message_id", msg.MessageID) @@ -231,7 +235,7 @@ func (c *Channel) handleImageMessage(msg *zaloMessage) { chatID = senderID } - if !c.checkDMPolicy(senderID, chatID) { + if !c.checkDMPolicy(ctx, senderID, chatID) { return } @@ -278,7 +282,7 @@ func (c *Channel) handleImageMessage(msg *zaloMessage) { // --- DM Policy --- -func (c *Channel) checkDMPolicy(senderID, chatID string) bool { +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { switch c.dmPolicy { case "disabled": slog.Debug("zalo message rejected: DMs disabled", "sender_id", senderID) @@ -298,7 +302,7 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { // Check if already paired or in allowlist paired := false if c.pairingService != nil { - p, err := c.pairingService.IsPaired(senderID, c.Name()) + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) if err != nil { slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", "sender_id", senderID, "channel", c.Name(), "error", err) @@ -314,12 +318,12 @@ func (c *Channel) checkDMPolicy(senderID, chatID string) bool { } // Send pairing reply (debounced) - c.sendPairingReply(senderID, chatID) + c.sendPairingReply(ctx, senderID, chatID) return false } } -func (c *Channel) sendPairingReply(senderID, chatID string) { +func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { if c.pairingService == nil { return } @@ -331,7 +335,7 @@ func (c *Channel) sendPairingReply(senderID, chatID string) { } } - code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default", nil) + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) if err != nil { slog.Debug("zalo pairing request failed", "sender_id", senderID, "error", err) return diff --git a/internal/config/tenant_paths.go b/internal/config/tenant_paths.go new file mode 100644 index 00000000..495887ac --- /dev/null +++ b/internal/config/tenant_paths.go @@ -0,0 +1,58 @@ +package config + +import ( + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +// masterTenantID mirrors store.MasterTenantID. +// Duplicated here to avoid an import cycle (store imports config). +var masterTenantID = uuid.MustParse("0193a5b0-7000-7000-8000-000000000001") + +// TenantDataDir returns the data directory root for a tenant. +// Master tenant returns dataDir unchanged (backward compat). +// Other tenants return dataDir/tenants/{slug}/. +func TenantDataDir(dataDir string, tenantID uuid.UUID, tenantSlug string) string { + if tenantID == masterTenantID { + return dataDir + } + result := filepath.Join(dataDir, "tenants", tenantSlug) + // Defense-in-depth: prevent path traversal via malicious slug. + tenantsBase := filepath.Join(dataDir, "tenants") + string(filepath.Separator) + if !strings.HasPrefix(result+string(filepath.Separator), tenantsBase) { + return filepath.Join(dataDir, "tenants", tenantID.String()) + } + return result +} + +// TenantWorkspace returns the workspace root for a tenant. +// Master tenant returns workspace unchanged (backward compat). +// Other tenants return workspace/tenants/{slug}/. +func TenantWorkspace(workspace string, tenantID uuid.UUID, tenantSlug string) string { + if tenantID == masterTenantID { + return workspace + } + result := filepath.Join(workspace, "tenants", tenantSlug) + tenantsBase := filepath.Join(workspace, "tenants") + string(filepath.Separator) + if !strings.HasPrefix(result+string(filepath.Separator), tenantsBase) { + return filepath.Join(workspace, "tenants", tenantID.String()) + } + return result +} + +// TenantTeamDir returns the team workspace directory for a tenant. +func TenantTeamDir(dataDir string, tenantID uuid.UUID, tenantSlug string, teamID uuid.UUID) string { + return filepath.Join(TenantDataDir(dataDir, tenantID, tenantSlug), "teams", teamID.String()) +} + +// TenantSkillsStoreDir returns the managed skills directory for a tenant. +func TenantSkillsStoreDir(dataDir string, tenantID uuid.UUID, tenantSlug string) string { + return filepath.Join(TenantDataDir(dataDir, tenantID, tenantSlug), "skills-store") +} + +// TenantMediaDir returns the media storage directory for a tenant. +func TenantMediaDir(dataDir string, tenantID uuid.UUID, tenantSlug string) string { + return filepath.Join(TenantDataDir(dataDir, tenantID, tenantSlug), "media") +} diff --git a/internal/gateway/client.go b/internal/gateway/client.go index b76d006d..837e338f 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -34,6 +34,14 @@ type Client struct { pairingPending bool // true while waiting for admin approval pairedSenderID string // senderID used for browser pairing auth (for revocation lookup) pairedChannel string // channel used for pairing auth (e.g., "browser") + + // Team access cache for event filtering (lazily populated). + teamIDs map[string]bool + + tenantID uuid.UUID // resolved tenant (uuid.Nil = cross-tenant) + crossTenant bool // true for owner/system admin + tenantName string // resolved tenant display name (set during connect) + tenantSlug string // resolved tenant URL slug (set during connect) } func NewClient(conn *websocket.Conn, server *Server, remoteIP string) *Client { @@ -192,6 +200,43 @@ func (c *Client) ConnectedAt() time.Time { return c.connectedAt } // RemoteAddr returns the peer IP:port. func (c *Client) RemoteAddr() string { return c.remoteAddr } +// TenantID returns the resolved tenant UUID (uuid.Nil means cross-tenant). +func (c *Client) TenantID() uuid.UUID { return c.tenantID } + +// IsCrossTenant returns true if the client has cross-tenant (owner/system admin) access. +func (c *Client) IsCrossTenant() bool { return c.crossTenant } + +// HasScope reports whether the client has the given scope. +func (c *Client) HasScope(scope permissions.Scope) bool { + for _, s := range c.scopes { + if s == scope { + return true + } + } + return false +} + +// hasTeamAccess checks if the client has access to a team (for event filtering). +// Returns true for admin role. For others, checks the lazily-populated teamIDs cache. +// TODO: populate teamIDs from team_user_grants on connect or first team event. +func (c *Client) hasTeamAccess(teamID string) bool { + if permissions.HasMinRole(c.role, permissions.RoleAdmin) { + return true + } + if c.teamIDs == nil { + return false + } + return c.teamIDs[teamID] +} + +// SetTeamAccess sets the team access cache for this client. +func (c *Client) SetTeamAccess(teamIDs []string) { + c.teamIDs = make(map[string]bool, len(teamIDs)) + for _, id := range teamIDs { + c.teamIDs[id] = true + } +} + // Close shuts down the client connection. func (c *Client) Close() { close(c.send) diff --git a/internal/gateway/event_filter.go b/internal/gateway/event_filter.go new file mode 100644 index 00000000..53d6c634 --- /dev/null +++ b/internal/gateway/event_filter.go @@ -0,0 +1,208 @@ +package gateway + +import ( + "encoding/json" + "strings" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/agent" + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// clientCanReceiveEvent checks whether a WS client should receive a given bus event. +// Admin clients receive all events. Non-admin clients are filtered by user/team scope. +func clientCanReceiveEvent(c *Client, event bus.Event) bool { + // Internal events are never forwarded. + if strings.HasPrefix(event.Name, "cache.") || event.Name == protocol.EventAuditLog { + return false + } + + // System-wide events go to everyone. + if isSystemEvent(event.Name) { + return true + } + + // Tenant isolation: fail-closed, 3-mode filtering. + // + // Mode 1: Unscoped admin (crossTenant=true, tenantID=Nil) → see everything + // Mode 2: Scoped admin (crossTenant=true, tenantID=X) → tenant X events + unscoped system events + // Mode 3: Regular user (crossTenant=false, tenantID=X) → ONLY tenant X events (fail-closed) + if !c.crossTenant && c.tenantID == uuid.Nil { + return false // fail-closed: no tenant assigned to non-admin client + } else if c.crossTenant && c.tenantID == uuid.Nil { + // Mode 1: unscoped cross-tenant admin → no tenant filtering + } else if c.tenantID != uuid.Nil { + // Event has explicit tenant → must match client's tenant + if event.TenantID != uuid.Nil && event.TenantID != c.tenantID { + return false + } + // Event has no tenant → only cross-tenant admin (scoped) can see unscoped events + if event.TenantID == uuid.Nil && !c.crossTenant { + return false // fail-closed: regular users blocked from unscoped events + } + } + + // Admin sees everything (when not tenant-scoped, handled above). + if permissions.HasMinRole(c.role, permissions.RoleAdmin) { + return true + } + + // Agent / chat events: filter by UserID. + if event.Name == protocol.EventAgent || event.Name == protocol.EventChat { + if uid := extractEventUserID(event); uid != "" { + return uid == c.userID + } + return true // no routing context → broadcast (legacy) + } + + // Session events: filter by UserID in payload. + if event.Name == protocol.EventSessionUpdated { + if uid := extractMapField(event.Payload, "userId"); uid != "" { + return uid == c.userID + } + return true + } + + // Cron events: filter by UserID in payload. + if event.Name == protocol.EventCron { + if ce, ok := event.Payload.(store.CronEvent); ok && ce.UserID != "" { + return ce.UserID == c.userID + } + if uid := extractMapField(event.Payload, "userId"); uid != "" { + return uid == c.userID + } + return true + } + + // Trace events: filter by UserID. + if event.Name == protocol.EventTraceUpdated { + if uid := extractMapField(event.Payload, "userId"); uid != "" { + return uid == c.userID + } + return true + } + + // Team events: filter by TeamID. + if strings.HasPrefix(event.Name, "team.") || strings.HasPrefix(event.Name, "delegation.") { + if tid := extractTeamID(event); tid != "" { + return c.hasTeamAccess(tid) + } + return true // no team context → broadcast + } + + // Tenant access revocation: deliver to the affected user only. + if event.Name == protocol.EventTenantAccessRevoked { + if uid := extractMapField(event.Payload, "user_id"); uid != "" { + return uid == c.userID + } + return false + } + + // Admin-only events: pairing, node, agent links. + if isAdminOnlyEvent(event.Name) { + return false // non-admin clients don't receive these + } + + // Exec approval events: scoped to the requesting user. + if strings.HasPrefix(event.Name, "exec.approval.") { + if uid := extractMapField(event.Payload, "userId"); uid != "" { + return uid == c.userID + } + return true + } + + // Zalo personal QR events: admin-only (channel management). + if strings.HasPrefix(event.Name, "zalo.personal.") { + return false + } + + // Skill dep events → broadcast (non-sensitive, skill names only). + if strings.HasPrefix(event.Name, "skill.") { + return true + } + + // Default: deny unknown events to non-admin (fail-closed). + return false +} + +// isSystemEvent returns true for events that should be broadcast to all clients. +func isSystemEvent(name string) bool { + switch name { + case protocol.EventHealth, protocol.EventPresence, protocol.EventVoicewakeChanged, + protocol.EventTick, protocol.EventShutdown, protocol.EventConnectChallenge, + protocol.EventTalkMode, protocol.EventHeartbeat: + return true + } + return false +} + +// isAdminOnlyEvent returns true for events that only admin clients should receive. +func isAdminOnlyEvent(name string) bool { + switch name { + case protocol.EventNodePairRequested, protocol.EventNodePairResolved, + protocol.EventDevicePairReq, protocol.EventDevicePairRes, + protocol.EventAgentLinkCreated, protocol.EventAgentLinkUpdated, protocol.EventAgentLinkDeleted, + protocol.EventWorkspaceFileChanged: + return true + } + return false +} + +// extractEventUserID extracts UserID from agent.AgentEvent payload. +func extractEventUserID(event bus.Event) string { + switch ae := event.Payload.(type) { + case agent.AgentEvent: + return ae.UserID + case *agent.AgentEvent: + return ae.UserID + } + return extractMapField(event.Payload, "userId") +} + +// extractTeamID extracts TeamID from team event payloads. +func extractTeamID(event bus.Event) string { + switch te := event.Payload.(type) { + case protocol.TeamTaskEventPayload: + return te.TeamID + case *protocol.TeamTaskEventPayload: + return te.TeamID + } + // Fallback: check map payloads. + if tid := extractMapField(event.Payload, "team_id"); tid != "" { + return tid + } + return extractMapField(event.Payload, "teamId") +} + +// extractMapField extracts a string field from a map[string]any or map[string]string payload. +// Falls back to JSON unmarshaling for struct payloads. +func extractMapField(payload any, key string) string { + switch m := payload.(type) { + case map[string]any: + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + case map[string]string: + return m[key] + default: + // Try JSON round-trip for struct payloads (lazy, used rarely). + data, err := json.Marshal(payload) + if err != nil { + return "" + } + var parsed map[string]any + if json.Unmarshal(data, &parsed) == nil { + if v, ok := parsed[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + } + } + return "" +} diff --git a/internal/gateway/methods/access.go b/internal/gateway/methods/access.go new file mode 100644 index 00000000..566f1817 --- /dev/null +++ b/internal/gateway/methods/access.go @@ -0,0 +1,18 @@ +package methods + +import ( + "slices" + + "github.com/nextlevelbuilder/goclaw/internal/permissions" +) + +// canSeeAll checks if user has full data visibility (admin role OR owner user). +func canSeeAll(role permissions.Role, ownerIDs []string, userID string) bool { + if permissions.HasMinRole(role, permissions.RoleAdmin) { + return true + } + if userID != "" && slices.Contains(ownerIDs, userID) { + return true + } + return false +} diff --git a/internal/gateway/methods/agent_links.go b/internal/gateway/methods/agent_links.go index b040e33b..290bf5fc 100644 --- a/internal/gateway/methods/agent_links.go +++ b/internal/gateway/methods/agent_links.go @@ -59,7 +59,7 @@ func (m *AgentLinksMethods) handleList(ctx context.Context, client *gateway.Clie return } - agentID, err := resolveAgentUUID(m.agentStore, params.AgentID) + agentID, err := resolveAgentUUID(ctx, m.agentStore, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error())) return @@ -133,12 +133,12 @@ func (m *AgentLinksMethods) handleCreate(ctx context.Context, client *gateway.Cl return } - sourceAgent, err := resolveAgentInfo(m.agentStore, params.SourceAgent) + sourceAgent, err := resolveAgentInfo(ctx, m.agentStore, params.SourceAgent) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "source agent: "+err.Error())) return } - targetAgent, err := resolveAgentInfo(m.agentStore, params.TargetAgent) + targetAgent, err := resolveAgentInfo(ctx, m.agentStore, params.TargetAgent) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "target agent: "+err.Error())) return @@ -408,15 +408,15 @@ func (m *AgentLinksMethods) invalidateLinkAgentsByID(ctx context.Context, source // --- helpers --- -func resolveAgentUUID(agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) { +func resolveAgentUUID(ctx context.Context, agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) { if id, err := uuid.Parse(keyOrID); err == nil { - ag, err := agentStore.GetByID(context.Background(), id) + ag, err := agentStore.GetByID(ctx, id) if err != nil { return uuid.Nil, err } return ag.ID, nil } - ag, err := agentStore.GetByKey(context.Background(), keyOrID) + ag, err := agentStore.GetByKey(ctx, keyOrID) if err != nil { return uuid.Nil, err } @@ -424,9 +424,9 @@ func resolveAgentUUID(agentStore store.AgentStore, keyOrID string) (uuid.UUID, e } // resolveAgentInfo returns full agent data for validation and cache invalidation. -func resolveAgentInfo(agentStore store.AgentStore, keyOrID string) (*store.AgentData, error) { +func resolveAgentInfo(ctx context.Context, agentStore store.AgentStore, keyOrID string) (*store.AgentData, error) { if id, err := uuid.Parse(keyOrID); err == nil { - return agentStore.GetByID(context.Background(), id) + return agentStore.GetByID(ctx, id) } - return agentStore.GetByKey(context.Background(), keyOrID) + return agentStore.GetByKey(ctx, keyOrID) } diff --git a/internal/gateway/methods/agents.go b/internal/gateway/methods/agents.go index dca94bac..5424a6c7 100644 --- a/internal/gateway/methods/agents.go +++ b/internal/gateway/methods/agents.go @@ -4,13 +4,13 @@ import ( "context" "encoding/json" "log/slog" - "slices" "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" "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/internal/tools" "github.com/nextlevelbuilder/goclaw/pkg/protocol" @@ -34,10 +34,7 @@ func NewAgentsMethods(agents *agent.Router, cfg *config.Config, cfgPath, workspa // isOwnerUser checks if the given user ID is in the configured owner IDs. func (m *AgentsMethods) isOwnerUser(userID string) bool { - if userID == "" { - return false - } - return slices.Contains(m.cfg.Gateway.OwnerIDs, userID) + return canSeeAll(permissions.RoleViewer, m.cfg.Gateway.OwnerIDs, userID) } func (m *AgentsMethods) Register(router *gateway.MethodRouter) { @@ -57,7 +54,7 @@ type agentParams struct { AgentID string `json:"agentId"` } -func (m *AgentsMethods) handleAgent(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *AgentsMethods) handleAgent(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params agentParams if req.Params != nil { json.Unmarshal(req.Params, ¶ms) @@ -66,7 +63,7 @@ func (m *AgentsMethods) handleAgent(_ context.Context, client *gateway.Client, r params.AgentID = "default" } - loop, err := m.agents.Get(params.AgentID) + loop, err := m.agents.Get(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return @@ -78,7 +75,7 @@ func (m *AgentsMethods) handleAgent(_ context.Context, client *gateway.Client, r })) } -func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *AgentsMethods) handleAgentWait(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params agentParams if req.Params != nil { json.Unmarshal(req.Params, ¶ms) @@ -87,7 +84,7 @@ func (m *AgentsMethods) handleAgentWait(_ context.Context, client *gateway.Clien params.AgentID = "default" } - loop, err := m.agents.Get(params.AgentID) + loop, err := m.agents.Get(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return diff --git a/internal/gateway/methods/agents_create.go b/internal/gateway/methods/agents_create.go index dd19758f..67815c1d 100644 --- a/internal/gateway/methods/agents_create.go +++ b/internal/gateway/methods/agents_create.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" @@ -28,6 +30,7 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client Avatar string `json:"avatar"` AgentType string `json:"agent_type"` // "open" (default) or "predefined" OwnerIDs []string `json:"owner_ids,omitempty"` // first entry used as DB owner_id; falls back to "system" + TenantID string `json:"tenant_id"` // required for cross-tenant callers; ignored otherwise // Per-agent config overrides ToolsConfig json.RawMessage `json:"tools_config,omitempty"` SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"` @@ -67,7 +70,6 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client if m.agentStore != nil { // --- DB-backed: create agent in store --- - ctx := context.Background() // Check if agent already exists in DB if existing, _ := m.agentStore.GetByKey(ctx, agentID); existing != nil { @@ -82,10 +84,28 @@ func (m *AgentsMethods) handleCreate(ctx context.Context, client *gateway.Client ownerID = params.OwnerIDs[0] } + // Resolve tenant_id: cross-tenant callers must provide it; others inherit their own tenant. + var tenantID uuid.UUID + if client.IsCrossTenant() { + if params.TenantID == "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "tenant_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 + } + tenantID = tid + } else { + tenantID = client.TenantID() + } + agentData := &store.AgentData{ AgentKey: agentID, DisplayName: params.Name, OwnerID: ownerID, + TenantID: tenantID, AgentType: agentType, Provider: m.cfg.Agents.Defaults.Provider, Model: m.cfg.Agents.Defaults.Model, diff --git a/internal/gateway/methods/agents_create_owner_test.go b/internal/gateway/methods/agents_create_owner_test.go index 1928d4d5..6e3768aa 100644 --- a/internal/gateway/methods/agents_create_owner_test.go +++ b/internal/gateway/methods/agents_create_owner_test.go @@ -31,6 +31,12 @@ func (s *createCaptureStore) GetByKey(_ context.Context, _ string) (*store.Agent func (s *createCaptureStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { return nil, nil } +func (s *createCaptureStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) { + return nil, nil +} +func (s *createCaptureStore) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.AgentData, error) { + return nil, nil +} func (s *createCaptureStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil } diff --git a/internal/gateway/methods/agents_delete.go b/internal/gateway/methods/agents_delete.go index 039ed9ef..cd3f6f1b 100644 --- a/internal/gateway/methods/agents_delete.go +++ b/internal/gateway/methods/agents_delete.go @@ -40,7 +40,6 @@ func (m *AgentsMethods) handleDelete(ctx context.Context, client *gateway.Client if m.agentStore != nil { // --- DB-backed: delete from store --- - ctx := context.Background() ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) diff --git a/internal/gateway/methods/agents_files.go b/internal/gateway/methods/agents_files.go index c6058209..d0dd2ac5 100644 --- a/internal/gateway/methods/agents_files.go +++ b/internal/gateway/methods/agents_files.go @@ -38,7 +38,7 @@ func (m *AgentsMethods) handleFilesList(ctx context.Context, client *gateway.Cli if m.agentStore != nil { // --- DB-backed: list from store --- - ctx := context.Background() + ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) @@ -137,7 +137,7 @@ func (m *AgentsMethods) handleFilesGet(ctx context.Context, client *gateway.Clie if m.agentStore != nil { // --- DB-backed: read from store --- - ctx := context.Background() + ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) @@ -236,7 +236,7 @@ func (m *AgentsMethods) handleFilesSet(ctx context.Context, client *gateway.Clie if m.agentStore != nil { // --- DB-backed: write to store --- - ctx := context.Background() + ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) diff --git a/internal/gateway/methods/agents_identity.go b/internal/gateway/methods/agents_identity.go index 8bd361e4..403e6899 100644 --- a/internal/gateway/methods/agents_identity.go +++ b/internal/gateway/methods/agents_identity.go @@ -15,7 +15,7 @@ import ( // --- agent.identity.get --- // Matching TS src/gateway/server-methods/agent.ts:601-643 -func (m *AgentsMethods) handleIdentityGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *AgentsMethods) handleIdentityGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params struct { AgentID string `json:"agentId"` SessionKey string `json:"sessionKey"` @@ -42,7 +42,6 @@ func (m *AgentsMethods) handleIdentityGet(_ context.Context, client *gateway.Cli if m.agentStore != nil { // --- DB-backed: read identity from store --- - ctx := context.Background() ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err == nil { result["name"] = ag.DisplayName diff --git a/internal/gateway/methods/agents_update.go b/internal/gateway/methods/agents_update.go index 2172ba91..9dee9639 100644 --- a/internal/gateway/methods/agents_update.go +++ b/internal/gateway/methods/agents_update.go @@ -46,7 +46,6 @@ func (m *AgentsMethods) handleUpdate(ctx context.Context, client *gateway.Client if m.agentStore != nil { // --- DB-backed: update agent in store --- - ctx := context.Background() ag, err := m.agentStore.GetByKey(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID))) diff --git a/internal/gateway/methods/api_keys.go b/internal/gateway/methods/api_keys.go index cb816788..1ca6ec93 100644 --- a/internal/gateway/methods/api_keys.go +++ b/internal/gateway/methods/api_keys.go @@ -35,7 +35,12 @@ func (m *APIKeysMethods) Register(router *gateway.MethodRouter) { func (m *APIKeysMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { locale := store.LocaleFromContext(ctx) - keys, err := m.apiKeys.List(ctx) + // Non-admin callers only see their own keys. + ownerID := "" + if client.Role() != permissions.RoleAdmin { + ownerID = client.UserID() + } + keys, err := m.apiKeys.List(ctx, ownerID) if err != nil { slog.Error("api_keys.list failed", "error", err) client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "API keys"))) @@ -54,6 +59,8 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien Name string `json:"name"` Scopes []string `json:"scopes"` ExpiresIn *int `json:"expires_in"` // seconds; nil = never + OwnerID string `json:"owner_id"` // optional; non-admin callers always get their own user_id + TenantID string `json:"tenant_id"` // optional UUID; cross-tenant callers may specify or omit (NULL = system key) } if req.Params != nil { if err := json.Unmarshal(req.Params, ¶ms); err != nil { @@ -79,6 +86,12 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien } } + // Non-admin callers always bind the key to their own user_id. + ownerID := params.OwnerID + if client.Role() != permissions.RoleAdmin { + ownerID = client.UserID() + } + raw, hash, prefix, err := crypto.GenerateAPIKey() if err != nil { slog.Error("api_keys.generate failed", "error", err) @@ -86,6 +99,34 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien return } + // Resolve tenant_id based on caller type. + var tenantID uuid.UUID // uuid.Nil = system-level (NULL in DB) + if client.IsCrossTenant() { + if params.TenantID != "" { + 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 + } + tenantID = tid + } + // else: uuid.Nil stays → system-level key + } else if client.HasScope(permissions.ScopeProvision) { + // Provision-scoped callers may create tenant-bound keys only (not system-level). + if params.TenantID != "" { + 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 + } + tenantID = tid + } else { + tenantID = client.TenantID() + } + } else { + tenantID = client.TenantID() + } + now := time.Now() key := &store.APIKeyData{ ID: store.GenNewID(), @@ -93,6 +134,8 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien Prefix: prefix, KeyHash: hash, Scopes: params.Scopes, + OwnerID: ownerID, + TenantID: tenantID, CreatedBy: store.UserIDFromContext(ctx), CreatedAt: now, UpdatedAt: now, @@ -139,7 +182,13 @@ func (m *APIKeysMethods) handleRevoke(ctx context.Context, client *gateway.Clien return } - if err := m.apiKeys.Revoke(ctx, id); err != nil { + // Non-admin callers can only revoke their own keys. + ownerID := "" + if client.Role() != permissions.RoleAdmin { + ownerID = client.UserID() + } + + if err := m.apiKeys.Revoke(ctx, id, ownerID); err != nil { slog.Error("api_keys.revoke failed", "error", err, "id", params.ID) client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "API key", params.ID))) return diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go index d670c6a3..bc981df4 100644 --- a/internal/gateway/methods/chat.go +++ b/internal/gateway/methods/chat.go @@ -10,6 +10,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/bus" + httpapi "github.com/nextlevelbuilder/goclaw/internal/http" "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" @@ -22,11 +23,11 @@ import ( // ChatMethods handles chat.send, chat.history, chat.abort, chat.inject. type ChatMethods struct { - agents *agent.Router - sessions store.SessionStore - rateLimiter *gateway.RateLimiter - eventBus bus.EventPublisher - postTurn tools.PostTurnProcessor + agents *agent.Router + sessions store.SessionStore + rateLimiter *gateway.RateLimiter + eventBus bus.EventPublisher + postTurn tools.PostTurnProcessor } func NewChatMethods(agents *agent.Router, sess store.SessionStore, rl *gateway.RateLimiter, eventBus bus.EventPublisher) *ChatMethods { @@ -114,7 +115,7 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re } } - loop, err := m.agents.Get(params.AgentID) + loop, err := m.agents.Get(ctx, params.AgentID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return @@ -222,24 +223,25 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re } // Auto-generate conversation title on first message (label empty = never titled). - if label := m.sessions.GetLabel(sessionKey); label == "" { + if label := m.sessions.GetLabel(ctx, sessionKey); label == "" { agentProvider := loop.Provider() agentModel := loop.Model() userMsg := params.Message + // Use runCtxBase (WithoutCancel + tenant-aware) so title save uses correct tenant. + titleCtx := runCtxBase go func() { - title := agent.GenerateTitle(context.Background(), agentProvider, agentModel, userMsg) + title := agent.GenerateTitle(titleCtx, agentProvider, agentModel, userMsg) if title == "" { return } - m.sessions.SetLabel(sessionKey, title) - if err := m.sessions.Save(sessionKey); err != nil { + m.sessions.SetLabel(titleCtx, sessionKey, title) + if err := m.sessions.Save(titleCtx, sessionKey); err != nil { slog.Warn("failed to save session title", "sessionKey", sessionKey, "error", err) return } - m.eventBus.Broadcast(bus.Event{ - Name: protocol.EventSessionUpdated, - Payload: map[string]string{"sessionKey": sessionKey, "label": title}, - }) + bus.BroadcastForTenant(m.eventBus, protocol.EventSessionUpdated, + client.TenantID(), + map[string]string{"sessionKey": sessionKey, "label": title, "userId": userID}) }() } @@ -277,7 +279,12 @@ func (m *ChatMethods) handleHistory(ctx context.Context, client *gateway.Client, sessionKey = sessions.BuildWSSessionKey(params.AgentID, uuid.NewString()) } - history := m.sessions.GetHistory(sessionKey) + history := m.sessions.GetHistory(ctx, sessionKey) + + // Sign file URLs before delivery — sessions store clean paths. + for i := range history { + history[i].Content = httpapi.SignFileURLs(history[i].Content, httpapi.FileSigningKey()) + } client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "messages": history, @@ -320,7 +327,7 @@ func (m *ChatMethods) handleInject(ctx context.Context, client *gateway.Client, // Create an assistant message with gateway-injected metadata messageID := uuid.NewString() - m.sessions.AddMessage(params.SessionKey, providers.Message{ + m.sessions.AddMessage(ctx, params.SessionKey, providers.Message{ Role: "assistant", Content: text, }) diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go index d8dcf46a..1a2dbf02 100644 --- a/internal/gateway/methods/config.go +++ b/internal/gateway/methods/config.go @@ -29,10 +29,25 @@ func NewConfigMethods(cfg *config.Config, cfgPath string, secretsStore store.Con } func (m *ConfigMethods) Register(router *gateway.MethodRouter) { - router.Register(protocol.MethodConfigGet, m.handleGet) - router.Register(protocol.MethodConfigApply, m.handleApply) - router.Register(protocol.MethodConfigPatch, m.handlePatch) - router.Register(protocol.MethodConfigSchema, m.handleSchema) + router.Register(protocol.MethodConfigGet, m.requireCrossTenant(m.handleGet)) + router.Register(protocol.MethodConfigApply, m.requireCrossTenant(m.handleApply)) + router.Register(protocol.MethodConfigPatch, m.requireCrossTenant(m.handlePatch)) + router.Register(protocol.MethodConfigSchema, m.requireCrossTenant(m.handleSchema)) +} + +// requireCrossTenant wraps a handler to only allow cross-tenant (owner/system) users. +func (m *ConfigMethods) requireCrossTenant(next gateway.MethodHandler) gateway.MethodHandler { + return func(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + if !client.IsCrossTenant() { + locale := store.LocaleFromContext(ctx) + client.SendResponse(protocol.NewErrorResponse( + req.ID, protocol.ErrUnauthorized, + i18n.T(locale, i18n.MsgPermissionDenied, req.Method), + )) + return + } + next(ctx, client, req) + } } func (m *ConfigMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { diff --git a/internal/gateway/methods/cron.go b/internal/gateway/methods/cron.go index 92d6b7ac..4969375e 100644 --- a/internal/gateway/methods/cron.go +++ b/internal/gateway/methods/cron.go @@ -7,6 +7,7 @@ import ( "regexp" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -19,10 +20,11 @@ var cronSlugRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) type CronMethods struct { service store.CronStore eventBus bus.EventPublisher + cfg *config.Config } -func NewCronMethods(service store.CronStore, eventBus bus.EventPublisher) *CronMethods { - return &CronMethods{service: service, eventBus: eventBus} +func NewCronMethods(service store.CronStore, eventBus bus.EventPublisher, cfg *config.Config) *CronMethods { + return &CronMethods{service: service, eventBus: eventBus, cfg: cfg} } func (m *CronMethods) Register(router *gateway.MethodRouter) { @@ -36,7 +38,7 @@ func (m *CronMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodCronRuns, m.handleRuns) } -func (m *CronMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *CronMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params struct { IncludeDisabled bool `json:"includeDisabled"` } @@ -44,7 +46,11 @@ func (m *CronMethods) handleList(_ context.Context, client *gateway.Client, req json.Unmarshal(req.Params, ¶ms) } - jobs := m.service.ListJobs(params.IncludeDisabled, "", "") + userID := "" + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + userID = client.UserID() + } + jobs := m.service.ListJobs(ctx, params.IncludeDisabled, "", userID) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "jobs": jobs, @@ -80,7 +86,7 @@ func (m *CronMethods) handleCreate(ctx context.Context, client *gateway.Client, return } - job, err := m.service.AddJob(params.Name, params.Schedule, params.Message, params.Deliver, params.Channel, params.To, params.AgentID, client.UserID()) + job, err := m.service.AddJob(ctx, params.Name, params.Schedule, params.Message, params.Deliver, params.Channel, params.To, params.AgentID, client.UserID()) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error())) return @@ -106,7 +112,15 @@ func (m *CronMethods) handleDelete(ctx context.Context, client *gateway.Client, return } - if err := m.service.RemoveJob(params.JobID); err != nil { + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + job, ok := m.service.GetJob(ctx, params.JobID) + if !ok || job.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job"))) + return + } + } + + if err := m.service.RemoveJob(ctx, params.JobID); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return } @@ -132,7 +146,15 @@ func (m *CronMethods) handleToggle(ctx context.Context, client *gateway.Client, return } - if err := m.service.EnableJob(params.JobID, params.Enabled); err != nil { + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + job, ok := m.service.GetJob(ctx, params.JobID) + if !ok || job.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job"))) + return + } + } + + if err := m.service.EnableJob(ctx, params.JobID, params.Enabled); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return } @@ -168,7 +190,15 @@ func (m *CronMethods) handleUpdate(ctx context.Context, client *gateway.Client, return } - job, err := m.service.UpdateJob(jobID, params.Patch) + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + existing, ok := m.service.GetJob(ctx, jobID) + if !ok || existing.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job"))) + return + } + } + + job, err := m.service.UpdateJob(ctx, jobID, params.Patch) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error())) return @@ -203,12 +233,19 @@ func (m *CronMethods) handleRun(ctx context.Context, client *gateway.Client, req force := params.Mode == "force" // Validate job exists before responding - _, ok := m.service.GetJob(jobID) + job, ok := m.service.GetJob(ctx, jobID) if !ok { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgJobNotFound))) return } + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + if job.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "cron job"))) + return + } + } + // Respond immediately — job execution happens in background client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "ok": true, @@ -216,14 +253,17 @@ func (m *CronMethods) handleRun(ctx context.Context, client *gateway.Client, req })) emitAudit(m.eventBus, client, "cron.run", "cron", jobID) + // Preserve tenant scope for async execution. + tenantID := store.TenantIDFromContext(ctx) go func() { - if _, _, err := m.service.RunJob(jobID, force); err != nil { + bgCtx := store.WithTenantID(context.Background(), tenantID) + if _, _, err := m.service.RunJob(bgCtx, jobID, force); err != nil { slog.Warn("cron.run background error", "jobId", jobID, "error", err) } }() } -func (m *CronMethods) handleRuns(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *CronMethods) handleRuns(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params struct { JobID string `json:"jobId"` ID string `json:"id"` @@ -239,7 +279,7 @@ func (m *CronMethods) handleRuns(_ context.Context, client *gateway.Client, req jobID = params.ID } - entries, total := m.service.GetRunLog(jobID, params.Limit, params.Offset) + entries, total := m.service.GetRunLog(ctx, jobID, params.Limit, params.Offset) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "entries": entries, "total": total, diff --git a/internal/gateway/methods/delegations.go b/internal/gateway/methods/delegations.go deleted file mode 100644 index 32110098..00000000 --- a/internal/gateway/methods/delegations.go +++ /dev/null @@ -1,137 +0,0 @@ -package methods - -import ( - "context" - "encoding/json" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/gateway" - "github.com/nextlevelbuilder/goclaw/internal/i18n" - "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" -) - -// DelegationsMethods handles delegations.* RPC methods. -type DelegationsMethods struct { - teamStore store.TeamStore -} - -func NewDelegationsMethods(teamStore store.TeamStore) *DelegationsMethods { - return &DelegationsMethods{teamStore: teamStore} -} - -func (m *DelegationsMethods) Register(router *gateway.MethodRouter) { - router.Register(protocol.MethodDelegationsList, m.handleList) - router.Register(protocol.MethodDelegationsGet, m.handleGet) -} - -func (m *DelegationsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { - locale := store.LocaleFromContext(ctx) - if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgDelegationsUnavailable))) - return - } - - var params struct { - SourceAgentID string `json:"source_agent_id"` - TargetAgentID string `json:"target_agent_id"` - TeamID string `json:"team_id"` - UserID string `json:"user_id"` - Status string `json:"status"` - Limit int `json:"limit"` - Offset int `json:"offset"` - } - if req.Params != nil { - _ = json.Unmarshal(req.Params, ¶ms) - } - - opts := store.DelegationHistoryListOpts{ - UserID: params.UserID, - Status: params.Status, - Limit: params.Limit, - Offset: params.Offset, - } - - if params.SourceAgentID != "" { - if id, err := uuid.Parse(params.SourceAgentID); err == nil { - opts.SourceAgentID = &id - } - } - if params.TargetAgentID != "" { - if id, err := uuid.Parse(params.TargetAgentID); err == nil { - opts.TargetAgentID = &id - } - } - if params.TeamID != "" { - if id, err := uuid.Parse(params.TeamID); err == nil { - opts.TeamID = &id - } - } - - records, total, err := m.teamStore.ListDelegationHistory(ctx, opts) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) - return - } - - // Truncate results for WS transport - const maxResultRunes = 500 - for i := range records { - if records[i].Result != nil { - r := []rune(*records[i].Result) - if len(r) > maxResultRunes { - s := string(r[:maxResultRunes]) + "..." - records[i].Result = &s - } - } - } - - client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ - "records": records, - "total": total, - })) -} - -func (m *DelegationsMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { - locale := store.LocaleFromContext(ctx) - if m.teamStore == nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgDelegationsUnavailable))) - return - } - - var params struct { - ID string `json:"id"` - } - if req.Params != nil { - _ = json.Unmarshal(req.Params, ¶ms) - } - if params.ID == "" { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "id"))) - return - } - - id, err := uuid.Parse(params.ID) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "id"))) - return - } - - record, err := m.teamStore.GetDelegationHistory(ctx, id) - if err != nil { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) - return - } - - // Truncate result for WS transport - const maxResultRunes = 8000 - if record.Result != nil { - r := []rune(*record.Result) - if len(r) > maxResultRunes { - s := string(r[:maxResultRunes]) + "..." - record.Result = &s - } - } - - client.SendResponse(protocol.NewOKResponse(req.ID, record)) -} diff --git a/internal/gateway/methods/pairing.go b/internal/gateway/methods/pairing.go index 69641730..d0611828 100644 --- a/internal/gateway/methods/pairing.go +++ b/internal/gateway/methods/pairing.go @@ -70,7 +70,7 @@ func (m *PairingMethods) handleRequest(ctx context.Context, client *gateway.Clie params.AccountID = "default" } - code, err := m.service.RequestPairing(params.SenderID, params.Channel, params.ChatID, params.AccountID, nil) + code, err := m.service.RequestPairing(ctx, params.SenderID, params.Channel, params.ChatID, params.AccountID, nil) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, err.Error())) return @@ -99,7 +99,7 @@ func (m *PairingMethods) handleApprove(ctx context.Context, client *gateway.Clie params.ApprovedBy = "operator" } - paired, err := m.service.ApprovePairing(params.Code, params.ApprovedBy) + paired, err := m.service.ApprovePairing(ctx, params.Code, params.ApprovedBy) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return @@ -135,7 +135,7 @@ func (m *PairingMethods) handleDeny(ctx context.Context, client *gateway.Client, return } - if err := m.service.DenyPairing(params.Code); err != nil { + if err := m.service.DenyPairing(ctx, params.Code); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return } @@ -150,9 +150,9 @@ func (m *PairingMethods) handleDeny(ctx context.Context, client *gateway.Client, })) } -func (m *PairingMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - pending := m.service.ListPending() - paired := m.service.ListPaired() +func (m *PairingMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + pending := m.service.ListPending(ctx) + paired := m.service.ListPaired(ctx) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "pending": pending, @@ -175,7 +175,7 @@ func (m *PairingMethods) handleRevoke(ctx context.Context, client *gateway.Clien return } - if err := m.service.RevokePairing(params.SenderID, params.Channel); err != nil { + if err := m.service.RevokePairing(ctx, params.SenderID, params.Channel); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, err.Error())) return } @@ -222,7 +222,7 @@ func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client return } - paired, pairErr := m.service.IsPaired(params.SenderID, "browser") + paired, pairErr := m.service.IsPaired(ctx, params.SenderID, "browser") if pairErr != nil { slog.Warn("security.pairing_check_failed", "sender_id", params.SenderID, "error", pairErr) } @@ -234,7 +234,7 @@ func (m *PairingMethods) handleBrowserPairingStatus(ctx context.Context, client } // Check if the pairing request still exists (not expired) - pending := m.service.ListPending() + pending := m.service.ListPending(ctx) for _, p := range pending { if p.SenderID == params.SenderID && p.Channel == "browser" { client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ diff --git a/internal/gateway/methods/sessions.go b/internal/gateway/methods/sessions.go index 11f6290b..02124e43 100644 --- a/internal/gateway/methods/sessions.go +++ b/internal/gateway/methods/sessions.go @@ -5,7 +5,9 @@ import ( "encoding/json" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" + httpapi "github.com/nextlevelbuilder/goclaw/internal/http" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" @@ -15,10 +17,11 @@ import ( type SessionsMethods struct { sessions store.SessionStore eventBus bus.EventPublisher + cfg *config.Config } -func NewSessionsMethods(sess store.SessionStore, eventBus bus.EventPublisher) *SessionsMethods { - return &SessionsMethods{sessions: sess, eventBus: eventBus} +func NewSessionsMethods(sess store.SessionStore, eventBus bus.EventPublisher, cfg *config.Config) *SessionsMethods { + return &SessionsMethods{sessions: sess, eventBus: eventBus, cfg: cfg} } func (m *SessionsMethods) Register(router *gateway.MethodRouter) { @@ -36,7 +39,7 @@ type sessionsListParams struct { Offset int `json:"offset"` } -func (m *SessionsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *SessionsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params sessionsListParams if req.Params != nil { json.Unmarshal(req.Params, ¶ms) @@ -47,18 +50,19 @@ func (m *SessionsMethods) handleList(_ context.Context, client *gateway.Client, } opts := store.SessionListOpts{ - AgentID: params.AgentID, - Channel: params.Channel, - Limit: params.Limit, - Offset: params.Offset, + AgentID: params.AgentID, + Channel: params.Channel, + Limit: params.Limit, + Offset: params.Offset, + TenantID: store.TenantIDFromContext(ctx), } - // Only filter by UserID when a channel filter is specified (e.g. chat sidebar sends channel="ws"). - // Sessions admin page omits channel → sees all sessions unfiltered. - if params.Channel != "" { + // Role-based filtering: admins/owners see all sessions; regular users see only their own. + // Tenant scope is always applied above — admin sees all sessions within the tenant. + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { opts.UserID = client.UserID() } - result := m.sessions.ListPagedRich(opts) + result := m.sessions.ListPagedRich(ctx, opts) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "sessions": result.Sessions, "total": result.Total, @@ -79,8 +83,26 @@ func (m *SessionsMethods) handlePreview(ctx context.Context, client *gateway.Cli return } - history := m.sessions.GetHistory(params.Key) - summary := m.sessions.GetSummary(params.Key) + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + sess := m.sessions.Get(ctx, params.Key) + if sess == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key))) + return + } + if sess.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session"))) + return + } + } + + history := m.sessions.GetHistory(ctx, params.Key) + summary := m.sessions.GetSummary(ctx, params.Key) + + // Sign file URLs before delivery — sessions store clean paths. + for i := range history { + history[i].Content = httpapi.SignFileURLs(history[i].Content, httpapi.FileSigningKey()) + } + summary = httpapi.SignFileURLs(summary, httpapi.FileSigningKey()) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "key": params.Key, @@ -109,23 +131,35 @@ func (m *SessionsMethods) handlePatch(ctx context.Context, client *gateway.Clien return } + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + sess := m.sessions.Get(ctx, params.Key) + if sess == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key))) + return + } + if sess.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session"))) + return + } + } + // Apply label patch if params.Label != nil { - m.sessions.SetLabel(params.Key, *params.Label) + m.sessions.SetLabel(ctx, params.Key, *params.Label) } // Apply model patch if params.Model != nil { - m.sessions.UpdateMetadata(params.Key, *params.Model, "", "") + m.sessions.UpdateMetadata(ctx, params.Key, *params.Model, "", "") } // Apply metadata patch if len(params.Metadata) > 0 { - m.sessions.SetSessionMetadata(params.Key, params.Metadata) + m.sessions.SetSessionMetadata(ctx, params.Key, params.Metadata) } // Save changes to DB - m.sessions.Save(params.Key) + m.sessions.Save(ctx, params.Key) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "ok": true, @@ -142,7 +176,19 @@ func (m *SessionsMethods) handleDelete(ctx context.Context, client *gateway.Clie return } - if err := m.sessions.Delete(params.Key); err != nil { + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + sess := m.sessions.Get(ctx, params.Key) + if sess == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key))) + return + } + if sess.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session"))) + return + } + } + + if err := m.sessions.Delete(ctx, params.Key); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) return } @@ -161,7 +207,19 @@ func (m *SessionsMethods) handleReset(ctx context.Context, client *gateway.Clien return } - m.sessions.Reset(params.Key) + if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) { + sess := m.sessions.Get(ctx, params.Key) + if sess == nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key))) + return + } + if sess.UserID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session"))) + return + } + } + + m.sessions.Reset(ctx, params.Key) client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "ok": true, diff --git a/internal/gateway/methods/skills.go b/internal/gateway/methods/skills.go index a96b1062..5ff87570 100644 --- a/internal/gateway/methods/skills.go +++ b/internal/gateway/methods/skills.go @@ -33,8 +33,8 @@ func (m *SkillsMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodSkillsUpdate, m.handleUpdate) } -func (m *SkillsMethods) handleList(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - allSkills := m.store.ListSkills() +func (m *SkillsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + allSkills := m.store.ListSkills(ctx) result := make([]map[string]any, 0, len(allSkills)) for _, s := range allSkills { @@ -86,13 +86,13 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r return } - info, ok := m.store.GetSkill(params.Name) + info, ok := m.store.GetSkill(ctx, params.Name) if !ok { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name))) return } - content, _ := m.store.LoadSkill(params.Name) + content, _ := m.store.LoadSkill(ctx, params.Name) resp := map[string]any{ "name": info.Name, @@ -116,7 +116,7 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r // skillUpdater is an optional interface for stores that support skill updates (e.g. PGSkillStore). type skillUpdater interface { - UpdateSkill(id uuid.UUID, updates map[string]any) error + UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error } func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { @@ -153,7 +153,7 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client } else { // Look up by name — use GetSkill which returns path info, but we need DB ID // For PGSkillStore, the name is the slug - info, exists := m.store.GetSkill(params.Name) + info, exists := m.store.GetSkill(ctx, params.Name) if !exists { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "skill", params.Name))) return @@ -172,17 +172,21 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client return } - // Ownership check: only skill owner or admin can update + // Ownership check: only skill owner or admin can update. + // Fail-closed: if store doesn't implement skillOwnerGetter, deny non-admin callers. if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) { - if ownerGetter, ok := m.store.(skillOwnerGetter); ok { - if ownerID, found := ownerGetter.GetSkillOwnerID(skillID); found && ownerID != client.UserID() { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update"))) - return - } + ownerGetter, ok := m.store.(skillOwnerGetter) + if !ok { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update"))) + return + } + if ownerID, found := ownerGetter.GetSkillOwnerID(skillID); found && ownerID != client.UserID() { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "skills.update"))) + return } } - if err := updater.UpdateSkill(skillID, params.Updates); err != nil { + if err := updater.UpdateSkill(ctx, skillID, params.Updates); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) return } diff --git a/internal/gateway/methods/teams.go b/internal/gateway/methods/teams.go index 53759aa5..a00c865b 100644 --- a/internal/gateway/methods/teams.go +++ b/internal/gateway/methods/teams.go @@ -10,6 +10,7 @@ import ( "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" ) @@ -22,7 +23,7 @@ type TeamsMethods struct { agentRouter *agent.Router // for cache invalidation msgBus *bus.MessageBus // for pub/sub cache invalidation eventBus bus.EventPublisher - dataDir string // workspace data directory for resolving file paths + dataDir string // workspace data directory for resolving file paths } func NewTeamsMethods(teamStore store.TeamStore, agentStore store.AgentStore, linkStore store.AgentLinkStore, agentRouter *agent.Router, msgBus *bus.MessageBus, eventBus bus.EventPublisher, dataDir string) *TeamsMethods { @@ -73,7 +74,14 @@ func (m *TeamsMethods) handleList(ctx context.Context, client *gateway.Client, r return } - teams, err := m.teamStore.ListTeams(ctx) + var teams []store.TeamData + var err error + if permissions.HasMinRole(client.Role(), permissions.RoleAdmin) { + teams, err = m.teamStore.ListTeams(ctx) + } else { + callerID := store.UserIDFromContext(ctx) + teams, err = m.teamStore.ListUserTeams(ctx, callerID) + } if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) return @@ -118,7 +126,7 @@ func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client, } // Resolve lead agent - leadAgent, err := resolveAgentInfo(m.agentStore, params.Lead) + leadAgent, err := resolveAgentInfo(ctx, m.agentStore, params.Lead) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "lead agent: "+err.Error())) return @@ -134,7 +142,7 @@ func (m *TeamsMethods) handleCreate(ctx context.Context, client *gateway.Client, // Resolve member agents var memberAgents []*store.AgentData for _, memberKey := range params.Members { - ag, err := resolveAgentInfo(m.agentStore, memberKey) + ag, err := resolveAgentInfo(ctx, m.agentStore, memberKey) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "member agent "+memberKey+": "+err.Error())) return diff --git a/internal/gateway/methods/teams_crud.go b/internal/gateway/methods/teams_crud.go index 1feed25d..58bcf229 100644 --- a/internal/gateway/methods/teams_crud.go +++ b/internal/gateway/methods/teams_crud.go @@ -10,6 +10,7 @@ import ( "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" ) @@ -50,6 +51,15 @@ func (m *TeamsMethods) handleGet(ctx context.Context, client *gateway.Client, re return } + // Non-admin callers must be a member of the team to view it. + if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) { + callerID := store.UserIDFromContext(ctx) + if ok, err := m.teamStore.HasTeamAccess(ctx, teamID, callerID); err != nil || !ok { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "team", params.TeamID))) + return + } + } + members, err := m.teamStore.ListMembers(ctx, teamID) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, err.Error())) @@ -92,6 +102,15 @@ func (m *TeamsMethods) handleDelete(ctx context.Context, client *gateway.Client, return } + // Non-admin callers must have team access to delete. + if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) { + callerID := store.UserIDFromContext(ctx) + if ok, err := m.teamStore.HasTeamAccess(ctx, teamID, callerID); err != nil || !ok { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "team", params.TeamID))) + return + } + } + // Fetch team and members before deleting for event + cache invalidation team, _ := m.teamStore.GetTeam(ctx, teamID) members, _ := m.teamStore.ListMembers(ctx, teamID) diff --git a/internal/gateway/methods/teams_members.go b/internal/gateway/methods/teams_members.go index b58e320d..9dfe2116 100644 --- a/internal/gateway/methods/teams_members.go +++ b/internal/gateway/methods/teams_members.go @@ -53,7 +53,7 @@ func (m *TeamsMethods) handleAddMember(ctx context.Context, client *gateway.Clie } // Resolve agent - ag, err := resolveAgentInfo(m.agentStore, params.Agent) + ag, err := resolveAgentInfo(ctx, m.agentStore, params.Agent) if err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "agent: "+err.Error())) return diff --git a/internal/gateway/methods/teams_tasks.go b/internal/gateway/methods/teams_tasks.go index 41c5956d..cfaa9078 100644 --- a/internal/gateway/methods/teams_tasks.go +++ b/internal/gateway/methods/teams_tasks.go @@ -12,6 +12,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/gateway" + httpapi "github.com/nextlevelbuilder/goclaw/internal/http" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" @@ -104,6 +105,13 @@ func (m *TeamsMethods) handleTaskGet(ctx context.Context, client *gateway.Client events, _ := m.teamStore.ListTaskEvents(ctx, taskID) attachments, _ := m.teamStore.ListTaskAttachments(ctx, taskID) + // Sign download URLs at delivery time (same pattern as chat file URLs). + for i := range attachments { + dlPath := fmt.Sprintf("/v1/teams/%s/attachments/%s/download", teamID, attachments[i].ID) + ft := httpapi.SignFileToken(dlPath, httpapi.FileSigningKey(), httpapi.FileTokenTTL) + attachments[i].DownloadURL = dlPath + "?ft=" + ft + } + client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{ "task": task, "comments": comments, diff --git a/internal/gateway/methods/teams_workspace.go b/internal/gateway/methods/teams_workspace.go index f9f389c4..52eb3804 100644 --- a/internal/gateway/methods/teams_workspace.go +++ b/internal/gateway/methods/teams_workspace.go @@ -4,12 +4,14 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "strings" "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -24,14 +26,54 @@ func (m *TeamsMethods) RegisterWorkspace(router *gateway.MethodRouter) { router.Register(protocol.MethodTeamsWorkspaceDelete, m.handleWorkspaceDelete) } -// teamWorkspaceDir returns the base directory for a team's workspace files. -// Pattern: {dataDir}/teams/{teamID}/ -// If chatID is provided, scopes to {dataDir}/teams/{teamID}/{chatID}/ -func teamWorkspaceDir(dataDir string, teamID uuid.UUID, chatID string) string { - if chatID != "" { - return filepath.Join(dataDir, "teams", teamID.String(), chatID) +// resolveWorkspacePath resolves fileName within scopeDir and validates that the +// canonical path (after symlink resolution) stays inside the scope directory. +// Returns the resolved disk path or an error if the path escapes the boundary. +func resolveWorkspacePath(scopeDir, fileName string) (string, error) { + diskPath := filepath.Clean(filepath.Join(scopeDir, fileName)) + + // Resolve scope dir to canonical form. + scopeReal, err := filepath.EvalSymlinks(filepath.Clean(scopeDir)) + if err != nil { + scopeReal = filepath.Clean(scopeDir) } - return filepath.Join(dataDir, "teams", teamID.String()) + + // Resolve target path — follow symlinks to detect escapes. + diskReal, err := filepath.EvalSymlinks(diskPath) + if err != nil { + if !os.IsNotExist(err) { + slog.Warn("security.workspace_path_resolve_failed", "path", fileName, "error", err) + return "", fmt.Errorf("invalid file_name") + } + // File doesn't exist yet — validate parent directory. + parentReal, parentErr := filepath.EvalSymlinks(filepath.Dir(diskPath)) + if parentErr != nil { + return "", fmt.Errorf("invalid file_name") + } + diskReal = filepath.Join(parentReal, filepath.Base(diskPath)) + } + + // Boundary check: canonical path must be inside canonical scope. + if diskReal != scopeReal && !strings.HasPrefix(diskReal, scopeReal+string(filepath.Separator)) { + slog.Warn("security.workspace_path_escape", "path", fileName, "resolved", diskReal, "scope", scopeReal) + return "", fmt.Errorf("invalid file_name") + } + + return diskPath, nil +} + +// teamWorkspaceDir returns the base directory for a team's workspace files. +// Scoped to tenant via config.TenantTeamDir: {dataDir}/tenants/{slug}/teams/{teamID}/ +// Master tenant: {dataDir}/teams/{teamID}/ (backward compat). +// If chatID is provided, further scopes to .../{chatID}/ +func teamWorkspaceDir(ctx context.Context, dataDir string, teamID uuid.UUID, chatID string) string { + tid := store.TenantIDFromContext(ctx) + slug := store.TenantSlugFromContext(ctx) + base := config.TenantTeamDir(dataDir, tid, slug, teamID) + if chatID != "" { + return filepath.Join(base, chatID) + } + return base } // workspaceFileEntry is the response shape for workspace file listing. @@ -76,7 +118,7 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway. shared = tools.IsSharedWorkspace(team.Settings) } - baseDir := teamWorkspaceDir(m.dataDir, teamID, "") + baseDir := teamWorkspaceDir(ctx, m.dataDir, teamID, "") var files []workspaceFileEntry if shared || params.ChatID != "" { @@ -84,7 +126,7 @@ func (m *TeamsMethods) handleWorkspaceList(ctx context.Context, client *gateway. scopeDir := baseDir scopeChatID := "" if !shared && params.ChatID != "" { - scopeDir = teamWorkspaceDir(m.dataDir, teamID, params.ChatID) + scopeDir = teamWorkspaceDir(ctx, m.dataDir, teamID, params.ChatID) scopeChatID = params.ChatID } files = walkDir(scopeDir, "", scopeChatID) @@ -207,11 +249,10 @@ func (m *TeamsMethods) handleWorkspaceRead(ctx context.Context, client *gateway. return } - scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID) - diskPath := filepath.Clean(filepath.Join(scopeDir, params.FileName)) - // Ensure resolved path stays within the workspace scope directory. - if !strings.HasPrefix(diskPath, filepath.Clean(scopeDir)+string(os.PathSeparator)) && diskPath != filepath.Clean(scopeDir) { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name")) + scopeDir := teamWorkspaceDir(ctx, m.dataDir, teamID, chatID) + diskPath, pathErr := resolveWorkspacePath(scopeDir, params.FileName) + if pathErr != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, pathErr.Error())) return } data, err := os.ReadFile(diskPath) @@ -283,11 +324,10 @@ func (m *TeamsMethods) handleWorkspaceDelete(ctx context.Context, client *gatewa return } - scopeDir := teamWorkspaceDir(m.dataDir, teamID, chatID) - diskPath := filepath.Clean(filepath.Join(scopeDir, params.FileName)) - // Ensure resolved path stays within the workspace scope directory. - if !strings.HasPrefix(diskPath, filepath.Clean(scopeDir)+string(os.PathSeparator)) && diskPath != filepath.Clean(scopeDir) { - client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "invalid file_name")) + scopeDir := teamWorkspaceDir(ctx, m.dataDir, teamID, chatID) + diskPath, pathErr := resolveWorkspacePath(scopeDir, params.FileName) + if pathErr != nil { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, pathErr.Error())) return } if err := os.Remove(diskPath); err != nil { diff --git a/internal/gateway/methods/teams_workspace_test.go b/internal/gateway/methods/teams_workspace_test.go new file mode 100644 index 00000000..510e9d96 --- /dev/null +++ b/internal/gateway/methods/teams_workspace_test.go @@ -0,0 +1,124 @@ +package methods + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestResolveWorkspacePath_NormalFile(t *testing.T) { + scope := t.TempDir() + if err := os.WriteFile(filepath.Join(scope, "file.txt"), []byte("data"), 0644); err != nil { + t.Fatal(err) + } + diskPath, err := resolveWorkspacePath(scope, "file.txt") + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + if filepath.Base(diskPath) != "file.txt" { + t.Fatalf("expected file.txt, got: %s", diskPath) + } +} + +func TestResolveWorkspacePath_NestedFile(t *testing.T) { + scope := t.TempDir() + sub := filepath.Join(scope, "sub") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "nested.txt"), []byte("data"), 0644); err != nil { + t.Fatal(err) + } + diskPath, err := resolveWorkspacePath(scope, "sub/nested.txt") + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + if filepath.Base(diskPath) != "nested.txt" { + t.Fatalf("expected nested.txt, got: %s", diskPath) + } +} + +func TestResolveWorkspacePath_TraversalBlocked(t *testing.T) { + scope := t.TempDir() + // Even though teams_workspace.go checks for ".." in the RPC handler, + // resolveWorkspacePath should also catch it via boundary check. + _, err := resolveWorkspacePath(scope, "../../../etc/passwd") + if err == nil { + t.Fatal("expected error for path traversal, got nil") + } +} + +func TestResolveWorkspacePath_SymlinkEscapeBlocked(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require special privileges on Windows") + } + scope := t.TempDir() + outside := t.TempDir() + secret := filepath.Join(outside, "secret.txt") + if err := os.WriteFile(secret, []byte("secret"), 0644); err != nil { + t.Fatal(err) + } + + // Plant a symlink inside workspace pointing outside + link := filepath.Join(scope, "evil_link") + if err := os.Symlink(secret, link); err != nil { + t.Fatal(err) + } + + _, err := resolveWorkspacePath(scope, "evil_link") + if err == nil { + t.Fatal("expected error for symlink escaping workspace, got nil") + } +} + +func TestResolveWorkspacePath_DirSymlinkEscapeBlocked(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require special privileges on Windows") + } + scope := t.TempDir() + outside := t.TempDir() + if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0644); err != nil { + t.Fatal(err) + } + + // Plant a directory symlink inside workspace pointing outside + link := filepath.Join(scope, "evil_dir") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + + _, err := resolveWorkspacePath(scope, "evil_dir/secret.txt") + if err == nil { + t.Fatal("expected error for dir symlink escape, got nil") + } +} + +func TestResolveWorkspacePath_SymlinkInsideAllowed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require special privileges on Windows") + } + scope := t.TempDir() + target := filepath.Join(scope, "real.txt") + if err := os.WriteFile(target, []byte("data"), 0644); err != nil { + t.Fatal(err) + } + link := filepath.Join(scope, "good_link") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + _, err := resolveWorkspacePath(scope, "good_link") + if err != nil { + t.Fatalf("expected success for symlink within workspace, got: %v", err) + } +} + +func TestResolveWorkspacePath_NonExistentFile(t *testing.T) { + scope := t.TempDir() + // Non-existent file in workspace should succeed (for new file creation) + _, err := resolveWorkspacePath(scope, "new_file.txt") + if err != nil { + t.Fatalf("expected success for non-existent file, got: %v", err) + } +} diff --git a/internal/gateway/methods/tenants.go b/internal/gateway/methods/tenants.go new file mode 100644 index 00000000..6108315d --- /dev/null +++ b/internal/gateway/methods/tenants.go @@ -0,0 +1,404 @@ +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("tenants.list", m.handleList) + router.Register("tenants.get", m.handleGet) + router.Register("tenants.create", m.handleCreate) + router.Register("tenants.update", m.handleUpdate) + router.Register("tenants.users.list", m.handleUsersList) + router.Register("tenants.users.add", m.handleUsersAdd) + router.Register("tenants.users.remove", m.handleUsersRemove) + router.Register("tenants.mine", m.handleMine) +} + +func (m *TenantsMethods) handleList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + locale := store.LocaleFromContext(ctx) + if !client.IsCrossTenant() { + 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.IsCrossTenant() { + 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.IsCrossTenant() && !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.IsCrossTenant() { + 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.IsCrossTenant() { + 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.IsCrossTenant() && !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.IsCrossTenant() { + 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"` + } + + // Cross-tenant admin: return all tenants with "owner" role + if client.IsCrossTenant() { + 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 + } + + entries := make([]tenantEntry, 0, len(memberships)) + for _, mem := range memberships { + t, err := m.tenantStore.GetTenant(ctx, mem.TenantID) + if err != nil || 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}, + }) +} diff --git a/internal/gateway/methods/usage.go b/internal/gateway/methods/usage.go index c2f705e2..e779a79c 100644 --- a/internal/gateway/methods/usage.go +++ b/internal/gateway/methods/usage.go @@ -37,7 +37,7 @@ func (m *UsageMethods) Register(router *gateway.MethodRouter) { router.Register(protocol.MethodUsageSummary, m.handleSummary) } -func (m *UsageMethods) handleGet(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { +func (m *UsageMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { var params struct { AgentID string `json:"agentId"` Limit int `json:"limit"` @@ -50,12 +50,12 @@ func (m *UsageMethods) handleGet(_ context.Context, client *gateway.Client, req params.Limit = 20 } - sessions := m.sessions.List(params.AgentID) + sessions := m.sessions.List(ctx, params.AgentID) records := make([]UsageRecord, 0, len(sessions)) for _, s := range sessions { // Get full session data for token info - data := m.sessions.GetOrCreate(s.Key) + data := m.sessions.GetOrCreate(ctx, s.Key) if data.InputTokens == 0 && data.OutputTokens == 0 { continue } @@ -95,8 +95,8 @@ func (m *UsageMethods) handleGet(_ context.Context, client *gateway.Client, req })) } -func (m *UsageMethods) handleSummary(_ context.Context, client *gateway.Client, req *protocol.RequestFrame) { - sessions := m.sessions.List("") // all agents +func (m *UsageMethods) handleSummary(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) { + sessions := m.sessions.List(ctx, "") // all agents type agentSummary struct { InputTokens int64 `json:"inputTokens"` @@ -109,7 +109,7 @@ func (m *UsageMethods) handleSummary(_ context.Context, client *gateway.Client, var totalRecords int for _, s := range sessions { - data := m.sessions.GetOrCreate(s.Key) + data := m.sessions.GetOrCreate(ctx, s.Key) if data.InputTokens == 0 && data.OutputTokens == 0 { continue } diff --git a/internal/gateway/router.go b/internal/gateway/router.go index 334b1d44..c0c06b28 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -7,6 +7,8 @@ import ( "log/slog" "time" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/cache" httpapi "github.com/nextlevelbuilder/goclaw/internal/http" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/permissions" @@ -19,8 +21,10 @@ type MethodHandler func(ctx context.Context, client *Client, req *protocol.Reque // MethodRouter maps method names to handlers. type MethodRouter struct { - handlers map[string]MethodHandler - server *Server + handlers map[string]MethodHandler + server *Server + tenantStore store.TenantStore // optional, for enriching connect response + permCache *cache.PermissionCache // optional, for caching tenant membership checks } func NewMethodRouter(server *Server) *MethodRouter { @@ -32,6 +36,12 @@ func NewMethodRouter(server *Server) *MethodRouter { return r } +// SetTenantStore sets the tenant store for enriching connect responses with tenant name/slug. +func (r *MethodRouter) SetTenantStore(ts store.TenantStore) { r.tenantStore = ts } + +// SetPermissionCache sets the permission cache for tenant membership checks. +func (r *MethodRouter) SetPermissionCache(pc *cache.PermissionCache) { r.permCache = pc } + // Register adds a method handler. func (r *MethodRouter) Register(method string, handler MethodHandler) { r.handlers[method] = handler @@ -67,8 +77,13 @@ func (r *MethodRouter) Handle(ctx context.Context, client *Client, req *protocol } } - // Inject locale into context for i18n support + // Inject locale + tenant into context. + // All connect paths now guarantee client.tenantID is set (cross-tenant defaults to MasterTenantID), + // so WithCrossTenant is no longer needed here. ctx = store.WithLocale(ctx, i18n.Normalize(client.locale)) + if client.TenantID() != uuid.Nil { + ctx = store.WithTenantID(ctx, client.TenantID()) + } slog.Debug("handling method", "method", req.Method, "client", client.id, "req_id", req.ID) handler(ctx, client, req) @@ -87,10 +102,13 @@ func (r *MethodRouter) registerDefaults() { func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *protocol.RequestFrame) { // Parse connect params var params struct { - Token string `json:"token"` - UserID string `json:"user_id"` - SenderID string `json:"sender_id"` // browser pairing: stored sender ID for reconnect - Locale string `json:"locale"` // user's preferred locale (en, vi, zh) + Token string `json:"token"` + UserID string `json:"user_id"` + SenderID string `json:"sender_id"` // browser pairing: stored sender ID for reconnect + Locale string `json:"locale"` // user's preferred locale (en, vi, zh) + TenantHint string `json:"tenant_hint"` // optional tenant slug for browser pairing multi-tenant + TenantID string `json:"tenant_id"` // cross-tenant admin: narrow scope to specific tenant (UUID or slug) + TenantScope string `json:"tenant_scope"` // deprecated: alias for tenant_id (backward compat) } if req.Params != nil { json.Unmarshal(req.Params, ¶ms) @@ -106,7 +124,39 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.role = permissions.RoleAdmin client.authenticated = true client.userID = params.UserID - r.sendConnectResponse(client, req.ID) + + // Only owner IDs get cross-tenant (god-mode) access; + // other users with the gateway token still get admin role + // but are scoped to their tenant memberships. + isOwner := isOwnerID(params.UserID, r.server.cfg.Gateway.OwnerIDs) + client.crossTenant = isOwner + + if isOwner { + // Cross-tenant admin can narrow scope to a specific tenant + tenantScope := params.TenantID + if tenantScope == "" { + tenantScope = params.TenantScope // backward compat + } + r.applyTenantScope(ctx, client, tenantScope) + // Always ensure tenant is set — prevents unscoped operations + // that use MasterTenantID fallback inconsistently with scoped sessions. + if client.tenantID == uuid.Nil { + client.tenantID = store.MasterTenantID + } + } else { + // Non-owner with gateway token: resolve tenant via hint or membership + hint := params.TenantID + if hint == "" { + hint = params.TenantScope + } + tid, errCode := r.resolveTenantHint(ctx, hint, params.UserID) + if errCode != "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked")) + return + } + client.tenantID = tid + } + r.sendConnectResponse(ctx, client, req.ID) return } @@ -120,8 +170,44 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.role = role client.scopes = scopes client.authenticated = true - client.userID = params.UserID - r.sendConnectResponse(client, req.ID) + // If the key has a bound owner, force user_id to owner_id. + if keyData.OwnerID != "" { + if params.UserID != "" && params.UserID != keyData.OwnerID { + slog.Warn("security.ws_api_key_owner_override", + "param_user_id", params.UserID, + "owner_id", keyData.OwnerID, + ) + } + client.userID = keyData.OwnerID + } else { + client.userID = params.UserID + } + if keyData.TenantID == uuid.Nil { + client.crossTenant = true + // Cross-tenant API key can narrow scope to a specific tenant + apiKeyScope := params.TenantID + if apiKeyScope == "" { + apiKeyScope = params.TenantScope // backward compat + } + r.applyTenantScope(ctx, client, apiKeyScope) + if client.tenantID == uuid.Nil { + client.tenantID = store.MasterTenantID + } + slog.Debug("security.ws_connect_resolved", + "client", client.id, + "role", string(client.role), + "cross_tenant", client.crossTenant, + "tenant_id", client.tenantID.String(), + ) + } else { + client.tenantID = keyData.TenantID + slog.Debug("security.ws_connect_resolved", + "client", client.id, + "role", string(client.role), + "tenant_id", client.tenantID.String(), + ) + } + r.sendConnectResponse(ctx, client, req.ID) return } } @@ -131,7 +217,8 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.role = permissions.RoleOperator client.authenticated = true client.userID = params.UserID - r.sendConnectResponse(client, req.ID) + client.tenantID = store.MasterTenantID + r.sendConnectResponse(ctx, client, req.ID) return } @@ -140,7 +227,7 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p // Path 3a: Reconnecting with a previously-paired sender_id if ps != nil && params.SenderID != "" { - paired, pairErr := ps.IsPaired(params.SenderID, "browser") + paired, pairErr := ps.IsPaired(ctx, params.SenderID, "browser") if pairErr != nil { slog.Warn("security.pairing_check_failed", "sender_id", params.SenderID, "error", pairErr) @@ -156,15 +243,21 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.userID = params.UserID client.pairedSenderID = params.SenderID client.pairedChannel = "browser" - slog.Info("browser pairing authenticated", "sender_id", params.SenderID, "client", client.id) - r.sendConnectResponse(client, req.ID) + tid, errCode := r.resolveTenantHint(ctx, params.TenantHint, params.UserID) + if errCode != "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked")) + return + } + client.tenantID = tid + slog.Info("browser pairing authenticated", "sender_id", params.SenderID, "client", client.id, "tenant_id", client.tenantID) + r.sendConnectResponse(ctx, client, req.ID) return } } // Path 3b: No token, no valid pairing → initiate browser pairing (if service available) if ps != nil && params.Token == "" { - code, err := ps.RequestPairing(client.id, "browser", "", "default", nil) + code, err := ps.RequestPairing(ctx, client.id, "browser", "", "default", nil) if err != nil { slog.Warn("browser pairing request failed", "error", err, "client", client.id) // Fall through to viewer role @@ -190,19 +283,134 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.role = permissions.RoleViewer client.authenticated = true client.userID = params.UserID - r.sendConnectResponse(client, req.ID) + tid, errCode := r.resolveTenantHint(ctx, params.TenantHint, params.UserID) + if errCode != "" { + client.SendResponse(protocol.NewErrorResponse(req.ID, errCode, "tenant access revoked")) + return + } + client.tenantID = tid + r.sendConnectResponse(ctx, client, req.ID) } -func (r *MethodRouter) sendConnectResponse(client *Client, reqID string) { - client.SendResponse(protocol.NewOKResponse(reqID, map[string]any{ - "protocol": protocol.ProtocolVersion, - "role": string(client.role), - "user_id": client.userID, +func (r *MethodRouter) sendConnectResponse(ctx context.Context, client *Client, reqID string) { + resp := map[string]any{ + "protocol": protocol.ProtocolVersion, + "role": string(client.role), + "user_id": client.userID, + "tenant_id": client.tenantID.String(), + "cross_tenant": client.crossTenant, "server": map[string]any{ "name": "goclaw", "version": "0.2.0", }, - })) + } + + // Enrich with tenant name/slug if tenant store available and tenant is set + if r.tenantStore != nil && client.tenantID != uuid.Nil { + if t, err := r.tenantStore.GetTenant(ctx, client.tenantID); err == nil && t != nil { + resp["tenant_name"] = t.Name + resp["tenant_slug"] = t.Slug + client.tenantName = t.Name + client.tenantSlug = t.Slug + } + } + + client.SendResponse(protocol.NewOKResponse(reqID, resp)) +} + +// isOwnerID checks if the given user ID is in the configured owner list. +// If no owner IDs configured, only "system" is treated as owner (fail-closed). +func isOwnerID(userID string, ownerIDs []string) bool { + if userID == "" { + return false + } + if len(ownerIDs) == 0 { + return userID == "system" + } + for _, id := range ownerIDs { + if id == userID { + return true + } + } + return false +} + +// resolveTenantHint resolves a tenant slug/UUID hint to a UUID with membership validation. +// Non-admin users must be a member of the tenant. +// Returns (uuid.Nil, ErrTenantAccessRevoked) when the user is not a member of the requested tenant. +// Returns (MasterTenantID, "") when no hint is provided. +func (r *MethodRouter) resolveTenantHint(ctx context.Context, hint, userID string) (uuid.UUID, string) { + if hint == "" || r.tenantStore == nil { + return store.MasterTenantID, "" + } + t, err := r.tenantStore.GetTenantBySlug(ctx, hint) + if err != nil || t == nil { + slog.Debug("tenant_hint not resolved, falling back to master", "hint", hint) + return store.MasterTenantID, "" + } + + // Validate membership: user must belong to the requested tenant. + // Deny tenant access for anonymous users (no userID) — fail-closed. + if userID == "" { + slog.Warn("security.tenant_hint_denied_anonymous", "hint", hint, "tenant_id", t.ID) + return uuid.Nil, protocol.ErrTenantAccessRevoked + } + role, err := r.getUserTenantRole(ctx, t.ID, userID) + if err != nil || role == "" { + slog.Warn("security.tenant_access_revoked", + "hint", hint, "user", userID, "tenant_id", t.ID, "error", err) + return uuid.Nil, protocol.ErrTenantAccessRevoked + } + return t.ID, "" +} + +// getUserTenantRole returns the user's role in a tenant, using permission cache if available. +func (r *MethodRouter) getUserTenantRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) { + // Check cache first + if r.permCache != nil { + if role, ok := r.permCache.GetTenantRole(ctx, tenantID, userID); ok { + slog.Debug("perm_cache.tenant_role.hit", "tenant", tenantID, "user", userID, "role", role) + return role, nil + } + slog.Debug("perm_cache.tenant_role.miss", "tenant", tenantID, "user", userID) + } + + // Fallback to DB + role, err := r.tenantStore.GetUserRole(ctx, tenantID, userID) + if err != nil { + return "", err + } + + // Cache the result (including empty role = not a member) + if r.permCache != nil { + r.permCache.SetTenantRole(ctx, tenantID, userID, role) + } + return role, nil +} + +// applyTenantScope narrows a cross-tenant client's data scope to a specific tenant. +// Client stays crossTenant=true (retains admin privileges) but tenantID is set +// so the router injects WithTenantID instead of WithCrossTenant for data filtering. +// Accepts both UUID and slug values. +func (r *MethodRouter) applyTenantScope(ctx context.Context, client *Client, tenantVal string) { + if tenantVal == "" || r.tenantStore == nil { + return + } + // Try UUID first, then slug + var t *store.TenantData + var err error + if tid, parseErr := uuid.Parse(tenantVal); parseErr == nil { + t, err = r.tenantStore.GetTenant(ctx, tid) + } else { + t, err = r.tenantStore.GetTenantBySlug(ctx, tenantVal) + } + if err != nil || t == nil { + slog.Debug("tenant scope not resolved, keeping unscoped", "value", tenantVal) + return + } + client.tenantID = t.ID + // Keep crossTenant=true so client retains admin role + tenant admin access + slog.Info("tenant scope applied", "client", client.id, "tenant", t.Slug, "tenant_id", t.ID) } func (r *MethodRouter) handleHealth(ctx context.Context, client *Client, req *protocol.RequestFrame) { @@ -266,7 +474,7 @@ func (r *MethodRouter) handleStatus(ctx context.Context, client *Client, req *pr sessionCount := 0 if r.server.sessions != nil { - sessionCount = len(r.server.sessions.List("")) + sessionCount = len(r.server.sessions.List(ctx, "")) } // Agents are lazily resolved — router only has loaded agents. diff --git a/internal/gateway/server.go b/internal/gateway/server.go index fa05c7a7..9eeedce2 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -43,15 +43,14 @@ type Server struct { tracesHandler *httpapi.TracesHandler // LLM trace listing API wakeHandler *httpapi.WakeHandler // external wake/trigger API mcpHandler *httpapi.MCPHandler // MCP server management API - customToolsHandler *httpapi.CustomToolsHandler // custom tool CRUD API channelInstancesHandler *httpapi.ChannelInstancesHandler // channel instance CRUD API providersHandler *httpapi.ProvidersHandler // provider CRUD API - delegationsHandler *httpapi.DelegationsHandler // delegation history API teamEventsHandler *httpapi.TeamEventsHandler // team event history API teamAttachmentsHandler *httpapi.TeamAttachmentsHandler // team attachment download API builtinToolsHandler *httpapi.BuiltinToolsHandler // builtin tool management API pendingMessagesHandler *httpapi.PendingMessagesHandler // pending messages API secureCLIHandler *httpapi.SecureCLIHandler // secure CLI credential CRUD API + mcpUserCredsHandler *httpapi.MCPUserCredentialsHandler // MCP per-user credentials API packagesHandler *httpapi.PackagesHandler // runtime package management API memoryHandler *httpapi.MemoryHandler // memory management API kgHandler *httpapi.KnowledgeGraphHandler // knowledge graph API @@ -64,6 +63,7 @@ type Server struct { usageHandler *httpapi.UsageHandler // usage analytics API apiKeysHandler *httpapi.APIKeysHandler // API key management apiKeyStore store.APIKeyStore // for API key auth lookup + tenantsHandler *httpapi.TenantsHandler // tenant management API docsHandler *httpapi.DocsHandler // OpenAPI spec + Swagger UI agentStore store.AgentStore // for context injection in tools_invoke msgBus *bus.MessageBus // for MCP bridge media delivery @@ -207,10 +207,9 @@ func (s *Server) BuildMux() *http.ServeMux { if s.mcpHandler != nil { s.mcpHandler.RegisterRoutes(mux) } - - // Custom tool CRUD API - if s.customToolsHandler != nil { - s.customToolsHandler.RegisterRoutes(mux) + // MCP per-user credentials API + if s.mcpUserCredsHandler != nil { + s.mcpUserCredsHandler.RegisterRoutes(mux) } // Secure CLI credential CRUD API @@ -228,10 +227,6 @@ func (s *Server) BuildMux() *http.ServeMux { s.providersHandler.RegisterRoutes(mux) } - // Delegation history API - if s.delegationsHandler != nil { - s.delegationsHandler.RegisterRoutes(mux) - } // Team event history API if s.teamEventsHandler != nil { @@ -287,6 +282,11 @@ func (s *Server) BuildMux() *http.ServeMux { s.apiKeysHandler.RegisterRoutes(mux) } + // Tenant management API + if s.tenantsHandler != nil { + s.tenantsHandler.RegisterRoutes(mux) + } + if s.activityHandler != nil { s.activityHandler.RegisterRoutes(mux) } @@ -485,10 +485,8 @@ func (s *Server) SetTracesHandler(h *httpapi.TracesHandler) { s.tracesHandler = func (s *Server) SetWakeHandler(h *httpapi.WakeHandler) { s.wakeHandler = h } // SetMCPHandler sets the MCP server management handler. -func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h } - -// SetCustomToolsHandler sets the custom tool CRUD handler. -func (s *Server) SetCustomToolsHandler(h *httpapi.CustomToolsHandler) { s.customToolsHandler = h } +func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h } +func (s *Server) SetMCPUserCredentialsHandler(h *httpapi.MCPUserCredentialsHandler) { s.mcpUserCredsHandler = h } // SetChannelInstancesHandler sets the channel instance CRUD handler. func (s *Server) SetChannelInstancesHandler(h *httpapi.ChannelInstancesHandler) { @@ -498,9 +496,6 @@ func (s *Server) SetChannelInstancesHandler(h *httpapi.ChannelInstancesHandler) // SetProvidersHandler sets the provider CRUD handler. func (s *Server) SetProvidersHandler(h *httpapi.ProvidersHandler) { s.providersHandler = h } -// SetDelegationsHandler sets the delegation history handler. -func (s *Server) SetDelegationsHandler(h *httpapi.DelegationsHandler) { s.delegationsHandler = h } - // SetTeamEventsHandler sets the team event history handler. func (s *Server) SetTeamEventsHandler(h *httpapi.TeamEventsHandler) { s.teamEventsHandler = h } @@ -531,6 +526,9 @@ func (s *Server) SetOAuthHandler(h *httpapi.OAuthHandler) { s.oauthHandler = h } // SetAPIKeysHandler sets the API key management handler. func (s *Server) SetAPIKeysHandler(h *httpapi.APIKeysHandler) { s.apiKeysHandler = h } +// SetTenantsHandler sets the tenant management handler. +func (s *Server) SetTenantsHandler(h *httpapi.TenantsHandler) { s.tenantsHandler = h } + // SetAPIKeyStore sets the API key store for token-based auth lookup. func (s *Server) SetAPIKeyStore(st store.APIKeyStore) { s.apiKeyStore = st } @@ -623,12 +621,11 @@ func (s *Server) registerClient(c *Client) { defer s.mu.Unlock() s.clients[c.id] = c - // Subscribe to bus events for this client (skip internal cache events) + // Subscribe to bus events with per-user/team filtering. s.eventPub.Subscribe(c.id, func(event bus.Event) { - if strings.HasPrefix(event.Name, "cache.") { - return // internal event, don't forward to WS clients + if clientCanReceiveEvent(c, event) { + c.SendEvent(*protocol.NewEvent(event.Name, event.Payload)) } - c.SendEvent(*protocol.NewEvent(event.Name, event.Payload)) }) slog.Info("client connected", "id", c.id) diff --git a/internal/heartbeat/ticker.go b/internal/heartbeat/ticker.go index f79c65bd..19a7742b 100644 --- a/internal/heartbeat/ticker.go +++ b/internal/heartbeat/ticker.go @@ -152,10 +152,23 @@ func (t *Ticker) runOne(ctx context.Context, hb store.AgentHeartbeat) { start := time.Now() agentIDStr := hb.AgentID.String() - // Resolve agent key for events. + // Resolve agent to get tenant scope + display key. + // System-level lookup (cross-tenant) since ticker is a global scheduler. + sysCtx := store.WithCrossTenant(context.Background()) agentKey := agentIDStr - if ag, err := t.agents.GetByID(ctx, hb.AgentID); err == nil { - agentKey = ag.AgentKey + ag, agErr := t.agents.GetByID(sysCtx, hb.AgentID) + if agErr != nil { + slog.Warn("heartbeat.agent_not_found", "agent_id", agentIDStr, "error", agErr) + return + } + agentKey = ag.AgentKey + + // Inject agent's tenant into context so all store operations + // (context files, sessions, etc.) are tenant-scoped. + if ag.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, ag.TenantID) + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) } // [1] Active hours filter. @@ -336,7 +349,7 @@ func (t *Ticker) finishRun(ctx context.Context, hb store.AgentHeartbeat, session // Cleanup isolated session — data is already in heartbeat_run_logs. if hb.IsolatedSession && t.sessions != nil && sessionKey != "" { - if err := t.sessions.Delete(sessionKey); err != nil { + if err := t.sessions.Delete(ctx, sessionKey); err != nil { slog.Debug("heartbeat.session_cleanup_failed", "session_key", sessionKey, "error", err) } } diff --git a/internal/http/activity.go b/internal/http/activity.go index 7c46ba21..1853669f 100644 --- a/internal/http/activity.go +++ b/internal/http/activity.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -39,6 +40,13 @@ func (h *ActivityHandler) handleList(w http.ResponseWriter, r *http.Request) { if v := r.URL.Query().Get("actor_id"); v != "" { opts.ActorID = v } + + // Non-admin callers may only see their own activity logs. + auth := resolveAuth(r, h.token) + if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { + callerID := store.UserIDFromContext(r.Context()) + opts.ActorID = callerID + } if v := r.URL.Query().Get("action"); v != "" { opts.Action = v } diff --git a/internal/http/agents.go b/internal/http/agents.go index 549997d3..3c767972 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -120,6 +120,17 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { } req.OwnerID = userID + + // Resolve tenant_id: cross-tenant callers must provide it; others inherit their own tenant. + if store.IsCrossTenant(r.Context()) { + if req.TenantID == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "tenant_id")}) + return + } + } else { + req.TenantID = store.TenantIDFromContext(r.Context()) + } + if req.AgentType == "" { req.AgentType = store.AgentTypeOpen } @@ -167,7 +178,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { // Start LLM summoning in background if applicable if req.Status == store.AgentStatusSummoning { - go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description) + go h.summoner.SummonAgent(req.ID, req.TenantID, req.Provider, req.Model, description) } emitAudit(h.msgBus, r, "agent.created", "agent", req.ID.String()) @@ -256,14 +267,13 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { // Cascade: if status changed, broadcast so channel instances and cron jobs react. if newStatus, ok := allowed["status"].(string); ok && newStatus != ag.Status { if h.msgBus != nil { - h.msgBus.Broadcast(bus.Event{ - Name: bus.EventAgentStatusChanged, - Payload: bus.AgentStatusChangedPayload{ + bus.BroadcastForTenant(h.msgBus, bus.EventAgentStatusChanged, + store.TenantIDFromContext(r.Context()), + bus.AgentStatusChangedPayload{ AgentID: id.String(), OldStatus: ag.Status, NewStatus: newStatus, - }, - }) + }) } } diff --git a/internal/http/agents_sharing.go b/internal/http/agents_sharing.go index f9cd8755..9cdbaeaf 100644 --- a/internal/http/agents_sharing.go +++ b/internal/http/agents_sharing.go @@ -168,7 +168,7 @@ func (h *AgentsHandler) handleRegenerate(w http.ResponseWriter, r *http.Request) return } - go h.summoner.RegenerateAgent(id, ag.Provider, ag.Model, req.Prompt) + go h.summoner.RegenerateAgent(id, store.TenantIDFromContext(r.Context()), ag.Provider, ag.Model, req.Prompt) emitAudit(h.msgBus, r, "agent.regenerated", "agent", id.String()) writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": store.AgentStatusSummoning}) @@ -214,7 +214,7 @@ func (h *AgentsHandler) handleResummon(w http.ResponseWriter, r *http.Request) { return } - go h.summoner.SummonAgent(id, ag.Provider, ag.Model, description) + go h.summoner.SummonAgent(id, store.TenantIDFromContext(r.Context()), ag.Provider, ag.Model, description) emitAudit(h.msgBus, r, "agent.resummoned", "agent", id.String()) writeJSON(w, http.StatusAccepted, map[string]string{"ok": "true", "status": store.AgentStatusSummoning}) diff --git a/internal/http/api_key_cache.go b/internal/http/api_key_cache.go index 372f9872..4ca76552 100644 --- a/internal/http/api_key_cache.go +++ b/internal/http/api_key_cache.go @@ -2,6 +2,7 @@ package http import ( "context" + "log/slog" "sync" "time" @@ -49,9 +50,15 @@ func (c *apiKeyCache) get(hash string) (*store.APIKeyData, permissions.Role, boo // getOrFetch returns a cached entry or fetches from the store on cache miss. func (c *apiKeyCache) getOrFetch(ctx context.Context, hash string) (*store.APIKeyData, permissions.Role) { + hashPrefix := hash + if len(hashPrefix) > 8 { + hashPrefix = hashPrefix[:8] + } if key, role, ok := c.get(hash); ok { + slog.Debug("api_key_cache.hit", "hash_prefix", hashPrefix) return key, role } + slog.Debug("api_key_cache.miss", "hash_prefix", hashPrefix) // Cache miss — fetch from DB keyData, err := c.store.GetByHash(ctx, hash) diff --git a/internal/http/api_key_cache_test.go b/internal/http/api_key_cache_test.go index 7d3d643b..749a0fd2 100644 --- a/internal/http/api_key_cache_test.go +++ b/internal/http/api_key_cache_test.go @@ -41,10 +41,10 @@ func (m *mockAPIKeyStore) TouchLastUsed(_ context.Context, id uuid.UUID) error { return nil } -func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil } -func (m *mockAPIKeyStore) List(_ context.Context) ([]store.APIKeyData, error) { return nil, nil } -func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID) error { return nil } -func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID) error { return nil } +func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil } +func (m *mockAPIKeyStore) List(_ context.Context, _ string) ([]store.APIKeyData, error) { return nil, nil } +func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID, _ string) error { return nil } +func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID, _ string) error { return nil } func (m *mockAPIKeyStore) getCalls() int { m.mu.Lock() diff --git a/internal/http/api_keys.go b/internal/http/api_keys.go index 3f5c38e4..dbbed9ff 100644 --- a/internal/http/api_keys.go +++ b/internal/http/api_keys.go @@ -42,7 +42,8 @@ func (h *APIKeysHandler) adminAuth(next http.HandlerFunc) http.HandlerFunc { func (h *APIKeysHandler) handleList(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) - keys, err := h.apiKeys.List(r.Context()) + // HTTP API key list is admin-only (adminAuth middleware), so no owner filter needed. + keys, err := h.apiKeys.List(r.Context(), "") if err != nil { slog.Error("api_keys.list failed", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "API keys")}) @@ -61,6 +62,7 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` Scopes []string `json:"scopes"` ExpiresIn *int `json:"expires_in"` // seconds; nil = never + TenantID string `json:"tenant_id"` // optional UUID; cross-tenant callers may specify or omit (NULL = system key) } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) @@ -95,6 +97,22 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) { return } + // Resolve tenant_id based on caller type. + var tenantID uuid.UUID // uuid.Nil = system-level (NULL in DB) + if store.IsCrossTenant(r.Context()) { + if input.TenantID != "" { + tid, err := uuid.Parse(input.TenantID) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant_id")}) + return + } + tenantID = tid + } + // else: uuid.Nil stays → system-level key + } else { + tenantID = store.TenantIDFromContext(r.Context()) + } + now := time.Now() key := &store.APIKeyData{ ID: store.GenNewID(), @@ -102,6 +120,7 @@ func (h *APIKeysHandler) handleCreate(w http.ResponseWriter, r *http.Request) { Prefix: prefix, KeyHash: hash, Scopes: input.Scopes, + TenantID: tenantID, CreatedBy: extractUserID(r), CreatedAt: now, UpdatedAt: now, @@ -141,7 +160,8 @@ func (h *APIKeysHandler) handleRevoke(w http.ResponseWriter, r *http.Request) { return } - if err := h.apiKeys.Revoke(r.Context(), id); err != nil { + // HTTP revoke is admin-only (adminAuth middleware), so no owner filter needed. + if err := h.apiKeys.Revoke(r.Context(), id, ""); err != nil { slog.Error("api_keys.revoke failed", "error", err, "id", idStr) writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "API key", idStr)}) return diff --git a/internal/http/audit.go b/internal/http/audit.go index 9de66e6e..19e4d848 100644 --- a/internal/http/audit.go +++ b/internal/http/audit.go @@ -29,6 +29,7 @@ func emitAudit(msgBus *bus.MessageBus, r *http.Request, action, entityType, enti EntityType: entityType, EntityID: entityID, IPAddress: r.RemoteAddr, + TenantID: store.TenantIDFromContext(r.Context()), }, }) } diff --git a/internal/http/auth.go b/internal/http/auth.go index 652fc62c..d8a27f20 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/crypto" "github.com/nextlevelbuilder/goclaw/internal/i18n" @@ -77,6 +78,8 @@ func extractAgentID(r *http.Request, model string) string { var pkgAPIKeyCache *apiKeyCache var pkgPairingStore store.PairingStore +var pkgTenantCache *tenantCache +var pkgOwnerIDs []string // InitAPIKeyCache initializes the shared API key cache with TTL and pubsub invalidation. // Must be called once during server startup before handling requests. @@ -97,6 +100,42 @@ func InitPairingAuth(ps store.PairingStore) { pkgPairingStore = ps } +// InitOwnerIDs sets the configured owner user IDs for HTTP auth. +// Only owners get cross-tenant access with gateway token; others are tenant-scoped. +func InitOwnerIDs(ids []string) { + pkgOwnerIDs = ids +} + +// isHTTPOwnerID checks if the user ID is a configured owner. +// If no owner IDs configured, only "system" is treated as owner (fail-closed). +func isHTTPOwnerID(userID string, ownerIDs []string) bool { + if userID == "" { + return false + } + if len(ownerIDs) == 0 { + return userID == "system" + } + for _, id := range ownerIDs { + if id == userID { + return true + } + } + return false +} + +// InitTenantStore sets the tenant cache for HTTP auth with TTL and pubsub invalidation. +// Allows gateway token requests to scope to a specific tenant via X-GoClaw-Tenant-Id header. +func InitTenantStore(ts store.TenantStore, mb *bus.MessageBus) { + pkgTenantCache = newTenantCache(ts, 5*time.Minute) + if mb != nil { + mb.Subscribe("http-tenant-cache", func(e bus.Event) { + if p, ok := e.Payload.(bus.CacheInvalidatePayload); ok && p.Kind == bus.CacheKindTenants { + pkgTenantCache.invalidateAll() + } + }) + } +} + // ResolveAPIKey checks if the bearer token is a valid API key using the shared cache. // Returns the key data and derived role, or nil if not found/expired/revoked. func ResolveAPIKey(ctx context.Context, token string) (*store.APIKeyData, permissions.Role) { @@ -111,6 +150,9 @@ func ResolveAPIKey(ctx context.Context, token string) (*store.APIKeyData, permis type authResult struct { Role permissions.Role Authenticated bool + KeyData *store.APIKeyData // non-nil when authenticated via API key + TenantID uuid.UUID // resolved tenant; uuid.Nil for cross-tenant + CrossTenant bool // true for owner/system admin } // resolveAuth determines the caller's role from the request. @@ -123,18 +165,54 @@ func resolveAuth(r *http.Request, gatewayToken string) authResult { // Useful for handlers that also accept tokens from query params. func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult { // Gateway token → admin + // Only owner IDs get cross-tenant access; others are tenant-scoped. + // If no owner IDs configured, all gateway token users get cross-tenant (backward compat). if gatewayToken != "" && tokenMatch(bearer, gatewayToken) { - return authResult{Role: permissions.RoleAdmin, Authenticated: true} + userID := extractUserID(r) + isOwner := isHTTPOwnerID(userID, pkgOwnerIDs) + res := authResult{Role: permissions.RoleAdmin, Authenticated: true, CrossTenant: isOwner} + tenantVal := r.Header.Get("X-GoClaw-Tenant-Id") + if isOwner && tenantVal != "" && pkgTenantCache != nil { + // Cross-tenant admin can narrow scope via header + if tid, err := uuid.Parse(tenantVal); err == nil { + if t, err := pkgTenantCache.GetTenant(r.Context(), tid); err == nil && t != nil { + res.TenantID = t.ID + } + } else if t, err := pkgTenantCache.GetTenantBySlug(r.Context(), tenantVal); err == nil && t != nil { + res.TenantID = t.ID + } + } else if !isOwner && pkgTenantCache != nil { + // Non-owner with gateway token: resolve tenant from header or fallback to master + if tenantVal != "" { + if tid, err := uuid.Parse(tenantVal); err == nil { + if t, err := pkgTenantCache.GetTenant(r.Context(), tid); err == nil && t != nil { + res.TenantID = t.ID + } + } else if t, err := pkgTenantCache.GetTenantBySlug(r.Context(), tenantVal); err == nil && t != nil { + res.TenantID = t.ID + } + } + if res.TenantID == uuid.Nil { + res.TenantID = store.MasterTenantID + } + } + return res } // API key → role from scopes - if _, role := ResolveAPIKey(r.Context(), bearer); role != "" { - return authResult{Role: role, Authenticated: true} + if keyData, role := ResolveAPIKey(r.Context(), bearer); role != "" { + res := authResult{Role: role, Authenticated: true, KeyData: keyData} + if keyData.TenantID == uuid.Nil { + res.CrossTenant = true + } else { + res.TenantID = keyData.TenantID + } + return res } // Browser pairing → operator (via X-GoClaw-Sender-Id header) if senderID := r.Header.Get("X-GoClaw-Sender-Id"); senderID != "" && pkgPairingStore != nil { - paired, err := pkgPairingStore.IsPaired(senderID, "browser") + paired, err := pkgPairingStore.IsPaired(r.Context(), senderID, "browser") if err == nil && paired { - return authResult{Role: permissions.RoleOperator, Authenticated: true} + return authResult{Role: permissions.RoleOperator, Authenticated: true, TenantID: store.MasterTenantID} } if err != nil { slog.Warn("security.http_pairing_check_failed", "sender_id", senderID, "error", err) @@ -144,7 +222,7 @@ func resolveAuthBearer(r *http.Request, gatewayToken, bearer string) authResult } // No auth configured → admin (no token = dev/single-user mode, full access) if gatewayToken == "" { - return authResult{Role: permissions.RoleAdmin, Authenticated: true} + return authResult{Role: permissions.RoleAdmin, Authenticated: true, TenantID: store.MasterTenantID} } return authResult{} } @@ -187,16 +265,60 @@ func requireAuth(token string, minRole permissions.Role, next http.HandlerFunc) } ctx := store.WithLocale(r.Context(), locale) - if userID := extractUserID(r); userID != "" { + userID := extractUserID(r) + // If the API key has a bound owner, force user_id to owner regardless of header. + if auth.KeyData != nil && auth.KeyData.OwnerID != "" { + if userID != "" && userID != auth.KeyData.OwnerID { + slog.Warn("security.api_key_owner_override", + "header_user_id", userID, + "owner_id", auth.KeyData.OwnerID, + ) + } + userID = auth.KeyData.OwnerID + } + if userID != "" { ctx = store.WithUserID(ctx, userID) } + if auth.CrossTenant && auth.TenantID != uuid.Nil { + // Cross-tenant admin with tenant scope: filter data by chosen tenant + ctx = store.WithTenantID(ctx, auth.TenantID) + slog.Debug("security.http_auth_resolved", + "path", r.URL.Path, + "role", string(auth.Role), + "tenant_scope", auth.TenantID.String(), + ) + } else if auth.CrossTenant { + // Auto-scope to MasterTenantID so all operations use a concrete tenant. + ctx = store.WithTenantID(ctx, store.MasterTenantID) + slog.Debug("security.http_auth_resolved", + "path", r.URL.Path, + "role", string(auth.Role), + "cross_tenant", true, + "auto_scope", store.MasterTenantID.String(), + ) + } else if auth.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, auth.TenantID) + slog.Debug("security.http_auth_resolved", + "path", r.URL.Path, + "role", string(auth.Role), + "tenant_id", auth.TenantID.String(), + ) + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + slog.Debug("security.http_auth_resolved", + "path", r.URL.Path, + "role", string(auth.Role), + "tenant_id", store.MasterTenantID.String(), + ) + } next(w, r.WithContext(ctx)) } } // requireAuthBearer is like requireAuth but accepts a pre-extracted bearer token. // Used by handlers that accept tokens from query params (files, media). -func requireAuthBearer(token string, minRole permissions.Role, bearer string, w http.ResponseWriter, r *http.Request) bool { +// Returns the authenticated request with user context applied (owner_id enforcement included). +func requireAuthBearer(token string, minRole permissions.Role, bearer string, w http.ResponseWriter, r *http.Request) (*http.Request, bool) { locale := extractLocale(r) auth := resolveAuthBearer(r, token, bearer) @@ -204,7 +326,7 @@ func requireAuthBearer(token string, minRole permissions.Role, bearer string, w writeJSON(w, http.StatusUnauthorized, map[string]string{ "error": i18n.T(locale, i18n.MsgUnauthorized), }) - return false + return r, false } required := minRole @@ -216,9 +338,33 @@ func requireAuthBearer(token string, minRole permissions.Role, bearer string, w writeJSON(w, http.StatusForbidden, map[string]string{ "error": i18n.T(locale, i18n.MsgPermissionDenied, r.URL.Path), }) - return false + return r, false } - return true + + // Apply user context + owner_id enforcement (same as requireAuth). + ctx := store.WithLocale(r.Context(), locale) + userID := extractUserID(r) + if auth.KeyData != nil && auth.KeyData.OwnerID != "" { + if userID != "" && userID != auth.KeyData.OwnerID { + slog.Warn("security.api_key_owner_override", + "header_user_id", userID, + "owner_id", auth.KeyData.OwnerID, + "path", r.URL.Path, + ) + } + userID = auth.KeyData.OwnerID + } + if userID != "" { + ctx = store.WithUserID(ctx, userID) + } + if auth.CrossTenant { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + } else if auth.TenantID != uuid.Nil { + ctx = store.WithTenantID(ctx, auth.TenantID) + } else { + ctx = store.WithTenantID(ctx, store.MasterTenantID) + } + return r.WithContext(ctx), true } // extractLocale parses the Accept-Language header and returns a supported locale. diff --git a/internal/http/builtin_tools.go b/internal/http/builtin_tools.go index 43e06ed9..f560bbd3 100644 --- a/internal/http/builtin_tools.go +++ b/internal/http/builtin_tools.go @@ -5,6 +5,8 @@ import ( "log/slog" "net/http" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -14,14 +16,15 @@ import ( // BuiltinToolsHandler handles built-in tool management endpoints. // Built-in tools are seeded at startup; only enabled and settings are editable. type BuiltinToolsHandler struct { - store store.BuiltinToolStore - token string - msgBus *bus.MessageBus + store store.BuiltinToolStore + tenantCfgStore store.BuiltinToolTenantConfigStore + token string + msgBus *bus.MessageBus } // NewBuiltinToolsHandler creates a handler for built-in tool management endpoints. -func NewBuiltinToolsHandler(s store.BuiltinToolStore, token string, msgBus *bus.MessageBus) *BuiltinToolsHandler { - return &BuiltinToolsHandler{store: s, token: token, msgBus: msgBus} +func NewBuiltinToolsHandler(s store.BuiltinToolStore, tenantCfgs store.BuiltinToolTenantConfigStore, token string, msgBus *bus.MessageBus) *BuiltinToolsHandler { + return &BuiltinToolsHandler{store: s, tenantCfgStore: tenantCfgs, token: token, msgBus: msgBus} } // RegisterRoutes registers all built-in tool routes on the given mux. @@ -29,6 +32,8 @@ func (h *BuiltinToolsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/tools/builtin", h.auth(h.handleList)) mux.HandleFunc("GET /v1/tools/builtin/{name}", h.auth(h.handleGet)) mux.HandleFunc("PUT /v1/tools/builtin/{name}", h.auth(h.handleUpdate)) + mux.HandleFunc("PUT /v1/tools/builtin/{name}/tenant-config", h.auth(h.handleSetTenantConfig)) + mux.HandleFunc("DELETE /v1/tools/builtin/{name}/tenant-config", h.auth(h.handleDeleteTenantConfig)) } func (h *BuiltinToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc { @@ -53,6 +58,28 @@ func (h *BuiltinToolsHandler) handleList(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")}) return } + + // Merge per-tenant overrides into response when tenant-scoped + tid := store.TenantIDFromContext(r.Context()) + if tid != uuid.Nil && h.tenantCfgStore != nil { + overrides, err := h.tenantCfgStore.ListAll(r.Context(), tid) + if err == nil && len(overrides) > 0 { + type toolWithTenant struct { + store.BuiltinToolDef + TenantEnabled *bool `json:"tenant_enabled"` + } + enriched := make([]toolWithTenant, len(result)) + for i, t := range result { + enriched[i] = toolWithTenant{BuiltinToolDef: t} + if enabled, ok := overrides[t.Name]; ok { + enriched[i].TenantEnabled = &enabled + } + } + writeJSON(w, http.StatusOK, map[string]any{"tools": enriched}) + return + } + } + writeJSON(w, http.StatusOK, map[string]any{"tools": result}) } @@ -111,3 +138,54 @@ func (h *BuiltinToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Reques h.emitCacheInvalidate(name) writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } + +// handleSetTenantConfig sets a per-tenant override for a builtin tool. +func (h *BuiltinToolsHandler) handleSetTenantConfig(w http.ResponseWriter, r *http.Request) { + if h.tenantCfgStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"}) + return + } + name := r.PathValue("name") + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"}) + return + } + + var body struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + if err := h.tenantCfgStore.Set(r.Context(), tid, name, body.Enabled); err != nil { + slog.Warn("set tenant tool config failed", "tool", name, "tenant", tid, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) +} + +// handleDeleteTenantConfig removes a per-tenant override (reverts to default). +func (h *BuiltinToolsHandler) handleDeleteTenantConfig(w http.ResponseWriter, r *http.Request) { + if h.tenantCfgStore == nil { + writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"}) + return + } + name := r.PathValue("name") + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"}) + return + } + + if err := h.tenantCfgStore.Delete(r.Context(), tid, name); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index b670d292..cd55660e 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -21,13 +21,14 @@ type ChannelInstancesHandler struct { agentStore store.AgentStore configPermStore store.ConfigPermissionStore contactStore store.ContactStore + tenantStore store.TenantStore token string msgBus *bus.MessageBus } // NewChannelInstancesHandler creates a handler for channel instance management endpoints. -func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, contactStore store.ContactStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler { - return &ChannelInstancesHandler{store: s, agentStore: agentStore, configPermStore: configPermStore, contactStore: contactStore, token: token, msgBus: msgBus} +func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, contactStore store.ContactStore, tenantStore store.TenantStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler { + return &ChannelInstancesHandler{store: s, agentStore: agentStore, configPermStore: configPermStore, contactStore: contactStore, tenantStore: tenantStore, token: token, msgBus: msgBus} } // RegisterRoutes registers all channel instance routes on the given mux. @@ -42,6 +43,12 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) { if h.contactStore != nil { mux.HandleFunc("GET /v1/contacts", h.auth(h.handleListContacts)) mux.HandleFunc("GET /v1/contacts/resolve", h.auth(h.handleResolveContacts)) + mux.HandleFunc("POST /v1/contacts/merge", h.auth(h.handleMergeContacts)) + mux.HandleFunc("POST /v1/contacts/unmerge", h.auth(h.handleUnmergeContacts)) + mux.HandleFunc("GET /v1/contacts/merged/{tenantUserId}", h.auth(h.handleListMergedContacts)) + } + if h.tenantStore != nil { + mux.HandleFunc("GET /v1/tenant-users", h.auth(h.handleListTenantUsers)) } // Group file writers (nested under channel instances) diff --git a/internal/http/chat_completions.go b/internal/http/chat_completions.go index 5a523a17..85dda076 100644 --- a/internal/http/chat_completions.go +++ b/internal/http/chat_completions.go @@ -136,7 +136,7 @@ func (h *ChatCompletionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques return } - loop, err := h.agents.Get(agentID) + loop, err := h.agents.Get(r.Context(), agentID) if err != nil { http.Error(w, fmt.Sprintf(`{"error":{"message":"%s"}}`, i18n.T(locale, i18n.MsgNotFound, "agent", agentID)), http.StatusNotFound) return @@ -205,7 +205,7 @@ func (h *ChatCompletionsHandler) handleNonStream(w http.ResponseWriter, r *http. Model: model, Choices: []chatChoice{{ Index: 0, - Message: &chatMessage{Role: "assistant", Content: result.Content}, + Message: &chatMessage{Role: "assistant", Content: SignFileURLs(result.Content, FileSigningKey())}, FinishReason: "stop", }}, } @@ -257,7 +257,7 @@ func (h *ChatCompletionsHandler) handleStream(w http.ResponseWriter, r *http.Req writeSSEChunk(w, flusher, completionID, model, &chatMessage{Content: "Error: " + err.Error()}, "stop") } else { // Send content chunk - writeSSEChunk(w, flusher, completionID, model, &chatMessage{Content: result.Content}, "stop") + writeSSEChunk(w, flusher, completionID, model, &chatMessage{Content: SignFileURLs(result.Content, FileSigningKey())}, "stop") } // Send [DONE] diff --git a/internal/http/contact_merge_handlers.go b/internal/http/contact_merge_handlers.go new file mode 100644 index 00000000..1e960b4b --- /dev/null +++ b/internal/http/contact_merge_handlers.go @@ -0,0 +1,198 @@ +package http + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// handleMergeContacts links selected contacts to a tenant_user identity. +// POST /v1/contacts/merge +func (h *ChannelInstancesHandler) handleMergeContacts(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTenantScopeRequired)}) + return + } + + var body struct { + ContactIDs []uuid.UUID `json:"contact_ids"` + TenantUserID *uuid.UUID `json:"tenant_user_id"` + CreateUser *struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + } `json:"create_user"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + if len(body.ContactIDs) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgContactIDsRequired)}) + return + } + if len(body.ContactIDs) > 500 { + body.ContactIDs = body.ContactIDs[:500] + } + hasTU := body.TenantUserID != nil + hasCU := body.CreateUser != nil + if hasTU == hasCU { // must have exactly one + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgMergeTargetRequired)}) + return + } + + var targetID uuid.UUID + + if hasTU { + // Link to existing tenant_user — verify same tenant. + tu, err := h.tenantStore.GetTenantUser(r.Context(), *body.TenantUserID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgTenantUserNotFound)}) + return + } + if tu.TenantID != tid { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgTenantMismatch)}) + return + } + targetID = tu.ID + } else { + // Create new tenant_user. + userID := body.CreateUser.UserID + displayName := body.CreateUser.DisplayName + if userID == "" { + // Fallback: derive from first contact's username. + userID = h.deriveUserIDFromContacts(r.Context(), body.ContactIDs) + } + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "user_id")}) + return + } + tu, err := h.tenantStore.CreateTenantUserReturning(r.Context(), tid, userID, displayName, store.TenantRoleMember) + if err != nil { + slog.Error("contacts.merge.create_user", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "tenant user", err.Error())}) + return + } + targetID = tu.ID + } + + if err := h.contactStore.MergeContacts(r.Context(), body.ContactIDs, targetID); err != nil { + slog.Error("contacts.merge", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToUpdate, "contacts", err.Error())}) + return + } + + emitAudit(h.msgBus, r, "contacts.merged", "tenant_user", targetID.String()) + writeJSON(w, http.StatusOK, map[string]any{ + "merged_id": targetID, + "merged_count": len(body.ContactIDs), + }) +} + +// handleUnmergeContacts removes merged_id from selected contacts. +// POST /v1/contacts/unmerge +func (h *ChannelInstancesHandler) handleUnmergeContacts(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTenantScopeRequired)}) + return + } + + var body struct { + ContactIDs []uuid.UUID `json:"contact_ids"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + if len(body.ContactIDs) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgContactIDsRequired)}) + return + } + if len(body.ContactIDs) > 500 { + body.ContactIDs = body.ContactIDs[:500] + } + + if err := h.contactStore.UnmergeContacts(r.Context(), body.ContactIDs); err != nil { + slog.Error("contacts.unmerge", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToUpdate, "contacts", err.Error())}) + return + } + + emitAudit(h.msgBus, r, "contacts.unmerged", "contacts", "") + writeJSON(w, http.StatusOK, map[string]any{"unmerged_count": len(body.ContactIDs)}) +} + +// handleListMergedContacts returns contacts linked to a tenant_user. +// GET /v1/contacts/merged/{tenantUserId} +func (h *ChannelInstancesHandler) handleListMergedContacts(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTenantScopeRequired)}) + return + } + + mergedID, err := uuid.Parse(r.PathValue("tenantUserId")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenantUserId")}) + return + } + + contacts, err := h.contactStore.GetContactsByMergedID(r.Context(), mergedID) + if err != nil { + slog.Error("contacts.merged.list", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "contacts")}) + return + } + if contacts == nil { + contacts = []store.ChannelContact{} + } + writeJSON(w, http.StatusOK, map[string]any{"contacts": contacts}) +} + +// handleListTenantUsers returns users for the current tenant (for merge dialog dropdown). +// GET /v1/tenant-users +func (h *ChannelInstancesHandler) handleListTenantUsers(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgTenantScopeRequired)}) + return + } + + users, err := h.tenantStore.ListUsers(r.Context(), tid) + if err != nil { + slog.Error("tenant_users.list", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tenant users")}) + return + } + if users == nil { + users = []store.TenantUserData{} + } + writeJSON(w, http.StatusOK, map[string]any{"users": users}) +} + +// deriveUserIDFromContacts returns the first contact's username or sender_id as fallback user_id. +func (h *ChannelInstancesHandler) deriveUserIDFromContacts(ctx context.Context, contactIDs []uuid.UUID) string { + if len(contactIDs) == 0 { + return "" + } + c, err := h.contactStore.GetContactByID(ctx, contactIDs[0]) + if err != nil { + return "" + } + if c.Username != nil && *c.Username != "" { + return *c.Username + } + return c.SenderID +} diff --git a/internal/http/custom_tools.go b/internal/http/custom_tools.go deleted file mode 100644 index 5b574cad..00000000 --- a/internal/http/custom_tools.go +++ /dev/null @@ -1,214 +0,0 @@ -package http - -import ( - "encoding/json" - "log/slog" - "net/http" - "strconv" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/bus" - "github.com/nextlevelbuilder/goclaw/internal/i18n" - "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/internal/tools" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" -) - -// CustomToolsHandler handles custom tool CRUD endpoints. -type CustomToolsHandler struct { - store store.CustomToolStore - token string - msgBus *bus.MessageBus - toolsReg *tools.Registry // for name collision checking on create -} - -// NewCustomToolsHandler creates a handler for custom tool management endpoints. -func NewCustomToolsHandler(s store.CustomToolStore, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry) *CustomToolsHandler { - return &CustomToolsHandler{store: s, token: token, msgBus: msgBus, toolsReg: toolsReg} -} - -// RegisterRoutes registers all custom tool routes on the given mux. -func (h *CustomToolsHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /v1/tools/custom", h.auth(h.handleList)) - mux.HandleFunc("POST /v1/tools/custom", h.auth(h.handleCreate)) - mux.HandleFunc("GET /v1/tools/custom/{id}", h.auth(h.handleGet)) - mux.HandleFunc("PUT /v1/tools/custom/{id}", h.auth(h.handleUpdate)) - mux.HandleFunc("DELETE /v1/tools/custom/{id}", h.auth(h.handleDelete)) -} - -func (h *CustomToolsHandler) auth(next http.HandlerFunc) http.HandlerFunc { - return requireAuth(h.token, "", next) -} - -func (h *CustomToolsHandler) emitCacheInvalidate(key string) { - if h.msgBus == nil { - return - } - h.msgBus.Broadcast(bus.Event{ - Name: protocol.EventCacheInvalidate, - Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindCustomTools, Key: key}, - }) -} - -func (h *CustomToolsHandler) handleList(w http.ResponseWriter, r *http.Request) { - locale := store.LocaleFromContext(r.Context()) - opts := store.CustomToolListOpts{ - Limit: 50, - Offset: 0, - } - - if v := r.URL.Query().Get("agent_id"); v != "" { - id, err := uuid.Parse(v) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")}) - return - } - opts.AgentID = &id - } - if v := r.URL.Query().Get("search"); v != "" { - opts.Search = v - } - if v := r.URL.Query().Get("limit"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { - opts.Limit = n - } - } - if v := r.URL.Query().Get("offset"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n >= 0 { - opts.Offset = n - } - } - - result, err := h.store.ListPaged(r.Context(), opts) - if err != nil { - slog.Error("custom_tools.list", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tools")}) - return - } - - total, _ := h.store.CountTools(r.Context(), opts) - - writeJSON(w, http.StatusOK, map[string]any{ - "tools": result, - "total": total, - "limit": opts.Limit, - "offset": opts.Offset, - }) -} - -func (h *CustomToolsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { - locale := store.LocaleFromContext(r.Context()) - var def store.CustomToolDef - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&def); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) - return - } - - if def.Name == "" || def.Command == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name and command")}) - return - } - if !isValidSlug(def.Name) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")}) - return - } - - // Check name collision with built-in/MCP tools - if h.toolsReg != nil { - if _, exists := h.toolsReg.Get(def.Name); exists { - writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "tool name", def.Name)}) - return - } - } - - userID := store.UserIDFromContext(r.Context()) - if userID != "" { - def.CreatedBy = userID - } - - if def.TimeoutSeconds <= 0 { - def.TimeoutSeconds = 60 - } - - if err := h.store.Create(r.Context(), &def); err != nil { - slog.Error("custom_tools.create", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - emitAudit(h.msgBus, r, "custom_tool.created", "custom_tool", def.ID.String()) - h.emitCacheInvalidate(def.ID.String()) - writeJSON(w, http.StatusCreated, def) -} - -func (h *CustomToolsHandler) handleGet(w http.ResponseWriter, r *http.Request) { - locale := store.LocaleFromContext(r.Context()) - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")}) - return - } - - def, err := h.store.Get(r.Context(), id) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "tool", id.String())}) - return - } - - writeJSON(w, http.StatusOK, def) -} - -func (h *CustomToolsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { - locale := store.LocaleFromContext(r.Context()) - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")}) - return - } - - var updates map[string]any - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&updates); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) - return - } - - if name, ok := updates["name"]; ok { - if s, _ := name.(string); !isValidSlug(s) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "name")}) - return - } - } - - // Allowlist: only permit known custom tool columns. - updates = filterAllowedKeys(updates, customToolAllowedFields) - - if err := h.store.Update(r.Context(), id, updates); err != nil { - slog.Error("custom_tools.update", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - emitAudit(h.msgBus, r, "custom_tool.updated", "custom_tool", id.String()) - h.emitCacheInvalidate(id.String()) - writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) -} - -func (h *CustomToolsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { - locale := store.LocaleFromContext(r.Context()) - id, err := uuid.Parse(r.PathValue("id")) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tool")}) - return - } - - if err := h.store.Delete(r.Context(), id); err != nil { - slog.Error("custom_tools.delete", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - emitAudit(h.msgBus, r, "custom_tool.deleted", "custom_tool", id.String()) - h.emitCacheInvalidate(id.String()) - writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) -} diff --git a/internal/http/delegations.go b/internal/http/delegations.go deleted file mode 100644 index d72b3870..00000000 --- a/internal/http/delegations.go +++ /dev/null @@ -1,98 +0,0 @@ -package http - -import ( - "net/http" - "strconv" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/i18n" - "github.com/nextlevelbuilder/goclaw/internal/store" -) - -// DelegationsHandler handles delegation history HTTP endpoints. -type DelegationsHandler struct { - teamStore store.TeamStore - token string -} - -func NewDelegationsHandler(teamStore store.TeamStore, token string) *DelegationsHandler { - return &DelegationsHandler{teamStore: teamStore, token: token} -} - -func (h *DelegationsHandler) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /v1/delegations", h.authMiddleware(h.handleList)) - mux.HandleFunc("GET /v1/delegations/{id}", h.authMiddleware(h.handleGet)) -} - -func (h *DelegationsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { - return requireAuth(h.token, "", next) -} - -func (h *DelegationsHandler) handleList(w http.ResponseWriter, r *http.Request) { - opts := store.DelegationHistoryListOpts{ - Limit: 50, - Offset: 0, - } - - if v := r.URL.Query().Get("source_agent_id"); v != "" { - if id, err := uuid.Parse(v); err == nil { - opts.SourceAgentID = &id - } - } - if v := r.URL.Query().Get("target_agent_id"); v != "" { - if id, err := uuid.Parse(v); err == nil { - opts.TargetAgentID = &id - } - } - if v := r.URL.Query().Get("team_id"); v != "" { - if id, err := uuid.Parse(v); err == nil { - opts.TeamID = &id - } - } - if v := r.URL.Query().Get("user_id"); v != "" { - opts.UserID = v - } - if v := r.URL.Query().Get("status"); v != "" { - opts.Status = v - } - if v := r.URL.Query().Get("limit"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { - opts.Limit = n - } - } - if v := r.URL.Query().Get("offset"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n >= 0 { - opts.Offset = n - } - } - - records, total, err := h.teamStore.ListDelegationHistory(r.Context(), opts) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - writeJSON(w, http.StatusOK, map[string]any{ - "records": records, - "total": total, - }) -} - -func (h *DelegationsHandler) handleGet(w http.ResponseWriter, r *http.Request) { - locale := store.LocaleFromContext(r.Context()) - idStr := r.PathValue("id") - id, err := uuid.Parse(idStr) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "delegation")}) - return - } - - record, err := h.teamStore.GetDelegationHistory(r.Context(), id) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - writeJSON(w, http.StatusOK, record) -} diff --git a/internal/http/file_token.go b/internal/http/file_token.go new file mode 100644 index 00000000..45770277 --- /dev/null +++ b/internal/http/file_token.go @@ -0,0 +1,89 @@ +package http + +import ( + "crypto/hmac" + crypto_rand "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// FileTokenTTL is the default TTL for signed file tokens. +const FileTokenTTL = 5 * time.Minute + +var ( + fileSigningKey string + fileSigningKeyOnce sync.Once +) + +// FileSigningKey returns a random 32-byte key for HMAC file token signing. +// Generated once at startup, lives in memory only. Tokens expire on restart +// which is acceptable for the short TTL — clients re-fetch signed URLs on reconnect. +func FileSigningKey() string { + fileSigningKeyOnce.Do(func() { + b := make([]byte, 32) + crypto_rand.Read(b) + fileSigningKey = base64.RawURLEncoding.EncodeToString(b) + }) + return fileSigningKey +} + +// SignFileToken creates a short-lived HMAC token for file access. +// Token format: {base64url_hmac_16bytes}.{unix_expiry} (~40 chars). +// The path is bound into the signature so tokens can't be reused for other files. +func SignFileToken(path, secret string, ttl time.Duration) string { + expiry := time.Now().Add(ttl).Unix() + sig := fileTokenHMAC(path, secret, expiry) + return fmt.Sprintf("%s.%d", sig, expiry) +} + +// VerifyFileToken validates a signed file token against a path and secret. +// Returns true if the HMAC matches and the token has not expired. +func VerifyFileToken(token, path, secret string) bool { + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 { + return false + } + expiry, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || time.Now().Unix() > expiry { + return false + } + expected := fileTokenHMAC(path, secret, expiry) + return hmac.Equal([]byte(parts[0]), []byte(expected)) +} + +// fileTokenHMAC computes the HMAC signature component. +func fileTokenHMAC(path, secret string, expiry int64) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(fmt.Sprintf("%s:%d", path, expiry))) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16]) +} + +// fileURLRe matches /v1/files/... and /v1/media/... URLs in markdown and plain text. +// Captures the full URL path (stops at whitespace, closing paren, quote, or angle bracket). +var fileURLRe = regexp.MustCompile(`(/v1/(?:files|media)/[^\s)"'<>]+)`) + +// SignFileURLs finds all /v1/files/ and /v1/media/ URLs in content and appends +// a signed ?ft= token. Used at delivery time (WS events, HTTP responses) to avoid +// persisting tokens in session messages. Skips URLs that already have ?ft=. +func SignFileURLs(content, secret string) string { + if secret == "" || !strings.Contains(content, "/v1/") { + return content + } + return fileURLRe.ReplaceAllStringFunc(content, func(url string) string { + if strings.Contains(url, "ft=") { + return url // already signed + } + ft := SignFileToken(url, secret, FileTokenTTL) + sep := "?" + if strings.Contains(url, "?") { + sep = "&" + } + return url + sep + "ft=" + ft + }) +} diff --git a/internal/http/files.go b/internal/http/files.go index 3962b081..49e1130c 100644 --- a/internal/http/files.go +++ b/internal/http/files.go @@ -8,7 +8,9 @@ import ( "path/filepath" "strings" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // FilesHandler serves files over HTTP with Bearer token auth. @@ -18,12 +20,14 @@ import ( type FilesHandler struct { token string workspace string // workspace root for fallback file search + dataDir string // data directory root for tenant path validation } // NewFilesHandler creates a handler that serves files by absolute path. // workspace is the root directory used for fallback generated file search. -func NewFilesHandler(token, workspace string) *FilesHandler { - return &FilesHandler{token: token, workspace: workspace} +// dataDir is used for tenant path validation (files must be within tenant's dirs). +func NewFilesHandler(token, workspace, dataDir string) *FilesHandler { + return &FilesHandler{token: token, workspace: workspace, dataDir: dataDir} } // RegisterRoutes registers the file serving route. @@ -33,15 +37,23 @@ func (h *FilesHandler) RegisterRoutes(mux *http.ServeMux) { func (h *FilesHandler) auth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // Accept token via Bearer header or ?token= query param (for ). - provided := extractBearerToken(r) - if provided == "" { - provided = r.URL.Query().Get("token") - } - if !requireAuthBearer(h.token, "", provided, w, r) { + // Priority 1: short-lived signed file token (?ft=) — decoupled from gateway token. + if ft := r.URL.Query().Get("ft"); ft != "" { + path := "/v1/files/" + r.PathValue("path") + if VerifyFileToken(ft, path, FileSigningKey()) { + next(w, r) + return + } + http.Error(w, "invalid or expired file token", http.StatusUnauthorized) return } - next(w, r) + // Priority 2: Bearer header (API clients only). + provided := extractBearerToken(r) + authedReq, ok := requireAuthBearer(h.token, "", provided, w, r) + if !ok { + return + } + next(w, authedReq) } } @@ -81,11 +93,27 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) { } } + // Tenant isolation: validate absolute path is within tenant's allowed directories. + // Skip for HMAC file tokens — the path is cryptographically bound in the signature, + // so it cannot be tampered with. File tokens are generated server-side with the correct path. + if r.URL.Query().Get("ft") == "" { + tenantData := config.TenantDataDir(h.dataDir, store.TenantIDFromContext(r.Context()), store.TenantSlugFromContext(r.Context())) + tenantWs := h.tenantWorkspace(r) + if !strings.HasPrefix(absPath, tenantData+string(filepath.Separator)) && + !strings.HasPrefix(absPath, tenantWs+string(filepath.Separator)) && + absPath != tenantData && absPath != tenantWs { + http.NotFound(w, r) + return + } + } + info, err := os.Stat(absPath) if err != nil || info.IsDir() { // Fallback: search workspace for file by basename (handles LLM-hallucinated paths). // Generated filenames (goclaw_gen_*) include nanosecond timestamps and are globally unique. - if resolved := h.findInWorkspace(filepath.Base(absPath)); resolved != "" { + // Workspace scoped to tenant to prevent cross-tenant file discovery. + ws := h.tenantWorkspace(r) + if resolved := h.findInWorkspace(ws, filepath.Base(absPath)); resolved != "" { absPath = resolved info, _ = os.Stat(absPath) } else { @@ -104,22 +132,29 @@ func (h *FilesHandler) handleServe(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, absPath) } +// tenantWorkspace resolves the workspace scoped to the requesting tenant. +func (h *FilesHandler) tenantWorkspace(r *http.Request) string { + tid := store.TenantIDFromContext(r.Context()) + slug := store.TenantSlugFromContext(r.Context()) + return config.TenantWorkspace(h.workspace, tid, slug) +} + // findInWorkspace searches the workspace directory tree for a file by basename. // Returns the absolute path if found, empty string otherwise. // Searches team directories including generated/ and system/ subdirs. -func (h *FilesHandler) findInWorkspace(basename string) string { - if h.workspace == "" || basename == "" { +func (h *FilesHandler) findInWorkspace(workspace, basename string) string { + if workspace == "" || basename == "" { return "" } var found string - _ = filepath.WalkDir(h.workspace, func(path string, d os.DirEntry, err error) error { + _ = filepath.WalkDir(workspace, func(path string, d os.DirEntry, err error) error { if err != nil { return filepath.SkipDir } if d.IsDir() { name := d.Name() // Allow workspace root + known directory structures - if name == "teams" || name == "generated" || name == "system" || path == h.workspace { + if name == "teams" || name == "generated" || name == "system" || name == ".uploads" || path == workspace { return nil } // Allow date directories (e.g. 2026-03-20) diff --git a/internal/http/knowledge_graph.go b/internal/http/knowledge_graph.go index 1606c04f..1cd73a29 100644 --- a/internal/http/knowledge_graph.go +++ b/internal/http/knowledge_graph.go @@ -1,6 +1,7 @@ package http import ( + "context" "net/http" kg "github.com/nextlevelbuilder/goclaw/internal/knowledgegraph" @@ -21,11 +22,11 @@ func NewKnowledgeGraphHandler(s store.KnowledgeGraphStore, providerReg *provider } // NewExtractor creates an Extractor from the given provider name and model. -func (h *KnowledgeGraphHandler) NewExtractor(providerName, model string, minConfidence float64) *kg.Extractor { +func (h *KnowledgeGraphHandler) NewExtractor(ctx context.Context, providerName, model string, minConfidence float64) *kg.Extractor { if h.providerReg == nil || providerName == "" || model == "" { return nil } - p, err := h.providerReg.Get(providerName) + p, err := h.providerReg.Get(ctx, providerName) if err != nil { return nil } diff --git a/internal/http/knowledge_graph_handlers.go b/internal/http/knowledge_graph_handlers.go index 63472969..75e2a241 100644 --- a/internal/http/knowledge_graph_handlers.go +++ b/internal/http/knowledge_graph_handlers.go @@ -179,7 +179,7 @@ func (h *KnowledgeGraphHandler) handleExtract(w http.ResponseWriter, r *http.Req return } - extractor := h.NewExtractor(body.Provider, body.Model, body.MinConf) + extractor := h.NewExtractor(r.Context(), body.Provider, body.Model, body.MinConf) if extractor == nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidProviderOrModel)}) return diff --git a/internal/http/mcp.go b/internal/http/mcp.go index 8830afe6..85ad6f34 100644 --- a/internal/http/mcp.go +++ b/internal/http/mcp.go @@ -18,12 +18,18 @@ type MCPToolLister interface { ServerToolNames(serverName string) []string } +// MCPPoolEvictor evicts pooled connections for a tenant+server (called on credential rotation). +type MCPPoolEvictor interface { + Evict(tenantID uuid.UUID, serverName string) +} + // MCPHandler handles MCP server management HTTP endpoints. type MCPHandler struct { - store store.MCPServerStore - token string - msgBus *bus.MessageBus - mgr MCPToolLister // optional, nil when Manager not available + store store.MCPServerStore + token string + msgBus *bus.MessageBus + mgr MCPToolLister // optional, nil when Manager not available + poolEvictor MCPPoolEvictor // optional, nil when pool not available } // NewMCPHandler creates a handler for MCP server management endpoints. @@ -31,6 +37,9 @@ func NewMCPHandler(s store.MCPServerStore, token string, msgBus *bus.MessageBus, return &MCPHandler{store: s, token: token, msgBus: msgBus, mgr: mgr} } +// SetPoolEvictor sets the pool evictor for credential rotation handling. +func (h *MCPHandler) SetPoolEvictor(e MCPPoolEvictor) { h.poolEvictor = e } + func (h *MCPHandler) emitCacheInvalidate() { if h.msgBus == nil { return @@ -177,12 +186,29 @@ func (h *MCPHandler) handleUpdateServer(w http.ResponseWriter, r *http.Request) // Allowlist: only permit known MCP server columns. updates = filterAllowedKeys(updates, mcpServerAllowedFields) + // Read server name before update for pool eviction + var serverName string + if srv, _ := h.store.GetServer(r.Context(), id); srv != nil { + serverName = srv.Name + } + if err := h.store.UpdateServer(r.Context(), id, updates); err != nil { slog.Error("mcp.update_server", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } + // Evict pool connections when credentials change (force reconnect with new creds) + if h.poolEvictor != nil && serverName != "" { + _, hasKey := updates["api_key"] + _, hasHeaders := updates["headers"] + _, hasEnv := updates["env"] + if hasKey || hasHeaders || hasEnv { + tid := store.TenantIDFromContext(r.Context()) + h.poolEvictor.Evict(tid, serverName) + } + } + h.emitCacheInvalidate() emitAudit(h.msgBus, r, "mcp_server.updated", "mcp_server", id.String()) writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) diff --git a/internal/http/mcp_user_credentials.go b/internal/http/mcp_user_credentials.go new file mode 100644 index 00000000..3e2ddd1d --- /dev/null +++ b/internal/http/mcp_user_credentials.go @@ -0,0 +1,108 @@ +package http + +import ( + "encoding/json" + "net/http" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// MCPUserCredentialsHandler handles per-user MCP credential endpoints. +type MCPUserCredentialsHandler struct { + store store.MCPServerStore + token string +} + +// NewMCPUserCredentialsHandler creates a handler for MCP user credential endpoints. +func NewMCPUserCredentialsHandler(s store.MCPServerStore, token string) *MCPUserCredentialsHandler { + return &MCPUserCredentialsHandler{store: s, token: token} +} + +// RegisterRoutes registers MCP user credential routes. +func (h *MCPUserCredentialsHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("PUT /v1/mcp/servers/{id}/user-credentials", h.auth(h.handleSet)) + mux.HandleFunc("GET /v1/mcp/servers/{id}/user-credentials", h.auth(h.handleGet)) + mux.HandleFunc("DELETE /v1/mcp/servers/{id}/user-credentials", h.auth(h.handleDelete)) +} + +func (h *MCPUserCredentialsHandler) auth(next http.HandlerFunc) http.HandlerFunc { + return requireAuth(h.token, "", next) +} + +func (h *MCPUserCredentialsHandler) handleSet(w http.ResponseWriter, r *http.Request) { + serverID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) + return + } + + userID := store.UserIDFromContext(r.Context()) + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user context required"}) + return + } + + var creds store.MCPUserCredentials + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&creds); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + if err := h.store.SetUserCredentials(r.Context(), serverID, userID, creds); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) +} + +func (h *MCPUserCredentialsHandler) handleGet(w http.ResponseWriter, r *http.Request) { + serverID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) + return + } + + userID := store.UserIDFromContext(r.Context()) + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user context required"}) + return + } + + creds, err := h.store.GetUserCredentials(r.Context(), serverID, userID) + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{"has_credentials": false}) + return + } + + // Return masked status (never expose actual credentials) + writeJSON(w, http.StatusOK, map[string]any{ + "has_credentials": true, + "has_api_key": creds.APIKey != "", + "has_headers": len(creds.Headers) > 0, + "has_env": len(creds.Env) > 0, + }) +} + +func (h *MCPUserCredentialsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { + serverID, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid server ID"}) + return + } + + userID := store.UserIDFromContext(r.Context()) + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user context required"}) + return + } + + if err := h.store.DeleteUserCredentials(r.Context(), serverID, userID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} diff --git a/internal/http/media_serve.go b/internal/http/media_serve.go index 52c71c8a..7e997f08 100644 --- a/internal/http/media_serve.go +++ b/internal/http/media_serve.go @@ -29,15 +29,23 @@ func (h *MediaServeHandler) RegisterRoutes(mux *http.ServeMux) { func (h *MediaServeHandler) auth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // Accept token via Bearer header or ?token= query param (for ). - provided := extractBearerToken(r) - if provided == "" { - provided = r.URL.Query().Get("token") - } - if !requireAuthBearer(h.token, "", provided, w, r) { + // Priority 1: short-lived signed file token (?ft=) — decoupled from gateway token. + if ft := r.URL.Query().Get("ft"); ft != "" { + mediaID := r.PathValue("id") + if VerifyFileToken(ft, "/v1/media/"+mediaID, FileSigningKey()) { + next(w, r) + return + } + http.Error(w, "invalid or expired file token", http.StatusUnauthorized) return } - next(w, r) + // Priority 2: Bearer header (API clients only). + provided := extractBearerToken(r) + authedReq, ok := requireAuthBearer(h.token, "", provided, w, r) + if !ok { + return + } + next(w, authedReq) } } diff --git a/internal/http/oauth.go b/internal/http/oauth.go index 0d789089..82350614 100644 --- a/internal/http/oauth.go +++ b/internal/http/oauth.go @@ -52,12 +52,16 @@ func (h *OAuthHandler) auth(next http.HandlerFunc) http.HandlerFunc { return requireAuth(h.token, "", next) } -func (h *OAuthHandler) newTokenSource() *oauth.DBTokenSource { - return oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName) +func (h *OAuthHandler) newTokenSource(r *http.Request) *oauth.DBTokenSource { + ts := oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName) + if tid := store.TenantIDFromContext(r.Context()); tid != uuid.Nil { + ts.WithTenantID(tid) + } + return ts } func (h *OAuthHandler) handleStatus(w http.ResponseWriter, r *http.Request) { - ts := h.newTokenSource() + ts := h.newTokenSource(r) if !ts.Exists(r.Context()) { writeJSON(w, http.StatusOK, map[string]any{"authenticated": false}) return @@ -83,7 +87,7 @@ func (h *OAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) { defer h.mu.Unlock() // Already authenticated? - ts := h.newTokenSource() + ts := h.newTokenSource(r) if ts.Exists(r.Context()) { if _, err := ts.Token(); err == nil { writeJSON(w, http.StatusOK, map[string]any{"status": "already_authenticated"}) @@ -191,14 +195,18 @@ func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Reque } func (h *OAuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) { - ts := h.newTokenSource() + ts := h.newTokenSource(r) if err := ts.Delete(r.Context()); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if h.providerReg != nil { - h.providerReg.Unregister(oauth.DefaultProviderName) + tid := store.TenantIDFromContext(r.Context()) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + h.providerReg.UnregisterForTenant(tid, oauth.DefaultProviderName) } emitAudit(h.msgBus, r, "oauth.logout", "oauth", "openai") @@ -207,7 +215,10 @@ func (h *OAuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) { // saveAndRegister persists the OAuth result to DB and registers the CodexProvider in-memory. func (h *OAuthHandler) saveAndRegister(ctx context.Context, tokenResp *oauth.OpenAITokenResponse) (uuid.UUID, error) { - ts := h.newTokenSource() + ts := oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName) + if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil { + ts.WithTenantID(tid) + } providerID, err := ts.SaveOAuthResult(ctx, tokenResp) if err != nil { return uuid.Nil, err diff --git a/internal/http/openapi_spec.json b/internal/http/openapi_spec.json index b274709d..683e1591 100644 --- a/internal/http/openapi_spec.json +++ b/internal/http/openapi_spec.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "GoClaw Gateway API", - "description": "PostgreSQL multi-tenant AI agent gateway with WebSocket RPC + HTTP API.\n\n## Authentication\n\nAll endpoints require a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer \n```\n\nYou can use either the **gateway token** (grants admin access) or an **API key** created via the API Keys endpoints (grants scoped access).\n\nIf no token is configured on the server, authentication is disabled (backward compatibility).\n\n## Common Headers\n\n| Header | Description |\n|--------|-------------|\n| `X-GoClaw-User-Id` | External user ID for multi-tenant context |\n| `X-GoClaw-Agent-Id` | Target agent ID (alternative to model prefix) |\n| `Accept-Language` | Locale for error messages (`en`, `vi`, `zh`) |\n\n## WebSocket Protocol\n\nConnect via `POST /ws` (upgrade). Protocol v3 uses frame types: `req`, `res`, `event`.\nFirst request must be `connect` with `{\"token\": \"...\", \"user_id\": \"...\", \"locale\": \"en\"}`.", + "description": "PostgreSQL multi-tenant AI agent gateway with WebSocket RPC + HTTP API.\n\n## Authentication\n\nAll endpoints require a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer \n```\n\nYou can use either the **gateway token** (grants admin access) or an **API key** created via the API Keys endpoints (grants scoped access).\n\nIf no token is configured on the server, authentication is disabled (backward compatibility).\n\n## Common Headers\n\n| Header | Description |\n|--------|-------------|\n| `X-GoClaw-User-Id` | External user ID for multi-tenant context |\n| `X-GoClaw-Agent-Id` | Target agent ID (alternative to model prefix) |\n| `X-GoClaw-Tenant-Id` | Tenant scope — UUID or slug (gateway token / cross-tenant API keys) |\n| `Accept-Language` | Locale for error messages (`en`, `vi`, `zh`) |\n\n## WebSocket Protocol\n\nConnect via `POST /ws` (upgrade). Protocol v3 uses frame types: `req`, `res`, `event`.\nFirst request must be `connect` with `{\"token\": \"...\", \"user_id\": \"...\", \"locale\": \"en\"}`.", "version": "0.2.0", "contact": { "name": "GoClaw", diff --git a/internal/http/pending_messages.go b/internal/http/pending_messages.go index ec7294f2..bb3b3e93 100644 --- a/internal/http/pending_messages.go +++ b/internal/http/pending_messages.go @@ -127,7 +127,7 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re } // Resolve an LLM provider for summarization using the default agent's config - provider, model := h.resolveProviderAndModel() + provider, model := h.resolveProviderAndModel(r.Context()) if provider == nil { // Fallback: hard delete if no provider available slog.Warn("compact.no_provider", "channel", req.ChannelName, "key", req.HistoryKey) @@ -162,14 +162,14 @@ func (h *PendingMessagesHandler) handleCompact(w http.ResponseWriter, r *http.Re // resolveProviderAndModel resolves the LLM provider+model for pending message compaction. // Priority: config provider/model > default agent's provider/model > first available provider. -func (h *PendingMessagesHandler) resolveProviderAndModel() (providers.Provider, string) { +func (h *PendingMessagesHandler) resolveProviderAndModel(ctx context.Context) (providers.Provider, string) { if h.providerReg == nil { return nil, "" } // Config-level provider/model override. if h.cfgProvider != "" { - if p, err := h.providerReg.Get(h.cfgProvider); err == nil { + if p, err := h.providerReg.Get(ctx, h.cfgProvider); err == nil { model := h.cfgModel if model == "" { model = p.DefaultModel() @@ -182,8 +182,8 @@ func (h *PendingMessagesHandler) resolveProviderAndModel() (providers.Provider, // Fallback: default agent's provider+model. if h.agentStore != nil { - if ag, err := h.agentStore.GetDefault(context.Background()); err == nil && ag.Provider != "" { - if p, err := h.providerReg.Get(ag.Provider); err == nil { + if ag, err := h.agentStore.GetDefault(ctx); err == nil && ag.Provider != "" { + if p, err := h.providerReg.GetForTenant(ag.TenantID, ag.Provider); err == nil { model := ag.Model if model == "" { model = p.DefaultModel() @@ -196,8 +196,8 @@ func (h *PendingMessagesHandler) resolveProviderAndModel() (providers.Provider, } // Fallback: first provider with a valid default model - for _, name := range h.providerReg.List() { - p, err := h.providerReg.Get(name) + for _, name := range h.providerReg.List(ctx) { + p, err := h.providerReg.Get(ctx, name) if err != nil { continue } diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go index 161dea9a..3cd5aa85 100644 --- a/internal/http/provider_verify.go +++ b/internal/http/provider_verify.go @@ -3,6 +3,7 @@ package http import ( "context" "encoding/json" + "errors" "net/http" "os/exec" "path/filepath" @@ -82,7 +83,9 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R return } - provider, err := h.providerReg.Get(p.Name) + // Use provider's own TenantID (not request context) so cross-tenant admins + // can verify providers belonging to other tenants. + provider, err := h.providerReg.GetForTenant(p.TenantID, p.Name) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "provider not registered: " + p.Name}) return @@ -95,7 +98,7 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R return } - ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() _, err = provider.Chat(ctx, providers.ChatRequest{ @@ -171,6 +174,14 @@ func isNonChatModel(model string) bool { // friendlyVerifyError extracts a human-readable message from provider errors. // Raw errors often contain JSON blobs like: `HTTP 400: minimax: {"type":"error","error":{"type":"bad_request_error","message":"unknown model ..."}}` func friendlyVerifyError(err error) string { + // Timeout / context cancellation → user-friendly message + if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "context deadline exceeded") { + return "Verification timed out — the provider took too long to respond. Please try again." + } + if errors.Is(err, context.Canceled) { + return "Verification was cancelled. Please try again." + } + msg := err.Error() // Try to extract "message" field from embedded JSON diff --git a/internal/http/providers.go b/internal/http/providers.go index dabfa56c..cbc29c9f 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -129,7 +129,7 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { mcpData.AgentMCPLookup = h.mcpLookup cliOpts = append(cliOpts, providers.WithClaudeCLIMCPConfigData(mcpData)) } - h.providerReg.Register(providers.NewClaudeCLIProvider(cliPath, cliOpts...)) + h.providerReg.RegisterForTenant(p.TenantID, providers.NewClaudeCLIProvider(cliPath, cliOpts...)) return } if p.APIKey == "" { @@ -138,25 +138,25 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { apiBase := h.resolveAPIBase(p) switch p.ProviderType { case store.ProviderChatGPTOAuth: - ts := oauth.NewDBTokenSource(h.store, h.secretStore, p.Name) - h.providerReg.Register(providers.NewCodexProvider(p.Name, ts, apiBase, "")) + ts := oauth.NewDBTokenSource(h.store, h.secretStore, p.Name).WithTenantID(p.TenantID) + h.providerReg.RegisterForTenant(p.TenantID, providers.NewCodexProvider(p.Name, ts, apiBase, "")) case store.ProviderAnthropicNative: - h.providerReg.Register(providers.NewAnthropicProvider(p.APIKey, + h.providerReg.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicBaseURL(apiBase))) case store.ProviderDashScope: - h.providerReg.Register(providers.NewDashScopeProvider(p.Name, p.APIKey, apiBase, "")) + h.providerReg.RegisterForTenant(p.TenantID, providers.NewDashScopeProvider(p.Name, p.APIKey, apiBase, "")) case store.ProviderBailian: base := apiBase if base == "" { base = "https://coding-intl.dashscope.aliyuncs.com/v1" } - h.providerReg.Register(providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus")) + h.providerReg.RegisterForTenant(p.TenantID, providers.NewOpenAIProvider(p.Name, p.APIKey, base, "qwen3.5-plus")) default: prov := providers.NewOpenAIProvider(p.Name, p.APIKey, apiBase, "") if p.ProviderType == store.ProviderMiniMax { prov.WithChatPath("/text/chatcompletion_v2") } - h.providerReg.Register(prov) + h.providerReg.RegisterForTenant(p.TenantID, prov) } } @@ -311,10 +311,10 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R if updated, err := h.store.GetProvider(r.Context(), id); err == nil { // Unregister old name if renamed to prevent ghost entries if oldName != "" && oldName != updated.Name { - h.providerReg.Unregister(oldName) + h.providerReg.UnregisterForTenant(updated.TenantID, oldName) } if !updated.Enabled { - h.providerReg.Unregister(updated.Name) + h.providerReg.UnregisterForTenant(updated.TenantID, updated.Name) } else { h.registerInMemory(updated) } @@ -341,10 +341,12 @@ func (h *ProvidersHandler) handleDeleteProvider(w http.ResponseWriter, r *http.R return } - // Read provider name before deleting so we can unregister it + // Read provider before deleting so we can unregister it var providerName string + var providerTenantID uuid.UUID if p, err := h.store.GetProvider(r.Context(), id); err == nil { providerName = p.Name + providerTenantID = p.TenantID } if err := h.store.DeleteProvider(r.Context(), id); err != nil { @@ -354,7 +356,7 @@ func (h *ProvidersHandler) handleDeleteProvider(w http.ResponseWriter, r *http.R } if h.providerReg != nil && providerName != "" { - h.providerReg.Unregister(providerName) + h.providerReg.UnregisterForTenant(providerTenantID, providerName) } if providerName != "" { h.emitProviderCacheInvalidate(providerName) diff --git a/internal/http/responses.go b/internal/http/responses.go index 94f2e3b8..436235e9 100644 --- a/internal/http/responses.go +++ b/internal/http/responses.go @@ -80,7 +80,7 @@ func (h *ResponsesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { agentID := extractAgentID(r, req.Model) userID := extractUserID(r) - loop, err := h.agents.Get(agentID) + loop, err := h.agents.Get(r.Context(), agentID) if err != nil { http.Error(w, fmt.Sprintf(`{"error":"agent not found: %s"}`, agentID), http.StatusNotFound) return diff --git a/internal/http/skills.go b/internal/http/skills.go index 38f0c75b..737ff01c 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -1,12 +1,17 @@ package http import ( + "context" "encoding/json" + "log/slog" "net/http" + "os" + "path/filepath" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/skills" @@ -19,15 +24,25 @@ const maxSkillUploadSize = 20 << 20 // 20 MB // SkillsHandler handles skill management HTTP endpoints. type SkillsHandler struct { - skills *pg.PGSkillStore - baseDir string // filesystem base for skill content - token string - msgBus *bus.MessageBus + skills *pg.PGSkillStore + baseDir string // filesystem base for skill content (skills-store/) — master tenant + dataDir string // parent data dir for tenant-scoped skill paths + bundledDir string // original bundled skills dir (fallback for broken managed copies) + token string + msgBus *bus.MessageBus } // NewSkillsHandler creates a handler for skill management endpoints. -func NewSkillsHandler(skills *pg.PGSkillStore, baseDir, token string, msgBus *bus.MessageBus) *SkillsHandler { - return &SkillsHandler{skills: skills, baseDir: baseDir, token: token, msgBus: msgBus} +func NewSkillsHandler(skills *pg.PGSkillStore, baseDir, dataDir, bundledDir, token string, msgBus *bus.MessageBus) *SkillsHandler { + return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, token: token, msgBus: msgBus} +} + +// tenantSkillsDir returns the skills-store directory scoped to the requesting tenant. +// Master tenant returns h.baseDir unchanged (backward compat). +func (h *SkillsHandler) tenantSkillsDir(r *http.Request) string { + tid := store.TenantIDFromContext(r.Context()) + slug := store.TenantSlugFromContext(r.Context()) + return config.TenantSkillsStoreDir(h.dataDir, tid, slug) } // emitCacheInvalidate broadcasts a cache invalidation event if msgBus is set. @@ -56,26 +71,53 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v1/skills/{id}/versions", h.authMiddleware(h.handleListVersions)) mux.HandleFunc("GET /v1/skills/{id}/files/{path...}", h.authMiddleware(h.handleReadFile)) mux.HandleFunc("GET /v1/skills/{id}/files", h.authMiddleware(h.handleListFiles)) - mux.HandleFunc("POST /v1/skills/rescan-deps", h.authMiddleware(h.handleRescanDeps)) - mux.HandleFunc("POST /v1/skills/install-deps", h.authMiddleware(h.handleInstallDeps)) - mux.HandleFunc("POST /v1/skills/install-dep", h.authMiddleware(h.handleInstallDep)) - mux.HandleFunc("GET /v1/skills/runtimes", h.authMiddleware(h.handleRuntimes)) - mux.HandleFunc("POST /v1/skills/{id}/toggle", h.authMiddleware(h.handleToggle)) + // System-level operations: admin + master tenant only. + // These execute shell commands (pip/npm install) and affect the entire server. + mux.HandleFunc("POST /v1/skills/rescan-deps", h.adminMiddleware(h.handleRescanDeps)) + mux.HandleFunc("POST /v1/skills/install-deps", h.adminMiddleware(h.handleInstallDeps)) + mux.HandleFunc("POST /v1/skills/install-dep", h.adminMiddleware(h.handleInstallDep)) + mux.HandleFunc("GET /v1/skills/runtimes", h.adminMiddleware(h.handleRuntimes)) + mux.HandleFunc("POST /v1/skills/{id}/toggle", h.adminMiddleware(h.handleToggle)) } func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { return requireAuth(h.token, "", next) } +// adminMiddleware requires admin role — used for system-level operations +// (rescan deps, install packages, toggle skills) that affect the entire server. +func (h *SkillsHandler) adminMiddleware(next http.HandlerFunc) http.HandlerFunc { + return requireAuth(h.token, permissions.RoleAdmin, next) +} + +// requireMasterTenant rejects requests from non-master tenants. +// System skill management (install packages, rescan deps) is a server-wide operation +// that should only be accessible to the master tenant or cross-tenant admins. +func (h *SkillsHandler) requireMasterTenant(w http.ResponseWriter, r *http.Request) bool { + ctx := r.Context() + if store.IsCrossTenant(ctx) { + return true + } + tid := store.TenantIDFromContext(ctx) + if tid == store.MasterTenantID { + return true + } + locale := store.LocaleFromContext(ctx) + writeJSON(w, http.StatusForbidden, map[string]string{ + "error": i18n.T(locale, i18n.MsgPermissionDenied, "system skill management"), + }) + return false +} + func (h *SkillsHandler) handleList(w http.ResponseWriter, r *http.Request) { - skills := h.skills.ListSkills() + skills := h.skills.ListSkills(r.Context()) writeJSON(w, http.StatusOK, map[string]any{"skills": skills}) } func (h *SkillsHandler) handleGet(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) id := r.PathValue("id") - skill, ok := h.skills.GetSkill(id) + skill, ok := h.skills.GetSkill(r.Context(), id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id)}) return @@ -96,7 +138,7 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { auth := resolveAuth(r, h.token) if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { userID := store.UserIDFromContext(r.Context()) - if ownerID, found := h.skills.GetSkillOwnerID(id); found && ownerID != userID { + if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), id); found && ownerID != userID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) return } @@ -114,7 +156,7 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { delete(updates, "is_system") delete(updates, "enabled") - if err := h.skills.UpdateSkill(id, updates); err != nil { + if err := h.skills.UpdateSkill(r.Context(), id, updates); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } @@ -137,13 +179,13 @@ func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { auth := resolveAuth(r, h.token) if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { userID := store.UserIDFromContext(r.Context()) - if ownerID, found := h.skills.GetSkillOwnerID(id); found && ownerID != userID { + if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), id); found && ownerID != userID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) return } } - if err := h.skills.DeleteSkill(id); err != nil { + if err := h.skills.DeleteSkill(r.Context(), id); err != nil { if err.Error() == "cannot delete system skill" { writeJSON(w, http.StatusForbidden, map[string]string{"error": "cannot delete system skill"}) return @@ -159,6 +201,9 @@ func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) { // handleInstallDeps installs missing dependencies for all system skills, then re-checks status. func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request) { + if !h.requireMasterTenant(w, r) { + return + } dirs := h.skills.ListSystemSkillDirs() if len(dirs) == 0 { writeJSON(w, http.StatusOK, map[string]string{"message": "no system skills"}) @@ -185,7 +230,7 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request } // Re-check all system skills and update status after install - allSkills := h.skills.ListAllSkills() + allSkills := h.skills.ListAllSkills(r.Context()) for _, sk := range allSkills { if !sk.IsSystem { continue @@ -194,8 +239,9 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request if !exists { continue } - m := skills.ScanSkillDeps(dir) + m := h.scanWithFallback(sk) if m == nil || m.IsEmpty() { + _ = dir // dir was used for direct scan; fallback uses sk.BaseDir continue } ok, miss := skills.CheckSkillDeps(m) @@ -204,7 +250,7 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request continue } if ok && sk.Status == "archived" { - _ = h.skills.UpdateSkill(id, map[string]any{"status": "active"}) + _ = h.skills.UpdateSkill(r.Context(), id, map[string]any{"status": "active"}) h.skills.BumpVersion() } status := "active" @@ -236,6 +282,9 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request // handleInstallDep installs a single dependency and re-checks all skill statuses. // Body: {"dep": "pip:openpyxl"} func (h *SkillsHandler) handleInstallDep(w http.ResponseWriter, r *http.Request) { + if !h.requireMasterTenant(w, r) { + return + } var body struct { Dep string `json:"dep"` } @@ -279,30 +328,40 @@ type depResult struct { // rescanAndUpdate re-checks all skills and updates their status + missing deps in DB. func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { - allSkills := h.skills.ListAllSkills() + allSkills := h.skills.ListAllSkills(store.WithCrossTenant(context.Background())) for _, sk := range allSkills { - manifest := skills.ScanSkillDeps(sk.BaseDir) - if manifest == nil || manifest.IsEmpty() { - results = append(results, depResult{Slug: sk.Slug, Status: "ok"}) - continue - } + manifest := h.scanWithFallback(sk) - ok, missing := skills.CheckSkillDeps(manifest) id, err := uuid.Parse(sk.ID) if err != nil { continue } + if manifest == nil || manifest.IsEmpty() { + // No deps needed — if archived, recover to active and clear stale deps. + if sk.Status == "archived" { + _ = h.skills.StoreMissingDeps(id, nil) + _ = h.skills.UpdateSkill(store.WithCrossTenant(context.Background()), id, map[string]any{"status": "active"}) + results = append(results, depResult{Slug: sk.Slug, Status: "active"}) + updated++ + slog.Debug("rescan: recovered archived skill (no deps)", "slug", sk.Slug) + } else { + results = append(results, depResult{Slug: sk.Slug, Status: "ok"}) + } + continue + } + + ok, missing := skills.CheckSkillDeps(manifest) _ = h.skills.StoreMissingDeps(id, missing) switch { case ok && sk.Status == "archived": - _ = h.skills.UpdateSkill(id, map[string]any{"status": "active"}) + _ = h.skills.UpdateSkill(store.WithCrossTenant(context.Background()), id, map[string]any{"status": "active"}) results = append(results, depResult{Slug: sk.Slug, Status: "active"}) updated++ case !ok && sk.Status == "active": - _ = h.skills.UpdateSkill(id, map[string]any{"status": "archived"}) + _ = h.skills.UpdateSkill(store.WithCrossTenant(context.Background()), id, map[string]any{"status": "archived"}) results = append(results, depResult{Slug: sk.Slug, Status: "archived", Missing: missing}) updated++ case !ok: @@ -310,6 +369,8 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { default: results = append(results, depResult{Slug: sk.Slug, Status: "ok"}) } + + slog.Debug("rescan: checked skill", "slug", sk.Slug, "ok", ok, "missing", len(missing)) } if updated > 0 { @@ -318,8 +379,49 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) { return updated, results } +// scanWithFallback scans skill deps from the managed dir, falling back to the +// bundled dir if the managed copy's scripts/ directory is missing or empty. +// If a fallback scan succeeds, re-copies the bundled scripts to the managed dir. +func (h *SkillsHandler) scanWithFallback(sk store.SkillInfo) *skills.SkillManifest { + manifest := skills.ScanSkillDeps(sk.BaseDir) + if manifest != nil && !manifest.IsEmpty() { + return manifest + } + + // Fallback: try bundled dir for system skills whose managed copy is broken. + if !sk.IsSystem || h.bundledDir == "" { + return manifest + } + + managedScripts := filepath.Join(sk.BaseDir, "scripts") + if _, err := os.Stat(managedScripts); err == nil { + // scripts/ exists in managed dir but scanner found nothing — not a copy issue. + return manifest + } + + bundledSkillDir := filepath.Join(h.bundledDir, sk.Slug) + bundledManifest := skills.ScanSkillDeps(bundledSkillDir) + if bundledManifest == nil || bundledManifest.IsEmpty() { + return manifest + } + + slog.Warn("rescan: managed scripts/ missing, using bundled fallback", + "slug", sk.Slug, "managed", sk.BaseDir, "bundled", bundledSkillDir) + + // Re-copy bundled scripts to managed dir so future scans work without fallback. + bundledScripts := filepath.Join(bundledSkillDir, "scripts") + if err := skills.CopyDir(bundledScripts, managedScripts); err != nil { + slog.Error("rescan: failed to re-copy bundled scripts", "slug", sk.Slug, "error", err) + } + + return bundledManifest +} + // handleRescanDeps re-checks dependencies for all skills (including archived) and updates their status. func (h *SkillsHandler) handleRescanDeps(w http.ResponseWriter, r *http.Request) { + if !h.requireMasterTenant(w, r) { + return + } updated, results := h.rescanAndUpdate() writeJSON(w, http.StatusOK, map[string]any{ "updated": updated, @@ -336,6 +438,9 @@ func (h *SkillsHandler) handleRuntimes(w http.ResponseWriter, _ *http.Request) { // Body: {"enabled": bool} // When enabling: re-checks deps and updates status to "active" or "archived" accordingly. func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) { + if !h.requireMasterTenant(w, r) { + return + } locale := store.LocaleFromContext(r.Context()) idStr := r.PathValue("id") id, err := uuid.Parse(idStr) @@ -352,7 +457,7 @@ func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) { return } - if err := h.skills.ToggleSkill(id, body.Enabled); err != nil { + if err := h.skills.ToggleSkill(r.Context(), id, body.Enabled); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } @@ -360,9 +465,9 @@ func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) { newStatus := "" if body.Enabled { // Re-check deps for this skill so its status reflects reality after being re-enabled. - sk, ok := h.skills.GetSkillByID(id) + sk, ok := h.skills.GetSkillByID(r.Context(), id) if ok { - manifest := skills.ScanSkillDeps(sk.BaseDir) + manifest := h.scanWithFallback(sk) if manifest != nil && !manifest.IsEmpty() { depOk, missing := skills.CheckSkillDeps(manifest) _ = h.skills.StoreMissingDeps(id, missing) @@ -374,7 +479,7 @@ func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) { } else { newStatus = "active" } - _ = h.skills.UpdateSkill(id, map[string]any{"status": newStatus}) + _ = h.skills.UpdateSkill(r.Context(), id, map[string]any{"status": newStatus}) } } diff --git a/internal/http/skills_grants.go b/internal/http/skills_grants.go index 3943144d..a19d4825 100644 --- a/internal/http/skills_grants.go +++ b/internal/http/skills_grants.go @@ -47,7 +47,7 @@ func (h *SkillsHandler) handleGrantAgent(w http.ResponseWriter, r *http.Request) // Ownership check (admins bypass) auth := resolveAuth(r, h.token) if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { - if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID { + if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), skillID); found && ownerID != userID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) return } @@ -96,7 +96,7 @@ func (h *SkillsHandler) handleRevokeAgent(w http.ResponseWriter, r *http.Request auth := resolveAuth(r, h.token) if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { userID := store.UserIDFromContext(r.Context()) - if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID { + if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), skillID); found && ownerID != userID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) return } @@ -133,7 +133,7 @@ func (h *SkillsHandler) handleGrantUser(w http.ResponseWriter, r *http.Request) // Ownership check (admins bypass) auth := resolveAuth(r, h.token) if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { - if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID { + if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), skillID); found && ownerID != userID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) return } @@ -179,7 +179,7 @@ func (h *SkillsHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request) auth := resolveAuth(r, h.token) if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { userID := store.UserIDFromContext(r.Context()) - if ownerID, found := h.skills.GetSkillOwnerID(skillID); found && ownerID != userID { + if ownerID, found := h.skills.GetSkillOwnerID(r.Context(), skillID); found && ownerID != userID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "only the skill owner can perform this action"}) return } diff --git a/internal/http/skills_upload.go b/internal/http/skills_upload.go index 384763d3..8912444d 100644 --- a/internal/http/skills_upload.go +++ b/internal/http/skills_upload.go @@ -117,8 +117,9 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { // Determine version (always increment — includes archived skills so re-upload gets v2+) version := h.skills.GetNextVersion(slug) - // Extract to filesystem: baseDir/slug/version/ - destDir := filepath.Join(h.baseDir, slug, fmt.Sprintf("%d", version)) + // Extract to filesystem: tenant-scoped skills-store/slug/version/ + tenantSkillsBase := h.tenantSkillsDir(r) + destDir := filepath.Join(tenantSkillsBase, slug, fmt.Sprintf("%d", version)) if err := os.MkdirAll(destDir, 0755); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create skill directory")}) return @@ -200,7 +201,7 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { ok, missing := skills.CheckSkillDeps(manifest) if !ok { // Set skill to archived due to missing deps - _ = h.skills.UpdateSkill(id, map[string]any{"status": "archived"}) + _ = h.skills.UpdateSkill(r.Context(), id, map[string]any{"status": "archived"}) response["deps_warning"] = "missing dependencies: " + skills.FormatMissing(missing) } } diff --git a/internal/http/skills_versions.go b/internal/http/skills_versions.go index 71579bc2..b63285d7 100644 --- a/internal/http/skills_versions.go +++ b/internal/http/skills_versions.go @@ -26,7 +26,7 @@ func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Reques return } - _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) + _, slug, currentVersion, ok := h.skills.GetSkillFilePath(r.Context(), id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())}) return @@ -73,7 +73,7 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) return } - _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) + _, slug, currentVersion, ok := h.skills.GetSkillFilePath(r.Context(), id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())}) return @@ -162,7 +162,7 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) { return } - _, slug, currentVersion, ok := h.skills.GetSkillFilePath(id) + _, slug, currentVersion, ok := h.skills.GetSkillFilePath(r.Context(), id) if !ok { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "skill", id.String())}) return diff --git a/internal/http/storage.go b/internal/http/storage.go index 8f3e2dcf..4cbc94f1 100644 --- a/internal/http/storage.go +++ b/internal/http/storage.go @@ -13,24 +13,28 @@ import ( "sync" "time" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/skills" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // StorageHandler provides HTTP endpoints for browsing and managing // files inside the ~/.goclaw/ data directory. // Skills directories are browsable (read-only) but deletion is blocked. +// sizeCacheEntry holds a cached storage size calculation for one tenant. +type sizeCacheEntry struct { + total int64 + files int + cachedAt time.Time +} + type StorageHandler struct { - baseDir string // resolved absolute path to ~/.goclaw/ + baseDir string // global data dir (resolved absolute path to ~/.goclaw/) token string - // sizeCache caches the total storage size for 60 minutes. - sizeCache struct { - mu sync.Mutex - total int64 - files int - cachedAt time.Time - } + // sizeCache caches the total storage size per tenant for 60 minutes. + sizeCache sync.Map // tenantBaseDir (string) → *sizeCacheEntry } // NewStorageHandler creates a handler for workspace storage management. @@ -50,6 +54,14 @@ func (h *StorageHandler) auth(next http.HandlerFunc) http.HandlerFunc { return requireAuth(h.token, "", next) } +// tenantBaseDir resolves the data directory scoped to the requesting tenant. +// Master tenant returns the global baseDir (backward compat). +func (h *StorageHandler) tenantBaseDir(r *http.Request) string { + tid := store.TenantIDFromContext(r.Context()) + slug := store.TenantSlugFromContext(r.Context()) + return config.TenantDataDir(h.baseDir, tid, slug) +} + // protectedDirs are top-level directories where deletion is blocked // (managed separately via the Skills page). var protectedDirs = []string{"skills", "skills-store"} @@ -91,11 +103,12 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { } } - rootDir := h.baseDir + base := h.tenantBaseDir(r) + rootDir := base if subPath != "" { - rootDir = filepath.Join(h.baseDir, filepath.Clean(subPath)) - if !strings.HasPrefix(rootDir, h.baseDir) { - slog.Warn("security.storage_escape", "resolved", rootDir, "root", h.baseDir) + rootDir = filepath.Join(base, filepath.Clean(subPath)) + if !strings.HasPrefix(rootDir, base) { + slog.Warn("security.storage_escape", "resolved", rootDir, "root", base) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } @@ -119,7 +132,7 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { if path == rootDir { return nil } - rel, _ := filepath.Rel(h.baseDir, path) + rel, _ := filepath.Rel(base, path) // Skip symlinks if d.Type()&os.ModeSymlink != 0 { @@ -183,7 +196,7 @@ func (h *StorageHandler) handleList(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "files": entries, - "baseDir": h.baseDir, + "baseDir": base, }) } @@ -205,34 +218,33 @@ func (h *StorageHandler) handleSize(w http.ResponseWriter, r *http.Request) { w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) - // Check cache - h.sizeCache.mu.Lock() - if !h.sizeCache.cachedAt.IsZero() && time.Since(h.sizeCache.cachedAt) < sizeCacheTTL { - total := h.sizeCache.total - files := h.sizeCache.files - h.sizeCache.mu.Unlock() - writeSizeEvent(w, flusher, map[string]any{"total": total, "files": files, "done": true, "cached": true}) - return + sizeBase := h.tenantBaseDir(r) + + // Check per-tenant cache + if entry, ok := h.sizeCache.Load(sizeBase); ok { + ce := entry.(*sizeCacheEntry) + if time.Since(ce.cachedAt) < sizeCacheTTL { + writeSizeEvent(w, flusher, map[string]any{"total": ce.total, "files": ce.files, "done": true, "cached": true}) + return + } } - h.sizeCache.mu.Unlock() // Walk and stream progress var total int64 var fileCount int lastFlush := time.Now() - filepath.WalkDir(h.baseDir, func(path string, d os.DirEntry, err error) error { + filepath.WalkDir(sizeBase, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } - // Check client disconnect if r.Context().Err() != nil { return filepath.SkipAll } if d.Type()&os.ModeSymlink != 0 { return nil } - rel, _ := filepath.Rel(h.baseDir, path) + rel, _ := filepath.Rel(sizeBase, path) if skills.IsSystemArtifact(rel) { return nil } @@ -240,7 +252,6 @@ func (h *StorageHandler) handleSize(w http.ResponseWriter, r *http.Request) { total += info.Size() fileCount++ } - // Emit progress every 50 files or 200ms if fileCount%50 == 0 || time.Since(lastFlush) > 200*time.Millisecond { writeSizeEvent(w, flusher, map[string]any{"current": total, "files": fileCount}) lastFlush = time.Now() @@ -248,12 +259,8 @@ func (h *StorageHandler) handleSize(w http.ResponseWriter, r *http.Request) { return nil }) - // Update cache - h.sizeCache.mu.Lock() - h.sizeCache.total = total - h.sizeCache.files = fileCount - h.sizeCache.cachedAt = time.Now() - h.sizeCache.mu.Unlock() + // Update per-tenant cache + h.sizeCache.Store(sizeBase, &sizeCacheEntry{total: total, files: fileCount, cachedAt: time.Now()}) // Send final event writeSizeEvent(w, flusher, map[string]any{"total": total, "files": fileCount, "done": true, "cached": false}) @@ -279,9 +286,10 @@ func (h *StorageHandler) handleRead(w http.ResponseWriter, r *http.Request) { return } - absPath := filepath.Join(h.baseDir, filepath.Clean(relPath)) - if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) { - slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir) + readBase := h.tenantBaseDir(r) + absPath := filepath.Join(readBase, filepath.Clean(relPath)) + if !strings.HasPrefix(absPath, readBase+string(filepath.Separator)) { + slog.Warn("security.storage_escape", "resolved", absPath, "root", readBase) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } @@ -346,9 +354,10 @@ func (h *StorageHandler) handleDelete(w http.ResponseWriter, r *http.Request) { return } - absPath := filepath.Join(h.baseDir, filepath.Clean(relPath)) - if !strings.HasPrefix(absPath, h.baseDir+string(filepath.Separator)) { - slog.Warn("security.storage_escape", "resolved", absPath, "root", h.baseDir) + delBase := h.tenantBaseDir(r) + absPath := filepath.Join(delBase, filepath.Clean(relPath)) + if !strings.HasPrefix(absPath, delBase+string(filepath.Separator)) { + slog.Warn("security.storage_escape", "resolved", absPath, "root", delBase) writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) return } diff --git a/internal/http/summoner.go b/internal/http/summoner.go index 8713350f..4e3feeb9 100644 --- a/internal/http/summoner.go +++ b/internal/http/summoner.go @@ -72,12 +72,12 @@ const singleCallTimeout = 300 * time.Second // On retry (resummon), skips files that were already generated (differ from template). // On success: stores generated files and sets agent status to "active". // On failure: keeps template files (already seeded) and sets status to store.AgentStatusSummonFailed. -func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, description string) { - ctx, cancel := context.WithTimeout(context.Background(), 600*time.Second) +func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, tenantID uuid.UUID, providerName, model, description string) { + ctx, cancel := context.WithTimeout(store.WithTenantID(context.Background(), tenantID), 600*time.Second) defer cancel() s.ensureUserPredefined(ctx, agentID) - s.emitEvent(agentID, SummonEventStarted, "", "") + s.emitEvent(agentID, tenantID, SummonEventStarted, "", "") // Check which files already exist (from a previous partial run) existingMap := s.loadExistingFiles(ctx, agentID) @@ -85,9 +85,9 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc // Skip if all files already generated if s.isGenerated(existingMap, bootstrap.SoulFile) && s.isGenerated(existingMap, bootstrap.IdentityFile) { slog.Info("summoning: all files already generated, skipping", "agent", agentID) - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.SoulFile, "") - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "") - s.finishSummon(ctx, agentID, existingMap[bootstrap.IdentityFile], "", description) + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.SoulFile, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.IdentityFile, "") + s.finishSummon(ctx, agentID, tenantID, existingMap[bootstrap.IdentityFile], "", description) return } @@ -98,15 +98,15 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc if err == nil { slog.Info("summoning: single-call succeeded", "agent", agentID) - s.storeFiles(ctx, agentID, files) - s.finishSummon(ctx, agentID, files[bootstrap.IdentityFile], files[frontmatterKey], description) + s.storeFiles(ctx, agentID, tenantID, files) + s.finishSummon(ctx, agentID, tenantID, files[bootstrap.IdentityFile], files[frontmatterKey], description) return } // Non-retryable error → fail immediately if !isRetryableError(err) { slog.Warn("summoning: single-call failed (non-retryable)", "agent", agentID, "error", err) - s.emitEvent(agentID, SummonEventFailed, "", err.Error()) + s.emitEvent(agentID, tenantID, SummonEventFailed, "", err.Error()) s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } @@ -124,12 +124,12 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc if s.isGenerated(existingMap, bootstrap.SoulFile) { soulContent = existingMap[bootstrap.SoulFile] slog.Info("summoning: SOUL.md already generated, skipping", "agent", agentID) - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.SoulFile, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.SoulFile, "") } else { soulFiles, soulErr := s.generateFiles(ctx, providerName, model, s.buildSoulPrompt(description)) if soulErr != nil { slog.Warn("summoning: SOUL.md generation failed", "agent", agentID, "error", soulErr) - s.emitEvent(agentID, SummonEventFailed, "", soulErr.Error()) + s.emitEvent(agentID, tenantID, SummonEventFailed, "", soulErr.Error()) s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } @@ -139,7 +139,7 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc if storeErr := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.SoulFile, soulContent); storeErr != nil { slog.Warn("summoning: failed to store SOUL.md", "agent", agentID, "error", storeErr) } else { - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.SoulFile, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.SoulFile, "") } } } @@ -152,12 +152,12 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc if !identityNeeded && !userPredNeeded { identityContent = existingMap[bootstrap.IdentityFile] slog.Info("summoning: IDENTITY.md + USER_PREDEFINED.md already generated, skipping", "agent", agentID) - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.IdentityFile, "") } else { idFiles, idErr := s.generateFiles(ctx, providerName, model, s.buildIdentityPrompt(description, soulContent)) if idErr != nil { slog.Warn("summoning: IDENTITY.md generation failed", "agent", agentID, "error", idErr) - s.emitEvent(agentID, SummonEventFailed, "", idErr.Error()) + s.emitEvent(agentID, tenantID, SummonEventFailed, "", idErr.Error()) s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } @@ -169,23 +169,23 @@ func (s *AgentSummoner) SummonAgent(agentID uuid.UUID, providerName, model, desc if storeErr := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.IdentityFile, identityContent); storeErr != nil { slog.Warn("summoning: failed to store IDENTITY.md", "agent", agentID, "error", storeErr) } else { - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.IdentityFile, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.IdentityFile, "") } } if upContent := idFiles[bootstrap.UserPredefinedFile]; upContent != "" && userPredNeeded { if storeErr := s.agents.SetAgentContextFile(ctx, agentID, bootstrap.UserPredefinedFile, upContent); storeErr != nil { slog.Warn("summoning: failed to store USER_PREDEFINED.md", "agent", agentID, "error", storeErr) } else { - s.emitEvent(agentID, SummonEventFileGenerated, bootstrap.UserPredefinedFile, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, bootstrap.UserPredefinedFile, "") } } } - s.finishSummon(ctx, agentID, identityContent, frontmatter, description) + s.finishSummon(ctx, agentID, tenantID, identityContent, frontmatter, description) } // finishSummon saves agent metadata and marks the agent as active. -func (s *AgentSummoner) finishSummon(ctx context.Context, agentID uuid.UUID, identityContent, frontmatter, description string) { +func (s *AgentSummoner) finishSummon(ctx context.Context, agentID, tenantID uuid.UUID, identityContent, frontmatter, description string) { updates := map[string]any{} if frontmatter == "" { frontmatter = truncateUTF8(description, 200) @@ -202,7 +202,7 @@ func (s *AgentSummoner) finishSummon(ctx context.Context, agentID uuid.UUID, ide } } s.setAgentStatus(ctx, agentID, store.AgentStatusActive) - s.emitEvent(agentID, SummonEventCompleted, "", "") + s.emitEvent(agentID, tenantID, SummonEventCompleted, "", "") slog.Info("summoning: completed", "agent", agentID) } diff --git a/internal/http/summoner_regenerate.go b/internal/http/summoner_regenerate.go index 937a78cb..9f182dcc 100644 --- a/internal/http/summoner_regenerate.go +++ b/internal/http/summoner_regenerate.go @@ -20,19 +20,19 @@ import ( // RegenerateAgent updates context files based on an edit prompt. // Reads existing files, sends them + edit instructions to LLM, stores results. // Synchronous — caller should run in goroutine if needed. -func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, editPrompt string) { - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) +func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, tenantID uuid.UUID, providerName, model, editPrompt string) { + ctx, cancel := context.WithTimeout(store.WithTenantID(context.Background(), tenantID), 300*time.Second) defer cancel() s.ensureUserPredefined(ctx, agentID) - s.emitEvent(agentID, SummonEventStarted, "", "") + s.emitEvent(agentID, tenantID, SummonEventStarted, "", "") // Read existing files for context existing, err := s.agents.GetAgentContextFiles(ctx, agentID) if err != nil { slog.Warn("summoning: failed to read existing files", "agent", agentID, "error", err) - s.emitEvent(agentID, SummonEventFailed, "", err.Error()) + s.emitEvent(agentID, tenantID, SummonEventFailed, "", err.Error()) s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } @@ -42,13 +42,13 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, files, err := s.generateFiles(ctx, providerName, model, prompt) if err != nil { slog.Warn("summoning: regeneration failed", "agent", agentID, "error", err) - s.emitEvent(agentID, SummonEventFailed, "", err.Error()) + s.emitEvent(agentID, tenantID, SummonEventFailed, "", err.Error()) // Use fresh context — the original may have timed out, but we still need to update status. s.setAgentStatus(context.Background(), agentID, store.AgentStatusSummonFailed) return } - s.storeFiles(ctx, agentID, files) + s.storeFiles(ctx, agentID, tenantID, files) // Update frontmatter + display_name if IDENTITY.md was regenerated updates := map[string]any{} @@ -65,7 +65,7 @@ func (s *AgentSummoner) RegenerateAgent(agentID uuid.UUID, providerName, model, } s.setAgentStatus(ctx, agentID, store.AgentStatusActive) - s.emitEvent(agentID, SummonEventCompleted, "", "") + s.emitEvent(agentID, tenantID, SummonEventCompleted, "", "") slog.Info("summoning: regeneration completed", "agent", agentID, "files", len(files)) } @@ -98,7 +98,7 @@ func (s *AgentSummoner) isGenerated(existingMap map[string]string, fileName stri // generateFiles calls the LLM and parses the XML-tagged response into file map. func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model, prompt string) (map[string]string, error) { - provider, err := s.resolveProvider(providerName) + provider, err := s.resolveProvider(ctx, providerName) if err != nil { return nil, fmt.Errorf("resolve provider: %w", err) } @@ -139,7 +139,7 @@ func (s *AgentSummoner) generateFiles(ctx context.Context, providerName, model, } // storeFiles saves generated files to agent_context_files and emits progress events. -func (s *AgentSummoner) storeFiles(ctx context.Context, agentID uuid.UUID, files map[string]string) { +func (s *AgentSummoner) storeFiles(ctx context.Context, agentID, tenantID uuid.UUID, files map[string]string) { for _, name := range summoningFiles { content, ok := files[name] if !ok || content == "" { @@ -149,23 +149,23 @@ func (s *AgentSummoner) storeFiles(ctx context.Context, agentID uuid.UUID, files slog.Warn("summoning: failed to store file", "agent", agentID, "file", name, "error", err) continue } - s.emitEvent(agentID, SummonEventFileGenerated, name, "") + s.emitEvent(agentID, tenantID, SummonEventFileGenerated, name, "") } } -func (s *AgentSummoner) resolveProvider(name string) (providers.Provider, error) { +func (s *AgentSummoner) resolveProvider(ctx context.Context, name string) (providers.Provider, error) { if s.providerReg == nil { return nil, fmt.Errorf("no provider registry") } - provider, err := s.providerReg.Get(name) + provider, err := s.providerReg.Get(ctx, name) if err != nil { // Fallback to first available provider - names := s.providerReg.List() + names := s.providerReg.List(ctx) if len(names) == 0 { return nil, fmt.Errorf("no providers configured") } - provider, err = s.providerReg.Get(names[0]) + provider, err = s.providerReg.Get(ctx, names[0]) if err != nil { return nil, err } @@ -197,7 +197,7 @@ func (s *AgentSummoner) setAgentStatus(ctx context.Context, agentID uuid.UUID, s } } -func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg string) { +func (s *AgentSummoner) emitEvent(agentID, tenantID uuid.UUID, eventType, fileName, errMsg string) { if s.msgBus == nil { return } @@ -211,8 +211,5 @@ func (s *AgentSummoner) emitEvent(agentID uuid.UUID, eventType, fileName, errMsg if errMsg != "" { payload["error"] = errMsg } - s.msgBus.Broadcast(bus.Event{ - Name: protocol.EventAgentSummoning, - Payload: payload, - }) + bus.BroadcastForTenant(s.msgBus, protocol.EventAgentSummoning, tenantID, payload) } diff --git a/internal/http/team_attachments.go b/internal/http/team_attachments.go index 65bfb2dc..90350974 100644 --- a/internal/http/team_attachments.go +++ b/internal/http/team_attachments.go @@ -30,14 +30,27 @@ func (h *TeamAttachmentsHandler) RegisterRoutes(mux *http.ServeMux) { func (h *TeamAttachmentsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - provided := extractBearerToken(r) - if provided == "" { - provided = r.URL.Query().Get("token") - } - if !requireAuthBearer(h.token, "", provided, w, r) { + // Priority 1: HMAC-signed file token (?ft=) — no gateway token exposure. + if ft := r.URL.Query().Get("ft"); ft != "" { + path := r.URL.Path // full path for HMAC binding + if VerifyFileToken(ft, path, FileSigningKey()) { + next(w, r) + return + } + http.Error(w, "invalid or expired file token", http.StatusUnauthorized) return } - next(w, r) + // Priority 2: Bearer header (API clients). + provided := extractBearerToken(r) + if provided == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + authedReq, ok := requireAuthBearer(h.token, "", provided, w, r) + if !ok { + return + } + next(w, authedReq) } } @@ -73,13 +86,18 @@ func (h *TeamAttachmentsHandler) handleDownload(w http.ResponseWriter, r *http.R return } - // Resolve absolute disk path: {dataDir}/teams/{teamID}/{chatID}/{path} - absPath := filepath.Join(h.dataDir, "teams", att.TeamID.String(), att.ChatID, att.Path) - - // Security: path traversal check — resolved path must stay within team workspace. - teamBase := filepath.Join(h.dataDir, "teams", att.TeamID.String()) - cleanPath := filepath.Clean(absPath) - if !strings.HasPrefix(cleanPath, teamBase+string(filepath.Separator)) && cleanPath != teamBase { + // Resolve disk path. Absolute paths (new) are used directly; + // relative paths (legacy) are joined with the team workspace directory. + var cleanPath string + if filepath.IsAbs(att.Path) { + cleanPath = filepath.Clean(att.Path) + } else { + // Legacy: {workspace}/teams/{teamID}/{chatID}/{relPath} + cleanPath = filepath.Clean(filepath.Join(h.dataDir, "teams", att.TeamID.String(), att.ChatID, att.Path)) + } + // Security: file must be within the workspace root to prevent path traversal. + wsRoot := filepath.Clean(h.dataDir) + string(filepath.Separator) + if !strings.HasPrefix(cleanPath, wsRoot) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid attachment path"}) return } diff --git a/internal/http/tenant_cache.go b/internal/http/tenant_cache.go new file mode 100644 index 00000000..2091b467 --- /dev/null +++ b/internal/http/tenant_cache.go @@ -0,0 +1,95 @@ +package http + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// tenantCacheEntry holds a cached tenant lookup result. +type tenantCacheEntry struct { + tenant *store.TenantData + fetchedAt time.Time +} + +// tenantCache is a TTL cache for tenant lookups by UUID and slug. +// Invalidated via bus CacheKindTenants events. +type tenantCache struct { + mu sync.RWMutex + byID map[uuid.UUID]*tenantCacheEntry + bySlug map[string]*tenantCacheEntry + ttl time.Duration + store store.TenantStore +} + +func newTenantCache(s store.TenantStore, ttl time.Duration) *tenantCache { + return &tenantCache{ + byID: make(map[uuid.UUID]*tenantCacheEntry), + bySlug: make(map[string]*tenantCacheEntry), + ttl: ttl, + store: s, + } +} + +// GetTenant returns a tenant by UUID, using cache when available. +func (c *tenantCache) GetTenant(ctx context.Context, id uuid.UUID) (*store.TenantData, error) { + c.mu.RLock() + if e, ok := c.byID[id]; ok && time.Since(e.fetchedAt) <= c.ttl { + c.mu.RUnlock() + slog.Debug("tenant_cache.hit", "id", id) + return e.tenant, nil + } + c.mu.RUnlock() + slog.Debug("tenant_cache.miss", "id", id) + + t, err := c.store.GetTenant(ctx, id) + if err != nil { + return nil, err + } + if t != nil { + c.put(t) + } + return t, nil +} + +// GetTenantBySlug returns a tenant by slug, using cache when available. +func (c *tenantCache) GetTenantBySlug(ctx context.Context, slug string) (*store.TenantData, error) { + c.mu.RLock() + if e, ok := c.bySlug[slug]; ok && time.Since(e.fetchedAt) <= c.ttl { + c.mu.RUnlock() + slog.Debug("tenant_cache.hit", "slug", slug) + return e.tenant, nil + } + c.mu.RUnlock() + slog.Debug("tenant_cache.miss", "slug", slug) + + t, err := c.store.GetTenantBySlug(ctx, slug) + if err != nil { + return nil, err + } + if t != nil { + c.put(t) + } + return t, nil +} + +func (c *tenantCache) put(t *store.TenantData) { + entry := &tenantCacheEntry{tenant: t, fetchedAt: time.Now()} + c.mu.Lock() + c.byID[t.ID] = entry + c.bySlug[t.Slug] = entry + c.mu.Unlock() +} + +// invalidateAll clears all cached entries. Called on bus cache.invalidate events. +func (c *tenantCache) invalidateAll() { + c.mu.Lock() + defer c.mu.Unlock() + slog.Debug("tenant_cache.invalidated", "entries", len(c.byID)) + c.byID = make(map[uuid.UUID]*tenantCacheEntry) + c.bySlug = make(map[string]*tenantCacheEntry) +} diff --git a/internal/http/tenants.go b/internal/http/tenants.go new file mode 100644 index 00000000..4e9ddc1b --- /dev/null +++ b/internal/http/tenants.go @@ -0,0 +1,312 @@ +package http + +import ( + "encoding/json" + "log/slog" + "net/http" + "os" + "path/filepath" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" + "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +// TenantsHandler handles tenant CRUD and membership endpoints. +type TenantsHandler struct { + tenantStore store.TenantStore + token string + msgBus *bus.MessageBus + workspace string // base workspace directory for tenant dirs +} + +// NewTenantsHandler creates a handler for tenant management endpoints. +func NewTenantsHandler(tenantStore store.TenantStore, token string, msgBus *bus.MessageBus, workspace string) *TenantsHandler { + return &TenantsHandler{tenantStore: tenantStore, token: token, msgBus: msgBus, workspace: workspace} +} + +// RegisterRoutes registers all tenant management routes on the given mux. +func (h *TenantsHandler) RegisterRoutes(mux *http.ServeMux) { + admin := func(next http.HandlerFunc) http.HandlerFunc { + return requireAuth(h.token, permissions.RoleAdmin, next) + } + mux.HandleFunc("GET /v1/tenants", admin(h.handleList)) + mux.HandleFunc("POST /v1/tenants", admin(h.handleCreate)) + mux.HandleFunc("GET /v1/tenants/{id}", admin(h.handleGet)) + mux.HandleFunc("PATCH /v1/tenants/{id}", admin(h.handleUpdate)) + mux.HandleFunc("GET /v1/tenants/{id}/users", admin(h.handleUsersList)) + mux.HandleFunc("POST /v1/tenants/{id}/users", admin(h.handleUsersAdd)) + mux.HandleFunc("DELETE /v1/tenants/{id}/users/{userId}", admin(h.handleUsersRemove)) +} + +func (h *TenantsHandler) handleList(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.list")}) + return + } + + tenants, err := h.tenantStore.ListTenants(r.Context()) + if err != nil { + slog.Error("tenants.list failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tenants")}) + return + } + if tenants == nil { + tenants = []store.TenantData{} + } + writeJSON(w, http.StatusOK, map[string]any{"tenants": tenants}) +} + +func (h *TenantsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.create")}) + return + } + + var input struct { + Name string `json:"name"` + Slug string `json:"slug"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + if input.Name == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name")}) + return + } + if input.Slug == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "slug")}) + return + } + if !isValidSlug(input.Slug) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "slug")}) + return + } + + tenant := &store.TenantData{ + ID: store.GenNewID(), + Name: input.Name, + Slug: input.Slug, + Status: store.TenantStatusActive, + } + + if err := h.tenantStore.CreateTenant(r.Context(), tenant); err != nil { + slog.Error("tenants.create failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "tenant", err.Error())}) + return + } + + // Create workspace directory for the tenant. + if h.workspace != "" { + tenantDir := filepath.Join(h.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) + } + } + + h.emitCacheInvalidate(bus.CacheKindTenantUsers, tenant.ID.String()) + emitAudit(h.msgBus, r, "tenant.created", "tenant", tenant.ID.String()) + writeJSON(w, http.StatusCreated, tenant) +} + +func (h *TenantsHandler) handleGet(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.get")}) + return + } + + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")}) + return + } + + tenant, err := h.tenantStore.GetTenant(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "tenant", id.String())}) + return + } + writeJSON(w, http.StatusOK, tenant) +} + +func (h *TenantsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.update")}) + return + } + + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")}) + return + } + + var input struct { + Name string `json:"name"` + Status string `json:"status"` + Settings map[string]any `json:"settings"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + updates := make(map[string]any) + if input.Name != "" { + updates["name"] = input.Name + } + if input.Status != "" { + updates["status"] = input.Status + } + if input.Settings != nil { + updates["settings"] = input.Settings + } + + if len(updates) == 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidUpdates)}) + return + } + + if err := h.tenantStore.UpdateTenant(r.Context(), id, updates); err != nil { + slog.Error("tenants.update failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToUpdate, "tenant", err.Error())}) + return + } + + h.emitCacheInvalidate(bus.CacheKindTenantUsers, id.String()) + emitAudit(h.msgBus, r, "tenant.updated", "tenant", id.String()) + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (h *TenantsHandler) handleUsersList(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.list")}) + return + } + + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")}) + return + } + + users, err := h.tenantStore.ListUsers(r.Context(), id) + if err != nil { + slog.Error("tenants.users.list failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "tenant users")}) + return + } + if users == nil { + users = []store.TenantUserData{} + } + writeJSON(w, http.StatusOK, map[string]any{"users": users}) +} + +func (h *TenantsHandler) handleUsersAdd(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.add")}) + return + } + + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")}) + return + } + + var input struct { + UserID string `json:"user_id"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + + if input.UserID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "user_id")}) + return + } + if input.Role == "" { + input.Role = store.TenantRoleMember + } + validRoles := map[string]bool{ + store.TenantRoleOwner: true, store.TenantRoleAdmin: true, + store.TenantRoleOperator: true, store.TenantRoleMember: true, store.TenantRoleViewer: true, + } + if !validRoles[input.Role] { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRole)}) + return + } + + if err := h.tenantStore.AddUser(r.Context(), id, input.UserID, input.Role); err != nil { + slog.Error("tenants.users.add failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "tenant user", err.Error())}) + return + } + + h.emitCacheInvalidate(bus.CacheKindTenantUsers, input.UserID) + emitAudit(h.msgBus, r, "tenant.user.added", "tenant", id.String()) + writeJSON(w, http.StatusCreated, map[string]string{"ok": "true"}) +} + +func (h *TenantsHandler) handleUsersRemove(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if !store.IsCrossTenant(r.Context()) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "tenants.users.remove")}) + return + } + + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "tenant")}) + return + } + + userID := r.PathValue("userId") + if userID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "userId")}) + return + } + + if err := h.tenantStore.RemoveUser(r.Context(), id, userID); err != nil { + slog.Error("tenants.users.remove failed", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDelete, "tenant user", err.Error())}) + return + } + + h.emitCacheInvalidate(bus.CacheKindTenantUsers, userID) + emitAudit(h.msgBus, r, "tenant.user.removed", "tenant", id.String()) + + // Notify affected user's WS sessions to force logout + if h.msgBus != nil { + h.msgBus.Broadcast(bus.Event{ + Name: protocol.EventTenantAccessRevoked, + Payload: map[string]string{"user_id": userID, "tenant_id": id.String()}, + }) + } + + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) +} + +func (h *TenantsHandler) emitCacheInvalidate(kind, key string) { + if h.msgBus == nil { + return + } + h.msgBus.Broadcast(bus.Event{ + Name: protocol.EventCacheInvalidate, + Payload: bus.CacheInvalidatePayload{Kind: kind, Key: key}, + }) +} diff --git a/internal/http/traces.go b/internal/http/traces.go index 90e2311f..383d1ed3 100644 --- a/internal/http/traces.go +++ b/internal/http/traces.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -73,6 +74,13 @@ func (h *TracesHandler) handleList(w http.ResponseWriter, r *http.Request) { } } + // Non-admin callers may only see their own traces. + auth := resolveAuth(r, h.token) + if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { + callerID := store.UserIDFromContext(r.Context()) + opts.UserID = callerID + } + traces, err := h.tracing.ListTraces(r.Context(), opts) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -104,6 +112,16 @@ func (h *TracesHandler) handleGet(w http.ResponseWriter, r *http.Request) { return } + // Non-admin callers may only access their own traces. + auth := resolveAuth(r, h.token) + if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) { + callerID := store.UserIDFromContext(r.Context()) + if trace.UserID != callerID { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceIDStr)}) + return + } + } + spans, err := h.tracing.GetTraceSpans(r.Context(), traceID) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -160,6 +178,21 @@ func (h *TracesHandler) handleExport(w http.ResponseWriter, r *http.Request) { return } + // Verify ownership before export. + rootTrace, err := h.tracing.GetTrace(r.Context(), traceID) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceID.String())}) + return + } + authExport := resolveAuth(r, h.token) + if !permissions.HasMinRole(authExport.Role, permissions.RoleAdmin) { + callerID := store.UserIDFromContext(r.Context()) + if rootTrace.UserID != callerID { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceID.String())}) + return + } + } + entry, err := h.collectTraceTree(r.Context(), traceID, 0) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "trace", traceID.String())}) diff --git a/internal/http/usage.go b/internal/http/usage.go index 5d75e627..13a923bb 100644 --- a/internal/http/usage.go +++ b/internal/http/usage.go @@ -185,6 +185,17 @@ func (h *UsageHandler) queryLiveHour(r *http.Request, from, to time.Time, q stor args := []any{from, to} idx := 3 + + // Tenant isolation: scope to caller's tenant + if !store.IsCrossTenant(r.Context()) { + tid := store.TenantIDFromContext(r.Context()) + if tid != uuid.Nil { + query += fmt.Sprintf(" AND tenant_id = $%d", idx) + args = append(args, tid) + idx++ + } + } + if q.AgentID != nil { query += fmt.Sprintf(" AND agent_id = $%d", idx) args = append(args, *q.AgentID) diff --git a/internal/http/validate.go b/internal/http/validate.go index 73c4c95c..e38178e2 100644 --- a/internal/http/validate.go +++ b/internal/http/validate.go @@ -35,7 +35,7 @@ var agentAllowedFields = map[string]bool{ "agent_key": true, "agent_type": true, "display_name": true, "provider": true, "model": true, "status": true, "context_window": true, "max_tool_iterations": true, - "workspace": true, "restrict_to_workspace": true, + "workspace": true, "frontmatter": true, "compaction_config": true, "memory_config": true, "other_config": true, "tools_config": true, "is_default": true, "budget_monthly_cents": true, "subagents_config": true, @@ -59,7 +59,7 @@ var mcpServerAllowedFields = map[string]bool{ "name": true, "transport": true, "command": true, "args": true, "url": true, "api_key": true, "env": true, "headers": true, "enabled": true, "tool_prefix": true, "timeout_seconds": true, - "agent_id": true, "config": true, + "agent_id": true, "config": true, "settings": true, } var channelInstanceAllowedFields = map[string]bool{ diff --git a/internal/http/wake.go b/internal/http/wake.go index 5f675b22..3cf3ddc4 100644 --- a/internal/http/wake.go +++ b/internal/http/wake.go @@ -93,7 +93,7 @@ func (h *WakeHandler) handleWake(w http.ResponseWriter, r *http.Request) { return } - loop, err := h.agents.Get(agentID) + loop, err := h.agents.Get(r.Context(), agentID) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", agentID)}) return diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index 24de5217..103aef00 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -93,9 +93,6 @@ func init() { MsgAgentIsTeamLead: "agent is already the team lead", MsgCannotRemoveTeamLead: "cannot remove the team lead", - // Delegations - MsgDelegationsUnavailable: "delegations not available", - // Channels MsgCannotDeleteDefaultInst: "cannot delete default channel instance", @@ -176,5 +173,13 @@ func init() { MsgSkillNudgePostscript: "This task involved several steps. Want me to save the process as a reusable skill? Reply **\"save as skill\"** or **\"skip\"**.", MsgSkillNudge70Pct: "[System] You are at 70% of your iteration budget. Consider whether any patterns from this session would make a good skill.", MsgSkillNudge90Pct: "[System] You are at 90% of your iteration budget. If this session involved reusable patterns, consider saving them as a skill before completing.", + + MsgInvalidRole: "invalid role: allowed values are owner, admin, operator, member, viewer", + + MsgContactIDsRequired: "contact_ids is required", + MsgMergeTargetRequired: "exactly one of tenant_user_id or create_user is required", + MsgTenantUserNotFound: "tenant user not found", + MsgTenantMismatch: "tenant user does not belong to this tenant", + MsgTenantScopeRequired: "tenant scope is required for this operation", }) } diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go index ae2036ab..49e8720c 100644 --- a/internal/i18n/catalog_vi.go +++ b/internal/i18n/catalog_vi.go @@ -93,9 +93,6 @@ func init() { MsgAgentIsTeamLead: "agent đã là trưởng nhóm", MsgCannotRemoveTeamLead: "không thể xóa trưởng nhóm", - // Delegations - MsgDelegationsUnavailable: "ủy quyền không khả dụng", - // Channels MsgCannotDeleteDefaultInst: "không thể xóa phiên bản channel mặc định", @@ -176,5 +173,13 @@ func init() { MsgSkillNudgePostscript: "Tác vụ này cần nhiều bước. Bạn muốn tôi lưu quy trình này thành kỹ năng tái sử dụng không? Trả lời **\"lưu kỹ năng\"** hoặc **\"bỏ qua\"**.", MsgSkillNudge70Pct: "[System] Bạn đã dùng 70% ngân sách vòng lặp. Cân nhắc xem các mẫu trong phiên này có nên lưu thành kỹ năng không.", MsgSkillNudge90Pct: "[System] Bạn đã dùng 90% ngân sách vòng lặp. Nếu phiên này có quy trình tái sử dụng, hãy cân nhắc lưu thành kỹ năng trước khi hoàn thành.", + + MsgInvalidRole: "vai trò không hợp lệ: giá trị cho phép là owner, admin, operator, member, viewer", + + MsgContactIDsRequired: "contact_ids là bắt buộc", + MsgMergeTargetRequired: "cần chính xác một trong tenant_user_id hoặc create_user", + MsgTenantUserNotFound: "không tìm thấy tenant user", + MsgTenantMismatch: "tenant user không thuộc tenant này", + MsgTenantScopeRequired: "cần xác định tenant để thực hiện thao tác này", }) } diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go index 2a806dc7..0402aaf8 100644 --- a/internal/i18n/catalog_zh.go +++ b/internal/i18n/catalog_zh.go @@ -93,9 +93,6 @@ func init() { MsgAgentIsTeamLead: "该Agent已是团队负责人", MsgCannotRemoveTeamLead: "无法移除团队负责人", - // Delegations - MsgDelegationsUnavailable: "委派功能不可用", - // Channels MsgCannotDeleteDefaultInst: "无法删除默认Channel实例", @@ -176,5 +173,13 @@ func init() { MsgSkillNudgePostscript: "此任务涉及多个步骤。要我将此过程保存为可重用技能吗?回复 **\"保存技能\"** 或 **\"跳过\"**。", MsgSkillNudge70Pct: "[System] 您已使用 70% 的迭代预算。请考虑本次会话中的模式是否值得保存为技能。", MsgSkillNudge90Pct: "[System] 您已使用 90% 的迭代预算。如果本次会话涉及可重用的模式,请考虑在完成前将其保存为技能。", + + MsgInvalidRole: "无效角色:允许的值为 owner、admin、operator、member、viewer", + + MsgContactIDsRequired: "contact_ids 为必填项", + MsgMergeTargetRequired: "必须提供 tenant_user_id 或 create_user 其中之一", + MsgTenantUserNotFound: "未找到租户用户", + MsgTenantMismatch: "租户用户不属于此租户", + MsgTenantScopeRequired: "此操作需要指定租户范围", }) } diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index f7909ed2..b090f23b 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -94,9 +94,6 @@ const ( MsgAgentIsTeamLead = "error.agent_is_team_lead" // "agent is already the team lead" MsgCannotRemoveTeamLead = "error.cannot_remove_team_lead" // "cannot remove the team lead" - // --- Delegations --- - MsgDelegationsUnavailable = "error.delegations_not_available" // "delegations not available" - // --- Channels --- MsgCannotDeleteDefaultInst = "error.cannot_delete_default_inst" // "cannot delete default channel instance" @@ -178,4 +175,14 @@ const ( MsgSkillNudgePostscript = "skill.nudge_postscript" MsgSkillNudge70Pct = "skill.nudge_70_pct" MsgSkillNudge90Pct = "skill.nudge_90_pct" + + // --- Tenants --- + MsgInvalidRole = "error.invalid_role" // "invalid role: allowed values are owner, admin, operator, member, viewer" + + // --- Contact merge --- + MsgContactIDsRequired = "error.contact_ids_required" // "contact_ids is required" + MsgMergeTargetRequired = "error.merge_target_required" // "exactly one of tenant_user_id or create_user is required" + MsgTenantUserNotFound = "error.tenant_user_not_found" // "tenant user not found" + MsgTenantMismatch = "error.tenant_mismatch" // "tenant user does not belong to this tenant" + MsgTenantScopeRequired = "error.tenant_scope_required" // "tenant scope is required for this operation" ) diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index f715450d..606dd496 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "encoding/json" "fmt" "log/slog" "sync" @@ -76,6 +77,7 @@ type Manager struct { pool *Pool poolServers map[string]struct{} // server names acquired from pool (for cleanup) poolToolNames map[string][]string // per-agent tool names for pool-backed servers + poolKeys map[string]string // server name → pool compound key (tenantID/name) for Release // Search mode: deferred tools not registered in registry deferredTools map[string]*BridgeTool // registeredName → BridgeTool @@ -167,13 +169,69 @@ func (m *Manager) LoadForAgent(ctx context.Context, agentID uuid.UUID, userID st continue } + // Skip server if it requires per-user credentials and user has none + if requireUserCreds(srv.Settings) { + if userID == "" { + continue + } + uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID) + if uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0) { + slog.Debug("mcp.skip_no_user_credentials", "server", srv.Name, "user", userID) + continue + } + } + args := jsonBytesToStringSlice(srv.Args) env := jsonBytesToStringMap(srv.Env) headers := jsonBytesToStringMap(srv.Headers) - if m.pool != nil { + // Inject APIKey into headers if present (bug fix: was never passed to connections) + if srv.APIKey != "" && headers["Authorization"] == "" { + if headers == nil { + headers = make(map[string]string) + } + headers["Authorization"] = "Bearer " + srv.APIKey + } + + // Merge per-user credentials (user overrides server defaults) + if userID != "" && m.store != nil { + if userCreds, err := m.store.GetUserCredentials(ctx, srv.ID, userID); err == nil && userCreds != nil { + if userCreds.APIKey != "" { + if headers == nil { + headers = make(map[string]string) + } + headers["Authorization"] = "Bearer " + userCreds.APIKey + } + for k, v := range userCreds.Headers { + if headers == nil { + headers = make(map[string]string) + } + headers[k] = v + } + for k, v := range userCreds.Env { + if env == nil { + env = make(map[string]string) + } + env[k] = v + } + } + } + + // Per-user credentials change connection params → can't share pool connection. + // Fall back to per-agent mode when user has custom credentials. + hasUserCreds := userID != "" && m.store != nil + if hasUserCreds { + if uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID); uc != nil && (uc.APIKey != "" || len(uc.Headers) > 0 || len(uc.Env) > 0) { + hasUserCreds = true + } else { + hasUserCreds = false + } + } + + if m.pool != nil && !hasUserCreds { // Pool mode: acquire shared connection, create per-agent BridgeTools - if err := m.connectViaPool(ctx, srv.Name, srv.Transport, srv.Command, + tid := store.TenantIDFromContext(ctx) + if err := m.connectViaPool(ctx, tid, srv.Name, srv.Transport, srv.Command, args, env, srv.URL, headers, srv.ToolPrefix, srv.TimeoutSec); err != nil { slog.Warn("mcp.server.connect_failed", "server", srv.Name, "error", err) continue @@ -359,7 +417,9 @@ func (m *Manager) Stop() { m.registry.Unregister(toolName) } if m.pool != nil { - m.pool.Release(name) + if pkey, ok := m.poolKeys[name]; ok { + m.pool.Release(pkey) + } } } else { // Standalone: close connection directly @@ -398,3 +458,15 @@ func (m *Manager) ServerStatus() []ServerStatus { } return statuses } + +// requireUserCreds checks if an MCP server's settings mandate per-user credentials. +func requireUserCreds(settings json.RawMessage) bool { + if len(settings) == 0 { + return false + } + var s struct { + RequireUserCredentials bool `json:"require_user_credentials"` + } + _ = json.Unmarshal(settings, &s) + return s.RequireUserCredentials +} diff --git a/internal/mcp/manager_connect.go b/internal/mcp/manager_connect.go index 9d4816cf..590cdeb2 100644 --- a/internal/mcp/manager_connect.go +++ b/internal/mcp/manager_connect.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/google/uuid" mcpclient "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/client/transport" mcpgo "github.com/mark3labs/mcp-go/mcp" @@ -123,8 +124,8 @@ func (m *Manager) registerBridgeTools(ss *serverState, mcpTools []mcpgo.Tool, se // connectViaPool acquires a shared connection from the pool and creates // per-agent BridgeTools pointing to the shared client/connected pointers. -func (m *Manager) connectViaPool(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int) error { - entry, err := m.pool.Acquire(ctx, name, transportType, command, args, env, url, headers, timeoutSec) +func (m *Manager) connectViaPool(ctx context.Context, tenantID uuid.UUID, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, toolPrefix string, timeoutSec int) error { + entry, err := m.pool.Acquire(ctx, tenantID, name, transportType, command, args, env, url, headers, timeoutSec) if err != nil { return err } @@ -132,7 +133,9 @@ func (m *Manager) connectViaPool(ctx context.Context, name, transportType, comma // Create per-agent BridgeTools from the pool's shared connection registeredNames := m.registerPoolBridgeTools(entry, name, toolPrefix, timeoutSec) - // Track server state and per-agent tool names + // Track server state and per-agent tool names. + // poolServers/poolToolNames keyed by plain name for Close() iteration. + // poolKeys maps plain name → pool compound key for Release(). m.mu.Lock() m.servers[name] = entry.state if m.poolServers == nil { @@ -143,6 +146,10 @@ func (m *Manager) connectViaPool(ctx context.Context, name, transportType, comma m.poolToolNames = make(map[string][]string) } m.poolToolNames[name] = registeredNames + if m.poolKeys == nil { + m.poolKeys = make(map[string]string) + } + m.poolKeys[name] = poolKey(tenantID, name) m.mu.Unlock() if len(registeredNames) > 0 { diff --git a/internal/mcp/manager_tools.go b/internal/mcp/manager_tools.go index f469abf3..5ba94c1b 100644 --- a/internal/mcp/manager_tools.go +++ b/internal/mcp/manager_tools.go @@ -63,7 +63,9 @@ func (m *Manager) unregisterAllTools() { m.registry.Unregister(toolName) } if m.pool != nil { - m.pool.Release(name) + if pkey, ok := m.poolKeys[name]; ok { + m.pool.Release(pkey) + } } } else { // Standalone: close connection directly diff --git a/internal/mcp/pool.go b/internal/mcp/pool.go index 4786051f..3dc81149 100644 --- a/internal/mcp/pool.go +++ b/internal/mcp/pool.go @@ -2,64 +2,126 @@ package mcp import ( "context" + "fmt" "log/slog" "sync" + "time" + "github.com/google/uuid" mcpgo "github.com/mark3labs/mcp-go/mcp" ) +// PoolConfig configures the MCP connection pool. +type PoolConfig struct { + MaxSize int // global max connections (default 100) + MaxIdle int // max idle connections to keep alive (default 20) + IdleTTL time.Duration // close idle connections after this (default 20m) + AcquireTimeout time.Duration // wait for pool slot before error (default 60s) +} + +// DefaultPoolConfig returns the default pool configuration. +func DefaultPoolConfig() PoolConfig { + return PoolConfig{ + MaxSize: 100, + MaxIdle: 20, + IdleTTL: 20 * time.Minute, + AcquireTimeout: 60 * time.Second, + } +} + // poolEntry holds a shared connection and its discovered tools. type poolEntry struct { state *serverState // connection + health state tools []mcpgo.Tool // discovered MCP tool definitions refCount int // number of active Manager references + lastUsed time.Time // last Acquire/Release time for idle eviction } // Pool manages shared MCP server connections across agents. -// One physical connection per server name, shared by all agents -// that have grants to that server. Each agent creates its own -// BridgeTools pointing to the shared client/connected pointers. +// Connections are keyed by tenantID/serverName for tenant isolation. type Pool struct { mu sync.Mutex servers map[string]*poolEntry + cfg PoolConfig + slot chan struct{} // semaphore for MaxSize + stopCh chan struct{} } -// NewPool creates a shared MCP connection pool. -func NewPool() *Pool { - return &Pool{ - servers: make(map[string]*poolEntry), +// NewPool creates a shared MCP connection pool with idle eviction. +func NewPool(cfg PoolConfig) *Pool { + if cfg.MaxSize <= 0 { + cfg.MaxSize = 100 } + if cfg.MaxIdle <= 0 { + cfg.MaxIdle = 20 + } + if cfg.IdleTTL <= 0 { + cfg.IdleTTL = 20 * time.Minute + } + if cfg.AcquireTimeout <= 0 { + cfg.AcquireTimeout = 60 * time.Second + } + + p := &Pool{ + servers: make(map[string]*poolEntry), + cfg: cfg, + slot: make(chan struct{}, cfg.MaxSize), + stopCh: make(chan struct{}), + } + go p.evictLoop() + return p } -// Acquire returns a shared connection for the named server. -// If no connection exists, it connects using the provided config. -// Increments the reference count. -func (p *Pool) Acquire(ctx context.Context, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, timeoutSec int) (*poolEntry, error) { - p.mu.Lock() +// poolKey builds a tenant-scoped key for pool lookups. +func poolKey(tenantID uuid.UUID, name string) string { + return tenantID.String() + "/" + name +} - if entry, ok := p.servers[name]; ok && entry.state.connected.Load() { +// Acquire returns a shared connection for the named server scoped to a tenant. +// If no connection exists, it connects using the provided config. +// Blocks up to AcquireTimeout if pool is at MaxSize. +func (p *Pool) Acquire(ctx context.Context, tenantID uuid.UUID, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, timeoutSec int) (*poolEntry, error) { + key := poolKey(tenantID, name) + + p.mu.Lock() + if entry, ok := p.servers[key]; ok && entry.state.connected.Load() { entry.refCount++ + entry.lastUsed = time.Now() p.mu.Unlock() - slog.Debug("mcp.pool.reuse", "server", name, "refCount", entry.refCount) + slog.Debug("mcp.pool.reuse", "key", key, "refCount", entry.refCount) return entry, nil } - // If entry exists but disconnected, close old connection first - if old, ok := p.servers[name]; ok { + // If entry exists but disconnected, close old and reclaim slot + if old, ok := p.servers[key]; ok { if old.state.cancel != nil { old.state.cancel() } if old.state.client != nil { _ = old.state.client.Close() } - delete(p.servers, name) + delete(p.servers, key) + // Return slot to semaphore + select { + case <-p.slot: + default: + } } - p.mu.Unlock() + // Acquire a slot (blocks if pool full, evicts idle if possible) + if err := p.acquireSlot(ctx); err != nil { + return nil, fmt.Errorf("mcp pool exhausted: %w", err) + } + // Connect outside the lock (may be slow) ss, mcpTools, err := connectAndDiscover(ctx, name, transportType, command, args, env, url, headers, timeoutSec) if err != nil { + // Return slot on failure + select { + case <-p.slot: + default: + } return nil, err } @@ -72,63 +134,226 @@ func (p *Pool) Acquire(ctx context.Context, name, transportType, command string, state: ss, tools: mcpTools, refCount: 1, + lastUsed: time.Now(), } p.mu.Lock() // Check if another goroutine connected while we were connecting - if existing, ok := p.servers[name]; ok && existing.state.connected.Load() { - // Use existing, close ours + if existing, ok := p.servers[key]; ok && existing.state.connected.Load() { p.mu.Unlock() hcancel() _ = ss.client.Close() + // Return our extra slot + select { + case <-p.slot: + default: + } p.mu.Lock() existing.refCount++ + existing.lastUsed = time.Now() p.mu.Unlock() return existing, nil } - p.servers[name] = entry + p.servers[key] = entry p.mu.Unlock() - slog.Info("mcp.pool.connected", "server", name, "tools", len(mcpTools)) + slog.Info("mcp.pool.connected", "key", key, "tools", len(mcpTools)) return entry, nil } +// acquireSlot tries to acquire a pool slot, evicting idle connections if needed. +func (p *Pool) acquireSlot(ctx context.Context) error { + // Fast path: slot available + select { + case p.slot <- struct{}{}: + return nil + default: + } + + // Try evicting one idle entry + p.mu.Lock() + evicted := p.evictOldestIdleLocked() + p.mu.Unlock() + + if evicted { + select { + case p.slot <- struct{}{}: + return nil + default: + } + } + + // Wait up to AcquireTimeout + timer := time.NewTimer(p.cfg.AcquireTimeout) + defer timer.Stop() + + select { + case p.slot <- struct{}{}: + return nil + case <-timer.C: + return fmt.Errorf("timeout after %s waiting for pool slot (max %d)", p.cfg.AcquireTimeout, p.cfg.MaxSize) + case <-ctx.Done(): + return ctx.Err() + } +} + // Release decrements the reference count for a server. -// The connection is NOT closed when refCount reaches 0 — it stays -// alive for future agents. Use Stop() to close all connections. -func (p *Pool) Release(name string) { +// Accepts the same key format as Acquire (tenantID + name). +func (p *Pool) Release(key string) { p.mu.Lock() defer p.mu.Unlock() - if entry, ok := p.servers[name]; ok { + if entry, ok := p.servers[key]; ok { entry.refCount-- if entry.refCount < 0 { entry.refCount = 0 } - slog.Debug("mcp.pool.release", "server", name, "refCount", entry.refCount) + entry.lastUsed = time.Now() + slog.Debug("mcp.pool.release", "key", key, "refCount", entry.refCount) } } -// Stop closes all pooled connections. Called on gateway shutdown. +// Stop closes all pooled connections and stops eviction. Called on gateway shutdown. func (p *Pool) Stop() { + close(p.stopCh) + p.mu.Lock() defer p.mu.Unlock() - for name, entry := range p.servers { + for key, entry := range p.servers { if entry.state.cancel != nil { entry.state.cancel() } if entry.state.client != nil { _ = entry.state.client.Close() } - slog.Debug("mcp.pool.stopped", "server", name) + slog.Debug("mcp.pool.stopped", "key", key) } p.servers = make(map[string]*poolEntry) } +// Evict closes a specific pooled connection by tenant + server name. +// Called when server credentials are rotated to force reconnection with new credentials. +func (p *Pool) Evict(tenantID uuid.UUID, serverName string) { + key := poolKey(tenantID, serverName) + p.mu.Lock() + defer p.mu.Unlock() + + entry, ok := p.servers[key] + if !ok { + return + } + if entry.state.cancel != nil { + entry.state.cancel() + } + if entry.state.client != nil { + _ = entry.state.client.Close() + } + delete(p.servers, key) + select { + case <-p.slot: + default: + } + slog.Info("mcp.pool.evicted_on_rotation", "key", key) +} + +// evictLoop runs periodically to close idle connections over MaxIdle count. +func (p *Pool) evictLoop() { + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + p.evictIdle() + } + } +} + +// evictIdle closes connections idle > IdleTTL when total idle exceeds MaxIdle. +func (p *Pool) evictIdle() { + p.mu.Lock() + defer p.mu.Unlock() + + now := time.Now() + var idleKeys []string + for key, entry := range p.servers { + if entry.refCount == 0 && now.Sub(entry.lastUsed) > p.cfg.IdleTTL { + idleKeys = append(idleKeys, key) + } + } + + // Count total idle (refCount == 0) + totalIdle := 0 + for _, entry := range p.servers { + if entry.refCount == 0 { + totalIdle++ + } + } + + // Only evict if over MaxIdle + toEvict := totalIdle - p.cfg.MaxIdle + if toEvict <= 0 && len(idleKeys) == 0 { + return + } + + // Evict TTL-expired first, then oldest if still over MaxIdle + for _, key := range idleKeys { + entry := p.servers[key] + if entry.state.cancel != nil { + entry.state.cancel() + } + if entry.state.client != nil { + _ = entry.state.client.Close() + } + delete(p.servers, key) + // Return slot + select { + case <-p.slot: + default: + } + slog.Debug("mcp.pool.evicted", "key", key, "reason", "idle_ttl") + } +} + +// evictOldestIdleLocked evicts one idle entry (oldest lastUsed). Caller must hold mu. +func (p *Pool) evictOldestIdleLocked() bool { + var oldestKey string + var oldestTime time.Time + + for key, entry := range p.servers { + if entry.refCount == 0 { + if oldestKey == "" || entry.lastUsed.Before(oldestTime) { + oldestKey = key + oldestTime = entry.lastUsed + } + } + } + + if oldestKey == "" { + return false + } + + entry := p.servers[oldestKey] + if entry.state.cancel != nil { + entry.state.cancel() + } + if entry.state.client != nil { + _ = entry.state.client.Close() + } + delete(p.servers, oldestKey) + // Return slot to semaphore + select { + case <-p.slot: + default: + } + slog.Debug("mcp.pool.evicted", "key", oldestKey, "reason", "make_room") + return true +} + // poolHealthLoop is a standalone health loop for pool-managed connections. -// Unlike Manager.healthLoop, it doesn't trigger reconnection via Manager -// since the pool owns the connection lifecycle. func poolHealthLoop(ctx context.Context, ss *serverState) { ticker := newHealthTicker() defer ticker.Stop() diff --git a/internal/media/store.go b/internal/media/store.go index b6505985..162871b6 100644 --- a/internal/media/store.go +++ b/internal/media/store.go @@ -36,7 +36,7 @@ func (s *Store) SaveFile(sessionKey, srcPath, mime string) (id string, dstPath s } mediaID := uuid.New().String() - ext := extFromMime(mime) + ext := ExtFromMime(mime) if ext == "" { ext = filepath.Ext(srcPath) } @@ -88,8 +88,8 @@ func (s *Store) sessionDir(sessionKey string) string { return filepath.Join(s.baseDir, hash) } -// extFromMime returns a file extension (with dot) for a MIME type. -func extFromMime(mime string) string { +// ExtFromMime returns a file extension (with dot) for a MIME type. +func ExtFromMime(mime string) string { switch { case strings.HasPrefix(mime, "image/jpeg"): return ".jpg" diff --git a/internal/oauth/token.go b/internal/oauth/token.go index 21f9b015..9e9a7ff2 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -36,6 +36,7 @@ type DBTokenSource struct { providerStore store.ProviderStore secretsStore store.ConfigSecretsStore providerName string + tenantID uuid.UUID // tenant context for DB queries mu sync.Mutex cachedToken string @@ -48,9 +49,16 @@ func NewDBTokenSource(provStore store.ProviderStore, secretsStore store.ConfigSe providerStore: provStore, secretsStore: secretsStore, providerName: providerName, + tenantID: store.MasterTenantID, } } +// WithTenantID sets the tenant context for DB queries. Must be called at init time before Token(). +func (ts *DBTokenSource) WithTenantID(tenantID uuid.UUID) *DBTokenSource { + ts.tenantID = tenantID + return ts +} + // Token returns a valid access token, refreshing if expired or about to expire. func (ts *DBTokenSource) Token() (string, error) { ts.mu.Lock() @@ -61,7 +69,7 @@ func (ts *DBTokenSource) Token() (string, error) { return ts.cachedToken, nil } - ctx := context.Background() + ctx := store.WithTenantID(context.Background(), ts.tenantID) // Load from DB if not cached if ts.cachedToken == "" { diff --git a/internal/permissions/policy.go b/internal/permissions/policy.go index 4bfe6369..0a95cdb8 100644 --- a/internal/permissions/policy.go +++ b/internal/permissions/policy.go @@ -37,6 +37,7 @@ const ( ScopeWrite Scope = "operator.write" ScopeApprovals Scope = "operator.approvals" ScopePairing Scope = "operator.pairing" + ScopeProvision Scope = "operator.provision" ) // AllScopes is the set of all valid API key scopes. @@ -46,6 +47,7 @@ var AllScopes = map[Scope]bool{ ScopeWrite: true, ScopeApprovals: true, ScopePairing: true, + ScopeProvision: true, } // ValidScope reports whether s is a recognised API key scope. diff --git a/internal/providers/registry.go b/internal/providers/registry.go index 9b26ec59..c06b0dc2 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -1,80 +1,164 @@ package providers import ( + "context" "fmt" "io" "log/slog" + "strings" "sync" + + "github.com/google/uuid" ) -// Registry manages available LLM providers. +// MasterTenantID is the default tenant for config-based providers. +var MasterTenantID = uuid.Must(uuid.Parse("0193a5b0-7000-7000-8000-000000000001")) + +// Registry manages available LLM providers with per-tenant isolation. +// Providers are keyed by "tenantID/name". Get falls back to master tenant. type Registry struct { - providers map[string]Provider - mu sync.RWMutex + providers map[string]Provider + mu sync.RWMutex + tenantFromCtx func(context.Context) uuid.UUID // injected to avoid circular import with store } -func NewRegistry() *Registry { +// NewRegistry creates a provider registry. +// tenantFromCtx extracts tenant UUID from context (pass store.TenantIDFromContext). +func NewRegistry(tenantFromCtx func(context.Context) uuid.UUID) *Registry { return &Registry{ - providers: make(map[string]Provider), + providers: make(map[string]Provider), + tenantFromCtx: tenantFromCtx, } } -// Register adds a provider to the registry. -// If a provider with the same name already exists, it is closed before replacement. +// compoundKey returns "tenantID/name" for registry lookup. +func compoundKey(tenantID uuid.UUID, name string) string { + return tenantID.String() + "/" + name +} + +// tenantFromContext resolves the tenant UUID from context, defaulting to MasterTenantID. +func (r *Registry) tenantFromContext(ctx context.Context) uuid.UUID { + if r.tenantFromCtx != nil { + if t := r.tenantFromCtx(ctx); t != uuid.Nil { + return t + } + } + return MasterTenantID +} + +// Register adds a provider to the registry under the master tenant. func (r *Registry) Register(provider Provider) { + r.RegisterForTenant(MasterTenantID, provider) +} + +// RegisterForTenant adds a provider under a specific tenant. +// If a provider with the same tenant+name already exists, it is closed before replacement. +func (r *Registry) RegisterForTenant(tenantID uuid.UUID, provider Provider) { r.mu.Lock() defer r.mu.Unlock() - if old, ok := r.providers[provider.Name()]; ok { + key := compoundKey(tenantID, provider.Name()) + if old, ok := r.providers[key]; ok { if c, ok := old.(io.Closer); ok { c.Close() } } - r.providers[provider.Name()] = provider + r.providers[key] = provider } -// Unregister removes a provider from the registry, closing it if it implements io.Closer. +// Unregister removes a provider from the master tenant. func (r *Registry) Unregister(name string) { + r.UnregisterForTenant(MasterTenantID, name) +} + +// UnregisterForTenant removes a provider from a specific tenant. +func (r *Registry) UnregisterForTenant(tenantID uuid.UUID, name string) { r.mu.Lock() defer r.mu.Unlock() - if old, ok := r.providers[name]; ok { + key := compoundKey(tenantID, name) + if old, ok := r.providers[key]; ok { if c, ok := old.(io.Closer); ok { c.Close() } - delete(r.providers, name) + delete(r.providers, key) } } -// Get returns a provider by name. -func (r *Registry) Get(name string) (Provider, error) { +// Get returns a provider by name, using tenant from context (falls back to master). +func (r *Registry) Get(ctx context.Context, name string) (Provider, error) { + return r.GetForTenant(r.tenantFromContext(ctx), name) +} + +// GetForTenant returns a provider by name for a specific tenant. +// Falls back to master tenant if not found for the given tenant. +func (r *Registry) GetForTenant(tenantID uuid.UUID, name string) (Provider, error) { r.mu.RLock() defer r.mu.RUnlock() - p, ok := r.providers[name] - if !ok { - return nil, fmt.Errorf("provider not found: %s", name) + + // Try tenant-specific first + if tenantID != MasterTenantID { + if p, ok := r.providers[compoundKey(tenantID, name)]; ok { + return p, nil + } } - return p, nil + // Fallback to master tenant + if p, ok := r.providers[compoundKey(MasterTenantID, name)]; ok { + return p, nil + } + return nil, fmt.Errorf("provider not found: %s", name) } // Close calls Close() on all providers that implement io.Closer. func (r *Registry) Close() { - r.mu.RLock() - defer r.mu.RUnlock() - for name, p := range r.providers { + r.mu.Lock() + defer r.mu.Unlock() + for key, p := range r.providers { if c, ok := p.(io.Closer); ok { if err := c.Close(); err != nil { - slog.Warn("provider close error", "name", name, "error", err) + slog.Warn("provider close error", "key", key, "error", err) } } } } -// List returns all registered provider names. -func (r *Registry) List() []string { +// List returns provider names visible to the tenant in context (tenant-specific + master defaults). +func (r *Registry) List(ctx context.Context) []string { + return r.ListForTenant(r.tenantFromContext(ctx)) +} + +// ListForTenant returns provider names available to a tenant (tenant-specific + master defaults). +func (r *Registry) ListForTenant(tenantID uuid.UUID) []string { r.mu.RLock() defer r.mu.RUnlock() - names := make([]string, 0, len(r.providers)) - for name := range r.providers { - names = append(names, name) + + seen := make(map[string]bool) + var names []string + + masterPrefix := MasterTenantID.String() + "/" + tenantPrefix := tenantID.String() + "/" + + // Add tenant-specific providers first + if tenantID != MasterTenantID { + for key := range r.providers { + if strings.HasPrefix(key, tenantPrefix) { + name := strings.TrimPrefix(key, tenantPrefix) + if !seen[name] { + seen[name] = true + names = append(names, name) + } + } + } } + + // Add master tenant providers (not already overridden) + for key := range r.providers { + if strings.HasPrefix(key, masterPrefix) { + name := strings.TrimPrefix(key, masterPrefix) + if !seen[name] { + seen[name] = true + names = append(names, name) + } + } + } + return names } diff --git a/internal/providers/types.go b/internal/providers/types.go index a0cb1ff3..0b3d204d 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -85,9 +85,10 @@ type ImageContent struct { // Stored in session JSONB (~60 bytes each) instead of megabytes for base64. // On reload, MediaRefs are resolved to file paths and loaded into Images (for images). type MediaRef struct { - ID string `json:"id"` // unique media ID (uuid) - MimeType string `json:"mime_type"` // e.g. "image/jpeg", "application/pdf" - Kind string `json:"kind"` // "image", "video", "audio", "document" + ID string `json:"id"` // unique media ID (uuid) + MimeType string `json:"mime_type"` // e.g. "image/jpeg", "application/pdf" + Kind string `json:"kind"` // "image", "video", "audio", "document" + Path string `json:"path,omitempty"` // absolute workspace path (persisted for /v1/files/ serving) } // Message represents a conversation message. diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 14ef8f19..f54311a5 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -1,6 +1,7 @@ package sessions import ( + "context" "encoding/json" "fmt" "os" @@ -63,7 +64,7 @@ func SessionKey(agentID, scopeKey string) string { } // GetOrCreate returns an existing session or creates a new one. -func (m *Manager) GetOrCreate(key string) *Session { +func (m *Manager) GetOrCreate(_ context.Context, key string) *Session { m.mu.Lock() defer m.mu.Unlock() @@ -82,7 +83,7 @@ func (m *Manager) GetOrCreate(key string) *Session { } // AddMessage appends a message to a session. -func (m *Manager) AddMessage(key string, msg providers.Message) { +func (m *Manager) AddMessage(_ context.Context, key string, msg providers.Message) { m.mu.Lock() defer m.mu.Unlock() @@ -101,7 +102,7 @@ func (m *Manager) AddMessage(key string, msg providers.Message) { } // GetHistory returns a copy of the message history. -func (m *Manager) GetHistory(key string) []providers.Message { +func (m *Manager) GetHistory(_ context.Context, key string) []providers.Message { m.mu.RLock() defer m.mu.RUnlock() @@ -116,7 +117,7 @@ func (m *Manager) GetHistory(key string) []providers.Message { } // GetSummary returns the session summary. -func (m *Manager) GetSummary(key string) string { +func (m *Manager) GetSummary(_ context.Context, key string) string { m.mu.RLock() defer m.mu.RUnlock() if s, ok := m.sessions[key]; ok { @@ -126,7 +127,7 @@ func (m *Manager) GetSummary(key string) string { } // SetSummary updates the session summary. -func (m *Manager) SetSummary(key, summary string) { +func (m *Manager) SetSummary(_ context.Context, key, summary string) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -136,7 +137,7 @@ func (m *Manager) SetSummary(key, summary string) { } // SetLabel updates the session label. -func (m *Manager) SetLabel(key, label string) { +func (m *Manager) SetLabel(_ context.Context, key, label string) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -146,7 +147,7 @@ func (m *Manager) SetLabel(key, label string) { } // UpdateMetadata sets model/provider/channel metadata on a session. -func (m *Manager) UpdateMetadata(key, model, provider, channel string) { +func (m *Manager) UpdateMetadata(_ context.Context, key, model, provider, channel string) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -163,7 +164,7 @@ func (m *Manager) UpdateMetadata(key, model, provider, channel string) { } // AccumulateTokens adds token counts from a completed run. -func (m *Manager) AccumulateTokens(key string, inputTokens, outputTokens int64) { +func (m *Manager) AccumulateTokens(_ context.Context, key string, inputTokens, outputTokens int64) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -173,7 +174,7 @@ func (m *Manager) AccumulateTokens(key string, inputTokens, outputTokens int64) } // IncrementCompaction bumps the compaction counter after summarization. -func (m *Manager) IncrementCompaction(key string) { +func (m *Manager) IncrementCompaction(_ context.Context, key string) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -182,7 +183,7 @@ func (m *Manager) IncrementCompaction(key string) { } // GetCompactionCount returns the current compaction count for a session. -func (m *Manager) GetCompactionCount(key string) int { +func (m *Manager) GetCompactionCount(_ context.Context, key string) int { m.mu.RLock() defer m.mu.RUnlock() if s, ok := m.sessions[key]; ok { @@ -192,7 +193,7 @@ func (m *Manager) GetCompactionCount(key string) int { } // GetMemoryFlushCompactionCount returns the compaction count at which memory flush last ran. -func (m *Manager) GetMemoryFlushCompactionCount(key string) int { +func (m *Manager) GetMemoryFlushCompactionCount(_ context.Context, key string) int { m.mu.RLock() defer m.mu.RUnlock() if s, ok := m.sessions[key]; ok { @@ -202,7 +203,7 @@ func (m *Manager) GetMemoryFlushCompactionCount(key string) int { } // SetMemoryFlushDone records that memory flush completed at the current compaction count. -func (m *Manager) SetMemoryFlushDone(key string) { +func (m *Manager) SetMemoryFlushDone(_ context.Context, key string) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -212,7 +213,7 @@ func (m *Manager) SetMemoryFlushDone(key string) { } // SetSpawnInfo sets subagent origin metadata on a session. -func (m *Manager) SetSpawnInfo(key, spawnedBy string, depth int) { +func (m *Manager) SetSpawnInfo(_ context.Context, key, spawnedBy string, depth int) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -222,7 +223,7 @@ func (m *Manager) SetSpawnInfo(key, spawnedBy string, depth int) { } // SetContextWindow caches the agent's context window on the session. -func (m *Manager) SetContextWindow(key string, cw int) { +func (m *Manager) SetContextWindow(_ context.Context, key string, cw int) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -231,7 +232,7 @@ func (m *Manager) SetContextWindow(key string, cw int) { } // GetContextWindow returns the cached context window for a session (0 if unset). -func (m *Manager) GetContextWindow(key string) int { +func (m *Manager) GetContextWindow(_ context.Context, key string) int { m.mu.RLock() defer m.mu.RUnlock() if s, ok := m.sessions[key]; ok { @@ -241,7 +242,7 @@ func (m *Manager) GetContextWindow(key string) int { } // SetLastPromptTokens records actual prompt tokens from the last LLM response. -func (m *Manager) SetLastPromptTokens(key string, tokens, msgCount int) { +func (m *Manager) SetLastPromptTokens(_ context.Context, key string, tokens, msgCount int) { m.mu.Lock() defer m.mu.Unlock() if s, ok := m.sessions[key]; ok { @@ -251,7 +252,7 @@ func (m *Manager) SetLastPromptTokens(key string, tokens, msgCount int) { } // GetLastPromptTokens returns the last known prompt tokens and message count. -func (m *Manager) GetLastPromptTokens(key string) (int, int) { +func (m *Manager) GetLastPromptTokens(_ context.Context, key string) (int, int) { m.mu.RLock() defer m.mu.RUnlock() if s, ok := m.sessions[key]; ok { @@ -261,7 +262,7 @@ func (m *Manager) GetLastPromptTokens(key string) (int, int) { } // TruncateHistory keeps only the last N messages. -func (m *Manager) TruncateHistory(key string, keepLast int) { +func (m *Manager) TruncateHistory(_ context.Context, key string, keepLast int) { m.mu.Lock() defer m.mu.Unlock() @@ -279,7 +280,7 @@ func (m *Manager) TruncateHistory(key string, keepLast int) { } // SetHistory replaces a session's message history with the given slice. -func (m *Manager) SetHistory(key string, msgs []providers.Message) { +func (m *Manager) SetHistory(_ context.Context, key string, msgs []providers.Message) { m.mu.Lock() defer m.mu.Unlock() @@ -290,7 +291,7 @@ func (m *Manager) SetHistory(key string, msgs []providers.Message) { } // Reset clears a session's history and summary. -func (m *Manager) Reset(key string) { +func (m *Manager) Reset(_ context.Context, key string) { m.mu.Lock() defer m.mu.Unlock() @@ -302,7 +303,7 @@ func (m *Manager) Reset(key string) { } // Delete removes a session entirely. -func (m *Manager) Delete(key string) error { +func (m *Manager) Delete(_ context.Context, key string) error { m.mu.Lock() delete(m.sessions, key) m.mu.Unlock() @@ -318,7 +319,7 @@ func (m *Manager) Delete(key string) error { } // List returns metadata for all sessions, optionally filtered by agent ID. -func (m *Manager) List(agentID string) []SessionInfo { +func (m *Manager) List(_ context.Context, agentID string) []SessionInfo { m.mu.RLock() defer m.mu.RUnlock() @@ -346,7 +347,7 @@ func (m *Manager) List(agentID string) []SessionInfo { // LastUsedChannel finds the most recently updated channel session for an agent // and extracts channel + chatID from the key. Returns ("", "") if none found. -func (m *Manager) LastUsedChannel(agentID string) (channel, chatID string) { +func (m *Manager) LastUsedChannel(_ context.Context, agentID string) (channel, chatID string) { m.mu.RLock() defer m.mu.RUnlock() @@ -392,7 +393,7 @@ type SessionInfo struct { } // Save persists a session to disk atomically. -func (m *Manager) Save(key string) error { +func (m *Manager) Save(_ context.Context, key string) error { if m.storage == "" { return nil } diff --git a/internal/skills/dep_scanner.go b/internal/skills/dep_scanner.go index adcb2b54..b61c7ebb 100644 --- a/internal/skills/dep_scanner.go +++ b/internal/skills/dep_scanner.go @@ -1,6 +1,7 @@ package skills import ( + "log/slog" "os" "path/filepath" "regexp" @@ -34,6 +35,7 @@ func scanScriptsDir(scriptsDir string) *SkillManifest { entries, err := os.ReadDir(scriptsDir) if err != nil { + slog.Debug("dep_scanner: scripts dir not found", "dir", scriptsDir, "error", err) return m } @@ -104,6 +106,11 @@ func scanScriptsDir(scriptsDir string) *SkillManifest { m.Requires = append(m.Requires, "node") } + if !m.IsEmpty() { + slog.Debug("dep_scanner: scanned", "dir", scriptsDir, + "bins", len(m.Requires), "py", len(m.RequiresPython), "node", len(m.RequiresNode)) + } + return m } diff --git a/internal/skills/loader.go b/internal/skills/loader.go index 9b43c18a..682546ca 100644 --- a/internal/skills/loader.go +++ b/internal/skills/loader.go @@ -10,6 +10,7 @@ package skills import ( + "context" "encoding/json" "fmt" "os" @@ -100,7 +101,7 @@ func (l *Loader) SetManagedDir(dir string) { // ListSkills returns all available skills, respecting the priority hierarchy. // Higher-priority sources override lower ones by name. -func (l *Loader) ListSkills() []Info { +func (l *Loader) ListSkills(_ context.Context) []Info { l.mu.Lock() defer l.mu.Unlock() @@ -278,7 +279,7 @@ func (l *Loader) findLatestVersion(slug string) (int, string) { // LoadSkill reads and returns the content of a skill by name (frontmatter stripped). // The {baseDir} placeholder in SKILL.md is replaced with the skill's absolute directory path. // Priority: workspace > agents > global > managed > builtin -func (l *Loader) LoadSkill(name string) (string, bool) { +func (l *Loader) LoadSkill(_ context.Context, name string) (string, bool) { // Check flat skill directories (workspace, agents, global) first for _, dir := range []string{l.workspaceSkills, l.projectAgentSkills, l.personalAgentSkills, l.globalSkills} { if dir == "" { @@ -324,12 +325,12 @@ func (l *Loader) LoadSkill(name string) (string, bool) { // LoadForContext loads multiple skills and formats them for system prompt injection. // If allowList is nil, all skills are loaded. If non-nil, only listed skills are loaded. -func (l *Loader) LoadForContext(allowList []string) string { +func (l *Loader) LoadForContext(ctx context.Context, allowList []string) string { var names []string if allowList == nil { // Load all available skills - for _, s := range l.ListSkills() { + for _, s := range l.ListSkills(ctx) { names = append(names, s.Name) } } else { @@ -342,7 +343,7 @@ func (l *Loader) LoadForContext(allowList []string) string { var parts []string for _, name := range names { - content, ok := l.LoadSkill(name) + content, ok := l.LoadSkill(ctx, name) if !ok { continue } @@ -359,8 +360,8 @@ func (l *Loader) LoadForContext(allowList []string) string { // BuildSummary returns an XML summary of skills for context injection. // If allowList is nil, all skills are included. If non-nil, only listed skills are included. // The format matches the TS XML used in system prompts. -func (l *Loader) BuildSummary(allowList []string) string { - allSkills := l.ListSkills() +func (l *Loader) BuildSummary(ctx context.Context, allowList []string) string { + allSkills := l.ListSkills(ctx) if len(allSkills) == 0 { return "" } @@ -423,8 +424,8 @@ func (l *Loader) Dirs() []string { // FilterSkills returns skills filtered by an allowlist. // If allowList is nil, all skills are returned. If empty slice, none are returned. -func (l *Loader) FilterSkills(allowList []string) []Info { - all := l.ListSkills() +func (l *Loader) FilterSkills(ctx context.Context, allowList []string) []Info { + all := l.ListSkills(ctx) if allowList == nil { return all } @@ -445,9 +446,9 @@ func (l *Loader) FilterSkills(allowList []string) []Info { } // GetSkill returns info about a specific skill. -func (l *Loader) GetSkill(name string) (*Info, bool) { +func (l *Loader) GetSkill(ctx context.Context, name string) (*Info, bool) { // Ensure cache is populated - l.ListSkills() + l.ListSkills(ctx) l.mu.RLock() defer l.mu.RUnlock() diff --git a/internal/skills/seeder.go b/internal/skills/seeder.go index a662282e..3c07975b 100644 --- a/internal/skills/seeder.go +++ b/internal/skills/seeder.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/store/pg" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -20,7 +21,7 @@ type SystemSkillStore interface { UpsertSystemSkill(ctx context.Context, p pg.SkillCreateParams) (uuid.UUID, bool, string, error) GetNextVersion(slug string) int BumpVersion() - UpdateSkill(id uuid.UUID, updates map[string]interface{}) error + UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]interface{}) error StoreMissingDeps(id uuid.UUID, missing []string) error } @@ -130,7 +131,7 @@ func (s *Seeder) Seed(ctx context.Context) (seeded int, skipped int, skills []se // scripts/ dir has content but the managed scripts/ dir is missing or empty. if needsReCopy(skillDir, actualDir) { slog.Info("seeder: managed dir incomplete, re-copying", "slug", slug, "dir", actualDir) - if err := copyDir(skillDir, actualDir); err != nil { + if err := CopyDir(skillDir, actualDir); err != nil { slog.Error("seeder: failed to re-copy skill files", "slug", slug, "error", err) } } @@ -140,7 +141,7 @@ func (s *Seeder) Seed(ctx context.Context) (seeded int, skipped int, skills []se } // Copy skill directory to managed dir - if err := copyDir(skillDir, destDir); err != nil { + if err := CopyDir(skillDir, destDir); err != nil { slog.Error("seeder: failed to copy skill files", "slug", slug, "error", err) continue } @@ -176,7 +177,7 @@ func (s *Seeder) CheckDepsAsync(skills []seededSkill, msgBus *bus.MessageBus) { status := "active" if !ok { status = "archived" - _ = s.store.UpdateSkill(sk.id, map[string]interface{}{"status": "archived"}) + _ = s.store.UpdateSkill(store.WithCrossTenant(context.Background()), sk.id, map[string]interface{}{"status": "archived"}) s.store.BumpVersion() slog.Warn("seeder: skill deps missing", "slug", sk.slug, "missing", FormatMissing(missing)) } @@ -230,7 +231,7 @@ func (s *Seeder) copySharedDir(name string) { return } - if err := copyDir(src, dst); err != nil { + if err := CopyDir(src, dst); err != nil { slog.Warn("seeder: failed to copy shared dir", "name", name, "error", err) } } @@ -239,7 +240,8 @@ func (s *Seeder) copySharedDir(name string) { // Resolves the top-level path and any mid-tree symlinks pointing to directories // so local module symlinks (e.g. scripts/office -> ../../_shared/office) // are copied as real directories rather than left as dangling entries. -func copyDir(src, dst string) error { +// CopyDir recursively copies a directory tree. +func CopyDir(src, dst string) error { resolved, err := filepath.EvalSymlinks(src) if err != nil { resolved = src @@ -264,7 +266,7 @@ func copyDir(src, dst string) error { // Detect and recurse into directory symlinks so local modules are fully copied. if info.Mode()&os.ModeSymlink != 0 { if realInfo, statErr := os.Stat(path); statErr == nil && realInfo.IsDir() { - return copyDir(path, target) + return CopyDir(path, target) } return nil // skip broken symlinks } diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index 52b0bf98..3b5e542b 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -41,7 +41,8 @@ const ( // AgentData represents an agent in the database. type AgentData struct { BaseModel - AgentKey string `json:"agent_key"` + TenantID uuid.UUID `json:"tenant_id"` + AgentKey string `json:"agent_key"` DisplayName string `json:"display_name,omitempty"` Frontmatter string `json:"frontmatter,omitempty"` // short expertise summary (NOT other_config.description which is the summoning prompt) OwnerID string `json:"owner_id"` @@ -315,6 +316,8 @@ type AgentStore interface { Create(ctx context.Context, agent *AgentData) error GetByKey(ctx context.Context, agentKey string) (*AgentData, error) GetByID(ctx context.Context, id uuid.UUID) (*AgentData, error) + GetByKeys(ctx context.Context, keys []string) ([]AgentData, error) + GetByIDs(ctx context.Context, ids []uuid.UUID) ([]AgentData, error) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error Delete(ctx context.Context, id uuid.UUID) error List(ctx context.Context, ownerID string) ([]AgentData, error) diff --git a/internal/store/api_key_store.go b/internal/store/api_key_store.go index baaf08dc..af8b245b 100644 --- a/internal/store/api_key_store.go +++ b/internal/store/api_key_store.go @@ -10,11 +10,13 @@ import ( // APIKeyData represents a gateway API key with scoped permissions. type APIKeyData struct { ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` // uuid.Nil when NULL in DB Name string `json:"name"` - Prefix string `json:"prefix"` // first 8 chars for display - KeyHash string `json:"-"` // SHA-256 hex, never serialized - Scopes []string `json:"scopes"` // e.g. ["operator.admin","operator.read"] - ExpiresAt *time.Time `json:"expires_at"` // nil = never + Prefix string `json:"prefix"` // first 8 chars for display + KeyHash string `json:"-"` // SHA-256 hex, never serialized + Scopes []string `json:"scopes"` // e.g. ["operator.admin","operator.read"] + OwnerID string `json:"owner_id,omitempty"` // bound user; when set, auth forces user_id = owner_id + ExpiresAt *time.Time `json:"expires_at"` // nil = never LastUsedAt *time.Time `json:"last_used_at"` Revoked bool `json:"revoked"` CreatedBy string `json:"created_by"` @@ -30,14 +32,14 @@ type APIKeyStore interface { // GetByHash looks up an active (non-revoked, non-expired) key by its SHA-256 hash. GetByHash(ctx context.Context, keyHash string) (*APIKeyData, error) - // List returns all API keys (for admin display; key hashes are not included). - List(ctx context.Context) ([]APIKeyData, error) + // List returns API keys. If ownerID is non-empty, filters to keys owned by that user. + List(ctx context.Context, ownerID string) ([]APIKeyData, error) - // Revoke marks a key as revoked. - Revoke(ctx context.Context, id uuid.UUID) error + // Revoke marks a key as revoked. If ownerID is non-empty, also enforces owner_id = ownerID. + Revoke(ctx context.Context, id uuid.UUID, ownerID string) error - // Delete permanently removes a key. - Delete(ctx context.Context, id uuid.UUID) error + // Delete permanently removes a key. If ownerID is non-empty, also enforces owner_id = ownerID. + Delete(ctx context.Context, id uuid.UUID, ownerID string) error // TouchLastUsed updates the last_used_at timestamp. TouchLastUsed(ctx context.Context, id uuid.UUID) error diff --git a/internal/store/channel_instance_store.go b/internal/store/channel_instance_store.go index 50b975ab..7e1d6acd 100644 --- a/internal/store/channel_instance_store.go +++ b/internal/store/channel_instance_store.go @@ -11,6 +11,7 @@ import ( // ChannelInstanceData represents a channel instance in the database. type ChannelInstanceData struct { BaseModel + TenantID uuid.UUID `json:"tenant_id,omitempty"` Name string `json:"name"` DisplayName string `json:"display_name"` ChannelType string `json:"channel_type"` diff --git a/internal/store/contact_store.go b/internal/store/contact_store.go index 0cc9e9b9..4715b7b8 100644 --- a/internal/store/contact_store.go +++ b/internal/store/contact_store.go @@ -49,7 +49,18 @@ type ContactStore interface { // Returns a map of sender_id → ChannelContact (first match per sender_id). GetContactsBySenderIDs(ctx context.Context, senderIDs []string) (map[string]ChannelContact, error) - // MergeContacts assigns the same merged_id to all given contact IDs, - // linking them as the same person across channels. - MergeContacts(ctx context.Context, contactIDs []uuid.UUID) error + // GetContactByID returns a single contact by primary key. Tenant-scoped via context. + GetContactByID(ctx context.Context, id uuid.UUID) (*ChannelContact, error) + + // MergeContacts sets merged_id = tenantUserID on all given contact IDs, + // linking them to a tenant_users identity. Tenant-scoped via context. + MergeContacts(ctx context.Context, contactIDs []uuid.UUID, tenantUserID uuid.UUID) error + + // UnmergeContacts sets merged_id = NULL on the given contact IDs. + // Tenant-scoped via context. + UnmergeContacts(ctx context.Context, contactIDs []uuid.UUID) error + + // GetContactsByMergedID returns all contacts linked to a given merged_id. + // Tenant-scoped via context. + GetContactsByMergedID(ctx context.Context, mergedID uuid.UUID) ([]ChannelContact, error) } diff --git a/internal/store/context.go b/internal/store/context.go index dae3e9d3..86a3a068 100644 --- a/internal/store/context.go +++ b/internal/store/context.go @@ -29,6 +29,12 @@ const ( SharedKGKey contextKey = "goclaw_shared_kg" // ShellDenyGroupsKey holds per-agent shell deny group overrides. ShellDenyGroupsKey contextKey = "goclaw_shell_deny_groups" + // TenantIDKey is the context key for the tenant UUID. + TenantIDKey contextKey = "goclaw_tenant_id" + // CrossTenantKey indicates the caller has cross-tenant access (owner/system admin). + CrossTenantKey contextKey = "goclaw_cross_tenant" + // TenantSlugKey stores the tenant's URL-safe slug for filesystem paths. + TenantSlugKey contextKey = "goclaw_tenant_slug" ) // WithShellDenyGroups returns a new context with shell deny group overrides. @@ -159,3 +165,42 @@ func LocaleFromContext(ctx context.Context) string { } return "en" } + +// WithTenantID returns a new context with the given tenant UUID. +func WithTenantID(ctx context.Context, id uuid.UUID) context.Context { + return context.WithValue(ctx, TenantIDKey, id) +} + +// TenantIDFromContext extracts the tenant UUID from context. +// Returns uuid.Nil if not set (fail-closed — callers must check). +func TenantIDFromContext(ctx context.Context) uuid.UUID { + if v, ok := ctx.Value(TenantIDKey).(uuid.UUID); ok { + return v + } + return uuid.Nil +} + +// WithCrossTenant returns a context flagged for cross-tenant access. +// Used by owner/system admin callers who can access all tenants. +func WithCrossTenant(ctx context.Context) context.Context { + return context.WithValue(ctx, CrossTenantKey, true) +} + +// IsCrossTenant returns true if the caller has cross-tenant access. +func IsCrossTenant(ctx context.Context) bool { + v, _ := ctx.Value(CrossTenantKey).(bool) + return v +} + +// WithTenantSlug returns a new context with the given tenant slug. +func WithTenantSlug(ctx context.Context, slug string) context.Context { + return context.WithValue(ctx, TenantSlugKey, slug) +} + +// TenantSlugFromContext extracts the tenant slug from context. Returns "" if not set. +func TenantSlugFromContext(ctx context.Context) string { + if v, ok := ctx.Value(TenantSlugKey).(string); ok { + return v + } + return "" +} diff --git a/internal/store/cron_store.go b/internal/store/cron_store.go index 29e942f9..a5e4bb24 100644 --- a/internal/store/cron_store.go +++ b/internal/store/cron_store.go @@ -1,10 +1,16 @@ package store -import "time" +import ( + "context" + "time" + + "github.com/google/uuid" +) // CronJob represents a scheduled job. type CronJob struct { ID string `json:"id"` + TenantID uuid.UUID `json:"tenantId,omitempty"` Name string `json:"name"` AgentID string `json:"agentId,omitempty"` UserID string `json:"userId,omitempty"` @@ -84,19 +90,20 @@ type CronEvent struct { Action string `json:"action"` // "running", "completed", "error" JobID string `json:"jobId"` JobName string `json:"jobName,omitempty"` + UserID string `json:"userId,omitempty"` // job owner for event filtering Status string `json:"status,omitempty"` // final status for completed/error Error string `json:"error,omitempty"` } // CronStore manages scheduled jobs. type CronStore interface { - AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*CronJob, error) - GetJob(jobID string) (*CronJob, bool) - ListJobs(includeDisabled bool, agentID, userID string) []CronJob - RemoveJob(jobID string) error - UpdateJob(jobID string, patch CronJobPatch) (*CronJob, error) - EnableJob(jobID string, enabled bool) error - GetRunLog(jobID string, limit, offset int) ([]CronRunLogEntry, int) + AddJob(ctx context.Context, name string, schedule CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*CronJob, error) + GetJob(ctx context.Context, jobID string) (*CronJob, bool) + ListJobs(ctx context.Context, includeDisabled bool, agentID, userID string) []CronJob + RemoveJob(ctx context.Context, jobID string) error + UpdateJob(ctx context.Context, jobID string, patch CronJobPatch) (*CronJob, error) + EnableJob(ctx context.Context, jobID string, enabled bool) error + GetRunLog(ctx context.Context, jobID string, limit, offset int) ([]CronRunLogEntry, int) Status() map[string]any // Lifecycle @@ -106,7 +113,7 @@ type CronStore interface { // Job execution SetOnJob(handler func(job *CronJob) (*CronJobResult, error)) SetOnEvent(handler func(event CronEvent)) - RunJob(jobID string, force bool) (ran bool, reason string, err error) + RunJob(ctx context.Context, jobID string, force bool) (ran bool, reason string, err error) SetDefaultTimezone(tz string) // Due job detection (for scheduler) diff --git a/internal/store/custom_tool_store.go b/internal/store/custom_tool_store.go deleted file mode 100644 index eb054198..00000000 --- a/internal/store/custom_tool_store.go +++ /dev/null @@ -1,44 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - - "github.com/google/uuid" -) - -// CustomToolDef represents a custom tool definition in the database. -type CustomToolDef struct { - BaseModel - Name string `json:"name"` - Description string `json:"description"` - Parameters json.RawMessage `json:"parameters"` - Command string `json:"command"` - WorkingDir string `json:"working_dir,omitempty"` - TimeoutSeconds int `json:"timeout_seconds"` - Env []byte `json:"-"` // encrypted JSONB — never serialized to API - AgentID *uuid.UUID `json:"agent_id,omitempty"` - Enabled bool `json:"enabled"` - CreatedBy string `json:"created_by"` -} - -// CustomToolListOpts configures custom tool listing with optional pagination and filtering. -type CustomToolListOpts struct { - AgentID *uuid.UUID - Search string - Limit int - Offset int -} - -// CustomToolStore manages custom tool definitions. -type CustomToolStore interface { - Create(ctx context.Context, def *CustomToolDef) error - Get(ctx context.Context, id uuid.UUID) (*CustomToolDef, error) - Update(ctx context.Context, id uuid.UUID, updates map[string]any) error - Delete(ctx context.Context, id uuid.UUID) error - ListGlobal(ctx context.Context) ([]CustomToolDef, error) - ListByAgent(ctx context.Context, agentID uuid.UUID) ([]CustomToolDef, error) - ListAll(ctx context.Context) ([]CustomToolDef, error) - ListPaged(ctx context.Context, opts CustomToolListOpts) ([]CustomToolDef, error) - CountTools(ctx context.Context, opts CustomToolListOpts) (int, error) -} diff --git a/internal/store/mcp_store.go b/internal/store/mcp_store.go index bd673a33..f736f2f5 100644 --- a/internal/store/mcp_store.go +++ b/internal/store/mcp_store.go @@ -76,6 +76,13 @@ type MCPAccessInfo struct { ToolDeny []string `json:"tool_deny,omitempty"` // effective deny list } +// MCPUserCredentials holds per-user credential overrides for an MCP server. +type MCPUserCredentials struct { + APIKey string `json:"api_key,omitempty"` // decrypted + Headers map[string]string `json:"headers,omitempty"` // decrypted + Env map[string]string `json:"env,omitempty"` // decrypted +} + // MCPServerStore manages MCP server configs and access grants. type MCPServerStore interface { // Server CRUD @@ -106,4 +113,9 @@ type MCPServerStore interface { CreateRequest(ctx context.Context, req *MCPAccessRequest) error ListPendingRequests(ctx context.Context) ([]MCPAccessRequest, error) ReviewRequest(ctx context.Context, requestID uuid.UUID, approved bool, reviewedBy, note string) error + + // Per-user credentials + GetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) (*MCPUserCredentials, error) + SetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string, creds MCPUserCredentials) error + DeleteUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) error } diff --git a/internal/store/pairing_store.go b/internal/store/pairing_store.go index 6f824628..2427b4ad 100644 --- a/internal/store/pairing_store.go +++ b/internal/store/pairing_store.go @@ -1,5 +1,7 @@ package store +import "context" + // PairingRequest represents a pending pairing code. type PairingRequestData struct { Code string `json:"code"` @@ -24,11 +26,11 @@ type PairedDeviceData struct { // PairingStore manages device pairing. type PairingStore interface { - RequestPairing(senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) - ApprovePairing(code, approvedBy string) (*PairedDeviceData, error) - DenyPairing(code string) error - RevokePairing(senderID, channel string) error - IsPaired(senderID, channel string) (bool, error) - ListPending() []PairingRequestData - ListPaired() []PairedDeviceData + RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) + ApprovePairing(ctx context.Context, code, approvedBy string) (*PairedDeviceData, error) + DenyPairing(ctx context.Context, code string) error + RevokePairing(ctx context.Context, senderID, channel string) error + IsPaired(ctx context.Context, senderID, channel string) (bool, error) + ListPending(ctx context.Context) []PairingRequestData + ListPaired(ctx context.Context) []PairedDeviceData } diff --git a/internal/store/pg/activity_store.go b/internal/store/pg/activity_store.go index bbb3cc0d..05d9e584 100644 --- a/internal/store/pg/activity_store.go +++ b/internal/store/pg/activity_store.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -20,17 +22,21 @@ func NewPGActivityStore(db *sql.DB) *PGActivityStore { } func (s *PGActivityStore) Log(ctx context.Context, entry *store.ActivityLog) error { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } _, err := s.db.ExecContext(ctx, - `INSERT INTO activity_logs (actor_type, actor_id, action, entity_type, entity_id, details, ip_address) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, + `INSERT INTO activity_logs (actor_type, actor_id, action, entity_type, entity_id, details, ip_address, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, entry.ActorType, entry.ActorID, entry.Action, - entry.EntityType, entry.EntityID, entry.Details, entry.IPAddress, + entry.EntityType, entry.EntityID, entry.Details, entry.IPAddress, tenantID, ) return err } func (s *PGActivityStore) List(ctx context.Context, opts store.ActivityListOpts) ([]store.ActivityLog, error) { - where, args := buildActivityWhere(opts) + where, args := buildActivityWhere(ctx, opts) limit := opts.Limit if limit <= 0 { limit = 50 @@ -61,7 +67,7 @@ func (s *PGActivityStore) List(ctx context.Context, opts store.ActivityListOpts) } func (s *PGActivityStore) Count(ctx context.Context, opts store.ActivityListOpts) (int, error) { - where, args := buildActivityWhere(opts) + where, args := buildActivityWhere(ctx, opts) query := fmt.Sprintf("SELECT COUNT(*) FROM activity_logs %s", where) var count int @@ -69,11 +75,20 @@ func (s *PGActivityStore) Count(ctx context.Context, opts store.ActivityListOpts return count, err } -func buildActivityWhere(opts store.ActivityListOpts) (string, []any) { +func buildActivityWhere(ctx context.Context, opts store.ActivityListOpts) (string, []any) { var conditions []string var args []any idx := 1 + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", idx)) + args = append(args, tenantID) + idx++ + } + } + if opts.ActorType != "" { conditions = append(conditions, fmt.Sprintf("actor_type = $%d", idx)) args = append(args, opts.ActorType) diff --git a/internal/store/pg/agent_links.go b/internal/store/pg/agent_links.go index e08d9bfa..1614b3bc 100644 --- a/internal/store/pg/agent_links.go +++ b/internal/store/pg/agent_links.go @@ -55,18 +55,31 @@ func (s *PGAgentLinkStore) CreateLink(ctx context.Context, link *store.AgentLink settings = json.RawMessage(`{}`) } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, `INSERT INTO agent_links (id, source_agent_id, target_agent_id, direction, team_id, description, - max_concurrent, settings, status, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + max_concurrent, settings, status, created_by, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`, link.ID, link.SourceAgentID, link.TargetAgentID, link.Direction, link.TeamID, link.Description, - link.MaxConcurrent, settings, link.Status, link.CreatedBy, now, now, + link.MaxConcurrent, settings, link.Status, link.CreatedBy, now, now, tenantID, ) return err } func (s *PGAgentLinkStore) DeleteLink(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM agent_links WHERE id = $1`, id) + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_links WHERE id = $1`, id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for delete") + } + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_links WHERE id = $1 AND tenant_id = $2`, id, tid) return err } @@ -75,16 +88,33 @@ func (s *PGAgentLinkStore) UpdateLink(ctx context.Context, id uuid.UUID, updates return nil } updates["updated_at"] = time.Now() - return execMapUpdate(ctx, s.db, "agent_links", id, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "agent_links", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "agent_links", updates, id, tid) } func (s *PGAgentLinkStore) GetLink(ctx context.Context, id uuid.UUID) (*store.AgentLinkData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+linkSelectCols+` FROM agent_links WHERE id = $1`, id) + return scanLinkRow(row) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, fmt.Errorf("link not found: %w", sql.ErrNoRows) + } row := s.db.QueryRowContext(ctx, - `SELECT `+linkSelectCols+` FROM agent_links WHERE id = $1`, id) + `SELECT `+linkSelectCols+` FROM agent_links WHERE id = $1 AND tenant_id = $2`, id, tenantID) return scanLinkRow(row) } func (s *PGAgentLinkStore) ListLinksFrom(ctx context.Context, agentID uuid.UUID) ([]store.AgentLinkData, error) { + tenantClause, qArgs := linkTenantClause(ctx, agentID, "l.source_agent_id = $1") rows, err := s.db.QueryContext(ctx, `SELECT `+linkSelectColsJoined+`, sa.agent_key AS source_agent_key, @@ -98,8 +128,8 @@ func (s *PGAgentLinkStore) ListLinksFrom(ctx context.Context, agentID uuid.UUID) JOIN agents sa ON sa.id = l.source_agent_id JOIN agents ta ON ta.id = l.target_agent_id LEFT JOIN agent_teams tm ON tm.id = l.team_id - WHERE l.source_agent_id = $1 - ORDER BY l.created_at`, agentID) + WHERE `+tenantClause+` + ORDER BY l.created_at`, qArgs...) if err != nil { return nil, err } @@ -108,6 +138,7 @@ func (s *PGAgentLinkStore) ListLinksFrom(ctx context.Context, agentID uuid.UUID) } func (s *PGAgentLinkStore) ListLinksTo(ctx context.Context, agentID uuid.UUID) ([]store.AgentLinkData, error) { + tenantClause, qArgs := linkTenantClause(ctx, agentID, "l.target_agent_id = $1") rows, err := s.db.QueryContext(ctx, `SELECT `+linkSelectColsJoined+`, sa.agent_key AS source_agent_key, @@ -121,8 +152,8 @@ func (s *PGAgentLinkStore) ListLinksTo(ctx context.Context, agentID uuid.UUID) ( JOIN agents sa ON sa.id = l.source_agent_id JOIN agents ta ON ta.id = l.target_agent_id LEFT JOIN agent_teams tm ON tm.id = l.team_id - WHERE l.target_agent_id = $1 - ORDER BY l.created_at`, agentID) + WHERE `+tenantClause+` + ORDER BY l.created_at`, qArgs...) if err != nil { return nil, err } @@ -130,6 +161,22 @@ func (s *PGAgentLinkStore) ListLinksTo(ctx context.Context, agentID uuid.UUID) ( return scanLinkRowsJoined(rows) } +// linkTenantClause builds the WHERE clause for agent_links queries. +// baseCondition must use $1 for the agentID parameter. +// Returns the full WHERE clause and args slice. +func linkTenantClause(ctx context.Context, agentID uuid.UUID, baseCondition string) (string, []any) { + args := []any{agentID} + if store.IsCrossTenant(ctx) { + return baseCondition, args + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + // fail-closed: return impossible condition + return baseCondition + " AND l.tenant_id = $2", append(args, uuid.Nil) + } + return baseCondition + " AND l.tenant_id = $2", append(args, tenantID) +} + func (s *PGAgentLinkStore) CanDelegate(ctx context.Context, fromAgentID, toAgentID uuid.UUID) (bool, error) { var exists bool err := s.db.QueryRowContext(ctx, diff --git a/internal/store/pg/agents.go b/internal/store/pg/agents.go index 4ac5e869..e66774f2 100644 --- a/internal/store/pg/agents.go +++ b/internal/store/pg/agents.go @@ -104,7 +104,7 @@ const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, pro context_window, max_tool_iterations, workspace, restrict_to_workspace, tools_config, sandbox_config, subagents_config, memory_config, compaction_config, context_pruning, other_config, - agent_type, is_default, status, budget_monthly_cents, created_at, updated_at` + agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id` func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error { if agent.ID == uuid.Nil { @@ -113,18 +113,22 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error now := time.Now() agent.CreatedAt = now agent.UpdatedAt = now + tenantID := agent.TenantID + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } _, err := s.db.ExecContext(ctx, `INSERT INTO agents (id, agent_key, display_name, frontmatter, owner_id, provider, model, context_window, max_tool_iterations, workspace, restrict_to_workspace, tools_config, sandbox_config, subagents_config, memory_config, compaction_config, context_pruning, other_config, - agent_type, is_default, status, budget_monthly_cents, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`, + agent_type, is_default, status, budget_monthly_cents, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)`, agent.ID, agent.AgentKey, agent.DisplayName, sql.NullString{String: agent.Frontmatter, Valid: agent.Frontmatter != ""}, agent.OwnerID, agent.Provider, agent.Model, agent.ContextWindow, agent.MaxToolIterations, agent.Workspace, agent.RestrictToWorkspace, jsonOrEmpty(agent.ToolsConfig), jsonOrNull(agent.SandboxConfig), jsonOrNull(agent.SubagentsConfig), jsonOrNull(agent.MemoryConfig), jsonOrNull(agent.CompactionConfig), jsonOrNull(agent.ContextPruning), jsonOrEmpty(agent.OtherConfig), - agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, now, now, + agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, now, now, tenantID, ) if err != nil { return err @@ -138,9 +142,20 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error } func (s *PGAgentStore) GetByKey(ctx context.Context, agentKey string) (*store.AgentData, error) { - row := s.db.QueryRowContext(ctx, - `SELECT `+agentSelectCols+` - FROM agents WHERE agent_key = $1 AND deleted_at IS NULL`, agentKey) + var row *sql.Row + if store.IsCrossTenant(ctx) { + row = s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE agent_key = $1 AND deleted_at IS NULL`, agentKey) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("agent not found: %s", agentKey) + } + row = s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE agent_key = $1 AND deleted_at IS NULL AND tenant_id = $2`, agentKey, tid) + } d, err := scanAgentRow(row) if err != nil { return nil, fmt.Errorf("agent not found: %s", agentKey) @@ -149,9 +164,20 @@ func (s *PGAgentStore) GetByKey(ctx context.Context, agentKey string) (*store.Ag } func (s *PGAgentStore) GetByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) { - row := s.db.QueryRowContext(ctx, - `SELECT `+agentSelectCols+` - FROM agents WHERE id = $1 AND deleted_at IS NULL`, id) + var row *sql.Row + if store.IsCrossTenant(ctx) { + row = s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE id = $1 AND deleted_at IS NULL`, id) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("agent not found: %s", id) + } + row = s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE id = $1 AND deleted_at IS NULL AND tenant_id = $2`, id, tid) + } d, err := scanAgentRow(row) if err != nil { return nil, fmt.Errorf("agent not found: %s", id) @@ -165,20 +191,39 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str return nil } - // If setting this agent as default, unset any existing default first. + // If setting this agent as default, unset any existing default first (scoped to same tenant). if v, ok := updates["is_default"]; ok { if isDefault, _ := v.(bool); isDefault { - if _, err := s.db.ExecContext(ctx, - "UPDATE agents SET is_default = false WHERE is_default = true AND id != $1 AND deleted_at IS NULL", id); err != nil { - slog.Warn("agents.unset_default", "error", err) + if store.IsCrossTenant(ctx) { + if _, err := s.db.ExecContext(ctx, + "UPDATE agents SET is_default = false WHERE is_default = true AND id != $1 AND deleted_at IS NULL", id); err != nil { + slog.Warn("agents.unset_default", "error", err) + } + } else { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + if _, err := s.db.ExecContext(ctx, + "UPDATE agents SET is_default = false WHERE is_default = true AND id != $1 AND deleted_at IS NULL AND tenant_id = $2", id, tid); err != nil { + slog.Warn("agents.unset_default", "error", err) + } + } } } } updates["updated_at"] = time.Now() - err := execMapUpdateWhere(ctx, s.db, "agents", updates, "id = $IDX AND deleted_at IS NULL", id) - if err != nil { - return err + if store.IsCrossTenant(ctx) { + if err := execMapUpdateWhere(ctx, s.db, "agents", updates, "id = $IDX AND deleted_at IS NULL", id); err != nil { + return err + } + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("agent not found: %s", id) + } + if err := execMapUpdateWhereTenant(ctx, s.db, "agents", updates, id, tid); err != nil { + return err + } } // Regenerate embedding when frontmatter changes @@ -194,22 +239,37 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str } func (s *PGAgentStore) Delete(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1", id) + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1", id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("agent not found: %s", id) + } + _, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1 AND tenant_id = $2", id, tid) return err } func (s *PGAgentStore) List(ctx context.Context, ownerID string) ([]store.AgentData, error) { - var rows *sql.Rows - var err error + q := `SELECT ` + agentSelectCols + ` FROM agents WHERE deleted_at IS NULL` + var args []any + argIdx := 1 + if ownerID != "" { - rows, err = s.db.QueryContext(ctx, - `SELECT `+agentSelectCols+` - FROM agents WHERE deleted_at IS NULL AND owner_id = $1 ORDER BY created_at DESC`, ownerID) - } else { - rows, err = s.db.QueryContext(ctx, - `SELECT `+agentSelectCols+` - FROM agents WHERE deleted_at IS NULL ORDER BY created_at DESC`) + q += fmt.Sprintf(" AND owner_id = $%d", argIdx) + args = append(args, ownerID) + argIdx++ } + + if clause, targs, err := tenantClauseN(ctx, argIdx); err == nil && clause != "" { + q += clause + args = append(args, targs...) + argIdx++ + } + + q += " ORDER BY created_at DESC" + rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } @@ -218,10 +278,21 @@ func (s *PGAgentStore) List(ctx context.Context, ownerID string) ([]store.AgentD } func (s *PGAgentStore) GetDefault(ctx context.Context) (*store.AgentData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE deleted_at IS NULL + ORDER BY is_default DESC, created_at ASC LIMIT 1`) + return scanAgentRow(row) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("agent not found: default") + } row := s.db.QueryRowContext(ctx, `SELECT `+agentSelectCols+` - FROM agents WHERE deleted_at IS NULL - ORDER BY is_default DESC, created_at ASC LIMIT 1`) + FROM agents WHERE deleted_at IS NULL AND tenant_id = $1 + ORDER BY is_default DESC, created_at ASC LIMIT 1`, tid) return scanAgentRow(row) } @@ -234,24 +305,43 @@ func (s *PGAgentStore) ShareAgent(ctx context.Context, agentID uuid.UUID, userID if err := store.ValidateUserID(grantedBy); err != nil { return err } + tid := tenantIDForInsert(ctx) _, err := s.db.ExecContext(ctx, - `INSERT INTO agent_shares (id, agent_id, user_id, role, granted_by, created_at) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO agent_shares (id, agent_id, user_id, role, granted_by, tenant_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (agent_id, user_id) DO UPDATE SET role = EXCLUDED.role, granted_by = EXCLUDED.granted_by`, - store.GenNewID(), agentID, userID, role, grantedBy, time.Now(), + store.GenNewID(), agentID, userID, role, grantedBy, tid, time.Now(), ) return err } func (s *PGAgentStore) RevokeShare(ctx context.Context, agentID uuid.UUID, userID string) error { + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, + "DELETE FROM agent_shares WHERE agent_id = $1 AND user_id = $2", agentID, userID) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } _, err := s.db.ExecContext(ctx, - "DELETE FROM agent_shares WHERE agent_id = $1 AND user_id = $2", agentID, userID) + "DELETE FROM agent_shares WHERE agent_id = $1 AND user_id = $2 AND tenant_id = $3", agentID, userID, tid) return err } func (s *PGAgentStore) ListShares(ctx context.Context, agentID uuid.UUID) ([]store.AgentShareData, error) { - rows, err := s.db.QueryContext(ctx, - "SELECT id, agent_id, user_id, role, granted_by, created_at FROM agent_shares WHERE agent_id = $1", agentID) + q := "SELECT id, agent_id, user_id, role, granted_by, created_at FROM agent_shares WHERE agent_id = $1" + args := []any{agentID} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + q += " AND tenant_id = $2" + args = append(args, tid) + } + rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } @@ -272,9 +362,20 @@ func (s *PGAgentStore) CanAccess(ctx context.Context, agentID uuid.UUID, userID // Check ownership + default flag var ownerID string var isDefault bool - err := s.db.QueryRowContext(ctx, - "SELECT owner_id, is_default FROM agents WHERE id = $1 AND deleted_at IS NULL", agentID, - ).Scan(&ownerID, &isDefault) + var err error + if store.IsCrossTenant(ctx) { + err = s.db.QueryRowContext(ctx, + "SELECT owner_id, is_default FROM agents WHERE id = $1 AND deleted_at IS NULL", agentID, + ).Scan(&ownerID, &isDefault) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return false, "", fmt.Errorf("agent not found") + } + err = s.db.QueryRowContext(ctx, + "SELECT owner_id, is_default FROM agents WHERE id = $1 AND deleted_at IS NULL AND tenant_id = $2", agentID, tid, + ).Scan(&ownerID, &isDefault) + } if err != nil { return false, "", fmt.Errorf("agent not found") } @@ -289,9 +390,19 @@ func (s *PGAgentStore) CanAccess(ctx context.Context, agentID uuid.UUID, userID } // Check shares var role string - err = s.db.QueryRowContext(ctx, - "SELECT role FROM agent_shares WHERE agent_id = $1 AND user_id = $2", agentID, userID, - ).Scan(&role) + if store.IsCrossTenant(ctx) { + err = s.db.QueryRowContext(ctx, + "SELECT role FROM agent_shares WHERE agent_id = $1 AND user_id = $2", agentID, userID, + ).Scan(&role) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return false, "", nil + } + err = s.db.QueryRowContext(ctx, + "SELECT role FROM agent_shares WHERE agent_id = $1 AND user_id = $2 AND tenant_id = $3", agentID, userID, tid, + ).Scan(&role) + } if err != nil { return false, "", nil } @@ -299,18 +410,49 @@ func (s *PGAgentStore) CanAccess(ctx context.Context, agentID uuid.UUID, userID } func (s *PGAgentStore) ListAccessible(ctx context.Context, userID string) ([]store.AgentData, error) { + if store.IsCrossTenant(ctx) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents + WHERE deleted_at IS NULL AND ( + owner_id = $1 + OR is_default = true + OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1) + OR ( + agent_type = 'predefined' + AND id IN ( + SELECT agent_id FROM channel_instances ci + WHERE ci.enabled = true + AND EXISTS ( + SELECT 1 FROM jsonb_array_elements_text(ci.config->'allow_from') af + WHERE af = $1 + ) + ) + ) + ) + ORDER BY created_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanAgentRows(rows) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } rows, err := s.db.QueryContext(ctx, `SELECT `+agentSelectCols+` FROM agents - WHERE deleted_at IS NULL AND ( + WHERE deleted_at IS NULL AND tenant_id = $2 AND ( owner_id = $1 OR is_default = true - OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1) + OR id IN (SELECT agent_id FROM agent_shares WHERE user_id = $1 AND tenant_id = $2) OR ( agent_type = 'predefined' AND id IN ( SELECT agent_id FROM channel_instances ci - WHERE ci.enabled = true + WHERE ci.enabled = true AND ci.tenant_id = $2 AND EXISTS ( SELECT 1 FROM jsonb_array_elements_text(ci.config->'allow_from') af WHERE af = $1 @@ -318,7 +460,7 @@ func (s *PGAgentStore) ListAccessible(ctx context.Context, userID string) ([]sto ) ) ) - ORDER BY created_at DESC`, userID) + ORDER BY created_at DESC`, userID, tid) if err != nil { return nil, err } @@ -340,7 +482,7 @@ func scanAgentRow(row agentRowScanner) (*store.AgentData, error) { err := row.Scan(&d.ID, &d.AgentKey, &d.DisplayName, &frontmatter, &d.OwnerID, &d.Provider, &d.Model, &d.ContextWindow, &d.MaxToolIterations, &d.Workspace, &d.RestrictToWorkspace, &toolsCfg, &sandboxCfg, &subagentsCfg, &memoryCfg, &compactionCfg, &pruningCfg, &otherCfg, - &d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents, &d.CreatedAt, &d.UpdatedAt) + &d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents, &d.CreatedAt, &d.UpdatedAt, &d.TenantID) if err != nil { return nil, err } @@ -443,3 +585,29 @@ func replaceIDX(s, replacement string) string { } return result.String() } + +// execMapUpdateWhereTenant is like execMapUpdateWhere but appends an AND tenant_id = $N filter. +// Column names are validated to prevent SQL injection. +func execMapUpdateWhereTenant(ctx context.Context, db *sql.DB, table string, updates map[string]any, id, tenantID uuid.UUID) error { + if len(updates) == 0 { + return nil + } + var setClauses []string + var args []any + i := 1 + for col, val := range updates { + if !validColumnName.MatchString(col) { + slog.Warn("security.invalid_column_name", "table", table, "column", col) + return fmt.Errorf("invalid column name: %q", col) + } + setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, i)) + args = append(args, val) + i++ + } + // $i = id, $i+1 = tenantID + args = append(args, id, tenantID) + q := fmt.Sprintf("UPDATE %s SET %s WHERE id = $%d AND tenant_id = $%d", + table, joinStrings(setClauses, ", "), i, i+1) + _, err := db.ExecContext(ctx, q, args...) + return err +} diff --git a/internal/store/pg/agents_batch.go b/internal/store/pg/agents_batch.go new file mode 100644 index 00000000..4d999164 --- /dev/null +++ b/internal/store/pg/agents_batch.go @@ -0,0 +1,76 @@ +package pg + +import ( + "context" + "database/sql" + "fmt" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// GetByKeys returns agents matching the given keys in a single query. +// Results are tenant-scoped unless cross-tenant context is set. +func (s *PGAgentStore) GetByKeys(ctx context.Context, keys []string) ([]store.AgentData, error) { + if len(keys) == 0 { + return nil, nil + } + + var rows *sql.Rows + var err error + + if store.IsCrossTenant(ctx) { + rows, err = s.db.QueryContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE agent_key = ANY($1) AND deleted_at IS NULL`, + pq.Array(keys)) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("no tenant context for batch agent lookup") + } + rows, err = s.db.QueryContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE agent_key = ANY($1) AND deleted_at IS NULL AND tenant_id = $2`, + pq.Array(keys), tid) + } + if err != nil { + return nil, fmt.Errorf("batch agent key lookup: %w", err) + } + defer rows.Close() + return scanAgentRows(rows) +} + +// GetByIDs returns agents matching the given UUIDs in a single query. +// Results are tenant-scoped unless cross-tenant context is set. +func (s *PGAgentStore) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]store.AgentData, error) { + if len(ids) == 0 { + return nil, nil + } + + var rows *sql.Rows + var err error + + if store.IsCrossTenant(ctx) { + rows, err = s.db.QueryContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE id = ANY($1) AND deleted_at IS NULL`, + pq.Array(ids)) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("no tenant context for batch agent lookup") + } + rows, err = s.db.QueryContext(ctx, + `SELECT `+agentSelectCols+` + FROM agents WHERE id = ANY($1) AND deleted_at IS NULL AND tenant_id = $2`, + pq.Array(ids), tid) + } + if err != nil { + return nil, fmt.Errorf("batch agent ID lookup: %w", err) + } + defer rows.Close() + return scanAgentRows(rows) +} diff --git a/internal/store/pg/agents_context.go b/internal/store/pg/agents_context.go index abafc779..de4376b0 100644 --- a/internal/store/pg/agents_context.go +++ b/internal/store/pg/agents_context.go @@ -17,8 +17,13 @@ import ( // --- Agent-level Context Files --- func (s *PGAgentStore) GetAgentContextFiles(ctx context.Context, agentID uuid.UUID) ([]store.AgentContextFileData, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, - "SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = $1", agentID) + "SELECT agent_id, file_name, content FROM agent_context_files WHERE agent_id = $1"+tClause, + append([]any{agentID}, tArgs...)...) if err != nil { return nil, err } @@ -37,10 +42,10 @@ func (s *PGAgentStore) GetAgentContextFiles(ctx context.Context, agentID uuid.UU func (s *PGAgentStore) SetAgentContextFile(ctx context.Context, agentID uuid.UUID, fileName, content string) error { _, err := s.db.ExecContext(ctx, - `INSERT INTO agent_context_files (id, agent_id, file_name, content, updated_at) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO agent_context_files (id, agent_id, file_name, content, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (agent_id, file_name) DO UPDATE SET content = EXCLUDED.content, updated_at = EXCLUDED.updated_at`, - store.GenNewID(), agentID, fileName, content, time.Now(), + store.GenNewID(), agentID, fileName, content, time.Now(), tenantIDForInsert(ctx), ) return err } @@ -48,8 +53,13 @@ func (s *PGAgentStore) SetAgentContextFile(ctx context.Context, agentID uuid.UUI // --- Per-user Context Files --- func (s *PGAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid.UUID, userID string) ([]store.UserContextFileData, error) { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, - "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = $1 AND user_id = $2", agentID, userID) + "SELECT agent_id, user_id, file_name, content FROM user_context_files WHERE agent_id = $1 AND user_id = $2"+tClause, + append([]any{agentID, userID}, tArgs...)...) if err != nil { return nil, err } @@ -68,18 +78,22 @@ func (s *PGAgentStore) GetUserContextFiles(ctx context.Context, agentID uuid.UUI func (s *PGAgentStore) SetUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName, content string) error { _, err := s.db.ExecContext(ctx, - `INSERT INTO user_context_files (id, agent_id, user_id, file_name, content, updated_at) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO user_context_files (id, agent_id, user_id, file_name, content, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (agent_id, user_id, file_name) DO UPDATE SET content = EXCLUDED.content, updated_at = EXCLUDED.updated_at`, - store.GenNewID(), agentID, userID, fileName, content, time.Now(), + store.GenNewID(), agentID, userID, fileName, content, time.Now(), tenantIDForInsert(ctx), ) return err } func (s *PGAgentStore) DeleteUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM user_context_files WHERE agent_id = $1 AND user_id = $2 AND file_name = $3", - agentID, userID, fileName) + tClause, tArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM user_context_files WHERE agent_id = $1 AND user_id = $2 AND file_name = $3"+tClause, + append([]any{agentID, userID, fileName}, tArgs...)...) return err } @@ -96,11 +110,11 @@ func (s *PGAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid. var isInserted bool var storedWorkspace sql.NullString err := s.db.QueryRowContext(ctx, ` - INSERT INTO user_agent_profiles (agent_id, user_id, workspace, first_seen_at, last_seen_at) - VALUES ($1, $2, NULLIF($3, ''), NOW(), NOW()) + INSERT INTO user_agent_profiles (agent_id, user_id, workspace, first_seen_at, last_seen_at, tenant_id) + VALUES ($1, $2, NULLIF($3, ''), NOW(), NOW(), $4) ON CONFLICT (agent_id, user_id) DO UPDATE SET last_seen_at = NOW() RETURNING (xmax = 0), workspace - `, agentID, userID, effectiveWs).Scan(&isInserted, &storedWorkspace) + `, agentID, userID, effectiveWs, tenantIDForInsert(ctx)).Scan(&isInserted, &storedWorkspace) if err != nil { return false, effectiveWs, err } @@ -115,16 +129,25 @@ func (s *PGAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid. // Used when admin manually adds a contact as an agent instance via the UI. func (s *PGAgentStore) EnsureUserProfile(ctx context.Context, agentID uuid.UUID, userID string) error { _, err := s.db.ExecContext(ctx, ` - INSERT INTO user_agent_profiles (agent_id, user_id, first_seen_at, last_seen_at) - VALUES ($1, $2, NOW(), NOW()) + INSERT INTO user_agent_profiles (agent_id, user_id, first_seen_at, last_seen_at, tenant_id) + VALUES ($1, $2, NOW(), NOW(), $3) ON CONFLICT (agent_id, user_id) DO NOTHING - `, agentID, userID) + `, agentID, userID, tenantIDForInsert(ctx)) return err } // --- User Instances --- func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]store.UserInstanceData, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } + // Tenant-scope the file count subquery to prevent cross-tenant leakage. + subTenantFilter := "" + if !store.IsCrossTenant(ctx) { + subTenantFilter = " AND tenant_id = $2" + } rows, err := s.db.QueryContext(ctx, ` SELECT p.user_id, TO_CHAR(p.first_seen_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS first_seen_at, @@ -135,12 +158,12 @@ func (s *PGAgentStore) ListUserInstances(ctx context.Context, agentID uuid.UUID) LEFT JOIN ( SELECT user_id, COUNT(*) AS cnt FROM user_context_files - WHERE agent_id = $1 + WHERE agent_id = $1`+subTenantFilter+` GROUP BY user_id ) fc ON fc.user_id = p.user_id - WHERE p.agent_id = $1 + WHERE p.agent_id = $1`+tClause+` ORDER BY p.last_seen_at DESC - `, agentID) + `, append([]any{agentID}, tArgs...)...) if err != nil { return nil, err } @@ -166,10 +189,14 @@ func (s *PGAgentStore) UpdateUserProfileMetadata(ctx context.Context, agentID uu if err != nil { return err } + tClause, tArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return err + } _, err = s.db.ExecContext(ctx, `UPDATE user_agent_profiles SET metadata = COALESCE(metadata, '{}') || $3::jsonb - WHERE agent_id = $1 AND user_id = $2`, - agentID, userID, metaJSON, + WHERE agent_id = $1 AND user_id = $2`+tClause, + append([]any{agentID, userID, metaJSON}, tArgs...)..., ) return err } @@ -177,10 +204,14 @@ func (s *PGAgentStore) UpdateUserProfileMetadata(ctx context.Context, agentID uu // --- User Overrides --- func (s *PGAgentStore) GetUserOverride(ctx context.Context, agentID uuid.UUID, userID string) (*store.UserAgentOverrideData, error) { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } var d store.UserAgentOverrideData - err := s.db.QueryRowContext(ctx, - "SELECT agent_id, user_id, provider, model FROM user_agent_overrides WHERE agent_id = $1 AND user_id = $2", - agentID, userID, + err = s.db.QueryRowContext(ctx, + "SELECT agent_id, user_id, provider, model FROM user_agent_overrides WHERE agent_id = $1 AND user_id = $2"+tClause, + append([]any{agentID, userID}, tArgs...)..., ).Scan(&d.AgentID, &d.UserID, &d.Provider, &d.Model) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -193,10 +224,10 @@ func (s *PGAgentStore) GetUserOverride(ctx context.Context, agentID uuid.UUID, u func (s *PGAgentStore) SetUserOverride(ctx context.Context, override *store.UserAgentOverrideData) error { _, err := s.db.ExecContext(ctx, - `INSERT INTO user_agent_overrides (id, agent_id, user_id, provider, model) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO user_agent_overrides (id, agent_id, user_id, provider, model, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (agent_id, user_id) DO UPDATE SET provider = EXCLUDED.provider, model = EXCLUDED.model`, - store.GenNewID(), override.AgentID, override.UserID, override.Provider, override.Model, + store.GenNewID(), override.AgentID, override.UserID, override.Provider, override.Model, tenantIDForInsert(ctx), ) return err } diff --git a/internal/store/pg/api_keys.go b/internal/store/pg/api_keys.go index b81ee289..d6248d5f 100644 --- a/internal/store/pg/api_keys.go +++ b/internal/store/pg/api_keys.go @@ -3,6 +3,8 @@ package pg import ( "context" "database/sql" + "fmt" + "strings" "time" "github.com/google/uuid" @@ -22,18 +24,26 @@ func NewPGAPIKeyStore(db *sql.DB) *PGAPIKeyStore { } func (s *PGAPIKeyStore) Create(ctx context.Context, key *store.APIKeyData) error { + var ownerID *string + if key.OwnerID != "" { + ownerID = &key.OwnerID + } + var tenantID *uuid.UUID + if key.TenantID != uuid.Nil { + tenantID = &key.TenantID + } _, err := s.db.ExecContext(ctx, - `INSERT INTO api_keys (id, name, prefix, key_hash, scopes, expires_at, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + `INSERT INTO api_keys (id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, key.ID, key.Name, key.Prefix, key.KeyHash, pq.Array(key.Scopes), - key.ExpiresAt, nilStr(key.CreatedBy), key.CreatedAt, key.UpdatedAt, + ownerID, tenantID, key.ExpiresAt, nilStr(key.CreatedBy), key.CreatedAt, key.UpdatedAt, ) return err } func (s *PGAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.APIKeyData, error) { row := s.db.QueryRowContext(ctx, - `SELECT id, name, prefix, key_hash, scopes, expires_at, last_used_at, revoked, created_by, created_at, updated_at + `SELECT id, name, prefix, key_hash, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at FROM api_keys WHERE key_hash = $1 AND NOT revoked AND (expires_at IS NULL OR expires_at > now())`, keyHash, @@ -41,9 +51,11 @@ func (s *PGAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.A var k store.APIKeyData var createdBy *string + var ownerID *string + var tenantID *uuid.UUID err := row.Scan( &k.ID, &k.Name, &k.Prefix, &k.KeyHash, pq.Array(&k.Scopes), - &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy, + &ownerID, &tenantID, &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy, &k.CreatedAt, &k.UpdatedAt, ) if err != nil { @@ -52,15 +64,47 @@ func (s *PGAPIKeyStore) GetByHash(ctx context.Context, keyHash string) (*store.A if createdBy != nil { k.CreatedBy = *createdBy } + if ownerID != nil { + k.OwnerID = *ownerID + } + if tenantID != nil { + k.TenantID = *tenantID + } return &k, nil } -func (s *PGAPIKeyStore) List(ctx context.Context) ([]store.APIKeyData, error) { +func (s *PGAPIKeyStore) List(ctx context.Context, ownerID string) ([]store.APIKeyData, error) { + var conditions []string + var args []any + argIdx := 1 + + if ownerID != "" { + conditions = append(conditions, fmt.Sprintf("owner_id = $%d", argIdx)) + args = append(args, ownerID) + argIdx++ + } + + // Tenant filter: include tenant-scoped keys + system keys (NULL tenant_id) + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("(tenant_id = $%d OR tenant_id IS NULL)", argIdx)) + args = append(args, tid) + argIdx++ + } + } + + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + rows, err := s.db.QueryContext(ctx, - `SELECT id, name, prefix, scopes, expires_at, last_used_at, revoked, created_by, created_at, updated_at - FROM api_keys + `SELECT id, name, prefix, scopes, owner_id, tenant_id, expires_at, last_used_at, revoked, created_by, created_at, updated_at + FROM api_keys`+where+` ORDER BY created_at DESC`, + args..., ) if err != nil { return nil, err @@ -71,9 +115,11 @@ func (s *PGAPIKeyStore) List(ctx context.Context) ([]store.APIKeyData, error) { for rows.Next() { var k store.APIKeyData var createdBy *string + var oID *string + var tID *uuid.UUID if err := rows.Scan( &k.ID, &k.Name, &k.Prefix, pq.Array(&k.Scopes), - &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy, + &oID, &tID, &k.ExpiresAt, &k.LastUsedAt, &k.Revoked, &createdBy, &k.CreatedAt, &k.UpdatedAt, ); err != nil { return nil, err @@ -81,16 +127,36 @@ func (s *PGAPIKeyStore) List(ctx context.Context) ([]store.APIKeyData, error) { if createdBy != nil { k.CreatedBy = *createdBy } + if oID != nil { + k.OwnerID = *oID + } + if tID != nil { + k.TenantID = *tID + } keys = append(keys, k) } return keys, rows.Err() } -func (s *PGAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID) error { - res, err := s.db.ExecContext(ctx, - `UPDATE api_keys SET revoked = true, updated_at = $2 WHERE id = $1`, - id, time.Now(), - ) +func (s *PGAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID, ownerID string) error { + q := "UPDATE api_keys SET revoked = true, updated_at = $1 WHERE id = $2" + args := []any{time.Now(), id} + argIdx := 3 + + if ownerID != "" { + q += fmt.Sprintf(" AND owner_id = $%d", argIdx) + args = append(args, ownerID) + argIdx++ + } + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND (tenant_id = $%d OR tenant_id IS NULL)", argIdx) + args = append(args, tid) + } + } + + res, err := s.db.ExecContext(ctx, q, args...) if err != nil { return err } @@ -101,10 +167,25 @@ func (s *PGAPIKeyStore) Revoke(ctx context.Context, id uuid.UUID) error { return nil } -func (s *PGAPIKeyStore) Delete(ctx context.Context, id uuid.UUID) error { - res, err := s.db.ExecContext(ctx, - `DELETE FROM api_keys WHERE id = $1`, id, - ) +func (s *PGAPIKeyStore) Delete(ctx context.Context, id uuid.UUID, ownerID string) error { + q := "DELETE FROM api_keys WHERE id = $1" + args := []any{id} + argIdx := 2 + + if ownerID != "" { + q += fmt.Sprintf(" AND owner_id = $%d", argIdx) + args = append(args, ownerID) + argIdx++ + } + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND (tenant_id = $%d OR tenant_id IS NULL)", argIdx) + args = append(args, tid) + } + } + + res, err := s.db.ExecContext(ctx, q, args...) if err != nil { return err } diff --git a/internal/store/pg/channel_contacts.go b/internal/store/pg/channel_contacts.go index d63a4ada..84517b68 100644 --- a/internal/store/pg/channel_contacts.go +++ b/internal/store/pg/channel_contacts.go @@ -11,6 +11,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) + // PGContactStore implements store.ContactStore backed by Postgres. type PGContactStore struct { db *sql.DB @@ -22,26 +23,39 @@ func NewPGContactStore(db *sql.DB) *PGContactStore { } func (s *PGContactStore) UpsertContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind string) error { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } _, err := s.db.ExecContext(ctx, ` - INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind) - VALUES ($1, NULLIF($2,''), $3, NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), NULLIF($7,'')) - ON CONFLICT (channel_type, sender_id) DO UPDATE SET + INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, tenant_id) + VALUES ($1, NULLIF($2,''), $3, NULLIF($4,''), NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8) + ON CONFLICT (tenant_id, channel_type, sender_id) DO UPDATE SET display_name = COALESCE(NULLIF($5,''), channel_contacts.display_name), username = COALESCE(NULLIF($6,''), channel_contacts.username), user_id = COALESCE(NULLIF($4,''), channel_contacts.user_id), channel_instance = COALESCE(NULLIF($2,''), channel_contacts.channel_instance), peer_kind = COALESCE(NULLIF($7,''), channel_contacts.peer_kind), last_seen_at = NOW()`, - channelType, channelInstance, senderID, userID, displayName, username, peerKind, + channelType, channelInstance, senderID, userID, displayName, username, peerKind, tenantID, ) return err } -func contactWhereClause(opts store.ContactListOpts) (string, []any, int) { +func contactWhereClause(ctx context.Context, opts store.ContactListOpts) (string, []any, int) { var conditions []string var args []any argIdx := 1 + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx)) + args = append(args, tenantID) + argIdx++ + } + } + if opts.ChannelType != "" { conditions = append(conditions, fmt.Sprintf("channel_type = $%d", argIdx)) args = append(args, opts.ChannelType) @@ -71,7 +85,7 @@ func contactWhereClause(opts store.ContactListOpts) (string, []any, int) { } func (s *PGContactStore) ListContacts(ctx context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) { - where, args, argIdx := contactWhereClause(opts) + where, args, argIdx := contactWhereClause(ctx, opts) query := `SELECT id, channel_type, channel_instance, sender_id, user_id, display_name, username, avatar_url, peer_kind, merged_id, @@ -113,7 +127,7 @@ func (s *PGContactStore) ListContacts(ctx context.Context, opts store.ContactLis } func (s *PGContactStore) CountContacts(ctx context.Context, opts store.ContactListOpts) (int, error) { - where, args, _ := contactWhereClause(opts) + where, args, _ := contactWhereClause(ctx, opts) var count int err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_contacts"+where, args...).Scan(&count) return count, err @@ -160,12 +174,30 @@ func (s *PGContactStore) GetContactsBySenderIDs(ctx context.Context, senderIDs [ return result, rows.Err() } -func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID) error { - if len(contactIDs) < 2 { +func (s *PGContactStore) GetContactByID(ctx context.Context, id uuid.UUID) (*store.ChannelContact, error) { + tid := store.TenantIDFromContext(ctx) + row := s.db.QueryRowContext(ctx, + `SELECT id, channel_type, channel_instance, sender_id, user_id, + display_name, username, avatar_url, peer_kind, merged_id, + first_seen_at, last_seen_at + FROM channel_contacts WHERE id = $1 AND tenant_id = $2`, id, tid) + var c store.ChannelContact + if err := row.Scan( + &c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID, + &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID, + &c.FirstSeenAt, &c.LastSeenAt, + ); err != nil { + return nil, err + } + return &c, nil +} + +func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID, tenantUserID uuid.UUID) error { + if len(contactIDs) == 0 { return nil } + tid := store.TenantIDFromContext(ctx) - // Check if any of the contacts already has a merged_id; reuse it. placeholders := make([]string, len(contactIDs)) args := make([]any, len(contactIDs)) for i, id := range contactIDs { @@ -174,22 +206,65 @@ func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UU } inClause := strings.Join(placeholders, ",") - var existingMergedID *uuid.UUID - err := s.db.QueryRowContext(ctx, - fmt.Sprintf("SELECT merged_id FROM channel_contacts WHERE id IN (%s) AND merged_id IS NOT NULL LIMIT 1", inClause), - args..., - ).Scan(&existingMergedID) - - mergedID := uuid.New() - if err == nil && existingMergedID != nil { - mergedID = *existingMergedID - } - - // Update all contacts with the merged_id. - args = append(args, mergedID) - _, err = s.db.ExecContext(ctx, - fmt.Sprintf("UPDATE channel_contacts SET merged_id = $%d WHERE id IN (%s)", len(args), inClause), - args..., + // $N+1 = tenantUserID, $N+2 = tenant_id + args = append(args, tenantUserID, tid) + q := fmt.Sprintf( + "UPDATE channel_contacts SET merged_id = $%d WHERE id IN (%s) AND tenant_id = $%d", + len(args)-1, inClause, len(args), ) + _, err := s.db.ExecContext(ctx, q, args...) return err } + +func (s *PGContactStore) UnmergeContacts(ctx context.Context, contactIDs []uuid.UUID) error { + if len(contactIDs) == 0 { + return nil + } + tid := store.TenantIDFromContext(ctx) + + placeholders := make([]string, len(contactIDs)) + args := make([]any, len(contactIDs)) + for i, id := range contactIDs { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = id + } + inClause := strings.Join(placeholders, ",") + + args = append(args, tid) + q := fmt.Sprintf( + "UPDATE channel_contacts SET merged_id = NULL WHERE id IN (%s) AND tenant_id = $%d", + inClause, len(args), + ) + _, err := s.db.ExecContext(ctx, q, args...) + return err +} + +func (s *PGContactStore) GetContactsByMergedID(ctx context.Context, mergedID uuid.UUID) ([]store.ChannelContact, error) { + tid := store.TenantIDFromContext(ctx) + + q := `SELECT id, channel_type, channel_instance, sender_id, user_id, + display_name, username, avatar_url, peer_kind, merged_id, + first_seen_at, last_seen_at + FROM channel_contacts WHERE merged_id = $1 AND tenant_id = $2 + ORDER BY last_seen_at DESC` + + rows, err := s.db.QueryContext(ctx, q, mergedID, tid) + if err != nil { + return nil, err + } + defer rows.Close() + + var contacts []store.ChannelContact + for rows.Next() { + var c store.ChannelContact + if err := rows.Scan( + &c.ID, &c.ChannelType, &c.ChannelInstance, &c.SenderID, &c.UserID, + &c.DisplayName, &c.Username, &c.AvatarURL, &c.PeerKind, &c.MergedID, + &c.FirstSeenAt, &c.LastSeenAt, + ); err != nil { + return nil, err + } + contacts = append(contacts, c) + } + return contacts, rows.Err() +} diff --git a/internal/store/pg/channel_instances.go b/internal/store/pg/channel_instances.go index 15ee29d0..6807ce2f 100644 --- a/internal/store/pg/channel_instances.go +++ b/internal/store/pg/channel_instances.go @@ -28,7 +28,7 @@ func NewPGChannelInstanceStore(db *sql.DB, encryptionKey string) *PGChannelInsta } const channelInstanceSelectCols = `id, name, display_name, channel_type, agent_id, - credentials, config, enabled, created_by, created_at, updated_at` + credentials, config, enabled, created_by, created_at, updated_at, tenant_id` func (s *PGChannelInstanceStore) Create(ctx context.Context, inst *store.ChannelInstanceData) error { if err := store.ValidateUserID(inst.CreatedBy); err != nil { @@ -54,26 +54,49 @@ func (s *PGChannelInstanceStore) Create(ctx context.Context, inst *store.Channel inst.CreatedAt = now inst.UpdatedAt = now + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, `INSERT INTO channel_instances (id, name, display_name, channel_type, agent_id, - credentials, config, enabled, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, + credentials, config, enabled, created_by, created_at, updated_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)`, inst.ID, inst.Name, inst.DisplayName, inst.ChannelType, inst.AgentID, credsBytes, jsonOrEmpty(inst.Config), - inst.Enabled, inst.CreatedBy, now, now, + inst.Enabled, inst.CreatedBy, now, now, tenantID, ) return err } func (s *PGChannelInstanceStore) Get(ctx context.Context, id uuid.UUID) (*store.ChannelInstanceData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = $1`, id) + return s.scanInstance(row) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } row := s.db.QueryRowContext(ctx, - `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = $1`, id) + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE id = $1 AND tenant_id = $2`, id, tenantID) return s.scanInstance(row) } func (s *PGChannelInstanceStore) GetByName(ctx context.Context, name string) (*store.ChannelInstanceData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = $1`, name) + return s.scanInstance(row) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } row := s.db.QueryRowContext(ctx, - `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = $1`, name) + `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE name = $1 AND tenant_id = $2`, name, tenantID) return s.scanInstance(row) } @@ -86,7 +109,7 @@ func (s *PGChannelInstanceStore) scanInstance(row *sql.Row) (*store.ChannelInsta err := row.Scan( &inst.ID, &inst.Name, &displayName, &inst.ChannelType, &inst.AgentID, &creds, &config, - &inst.Enabled, &inst.CreatedBy, &inst.CreatedAt, &inst.UpdatedAt, + &inst.Enabled, &inst.CreatedBy, &inst.CreatedAt, &inst.UpdatedAt, &inst.TenantID, ) if err != nil { return nil, err @@ -124,7 +147,7 @@ func (s *PGChannelInstanceStore) scanInstances(rows *sql.Rows) ([]store.ChannelI if err := rows.Scan( &inst.ID, &inst.Name, &displayName, &inst.ChannelType, &inst.AgentID, &creds, &config, - &inst.Enabled, &inst.CreatedBy, &inst.CreatedAt, &inst.UpdatedAt, + &inst.Enabled, &inst.CreatedBy, &inst.CreatedAt, &inst.UpdatedAt, &inst.TenantID, ); err != nil { continue } @@ -196,7 +219,14 @@ func (s *PGChannelInstanceStore) Update(ctx context.Context, id uuid.UUID, updat updates["credentials"] = credsBytes } updates["updated_at"] = time.Now() - return execMapUpdate(ctx, s.db, "channel_instances", id, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "channel_instances", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "channel_instances", updates, id, tid) } // loadExistingCreds reads and decrypts the current credentials for merging. @@ -222,13 +252,31 @@ func (s *PGChannelInstanceStore) loadExistingCreds(ctx context.Context, id uuid. } func (s *PGChannelInstanceStore) Delete(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = $1", id) + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = $1", id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + _, err := s.db.ExecContext(ctx, "DELETE FROM channel_instances WHERE id = $1 AND tenant_id = $2", id, tid) return err } func (s *PGChannelInstanceStore) ListEnabled(ctx context.Context) ([]store.ChannelInstanceData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+channelInstanceSelectCols+` FROM channel_instances WHERE enabled = true ORDER BY name`) + query := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances WHERE enabled = true` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + query += ` AND tenant_id = $1` + qArgs = append(qArgs, tenantID) + } + query += ` ORDER BY name` + rows, err := s.db.QueryContext(ctx, query, qArgs...) if err != nil { return nil, err } @@ -236,19 +284,38 @@ func (s *PGChannelInstanceStore) ListEnabled(ctx context.Context) ([]store.Chann } func (s *PGChannelInstanceStore) ListAll(ctx context.Context) ([]store.ChannelInstanceData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+channelInstanceSelectCols+` FROM channel_instances ORDER BY name`) + query := `SELECT ` + channelInstanceSelectCols + ` FROM channel_instances` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + query += ` WHERE tenant_id = $1` + qArgs = append(qArgs, tenantID) + } + query += ` ORDER BY name` + rows, err := s.db.QueryContext(ctx, query, qArgs...) if err != nil { return nil, err } return s.scanInstances(rows) } -func buildChannelInstanceWhere(opts store.ChannelInstanceListOpts) (string, []any) { +func buildChannelInstanceWhere(ctx context.Context, opts store.ChannelInstanceListOpts) (string, []any) { var conditions []string var args []any argIdx := 1 + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx)) + args = append(args, tenantID) + argIdx++ + } + } + if opts.Search != "" { conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d ESCAPE '\\' OR display_name ILIKE $%d ESCAPE '\\' OR channel_type ILIKE $%d ESCAPE '\\')", argIdx, argIdx, argIdx)) escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search) @@ -263,7 +330,7 @@ func buildChannelInstanceWhere(opts store.ChannelInstanceListOpts) (string, []an } func (s *PGChannelInstanceStore) ListPaged(ctx context.Context, opts store.ChannelInstanceListOpts) ([]store.ChannelInstanceData, error) { - where, args := buildChannelInstanceWhere(opts) + where, args := buildChannelInstanceWhere(ctx, opts) limit := opts.Limit if limit <= 0 { limit = 50 @@ -279,7 +346,7 @@ func (s *PGChannelInstanceStore) ListPaged(ctx context.Context, opts store.Chann } func (s *PGChannelInstanceStore) CountInstances(ctx context.Context, opts store.ChannelInstanceListOpts) (int, error) { - where, args := buildChannelInstanceWhere(opts) + where, args := buildChannelInstanceWhere(ctx, opts) var count int err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM channel_instances"+where, args...).Scan(&count) return count, err diff --git a/internal/store/pg/config_permissions.go b/internal/store/pg/config_permissions.go index 042199bb..5dfe9d75 100644 --- a/internal/store/pg/config_permissions.go +++ b/internal/store/pg/config_permissions.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "strconv" "strings" "sync" @@ -65,7 +66,11 @@ func (s *PGConfigPermissionStore) InvalidateCache() { // CheckPermission evaluates deny-first, allow-second permission with Go-level wildcard matching. func (s *PGConfigPermissionStore) CheckPermission(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) (bool, error) { - cacheKey := agentID.String() + ":" + userID + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + cacheKey := tid.String() + ":" + agentID.String() + ":" + userID // Check cache. s.mu.RLock() @@ -76,10 +81,14 @@ func (s *PGConfigPermissionStore) CheckPermission(ctx context.Context, agentID u s.mu.RUnlock() // Fetch from DB. + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return false, err + } rows, err := s.db.QueryContext(ctx, `SELECT scope, config_type, permission FROM agent_config_permissions - WHERE agent_id = $1 AND user_id = $2`, - agentID, userID, + WHERE agent_id = $1 AND user_id = $2`+tClause, + append([]any{agentID, userID}, tArgs...)..., ) if err != nil { return false, err @@ -146,14 +155,14 @@ func (s *PGConfigPermissionStore) Grant(ctx context.Context, perm *store.ConfigP } now := time.Now() _, err := s.db.ExecContext(ctx, - `INSERT INTO agent_config_permissions (agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8) + `INSERT INTO agent_config_permissions (agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8,$9) ON CONFLICT (agent_id, scope, config_type, user_id) DO UPDATE SET permission = EXCLUDED.permission, granted_by = EXCLUDED.granted_by, metadata = EXCLUDED.metadata, updated_at = EXCLUDED.updated_at`, - perm.AgentID, perm.Scope, perm.ConfigType, perm.UserID, perm.Permission, perm.GrantedBy, meta, now, + perm.AgentID, perm.Scope, perm.ConfigType, perm.UserID, perm.Permission, perm.GrantedBy, meta, now, tenantIDForInsert(ctx), ) if err == nil { s.InvalidateCache() @@ -162,9 +171,13 @@ func (s *PGConfigPermissionStore) Grant(ctx context.Context, perm *store.ConfigP } func (s *PGConfigPermissionStore) Revoke(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) error { - _, err := s.db.ExecContext(ctx, - `DELETE FROM agent_config_permissions WHERE agent_id = $1 AND scope = $2 AND config_type = $3 AND user_id = $4`, - agentID, scope, configType, userID, + tClause, tArgs, err := tenantClauseN(ctx, 5) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM agent_config_permissions WHERE agent_id = $1 AND scope = $2 AND config_type = $3 AND user_id = $4`+tClause, + append([]any{agentID, scope, configType, userID}, tArgs...)..., ) if err == nil { s.InvalidateCache() @@ -173,6 +186,10 @@ func (s *PGConfigPermissionStore) Revoke(ctx context.Context, agentID uuid.UUID, } func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, configType, scope string) ([]store.ConfigPermission, error) { + tClause, tArgs, err := tenantClauseN(ctx, 0) // paramN unused; we append manually + if err != nil { + return nil, err + } query := `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at FROM agent_config_permissions WHERE agent_id = $1` args := []any{agentID} @@ -185,6 +202,10 @@ func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, c args = append(args, scope) query += ` AND scope = $` + itoa(len(args)) } + if tClause != "" { + args = append(args, tArgs...) + query += fmt.Sprintf(" AND tenant_id = $%d", len(args)) + } query += ` ORDER BY created_at` rows, err := s.db.QueryContext(ctx, query, args...) @@ -199,7 +220,11 @@ func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, c // ListFileWriters returns cached file_writer allow permissions for a given agentID+scope. // Hot-path: called during system prompt injection for every group message. func (s *PGConfigPermissionStore) ListFileWriters(ctx context.Context, agentID uuid.UUID, scope string) ([]store.ConfigPermission, error) { - cacheKey := agentID.String() + ":" + scope + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + cacheKey := tid.String() + ":" + agentID.String() + ":" + scope s.fwMu.RLock() if entry, ok := s.fwCache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL { @@ -208,12 +233,16 @@ func (s *PGConfigPermissionStore) ListFileWriters(ctx context.Context, agentID u } s.fwMu.RUnlock() + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at FROM agent_config_permissions - WHERE agent_id = $1 AND config_type = 'file_writer' AND scope = $2 AND permission = 'allow' + WHERE agent_id = $1 AND config_type = 'file_writer' AND scope = $2 AND permission = 'allow'`+tClause+` ORDER BY created_at`, - agentID, scope, + append([]any{agentID, scope}, tArgs...)..., ) if err != nil { return nil, err diff --git a/internal/store/pg/config_secrets.go b/internal/store/pg/config_secrets.go index e5281239..fb222ff4 100644 --- a/internal/store/pg/config_secrets.go +++ b/internal/store/pg/config_secrets.go @@ -21,9 +21,10 @@ func NewPGConfigSecretsStore(db *sql.DB, encryptionKey string) *PGConfigSecretsS } func (s *PGConfigSecretsStore) Get(ctx context.Context, key string) (string, error) { + tid := tenantIDForInsert(ctx) // fallback to master var value []byte err := s.db.QueryRowContext(ctx, - `SELECT value FROM config_secrets WHERE key = $1`, key).Scan(&value) + `SELECT value FROM config_secrets WHERE key = $1 AND tenant_id = $2`, key, tid).Scan(&value) if err != nil { return "", err } @@ -50,21 +51,24 @@ func (s *PGConfigSecretsStore) Set(ctx context.Context, key, value string) error stored = []byte(value) } + tid := tenantIDForInsert(ctx) _, err := s.db.ExecContext(ctx, - `INSERT INTO config_secrets (key, value, updated_at) VALUES ($1, $2, $3) - ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = $3`, - key, stored, time.Now(), + `INSERT INTO config_secrets (key, value, updated_at, tenant_id) VALUES ($1, $2, $3, $4) + ON CONFLICT (key, tenant_id) DO UPDATE SET value = $2, updated_at = $3`, + key, stored, time.Now(), tid, ) return err } func (s *PGConfigSecretsStore) Delete(ctx context.Context, key string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM config_secrets WHERE key = $1`, key) + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, `DELETE FROM config_secrets WHERE key = $1 AND tenant_id = $2`, key, tid) return err } func (s *PGConfigSecretsStore) GetAll(ctx context.Context) (map[string]string, error) { - rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM config_secrets`) + tid := tenantIDForInsert(ctx) + rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM config_secrets WHERE tenant_id = $1`, tid) if err != nil { return nil, err } diff --git a/internal/store/pg/cron_crud.go b/internal/store/pg/cron_crud.go index 4e695ab7..bebe05e1 100644 --- a/internal/store/pg/cron_crud.go +++ b/internal/store/pg/cron_crud.go @@ -1,8 +1,10 @@ package pg import ( + "context" "encoding/json" "fmt" + "log/slog" "time" "github.com/google/uuid" @@ -10,7 +12,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) { +func (s *PGCronStore) AddJob(ctx context.Context, name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) { // Apply default timezone for cron expressions when not set per-job. if schedule.TZ == "" && schedule.Kind == "cron" && s.defaultTZ != "" { schedule.TZ = s.defaultTZ @@ -65,10 +67,10 @@ func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message s nextRun := computeNextRun(&schedule, now, s.defaultTZ) _, err := s.db.Exec( - `INSERT INTO cron_jobs (id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, + `INSERT INTO cron_jobs (id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, interval_ms, payload, delete_after_run, next_run_at, created_at, updated_at) - VALUES ($1, $2, $3, $4, true, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, - id, agentUUID, userIDPtr, name, scheduleKind, cronExpr, runAt, tz, + VALUES ($1, $2, $3, $4, $5, true, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, + id, tenantIDForInsert(ctx), agentUUID, userIDPtr, name, scheduleKind, cronExpr, runAt, tz, intervalMS, payloadJSON, deleteAfterRun, nextRun, now, now, ) if err != nil { @@ -77,24 +79,24 @@ func (s *PGCronStore) AddJob(name string, schedule store.CronSchedule, message s s.cacheLoaded = false // invalidate cache - job, _ := s.GetJob(id.String()) + job, _ := s.GetJob(ctx, id.String()) return job, nil } -func (s *PGCronStore) GetJob(jobID string) (*store.CronJob, bool) { +func (s *PGCronStore) GetJob(ctx context.Context, jobID string) (*store.CronJob, bool) { id, err := uuid.Parse(jobID) if err != nil { return nil, false } - job, err := s.scanJob(id) + job, err := s.scanJob(ctx, id) if err != nil { return nil, false } return job, true } -func (s *PGCronStore) ListJobs(includeDisabled bool, agentID, userID string) []store.CronJob { - q := `SELECT id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, +func (s *PGCronStore) ListJobs(ctx context.Context, includeDisabled bool, agentID, userID string) []store.CronJob { + q := `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error, created_at, updated_at FROM cron_jobs WHERE 1=1` @@ -118,6 +120,16 @@ func (s *PGCronStore) ListJobs(includeDisabled bool, agentID, userID string) []s args = append(args, userID) argIdx++ } + clause, targs, tErr := tenantClauseN(ctx, argIdx) + if tErr != nil { + slog.Warn("cron.ListJobs: tenant context missing, returning empty (fail-closed)", "error", tErr) + return nil + } + if clause != "" { + q += clause + args = append(args, targs...) + argIdx++ + } q += " ORDER BY created_at DESC" @@ -138,28 +150,60 @@ func (s *PGCronStore) ListJobs(includeDisabled bool, agentID, userID string) []s return result } -func (s *PGCronStore) RemoveJob(jobID string) error { +func (s *PGCronStore) RemoveJob(ctx context.Context, jobID string) error { id, err := uuid.Parse(jobID) if err != nil { return fmt.Errorf("invalid job ID: %s", jobID) } - _, err = s.db.Exec("DELETE FROM cron_jobs WHERE id = $1", id) + + q := "DELETE FROM cron_jobs WHERE id = $1" + args := []any{id} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + + res, err := s.db.ExecContext(ctx, q, args...) if err != nil { return err } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("job not found") + } s.cacheLoaded = false return nil } -func (s *PGCronStore) EnableJob(jobID string, enabled bool) error { +func (s *PGCronStore) EnableJob(ctx context.Context, jobID string, enabled bool) error { id, err := uuid.Parse(jobID) if err != nil { return fmt.Errorf("invalid job ID: %s", jobID) } - _, err = s.db.Exec("UPDATE cron_jobs SET enabled = $1, updated_at = $2 WHERE id = $3", enabled, time.Now(), id) + + q := "UPDATE cron_jobs SET enabled = $1, updated_at = $2 WHERE id = $3" + args := []any{enabled, time.Now(), id} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + + res, err := s.db.ExecContext(ctx, q, args...) if err != nil { return err } + if n, _ := res.RowsAffected(); n == 0 { + return fmt.Errorf("job not found") + } s.cacheLoaded = false return nil } diff --git a/internal/store/pg/cron_exec.go b/internal/store/pg/cron_exec.go index 9a7bec99..0c8da01c 100644 --- a/internal/store/pg/cron_exec.go +++ b/internal/store/pg/cron_exec.go @@ -1,6 +1,7 @@ package pg import ( + "context" "database/sql" "fmt" "time" @@ -10,8 +11,8 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) { - job, ok := s.GetJob(jobID) +func (s *PGCronStore) RunJob(ctx context.Context, jobID string, force bool) (bool, string, error) { + job, ok := s.GetJob(ctx, jobID) if !ok { return false, "", fmt.Errorf("job %s not found", jobID) } @@ -32,7 +33,7 @@ func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) { s.cacheLoaded = false s.mu.Unlock() - s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name}) + s.emitEvent(store.CronEvent{Action: "running", JobID: job.ID, JobName: job.Name, UserID: job.UserID}) // Use executeOneJob for proper state updates, run logging, and retry s.executeOneJob(*job, handler) @@ -42,7 +43,7 @@ func (s *PGCronStore) RunJob(jobID string, force bool) (bool, string, error) { return true, "", nil } -func (s *PGCronStore) GetRunLog(jobID string, limit, offset int) ([]store.CronRunLogEntry, int) { +func (s *PGCronStore) GetRunLog(ctx context.Context, jobID string, limit, offset int) ([]store.CronRunLogEntry, int) { if limit <= 0 { limit = 20 } @@ -50,24 +51,47 @@ func (s *PGCronStore) GetRunLog(jobID string, limit, offset int) ([]store.CronRu offset = 0 } - const cols = "job_id, status, error, summary, ran_at, COALESCE(duration_ms, 0), COALESCE(input_tokens, 0), COALESCE(output_tokens, 0)" + const cols = "r.job_id, r.status, r.error, r.summary, r.ran_at, COALESCE(r.duration_ms, 0), COALESCE(r.input_tokens, 0), COALESCE(r.output_tokens, 0)" + + // Build tenant-aware WHERE clause via JOIN with cron_jobs. + var tenantJoin, tenantWhere string + var tenantArgs []any + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + tenantJoin = " JOIN cron_jobs j ON r.job_id = j.id" + tenantWhere = " AND j.tenant_id = $1" + tenantArgs = append(tenantArgs, tid) + } + } var total int var rows *sql.Rows var err error + if jobID != "" { id, parseErr := uuid.Parse(jobID) if parseErr != nil { return nil, 0 } - s.db.QueryRow("SELECT COUNT(*) FROM cron_run_logs WHERE job_id = $1", id).Scan(&total) - rows, err = s.db.Query( - "SELECT "+cols+" FROM cron_run_logs WHERE job_id = $1 ORDER BY ran_at DESC LIMIT $2 OFFSET $3", - id, limit, offset) + argIdx := len(tenantArgs) + 1 + countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE r.job_id = $%d%s", tenantJoin, argIdx, tenantWhere) + countArgs := append(tenantArgs, id) + s.db.QueryRowContext(ctx, countQ, countArgs...).Scan(&total) + + dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE r.job_id = $%d%s ORDER BY r.ran_at DESC LIMIT $%d OFFSET $%d", + cols, tenantJoin, argIdx, tenantWhere, argIdx+1, argIdx+2) + dataArgs := append(tenantArgs, id, limit, offset) + rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...) } else { - s.db.QueryRow("SELECT COUNT(*) FROM cron_run_logs").Scan(&total) - rows, err = s.db.Query( - "SELECT "+cols+" FROM cron_run_logs ORDER BY ran_at DESC LIMIT $1 OFFSET $2", limit, offset) + argIdx := len(tenantArgs) + 1 + countQ := fmt.Sprintf("SELECT COUNT(*) FROM cron_run_logs r%s WHERE 1=1%s", tenantJoin, tenantWhere) + s.db.QueryRowContext(ctx, countQ, tenantArgs...).Scan(&total) + + dataQ := fmt.Sprintf("SELECT %s FROM cron_run_logs r%s WHERE 1=1%s ORDER BY r.ran_at DESC LIMIT $%d OFFSET $%d", + cols, tenantJoin, tenantWhere, argIdx, argIdx+1) + dataArgs := append(tenantArgs, limit, offset) + rows, err = s.db.QueryContext(ctx, dataQ, dataArgs...) } if err != nil { return nil, 0 diff --git a/internal/store/pg/cron_scan.go b/internal/store/pg/cron_scan.go index 8d9d7ff0..4ea4217a 100644 --- a/internal/store/pg/cron_scan.go +++ b/internal/store/pg/cron_scan.go @@ -1,8 +1,10 @@ package pg import ( + "context" "database/sql" "encoding/json" + "fmt" "time" "github.com/adhocore/gronx" @@ -11,11 +13,23 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -func (s *PGCronStore) scanJob(id uuid.UUID) (*store.CronJob, error) { - row := s.db.QueryRow( - `SELECT id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, +// scanJob fetches a single cron job by ID with tenant filtering. +func (s *PGCronStore) scanJob(ctx context.Context, id uuid.UUID) (*store.CronJob, error) { + q := `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error, - created_at, updated_at FROM cron_jobs WHERE id = $1`, id) + created_at, updated_at FROM cron_jobs WHERE id = $1` + args := []any{id} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + + row := s.db.QueryRowContext(ctx, q, args...) return scanCronSingleRow(row) } @@ -27,6 +41,7 @@ type cronRowScanner interface { func scanCronRow(row cronRowScanner) (*store.CronJob, error) { var id uuid.UUID + var tenantID uuid.UUID var agentID *uuid.UUID var userID *string var name, scheduleKind string @@ -37,7 +52,7 @@ func scanCronRow(row cronRowScanner) (*store.CronJob, error) { var payloadJSON []byte var createdAt, updatedAt time.Time - err := row.Scan(&id, &agentID, &userID, &name, &enabled, &scheduleKind, &cronExpr, &runAt, &tz, + err := row.Scan(&id, &tenantID, &agentID, &userID, &name, &enabled, &scheduleKind, &cronExpr, &runAt, &tz, &intervalMS, &payloadJSON, &deleteAfterRun, &nextRunAt, &lastRunAt, &lastStatus, &lastError, &createdAt, &updatedAt) if err != nil { @@ -48,9 +63,10 @@ func scanCronRow(row cronRowScanner) (*store.CronJob, error) { json.Unmarshal(payloadJSON, &payload) job := &store.CronJob{ - ID: id.String(), - Name: name, - Enabled: enabled, + ID: id.String(), + TenantID: tenantID, + Name: name, + Enabled: enabled, Schedule: store.CronSchedule{ Kind: scheduleKind, }, diff --git a/internal/store/pg/cron_scheduler.go b/internal/store/pg/cron_scheduler.go index 442a530c..e8c97c0b 100644 --- a/internal/store/pg/cron_scheduler.go +++ b/internal/store/pg/cron_scheduler.go @@ -34,7 +34,7 @@ func (s *PGCronStore) GetDueJobs(now time.Time) []store.CronJob { // refreshJobCache reloads all enabled jobs from DB. Must be called with mu held. func (s *PGCronStore) refreshJobCache() { rows, err := s.db.Query( - `SELECT id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, + `SELECT id, tenant_id, agent_id, user_id, name, enabled, schedule_kind, cron_expression, run_at, timezone, interval_ms, payload, delete_after_run, next_run_at, last_run_at, last_status, last_error, created_at, updated_at FROM cron_jobs WHERE enabled = true`) if err != nil { @@ -244,7 +244,7 @@ func (s *PGCronStore) executeOneJob(job store.CronJob, handler func(job *store.C } // Emit completion event - evt := store.CronEvent{Action: "completed", JobID: job.ID, JobName: job.Name, Status: status} + evt := store.CronEvent{Action: "completed", JobID: job.ID, JobName: job.Name, UserID: job.UserID, Status: status} if err != nil { evt.Action = "error" evt.Error = err.Error() diff --git a/internal/store/pg/cron_update.go b/internal/store/pg/cron_update.go index 01e1f545..f845da7e 100644 --- a/internal/store/pg/cron_update.go +++ b/internal/store/pg/cron_update.go @@ -12,7 +12,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store.CronJob, error) { +func (s *PGCronStore) UpdateJob(ctx context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) { id, err := uuid.Parse(jobID) if err != nil { return nil, fmt.Errorf("invalid job ID: %s", jobID) @@ -25,14 +25,25 @@ func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store. if patch.Enabled != nil { updates["enabled"] = *patch.Enabled } + // Build tenant WHERE suffix for internal reads (defense-in-depth). + tenantSuffix := "" + var tenantArg []any + if !store.IsCrossTenant(ctx) { + if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil { + tenantSuffix = " AND tenant_id = $2" + tenantArg = append(tenantArg, tid) + } + } + if patch.Schedule != nil { // Fetch current schedule to merge with patch (partial updates) var curKind string var curExpr, curTZ *string var curIntervalMS *int64 var curRunAt *time.Time - s.db.QueryRow( - "SELECT schedule_kind, cron_expression, timezone, interval_ms, run_at FROM cron_jobs WHERE id = $1", id, + s.db.QueryRowContext(ctx, + "SELECT schedule_kind, cron_expression, timezone, interval_ms, run_at FROM cron_jobs WHERE id = $1"+tenantSuffix, + append([]any{id}, tenantArg...)..., ).Scan(&curKind, &curExpr, &curTZ, &curIntervalMS, &curRunAt) // Resolve the effective schedule kind @@ -151,7 +162,7 @@ func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store. needsPayloadUpdate := patch.Message != "" || patch.Deliver != nil || patch.Channel != nil || patch.To != nil || patch.WakeHeartbeat != nil if needsPayloadUpdate { var payloadJSON []byte - if scanErr := s.db.QueryRow("SELECT payload FROM cron_jobs WHERE id = $1", id).Scan(&payloadJSON); scanErr == nil { + if scanErr := s.db.QueryRowContext(ctx, "SELECT payload FROM cron_jobs WHERE id = $1"+tenantSuffix, append([]any{id}, tenantArg...)...).Scan(&payloadJSON); scanErr == nil { var payload store.CronPayload json.Unmarshal(payloadJSON, &payload) @@ -178,11 +189,22 @@ func (s *PGCronStore) UpdateJob(jobID string, patch store.CronJobPatch) (*store. updates["updated_at"] = time.Now() - if err := execMapUpdate(context.Background(), s.db, "cron_jobs", id, updates); err != nil { + var execErr error + if store.IsCrossTenant(ctx) { + execErr = execMapUpdate(ctx, s.db, "cron_jobs", id, updates) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + execErr = fmt.Errorf("tenant_id required for update") + } else { + execErr = execMapUpdateWhereTenant(ctx, s.db, "cron_jobs", updates, id, tid) + } + } + if err := execErr; err != nil { return nil, err } s.cacheLoaded = false - job, _ := s.scanJob(id) + job, _ := s.scanJob(ctx, id) return job, nil } diff --git a/internal/store/pg/custom_tools.go b/internal/store/pg/custom_tools.go deleted file mode 100644 index 37026bce..00000000 --- a/internal/store/pg/custom_tools.go +++ /dev/null @@ -1,236 +0,0 @@ -package pg - -import ( - "context" - "database/sql" - "fmt" - "log/slog" - "strings" - "time" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/crypto" - "github.com/nextlevelbuilder/goclaw/internal/store" -) - -// PGCustomToolStore implements store.CustomToolStore backed by Postgres. -type PGCustomToolStore struct { - db *sql.DB - encKey string -} - -func NewPGCustomToolStore(db *sql.DB, encryptionKey string) *PGCustomToolStore { - return &PGCustomToolStore{db: db, encKey: encryptionKey} -} - -const customToolSelectCols = `id, name, description, parameters, command, working_dir, - timeout_seconds, env, agent_id, enabled, created_by, created_at, updated_at` - -func (s *PGCustomToolStore) Create(ctx context.Context, def *store.CustomToolDef) error { - if err := store.ValidateUserID(def.CreatedBy); err != nil { - return err - } - if def.ID == uuid.Nil { - def.ID = store.GenNewID() - } - - // Encrypt env if provided - var envBytes []byte - if len(def.Env) > 0 && s.encKey != "" { - encrypted, err := crypto.Encrypt(string(def.Env), s.encKey) - if err != nil { - return fmt.Errorf("encrypt env: %w", err) - } - envBytes = []byte(encrypted) - } else { - envBytes = def.Env - } - - now := time.Now() - def.CreatedAt = now - def.UpdatedAt = now - - _, err := s.db.ExecContext(ctx, - `INSERT INTO custom_tools (id, name, description, parameters, command, working_dir, - timeout_seconds, env, agent_id, enabled, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, - def.ID, def.Name, def.Description, - jsonOrEmpty(def.Parameters), - def.Command, nilStr(def.WorkingDir), - def.TimeoutSeconds, envBytes, - nilUUID(def.AgentID), def.Enabled, - def.CreatedBy, now, now, - ) - return err -} - -func (s *PGCustomToolStore) Get(ctx context.Context, id uuid.UUID) (*store.CustomToolDef, error) { - row := s.db.QueryRowContext(ctx, - `SELECT `+customToolSelectCols+` FROM custom_tools WHERE id = $1`, id) - return s.scanTool(row) -} - -func (s *PGCustomToolStore) scanTool(row *sql.Row) (*store.CustomToolDef, error) { - var def store.CustomToolDef - var workingDir *string - var agentID *uuid.UUID - var params *[]byte // pgx workaround: can't scan NULL JSONB into *json.RawMessage - var env []byte - - err := row.Scan( - &def.ID, &def.Name, &def.Description, ¶ms, - &def.Command, &workingDir, - &def.TimeoutSeconds, &env, &agentID, - &def.Enabled, &def.CreatedBy, &def.CreatedAt, &def.UpdatedAt, - ) - if err != nil { - return nil, err - } - - def.WorkingDir = derefStr(workingDir) - def.AgentID = agentID - if params != nil { - def.Parameters = *params - } - - // Decrypt env - if len(env) > 0 && s.encKey != "" { - decrypted, err := crypto.Decrypt(string(env), s.encKey) - if err != nil { - slog.Warn("custom_tools: failed to decrypt env", "tool", def.Name, "error", err) - } else { - def.Env = []byte(decrypted) - } - } else { - def.Env = env - } - - return &def, nil -} - -func (s *PGCustomToolStore) scanTools(rows *sql.Rows) ([]store.CustomToolDef, error) { - defer rows.Close() - var result []store.CustomToolDef - for rows.Next() { - var def store.CustomToolDef - var workingDir *string - var agentID *uuid.UUID - var params *[]byte - var env []byte - - if err := rows.Scan( - &def.ID, &def.Name, &def.Description, ¶ms, - &def.Command, &workingDir, - &def.TimeoutSeconds, &env, &agentID, - &def.Enabled, &def.CreatedBy, &def.CreatedAt, &def.UpdatedAt, - ); err != nil { - continue - } - - def.WorkingDir = derefStr(workingDir) - def.AgentID = agentID - if params != nil { - def.Parameters = *params - } - if len(env) > 0 && s.encKey != "" { - if decrypted, err := crypto.Decrypt(string(env), s.encKey); err == nil { - def.Env = []byte(decrypted) - } - } else { - def.Env = env - } - - result = append(result, def) - } - return result, nil -} - -func (s *PGCustomToolStore) Update(ctx context.Context, id uuid.UUID, updates map[string]any) error { - // Encrypt env if present - if envVal, ok := updates["env"]; ok { - if envStr, isStr := envVal.(string); isStr && envStr != "" && s.encKey != "" { - encrypted, err := crypto.Encrypt(envStr, s.encKey) - if err != nil { - return fmt.Errorf("encrypt env: %w", err) - } - updates["env"] = []byte(encrypted) - } - } - updates["updated_at"] = time.Now() - return execMapUpdate(ctx, s.db, "custom_tools", id, updates) -} - -func (s *PGCustomToolStore) Delete(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, "DELETE FROM custom_tools WHERE id = $1", id) - return err -} - -func (s *PGCustomToolStore) ListGlobal(ctx context.Context) ([]store.CustomToolDef, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+customToolSelectCols+` FROM custom_tools WHERE agent_id IS NULL AND enabled = true ORDER BY name`) - if err != nil { - return nil, err - } - return s.scanTools(rows) -} - -func (s *PGCustomToolStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.CustomToolDef, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+customToolSelectCols+` FROM custom_tools WHERE agent_id = $1 AND enabled = true ORDER BY name`, agentID) - if err != nil { - return nil, err - } - return s.scanTools(rows) -} - -func (s *PGCustomToolStore) ListAll(ctx context.Context) ([]store.CustomToolDef, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+customToolSelectCols+` FROM custom_tools WHERE enabled = true ORDER BY name`) - if err != nil { - return nil, err - } - return s.scanTools(rows) -} - -func buildCustomToolWhere(opts store.CustomToolListOpts) (string, []any) { - conditions := []string{"enabled = true"} - var args []any - argIdx := 1 - - if opts.AgentID != nil { - conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx)) - args = append(args, *opts.AgentID) - argIdx++ - } - if opts.Search != "" { - conditions = append(conditions, fmt.Sprintf("(name ILIKE $%d ESCAPE '\\' OR description ILIKE $%d ESCAPE '\\')", argIdx, argIdx)) - escaped := strings.NewReplacer("%", "\\%", "_", "\\_").Replace(opts.Search) - args = append(args, "%"+escaped+"%") - } - - return " WHERE " + strings.Join(conditions, " AND "), args -} - -func (s *PGCustomToolStore) ListPaged(ctx context.Context, opts store.CustomToolListOpts) ([]store.CustomToolDef, error) { - where, args := buildCustomToolWhere(opts) - limit := opts.Limit - if limit <= 0 { - limit = 50 - } - q := `SELECT ` + customToolSelectCols + ` FROM custom_tools` + where + - fmt.Sprintf(" ORDER BY name OFFSET %d LIMIT %d", opts.Offset, limit) - - rows, err := s.db.QueryContext(ctx, q, args...) - if err != nil { - return nil, err - } - return s.scanTools(rows) -} - -func (s *PGCustomToolStore) CountTools(ctx context.Context, opts store.CustomToolListOpts) (int, error) { - where, args := buildCustomToolWhere(opts) - var count int - err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM custom_tools"+where, args...).Scan(&count) - return count, err -} diff --git a/internal/store/pg/factory.go b/internal/store/pg/factory.go index dd777c1e..563e4c54 100644 --- a/internal/store/pg/factory.go +++ b/internal/store/pg/factory.go @@ -32,7 +32,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { Providers: NewPGProviderStore(db, cfg.EncryptionKey), Tracing: NewPGTracingStore(db), MCP: NewPGMCPServerStore(db, cfg.EncryptionKey), - CustomTools: NewPGCustomToolStore(db, cfg.EncryptionKey), ChannelInstances: NewPGChannelInstanceStore(db, cfg.EncryptionKey), ConfigSecrets: NewPGConfigSecretsStore(db, cfg.EncryptionKey), AgentLinks: NewPGAgentLinkStore(db), @@ -46,6 +45,9 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) { SecureCLI: NewPGSecureCLIStore(db, cfg.EncryptionKey), APIKeys: NewPGAPIKeyStore(db), Heartbeats: NewPGHeartbeatStore(db), - ConfigPermissions: NewPGConfigPermissionStore(db), + ConfigPermissions: NewPGConfigPermissionStore(db), + Tenants: NewPGTenantStore(db), + BuiltinToolTenantCfgs: NewPGBuiltinToolTenantConfigStore(db), + SkillTenantCfgs: NewPGSkillTenantConfigStore(db), }, nil } diff --git a/internal/store/pg/helpers.go b/internal/store/pg/helpers.go index 683a80bb..ff748d21 100644 --- a/internal/store/pg/helpers.go +++ b/internal/store/pg/helpers.go @@ -11,6 +11,8 @@ import ( "time" "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" ) // validColumnName matches safe SQL identifiers (letters, digits, underscores). @@ -195,15 +197,50 @@ func execMapUpdate(ctx context.Context, db *sql.DB, table string, id uuid.UUID, // tablesWithUpdatedAt lists tables that have an updated_at column. var tablesWithUpdatedAt = map[string]bool{ "agents": true, "llm_providers": true, "sessions": true, - "channel_instances": true, "cron_jobs": true, "custom_tools": true, + "channel_instances": true, "cron_jobs": true, "skills": true, "mcp_servers": true, "agent_links": true, "agent_teams": true, "team_tasks": true, "builtin_tools": true, "agent_context_files": true, "user_context_files": true, "user_agent_overrides": true, "config_secrets": true, "memory_documents": true, "memory_chunks": true, "embedding_cache": true, - "secure_cli_binaries": true, + "secure_cli_binaries": true, "tenants": true, } func tableHasUpdatedAt(table string) bool { return tablesWithUpdatedAt[table] } + +// --- Tenant filter helpers --- + +// tenantClauseN returns an " AND tenant_id = $N" clause and the tenant UUID as the arg. +// Returns ("", nil, nil) for cross-tenant callers (skip filter). +// Returns error if tenant is missing from context (fail-closed). +func tenantClauseN(ctx context.Context, paramN int) (clause string, args []any, err error) { + if store.IsCrossTenant(ctx) { + return "", nil, nil + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return "", nil, fmt.Errorf("tenant_id required") + } + return fmt.Sprintf(" AND tenant_id = $%d", paramN), []any{tid}, nil +} + +// tenantIDForInsert returns the tenant UUID for INSERT operations. +// Falls back to MasterTenantID when no tenant in context. +func tenantIDForInsert(ctx context.Context) uuid.UUID { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return store.MasterTenantID + } + return tid +} + +// requireTenantID returns the tenant UUID or an error if missing (fail-closed). +func requireTenantID(ctx context.Context) (uuid.UUID, error) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return uuid.Nil, fmt.Errorf("tenant_id required") + } + return tid, nil +} diff --git a/internal/store/pg/knowledge_graph.go b/internal/store/pg/knowledge_graph.go index e7b75877..55bfe98d 100644 --- a/internal/store/pg/knowledge_graph.go +++ b/internal/store/pg/knowledge_graph.go @@ -39,10 +39,11 @@ func (s *PGKnowledgeGraphStore) UpsertEntity(ctx context.Context, entity *store. } now := time.Now() id := uuid.Must(uuid.NewV7()) + tid := tenantIDForInsert(ctx) _, err = s.db.ExecContext(ctx, ` INSERT INTO kg_entities - (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) + (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, tenant_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12) ON CONFLICT (agent_id, user_id, external_id) DO UPDATE SET name = EXCLUDED.name, entity_type = EXCLUDED.entity_type, @@ -50,9 +51,10 @@ func (s *PGKnowledgeGraphStore) UpsertEntity(ctx context.Context, entity *store. properties = EXCLUDED.properties, source_id = EXCLUDED.source_id, confidence = EXCLUDED.confidence, + tenant_id = EXCLUDED.tenant_id, updated_at = EXCLUDED.updated_at`, id, aid, entity.UserID, entity.ExternalID, entity.Name, entity.EntityType, - entity.Description, props, entity.SourceID, entity.Confidence, now, + entity.Description, props, entity.SourceID, entity.Confidence, tid, now, ) return err } @@ -62,20 +64,28 @@ func (s *PGKnowledgeGraphStore) GetEntity(ctx context.Context, agentID, userID, eid := mustParseUUID(entityID) if store.IsSharedKG(ctx) { + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } row := s.db.QueryRowContext(ctx, ` SELECT id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, created_at, updated_at - FROM kg_entities WHERE id = $1 AND agent_id = $2`, - eid, aid, + FROM kg_entities WHERE id = $1 AND agent_id = $2`+tc, + append([]any{eid, aid}, tcArgs...)..., ) return scanEntity(row) } + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return nil, err + } row := s.db.QueryRowContext(ctx, ` SELECT id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, created_at, updated_at - FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`, - eid, aid, userID, + FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`+tc, + append([]any{eid, aid, userID}, tcArgs...)..., ) return scanEntity(row) } @@ -84,15 +94,23 @@ func (s *PGKnowledgeGraphStore) DeleteEntity(ctx context.Context, agentID, userI aid := mustParseUUID(agentID) eid := mustParseUUID(entityID) if store.IsSharedKG(ctx) { - _, err := s.db.ExecContext(ctx, - `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2`, - eid, aid, + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2`+tc, + append([]any{eid, aid}, tcArgs...)..., ) return err } - _, err := s.db.ExecContext(ctx, - `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`, - eid, aid, userID, + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM kg_entities WHERE id = $1 AND agent_id = $2 AND user_id = $3`+tc, + append([]any{eid, aid, userID}, tcArgs...)..., ) return err } @@ -118,6 +136,15 @@ func (s *PGKnowledgeGraphStore) ListEntities(ctx context.Context, agentID, userI args = append(args, opts.EntityType) idx++ } + tc, tcArgs, err := tenantClauseN(ctx, idx) + if err != nil { + return nil, err + } + if tc != "" { + where += tc + args = append(args, tcArgs...) + idx++ + } args = append(args, limit, opts.Offset) query := fmt.Sprintf(` SELECT id, agent_id, user_id, external_id, name, entity_type, description, @@ -201,6 +228,15 @@ func (s *PGKnowledgeGraphStore) ilikeSearchEntities(ctx context.Context, agentID args = append(args, userID) idx++ } + tc, tcArgs, err := tenantClauseN(ctx, idx) + if err != nil { + return nil, err + } + if tc != "" { + where += tc + args = append(args, tcArgs...) + idx++ + } args = append(args, pattern, limit) q := fmt.Sprintf(` SELECT id, agent_id, user_id, external_id, name, entity_type, description, @@ -247,6 +283,15 @@ func (s *PGKnowledgeGraphStore) vectorSearchEntities(ctx context.Context, embedd args = append(args, userID) idx++ } + tc, tcArgs, err := tenantClauseN(ctx, idx) + if err != nil { + return nil, err + } + if tc != "" { + where += tc + args = append(args, tcArgs...) + idx++ + } args = append(args, vecStr, limit) q := fmt.Sprintf(` SELECT id, agent_id, user_id, external_id, name, entity_type, description, @@ -330,14 +375,16 @@ func (s *PGKnowledgeGraphStore) UpsertRelation(ctx context.Context, relation *st } id := uuid.Must(uuid.NewV7()) now := time.Now() + tid := tenantIDForInsert(ctx) _, err = s.db.ExecContext(ctx, ` INSERT INTO kg_relations - (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, tenant_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (agent_id, user_id, source_entity_id, relation_type, target_entity_id) DO UPDATE SET confidence = EXCLUDED.confidence, - properties = EXCLUDED.properties`, - id, aid, relation.UserID, src, relation.RelationType, tgt, relation.Confidence, props, now, + properties = EXCLUDED.properties, + tenant_id = EXCLUDED.tenant_id`, + id, aid, relation.UserID, src, relation.RelationType, tgt, relation.Confidence, props, tid, now, ) return err } @@ -346,15 +393,23 @@ func (s *PGKnowledgeGraphStore) DeleteRelation(ctx context.Context, agentID, use aid := mustParseUUID(agentID) rid := mustParseUUID(relationID) if store.IsSharedKG(ctx) { - _, err := s.db.ExecContext(ctx, - `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2`, - rid, aid, + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2`+tc, + append([]any{rid, aid}, tcArgs...)..., ) return err } - _, err := s.db.ExecContext(ctx, - `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2 AND user_id = $3`, - rid, aid, userID, + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM kg_relations WHERE id = $1 AND agent_id = $2 AND user_id = $3`+tc, + append([]any{rid, aid, userID}, tcArgs...)..., ) return err } @@ -366,21 +421,29 @@ func (s *PGKnowledgeGraphStore) ListRelations(ctx context.Context, agentID, user var q string var args []any if store.IsSharedKG(ctx) { + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } q = `SELECT id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, created_at FROM kg_relations WHERE agent_id = $1 - AND (source_entity_id = $2 OR target_entity_id = $2) + AND (source_entity_id = $2 OR target_entity_id = $2)` + tc + ` ORDER BY created_at DESC` - args = []any{aid, eid} + args = append([]any{aid, eid}, tcArgs...) } else { + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return nil, err + } q = `SELECT id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, created_at FROM kg_relations WHERE agent_id = $1 AND user_id = $2 - AND (source_entity_id = $3 OR target_entity_id = $3) + AND (source_entity_id = $3 OR target_entity_id = $3)` + tc + ` ORDER BY created_at DESC` - args = []any{aid, userID, eid} + args = append([]any{aid, userID, eid}, tcArgs...) } rows, err := s.db.QueryContext(ctx, q, args...) @@ -404,6 +467,15 @@ func (s *PGKnowledgeGraphStore) ListAllRelations(ctx context.Context, agentID, u args = append(args, userID) idx++ } + tc, tcArgs, err := tenantClauseN(ctx, idx) + if err != nil { + return nil, err + } + if tc != "" { + where += tc + args = append(args, tcArgs...) + idx++ + } args = append(args, limit) q := fmt.Sprintf(` SELECT id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, @@ -427,6 +499,7 @@ func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, u aid := mustParseUUID(agentID) now := time.Now() + tid := tenantIDForInsert(ctx) // Upsert entities and build external_id → DB UUID lookup for relations extIDToUUID := make(map[string]uuid.UUID, len(entities)) @@ -440,8 +513,8 @@ func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, u var actualID uuid.UUID if err := tx.QueryRowContext(ctx, ` INSERT INTO kg_entities - (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) + (id, agent_id, user_id, external_id, name, entity_type, description, properties, source_id, confidence, tenant_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12) ON CONFLICT (agent_id, user_id, external_id) DO UPDATE SET name = EXCLUDED.name, entity_type = EXCLUDED.entity_type, @@ -449,10 +522,11 @@ func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, u properties = EXCLUDED.properties, source_id = EXCLUDED.source_id, confidence = EXCLUDED.confidence, + tenant_id = EXCLUDED.tenant_id, updated_at = EXCLUDED.updated_at RETURNING id`, id, aid, userID, e.ExternalID, e.Name, e.EntityType, - e.Description, props, e.SourceID, e.Confidence, now, + e.Description, props, e.SourceID, e.Confidence, tid, now, ).Scan(&actualID); err != nil { return err } @@ -500,12 +574,13 @@ func (s *PGKnowledgeGraphStore) IngestExtraction(ctx context.Context, agentID, u id := uuid.Must(uuid.NewV7()) if _, err := tx.ExecContext(ctx, ` INSERT INTO kg_relations - (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + (id, agent_id, user_id, source_entity_id, relation_type, target_entity_id, confidence, properties, tenant_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (agent_id, user_id, source_entity_id, relation_type, target_entity_id) DO UPDATE SET confidence = EXCLUDED.confidence, - properties = EXCLUDED.properties`, - id, aid, userID, src, r.RelationType, tgt, r.Confidence, props, now, + properties = EXCLUDED.properties, + tenant_id = EXCLUDED.tenant_id`, + id, aid, userID, src, r.RelationType, tgt, r.Confidence, props, tid, now, ); err != nil { return err } @@ -519,14 +594,22 @@ func (s *PGKnowledgeGraphStore) PruneByConfidence(ctx context.Context, agentID, var res sql.Result var err error if store.IsSharedKG(ctx) { + tc, tcArgs, tcErr := tenantClauseN(ctx, 3) + if tcErr != nil { + return 0, tcErr + } res, err = s.db.ExecContext(ctx, - `DELETE FROM kg_entities WHERE agent_id = $1 AND confidence < $2`, - aid, minConfidence, + `DELETE FROM kg_entities WHERE agent_id = $1 AND confidence < $2`+tc, + append([]any{aid, minConfidence}, tcArgs...)..., ) } else { + tc, tcArgs, tcErr := tenantClauseN(ctx, 4) + if tcErr != nil { + return 0, tcErr + } res, err = s.db.ExecContext(ctx, - `DELETE FROM kg_entities WHERE agent_id = $1 AND user_id = $2 AND confidence < $3`, - aid, userID, minConfidence, + `DELETE FROM kg_entities WHERE agent_id = $1 AND user_id = $2 AND confidence < $3`+tc, + append([]any{aid, userID, minConfidence}, tcArgs...)..., ) } if err != nil { @@ -542,24 +625,32 @@ func (s *PGKnowledgeGraphStore) Stats(ctx context.Context, agentID, userID strin userFilter := "" args := []any{aid} + idx := 2 if userID != "" { - userFilter = " AND user_id = $2" + userFilter = fmt.Sprintf(" AND user_id = $%d", idx) args = append(args, userID) + idx++ } + tc, tcArgs, err := tenantClauseN(ctx, idx) + if err != nil { + return nil, err + } + tenantFilter := tc + args = append(args, tcArgs...) if err := s.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter, args..., + `SELECT COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter+tenantFilter, args..., ).Scan(&stats.EntityCount); err != nil { return nil, err } if err := s.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM kg_relations WHERE agent_id = $1`+userFilter, args..., + `SELECT COUNT(*) FROM kg_relations WHERE agent_id = $1`+userFilter+tenantFilter, args..., ).Scan(&stats.RelationCount); err != nil { return nil, err } rows, err := s.db.QueryContext(ctx, - `SELECT entity_type, COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter+` GROUP BY entity_type`, args..., + `SELECT entity_type, COUNT(*) FROM kg_entities WHERE agent_id = $1`+userFilter+tenantFilter+` GROUP BY entity_type`, args..., ) if err != nil { return nil, err diff --git a/internal/store/pg/knowledge_graph_embedding.go b/internal/store/pg/knowledge_graph_embedding.go index 53cc3aa1..96dcaea2 100644 --- a/internal/store/pg/knowledge_graph_embedding.go +++ b/internal/store/pg/knowledge_graph_embedding.go @@ -2,6 +2,7 @@ package pg import ( "context" + "fmt" "log/slog" "github.com/google/uuid" @@ -17,12 +18,20 @@ func (s *PGKnowledgeGraphStore) BackfillKGEmbeddings(ctx context.Context) (int, const batchSize = 50 total := 0 + // $1=batchSize (LIMIT), tenant at $2 if not cross-tenant + tc, tcArgs, tcErr := tenantClauseN(ctx, 2) + if tcErr != nil { + return 0, tcErr + } + batchQ := fmt.Sprintf(`SELECT id, name, description FROM kg_entities + WHERE embedding IS NULL%s + ORDER BY created_at DESC + LIMIT $1`, tc) + batchBaseArgs := tcArgs // tenant uuid(s) prepended after $1 + for { - rows, err := s.db.QueryContext(ctx, - `SELECT id, name, description FROM kg_entities - WHERE embedding IS NULL - ORDER BY created_at DESC - LIMIT $1`, batchSize) + queryArgs := append([]any{batchSize}, batchBaseArgs...) + rows, err := s.db.QueryContext(ctx, batchQ, queryArgs...) if err != nil { return total, err } diff --git a/internal/store/pg/knowledge_graph_traversal.go b/internal/store/pg/knowledge_graph_traversal.go index 2f40f7e9..669470f4 100644 --- a/internal/store/pg/knowledge_graph_traversal.go +++ b/internal/store/pg/knowledge_graph_traversal.go @@ -3,6 +3,7 @@ package pg import ( "context" "encoding/json" + "fmt" "time" "github.com/lib/pq" @@ -33,7 +34,13 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s var q string var args []any if store.IsSharedKG(ctx) { - q = ` + // fixed params: $1=startID, $2=aid; tenant at $3 (if needed); maxDepth last + tc, tcArgs, tcErr := tenantClauseN(ctx, 3) + if tcErr != nil { + return nil, tcErr + } + depthN := 3 + len(tcArgs) + q = fmt.Sprintf(` WITH RECURSIVE paths AS ( SELECT e.id, e.agent_id, e.user_id, e.external_id, @@ -44,7 +51,7 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s ARRAY[e.id::text] AS path, ''::text AS via FROM kg_entities e - WHERE e.id = $1 AND e.agent_id = $2 + WHERE e.id = $1 AND e.agent_id = $2%s UNION ALL @@ -59,7 +66,7 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s FROM paths p JOIN kg_relations r ON p.id = r.source_entity_id AND r.agent_id = $2 JOIN kg_entities e ON r.target_entity_id = e.id AND e.agent_id = $2 - WHERE p.depth < $3 + WHERE p.depth < $%d AND NOT e.id::text = ANY(p.path) ) SELECT @@ -68,10 +75,17 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s properties, source_id, confidence, created_at, updated_at, depth, path, via - FROM paths WHERE depth > 1` - args = []any{startID, aid, maxDepth} + FROM paths WHERE depth > 1`, tc, depthN) + args = append([]any{startID, aid}, tcArgs...) + args = append(args, maxDepth) } else { - q = ` + // fixed params: $1=startID, $2=aid, $3=userID; tenant at $4 (if needed); maxDepth last + tc, tcArgs, tcErr := tenantClauseN(ctx, 4) + if tcErr != nil { + return nil, tcErr + } + depthN := 4 + len(tcArgs) + q = fmt.Sprintf(` WITH RECURSIVE paths AS ( SELECT e.id, e.agent_id, e.user_id, e.external_id, @@ -82,7 +96,7 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s ARRAY[e.id::text] AS path, ''::text AS via FROM kg_entities e - WHERE e.id = $1 AND e.agent_id = $2 AND e.user_id = $3 + WHERE e.id = $1 AND e.agent_id = $2 AND e.user_id = $3%s UNION ALL @@ -97,7 +111,7 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s FROM paths p JOIN kg_relations r ON p.id = r.source_entity_id AND r.user_id = $3 JOIN kg_entities e ON r.target_entity_id = e.id AND e.user_id = $3 - WHERE p.depth < $4 + WHERE p.depth < $%d AND NOT e.id::text = ANY(p.path) ) SELECT @@ -106,8 +120,9 @@ func (s *PGKnowledgeGraphStore) Traverse(ctx context.Context, agentID, userID, s properties, source_id, confidence, created_at, updated_at, depth, path, via - FROM paths WHERE depth > 1` - args = []any{startID, aid, userID, maxDepth} + FROM paths WHERE depth > 1`, tc, depthN) + args = append([]any{startID, aid, userID}, tcArgs...) + args = append(args, maxDepth) } rows, err := tx.QueryContext(ctx, q, args...) diff --git a/internal/store/pg/mcp_servers.go b/internal/store/pg/mcp_servers.go index acd30c6c..60c6e347 100644 --- a/internal/store/pg/mcp_servers.go +++ b/internal/store/pg/mcp_servers.go @@ -48,30 +48,56 @@ func (s *PGMCPServerStore) CreateServer(ctx context.Context, srv *store.MCPServe srv.UpdatedAt = now encHeaders := s.encryptJSONB(jsonOrEmpty(srv.Headers)) encEnv := s.encryptJSONB(jsonOrEmpty(srv.Env)) + + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, `INSERT INTO mcp_servers (id, name, display_name, transport, command, args, url, headers, env, - api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)`, + api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)`, srv.ID, srv.Name, nilStr(srv.DisplayName), srv.Transport, nilStr(srv.Command), jsonOrEmpty(srv.Args), nilStr(srv.URL), encHeaders, encEnv, nilStr(apiKey), nilStr(srv.ToolPrefix), srv.TimeoutSec, - jsonOrEmpty(srv.Settings), srv.Enabled, srv.CreatedBy, now, now, + jsonOrEmpty(srv.Settings), srv.Enabled, srv.CreatedBy, now, now, tenantID, ) return err } func (s *PGMCPServerStore) GetServer(ctx context.Context, id uuid.UUID) (*store.MCPServerData, error) { + if store.IsCrossTenant(ctx) { + return s.scanServer(s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, transport, command, args, url, headers, env, + api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at + FROM mcp_servers WHERE id = $1`, id)) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } return s.scanServer(s.db.QueryRowContext(ctx, `SELECT id, name, display_name, transport, command, args, url, headers, env, api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at - FROM mcp_servers WHERE id = $1`, id)) + FROM mcp_servers WHERE id = $1 AND tenant_id = $2`, id, tenantID)) } func (s *PGMCPServerStore) GetServerByName(ctx context.Context, name string) (*store.MCPServerData, error) { + if store.IsCrossTenant(ctx) { + return s.scanServer(s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, transport, command, args, url, headers, env, + api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at + FROM mcp_servers WHERE name = $1`, name)) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } return s.scanServer(s.db.QueryRowContext(ctx, `SELECT id, name, display_name, transport, command, args, url, headers, env, api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at - FROM mcp_servers WHERE name = $1`, name)) + FROM mcp_servers WHERE name = $1 AND tenant_id = $2`, name, tenantID)) } func (s *PGMCPServerStore) scanServer(row *sql.Row) (*store.MCPServerData, error) { @@ -108,10 +134,20 @@ func (s *PGMCPServerStore) scanServer(row *sql.Row) (*store.MCPServerData, error } func (s *PGMCPServerStore) ListServers(ctx context.Context) ([]store.MCPServerData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT id, name, display_name, transport, command, args, url, headers, env, + query := `SELECT id, name, display_name, transport, command, args, url, headers, env, api_key, tool_prefix, timeout_sec, settings, enabled, created_by, created_at, updated_at - FROM mcp_servers ORDER BY name`) + FROM mcp_servers` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return []store.MCPServerData{}, nil + } + query += ` WHERE tenant_id = $1` + qArgs = append(qArgs, tenantID) + } + query += ` ORDER BY name` + rows, err := s.db.QueryContext(ctx, query, qArgs...) if err != nil { return nil, err } @@ -178,11 +214,26 @@ func (s *PGMCPServerStore) UpdateServer(ctx context.Context, id uuid.UUID, updat } } updates["updated_at"] = time.Now() - return execMapUpdate(ctx, s.db, "mcp_servers", id, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "mcp_servers", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "mcp_servers", updates, id, tid) } func (s *PGMCPServerStore) DeleteServer(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = $1", id) + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = $1", id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + _, err := s.db.ExecContext(ctx, "DELETE FROM mcp_servers WHERE id = $1 AND tenant_id = $2", id, tid) return err } diff --git a/internal/store/pg/mcp_servers_access.go b/internal/store/pg/mcp_servers_access.go index 72adca4d..2484da13 100644 --- a/internal/store/pg/mcp_servers_access.go +++ b/internal/store/pg/mcp_servers_access.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" "github.com/google/uuid" @@ -23,29 +24,39 @@ func (s *PGMCPServerStore) GrantToAgent(ctx context.Context, g *store.MCPAgentGr } g.CreatedAt = time.Now() _, err := s.db.ExecContext(ctx, - `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (server_id, agent_id) DO UPDATE SET enabled = EXCLUDED.enabled, tool_allow = EXCLUDED.tool_allow, tool_deny = EXCLUDED.tool_deny, config_overrides = EXCLUDED.config_overrides, granted_by = EXCLUDED.granted_by`, g.ID, g.ServerID, g.AgentID, g.Enabled, jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), jsonOrNull(g.ConfigOverrides), - g.GrantedBy, g.CreatedAt, + g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx), ) return err } func (s *PGMCPServerStore) RevokeFromAgent(ctx context.Context, serverID, agentID uuid.UUID) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM mcp_agent_grants WHERE server_id = $1 AND agent_id = $2", serverID, agentID) + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM mcp_agent_grants WHERE server_id = $1 AND agent_id = $2"+tClause, + append([]any{serverID, agentID}, tArgs...)...) return err } func (s *PGMCPServerStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]store.MCPAgentGrant, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT id, server_id, agent_id, enabled, tool_allow, tool_deny, config_overrides, granted_by, created_at - FROM mcp_agent_grants WHERE agent_id = $1`, agentID) + FROM mcp_agent_grants WHERE agent_id = $1`+tClause, + append([]any{agentID}, tArgs...)...) if err != nil { return nil, err } @@ -64,11 +75,16 @@ func (s *PGMCPServerStore) ListAgentGrants(ctx context.Context, agentID uuid.UUI } func (s *PGMCPServerStore) ListServerGrants(ctx context.Context, serverID uuid.UUID) ([]store.MCPAgentGrant, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT id, server_id, agent_id, enabled, COALESCE(tool_allow, '[]'::jsonb), COALESCE(tool_deny, '[]'::jsonb), COALESCE(config_overrides, '{}'::jsonb), granted_by, created_at - FROM mcp_agent_grants WHERE server_id = $1 ORDER BY created_at`, serverID) + FROM mcp_agent_grants WHERE server_id = $1`+tClause+` ORDER BY created_at`, + append([]any{serverID}, tArgs...)...) if err != nil { return nil, err } @@ -89,8 +105,13 @@ func (s *PGMCPServerStore) ListServerGrants(ctx context.Context, serverID uuid.U // --- Counts --- func (s *PGMCPServerStore) CountAgentGrantsByServer(ctx context.Context) (map[uuid.UUID]int, error) { + tClause, tArgs, err := tenantClauseN(ctx, 1) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, - `SELECT server_id, COUNT(*) FROM mcp_agent_grants GROUP BY server_id`) + `SELECT server_id, COUNT(*) FROM mcp_agent_grants WHERE 1=1`+tClause+` GROUP BY server_id`, + tArgs...) if err != nil { return nil, err } @@ -122,27 +143,36 @@ func (s *PGMCPServerStore) GrantToUser(ctx context.Context, g *store.MCPUserGran } g.CreatedAt = time.Now() _, err := s.db.ExecContext(ctx, - `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, tool_deny, granted_by, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) + `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, tool_deny, granted_by, created_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (server_id, user_id) DO UPDATE SET enabled = EXCLUDED.enabled, tool_allow = EXCLUDED.tool_allow, tool_deny = EXCLUDED.tool_deny, granted_by = EXCLUDED.granted_by`, g.ID, g.ServerID, g.UserID, g.Enabled, jsonOrNull(g.ToolAllow), jsonOrNull(g.ToolDeny), - g.GrantedBy, g.CreatedAt, + g.GrantedBy, g.CreatedAt, tenantIDForInsert(ctx), ) return err } func (s *PGMCPServerStore) RevokeFromUser(ctx context.Context, serverID uuid.UUID, userID string) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM mcp_user_grants WHERE server_id = $1 AND user_id = $2", serverID, userID) + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM mcp_user_grants WHERE server_id = $1 AND user_id = $2"+tClause, + append([]any{serverID, userID}, tArgs...)...) return err } // --- Resolution --- func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.MCPAccessInfo, error) { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT ms.id, ms.name, ms.display_name, ms.transport, ms.command, ms.args, ms.url, ms.headers, ms.env, ms.api_key, ms.tool_prefix, ms.timeout_sec, ms.settings, ms.enabled, ms.created_by, ms.created_at, ms.updated_at, @@ -151,8 +181,9 @@ func (s *PGMCPServerStore) ListAccessible(ctx context.Context, agentID uuid.UUID INNER JOIN mcp_agent_grants mag ON ms.id = mag.server_id AND mag.agent_id = $1 AND mag.enabled = true LEFT JOIN mcp_user_grants mug ON ms.id = mug.server_id AND mug.user_id = $2 WHERE ms.enabled = true - AND (mug.id IS NULL OR mug.enabled = true)`, - agentID, userID) + AND (mug.id IS NULL OR mug.enabled = true)`+ + strings.Replace(tClause, "tenant_id", "ms.tenant_id", 1), + append([]any{agentID, userID}, tArgs...)...) if err != nil { return nil, err } @@ -213,20 +244,25 @@ func (s *PGMCPServerStore) CreateRequest(ctx context.Context, req *store.MCPAcce req.Status = "pending" req.CreatedAt = time.Now() _, err := s.db.ExecContext(ctx, - `INSERT INTO mcp_access_requests (id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, created_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + `INSERT INTO mcp_access_requests (id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, created_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, req.ID, req.ServerID, nilUUID(req.AgentID), nilStr(req.UserID), req.Scope, req.Status, nilStr(req.Reason), - jsonOrNull(req.ToolAllow), req.RequestedBy, req.CreatedAt, + jsonOrNull(req.ToolAllow), req.RequestedBy, req.CreatedAt, tenantIDForInsert(ctx), ) return err } func (s *PGMCPServerStore) ListPendingRequests(ctx context.Context) ([]store.MCPAccessRequest, error) { + tClause, tArgs, err := tenantClauseN(ctx, 1) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT id, server_id, agent_id, user_id, scope, status, reason, tool_allow, requested_by, reviewed_by, reviewed_at, review_note, created_at - FROM mcp_access_requests WHERE status = 'pending' ORDER BY created_at`) + FROM mcp_access_requests WHERE status = 'pending'`+tClause+` ORDER BY created_at`, + tArgs...) if err != nil { return nil, err } @@ -266,9 +302,14 @@ func (s *PGMCPServerStore) ReviewRequest(ctx context.Context, requestID uuid.UUI var req store.MCPAccessRequest var agentID *uuid.UUID var userID *string + tClause, tArgs, err2 := tenantClauseN(ctx, 2) + if err2 != nil { + return err2 + } err = tx.QueryRowContext(ctx, `SELECT id, server_id, agent_id, user_id, scope, status, tool_allow - FROM mcp_access_requests WHERE id = $1 AND status = 'pending'`, requestID, + FROM mcp_access_requests WHERE id = $1 AND status = 'pending'`+tClause, + append([]any{requestID}, tArgs...)..., ).Scan(&req.ID, &req.ServerID, &agentID, &userID, &req.Scope, &req.Status, &req.ToolAllow) if err != nil { return fmt.Errorf("request not found or not pending: %w", err) @@ -297,20 +338,20 @@ func (s *PGMCPServerStore) ReviewRequest(ctx context.Context, requestID uuid.UUI return fmt.Errorf("agent_id required for agent scope") } _, err = tx.ExecContext(ctx, - `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, granted_by, created_at) - VALUES ($1,$2,$3,true,$4,$5,$6) + `INSERT INTO mcp_agent_grants (id, server_id, agent_id, enabled, tool_allow, granted_by, created_at, tenant_id) + VALUES ($1,$2,$3,true,$4,$5,$6,$7) ON CONFLICT (server_id, agent_id) DO UPDATE SET enabled = true, tool_allow = EXCLUDED.tool_allow, granted_by = EXCLUDED.granted_by`, - store.GenNewID(), req.ServerID, *agentID, jsonOrNull(req.ToolAllow), reviewedBy, now, + store.GenNewID(), req.ServerID, *agentID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx), ) case "user": if userID == nil || *userID == "" { return fmt.Errorf("user_id required for user scope") } _, err = tx.ExecContext(ctx, - `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, granted_by, created_at) - VALUES ($1,$2,$3,true,$4,$5,$6) + `INSERT INTO mcp_user_grants (id, server_id, user_id, enabled, tool_allow, granted_by, created_at, tenant_id) + VALUES ($1,$2,$3,true,$4,$5,$6,$7) ON CONFLICT (server_id, user_id) DO UPDATE SET enabled = true, tool_allow = EXCLUDED.tool_allow, granted_by = EXCLUDED.granted_by`, - store.GenNewID(), req.ServerID, *userID, jsonOrNull(req.ToolAllow), reviewedBy, now, + store.GenNewID(), req.ServerID, *userID, jsonOrNull(req.ToolAllow), reviewedBy, now, tenantIDForInsert(ctx), ) default: return fmt.Errorf("unknown scope: %s", req.Scope) diff --git a/internal/store/pg/mcp_user_credentials.go b/internal/store/pg/mcp_user_credentials.go new file mode 100644 index 00000000..06bda59c --- /dev/null +++ b/internal/store/pg/mcp_user_credentials.go @@ -0,0 +1,100 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// GetUserCredentials returns per-user credential overrides for an MCP server. +// Returns (nil, nil) if no per-user credentials exist. +func (s *PGMCPServerStore) GetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) (*store.MCPUserCredentials, error) { + tid := tenantIDForInsert(ctx) + var apiKey sql.NullString + var headersEnc, envEnc []byte + + err := s.db.QueryRowContext(ctx, + `SELECT api_key, headers, env FROM mcp_user_credentials + WHERE server_id = $1 AND user_id = $2 AND tenant_id = $3`, + serverID, userID, tid, + ).Scan(&apiKey, &headersEnc, &envEnc) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + + creds := &store.MCPUserCredentials{} + if apiKey.Valid && apiKey.String != "" && s.encKey != "" { + if dec, err := crypto.Decrypt(apiKey.String, s.encKey); err == nil { + creds.APIKey = dec + } + } else if apiKey.Valid { + creds.APIKey = apiKey.String + } + if len(headersEnc) > 0 { + dec := s.decryptJSONB(headersEnc) + json.Unmarshal(dec, &creds.Headers) + } + if len(envEnc) > 0 { + dec := s.decryptJSONB(envEnc) + json.Unmarshal(dec, &creds.Env) + } + + return creds, nil +} + +// SetUserCredentials creates or updates per-user MCP credentials. +func (s *PGMCPServerStore) SetUserCredentials(ctx context.Context, serverID uuid.UUID, userID string, creds store.MCPUserCredentials) error { + tid := tenantIDForInsert(ctx) + + var apiKeyEnc sql.NullString + if creds.APIKey != "" && s.encKey != "" { + enc, err := crypto.Encrypt(creds.APIKey, s.encKey) + if err != nil { + return fmt.Errorf("encrypt mcp user api_key: %w", err) + } + apiKeyEnc = sql.NullString{String: enc, Valid: true} + } else if creds.APIKey != "" { + apiKeyEnc = sql.NullString{String: creds.APIKey, Valid: true} + } + + var headersEnc, envEnc []byte + if len(creds.Headers) > 0 { + raw, _ := json.Marshal(creds.Headers) + headersEnc = s.encryptJSONB(raw) + } + if len(creds.Env) > 0 { + raw, _ := json.Marshal(creds.Env) + envEnc = s.encryptJSONB(raw) + } + + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO mcp_user_credentials (id, server_id, user_id, api_key, headers, env, tenant_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) + ON CONFLICT (server_id, user_id, tenant_id) DO UPDATE SET + api_key = $4, headers = $5, env = $6, updated_at = $8`, + uuid.Must(uuid.NewV7()), serverID, userID, apiKeyEnc, headersEnc, envEnc, tid, now, + ) + return err +} + +// DeleteUserCredentials removes per-user MCP credentials. +func (s *PGMCPServerStore) DeleteUserCredentials(ctx context.Context, serverID uuid.UUID, userID string) error { + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, + `DELETE FROM mcp_user_credentials WHERE server_id = $1 AND user_id = $2 AND tenant_id = $3`, + serverID, userID, tid, + ) + return err +} diff --git a/internal/store/pg/memory_admin.go b/internal/store/pg/memory_admin.go index 2adf19db..45764dc2 100644 --- a/internal/store/pg/memory_admin.go +++ b/internal/store/pg/memory_admin.go @@ -9,10 +9,20 @@ import ( // ListAllDocumentsGlobal returns all documents across all agents (for admin overview). func (s *PGMemoryStore) ListAllDocumentsGlobal(ctx context.Context) ([]store.DocumentInfo, error) { + var whereClause string + var args []any + if !store.IsCrossTenant(ctx) { + tid, err := requireTenantID(ctx) + if err != nil { + return nil, err + } + whereClause = "WHERE tenant_id = $1" + args = []any{tid} + } rows, err := s.db.QueryContext(ctx, `SELECT agent_id, path, hash, user_id, updated_at - FROM memory_documents - ORDER BY updated_at DESC`) + FROM memory_documents `+whereClause+` + ORDER BY updated_at DESC`, args...) if err != nil { return nil, err } @@ -43,10 +53,14 @@ func (s *PGMemoryStore) ListAllDocumentsGlobal(ctx context.Context) ([]store.Doc // ListAllDocuments returns all documents for an agent across all users (global + personal). func (s *PGMemoryStore) ListAllDocuments(ctx context.Context, agentID string) ([]store.DocumentInfo, error) { aid := mustParseUUID(agentID) + tc, tcArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT agent_id, path, hash, user_id, updated_at - FROM memory_documents WHERE agent_id = $1 - ORDER BY updated_at DESC`, aid) + FROM memory_documents WHERE agent_id = $1`+tc+` + ORDER BY updated_at DESC`, append([]any{aid}, tcArgs...)...) if err != nil { return nil, err } @@ -81,23 +95,31 @@ func (s *PGMemoryStore) GetDocumentDetail(ctx context.Context, agentID, userID, var q string var args []any if userID == "" { + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at, COUNT(c.id) AS chunk_count, COUNT(c.embedding) AS embedded_count FROM memory_documents d LEFT JOIN memory_chunks c ON c.document_id = d.id - WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL + WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL` + tc + ` GROUP BY d.id` - args = []any{aid, path} + args = append([]any{aid, path}, tcArgs...) } else { + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return nil, err + } q = `SELECT d.path, d.content, d.hash, d.user_id, d.created_at, d.updated_at, COUNT(c.id) AS chunk_count, COUNT(c.embedding) AS embedded_count FROM memory_documents d LEFT JOIN memory_chunks c ON c.document_id = d.id - WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3 + WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3` + tc + ` GROUP BY d.id` - args = []any{aid, path, userID} + args = append([]any{aid, path, userID}, tcArgs...) } var detail store.DocumentDetail @@ -126,23 +148,31 @@ func (s *PGMemoryStore) ListChunks(ctx context.Context, agentID, userID, path st var q string var args []any if userID == "" { + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } q = `SELECT c.id, c.start_line, c.end_line, c.text AS text_preview, (c.embedding IS NOT NULL) AS has_embedding FROM memory_chunks c JOIN memory_documents d ON c.document_id = d.id - WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL + WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id IS NULL` + tc + ` ORDER BY c.start_line` - args = []any{aid, path} + args = append([]any{aid, path}, tcArgs...) } else { + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return nil, err + } q = `SELECT c.id, c.start_line, c.end_line, c.text AS text_preview, (c.embedding IS NOT NULL) AS has_embedding FROM memory_chunks c JOIN memory_documents d ON c.document_id = d.id - WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3 + WHERE d.agent_id = $1 AND d.path = $2 AND d.user_id = $3` + tc + ` ORDER BY c.start_line` - args = []any{aid, path, userID} + args = append([]any{aid, path, userID}, tcArgs...) } rows, err := s.db.QueryContext(ctx, q, args...) diff --git a/internal/store/pg/memory_docs.go b/internal/store/pg/memory_docs.go index 846216a6..c06f551b 100644 --- a/internal/store/pg/memory_docs.go +++ b/internal/store/pg/memory_docs.go @@ -48,13 +48,21 @@ func (s *PGMemoryStore) GetDocument(ctx context.Context, agentID, userID, path s var err error if userID == "" { + tc, tcArgs, tcErr := tenantClauseN(ctx, 3) + if tcErr != nil { + return "", tcErr + } err = s.db.QueryRowContext(ctx, - "SELECT content FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL", - aid, path).Scan(&content) + "SELECT content FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL"+tc, + append([]any{aid, path}, tcArgs...)...).Scan(&content) } else { + tc, tcArgs, tcErr := tenantClauseN(ctx, 4) + if tcErr != nil { + return "", tcErr + } err = s.db.QueryRowContext(ctx, - "SELECT content FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3", - aid, path, userID).Scan(&content) + "SELECT content FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3"+tc, + append([]any{aid, path, userID}, tcArgs...)...).Scan(&content) } if err != nil { return "", err @@ -67,6 +75,7 @@ func (s *PGMemoryStore) PutDocument(ctx context.Context, agentID, userID, path, hash := memory.ContentHash(content) id := uuid.Must(uuid.NewV7()) now := time.Now() + tid := tenantIDForInsert(ctx) var uid *string if userID != "" { @@ -74,11 +83,11 @@ func (s *PGMemoryStore) PutDocument(ctx context.Context, agentID, userID, path, } _, err := s.db.ExecContext(ctx, - `INSERT INTO memory_documents (id, agent_id, user_id, path, content, hash, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO memory_documents (id, agent_id, user_id, path, content, hash, tenant_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (agent_id, COALESCE(user_id, ''), path) - DO UPDATE SET content = EXCLUDED.content, hash = EXCLUDED.hash, updated_at = EXCLUDED.updated_at`, - id, aid, uid, path, content, hash, now, + DO UPDATE SET content = EXCLUDED.content, hash = EXCLUDED.hash, tenant_id = EXCLUDED.tenant_id, updated_at = EXCLUDED.updated_at`, + id, aid, uid, path, content, hash, tid, now, ) return err } @@ -88,13 +97,21 @@ func (s *PGMemoryStore) DeleteDocument(ctx context.Context, agentID, userID, pat var res sql.Result var err error if userID == "" { + tc, tcArgs, tcErr := tenantClauseN(ctx, 3) + if tcErr != nil { + return tcErr + } res, err = s.db.ExecContext(ctx, - "DELETE FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL", - aid, path) + "DELETE FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL"+tc, + append([]any{aid, path}, tcArgs...)...) } else { + tc, tcArgs, tcErr := tenantClauseN(ctx, 4) + if tcErr != nil { + return tcErr + } res, err = s.db.ExecContext(ctx, - "DELETE FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3", - aid, path, userID) + "DELETE FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3"+tc, + append([]any{aid, path, userID}, tcArgs...)...) } if err != nil { return err @@ -112,11 +129,21 @@ func (s *PGMemoryStore) ListDocuments(ctx context.Context, agentID, userID strin var rows *sql.Rows var err error if userID == "" { + tc, tcArgs, tcErr := tenantClauseN(ctx, 2) + if tcErr != nil { + return nil, tcErr + } rows, err = s.db.QueryContext(ctx, - "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = $1 AND user_id IS NULL", aid) + "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = $1 AND user_id IS NULL"+tc, + append([]any{aid}, tcArgs...)...) } else { + tc, tcArgs, tcErr := tenantClauseN(ctx, 3) + if tcErr != nil { + return nil, tcErr + } rows, err = s.db.QueryContext(ctx, - "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = $1 AND (user_id IS NULL OR user_id = $2)", aid, userID) + "SELECT path, hash, user_id, updated_at FROM memory_documents WHERE agent_id = $1 AND (user_id IS NULL OR user_id = $2)"+tc, + append([]any{aid, userID}, tcArgs...)...) } if err != nil { return nil, err @@ -157,13 +184,21 @@ func (s *PGMemoryStore) IndexDocument(ctx context.Context, agentID, userID, path // Get document ID var docID uuid.UUID if userID == "" { + tc, tcArgs, tcErr := tenantClauseN(ctx, 3) + if tcErr != nil { + return tcErr + } err = s.db.QueryRowContext(ctx, - "SELECT id FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL", - aid, path).Scan(&docID) + "SELECT id FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id IS NULL"+tc, + append([]any{aid, path}, tcArgs...)...).Scan(&docID) } else { + tc, tcArgs, tcErr := tenantClauseN(ctx, 4) + if tcErr != nil { + return tcErr + } err = s.db.QueryRowContext(ctx, - "SELECT id FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3", - aid, path, userID).Scan(&docID) + "SELECT id FROM memory_documents WHERE agent_id = $1 AND path = $2 AND user_id = $3"+tc, + append([]any{aid, path, userID}, tcArgs...)...).Scan(&docID) } if err != nil { return err @@ -268,6 +303,7 @@ func (s *PGMemoryStore) IndexDocument(ctx context.Context, agentID, userID, path } // Insert chunks + tid := tenantIDForInsert(ctx) for i, tc := range chunks { hash := memory.ContentHash(tc.Text) chunkID := uuid.Must(uuid.NewV7()) @@ -281,17 +317,17 @@ func (s *PGMemoryStore) IndexDocument(ctx context.Context, agentID, userID, path if embeddings != nil && i < len(embeddings) && embeddings[i] != nil { // Insert with embedding via raw SQL (pgvector) s.db.ExecContext(ctx, - `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, embedding, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::vector, $11)`, + `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, embedding, tenant_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::vector, $11, $12)`, chunkID, aid, docID, uid, path, tc.StartLine, tc.EndLine, hash, tc.Text, - vectorToString(embeddings[i]), now, + vectorToString(embeddings[i]), tid, now, ) } else { s.db.ExecContext(ctx, - `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `INSERT INTO memory_chunks (id, agent_id, document_id, user_id, path, start_line, end_line, hash, text, tenant_id, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT DO NOTHING`, - chunkID, aid, docID, uid, path, tc.StartLine, tc.EndLine, hash, tc.Text, now, + chunkID, aid, docID, uid, path, tc.StartLine, tc.EndLine, hash, tc.Text, tid, now, ) } } diff --git a/internal/store/pg/memory_embedding_cache.go b/internal/store/pg/memory_embedding_cache.go index a0e2070e..e43e04a4 100644 --- a/internal/store/pg/memory_embedding_cache.go +++ b/internal/store/pg/memory_embedding_cache.go @@ -67,6 +67,7 @@ func (s *PGMemoryStore) writeEmbeddingCache(ctx context.Context, entries []embed } now := time.Now() + tenantID := tenantIDForInsert(ctx) // Process in batches of 100 to avoid exceeding max query params const batchSize = 100 @@ -78,16 +79,16 @@ func (s *PGMemoryStore) writeEmbeddingCache(ctx context.Context, entries []embed batch := entries[start:end] var sb strings.Builder - sb.WriteString(`INSERT INTO embedding_cache (hash, provider, model, embedding, dims, created_at, updated_at) VALUES `) - args := make([]any, 0, len(batch)*6) + sb.WriteString(`INSERT INTO embedding_cache (hash, provider, model, embedding, dims, created_at, updated_at, tenant_id) VALUES `) + args := make([]any, 0, len(batch)*7) for i, e := range batch { if i > 0 { sb.WriteByte(',') } - base := i * 6 - fmt.Fprintf(&sb, "($%d,$%d,$%d,$%d::vector,$%d,$%d,$%d)", - base+1, base+2, base+3, base+4, base+5, base+6, base+6) - args = append(args, e.Hash, provider, model, vectorToString(e.Embedding), len(e.Embedding), now) + base := i * 7 + fmt.Fprintf(&sb, "($%d,$%d,$%d,$%d::vector,$%d,$%d,$%d,$%d)", + base+1, base+2, base+3, base+4, base+5, base+6, base+6, base+7) + args = append(args, e.Hash, provider, model, vectorToString(e.Embedding), len(e.Embedding), now, tenantID) } sb.WriteString(` ON CONFLICT (hash, provider, model) DO UPDATE SET embedding = EXCLUDED.embedding, dims = EXCLUDED.dims, updated_at = EXCLUDED.updated_at`) diff --git a/internal/store/pg/memory_search.go b/internal/store/pg/memory_search.go index aec71906..6d55585c 100644 --- a/internal/store/pg/memory_search.go +++ b/internal/store/pg/memory_search.go @@ -2,6 +2,7 @@ package pg import ( "context" + "fmt" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -81,21 +82,37 @@ func (s *PGMemoryStore) ftsSearch(ctx context.Context, query string, agentID any var args []any if userID != "" { - q = `SELECT path, start_line, end_line, text, user_id, + // fixed params: $1=query, $2=agentID, $3=query, $4=userID + // tenant clause appended at $5 (if filtered), then LIMIT at $5 or $6 + tc, tcArgs, err := tenantClauseN(ctx, 5) + if err != nil { + return nil, err + } + limitN := 5 + len(tcArgs) + q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id, ts_rank(tsv, plainto_tsquery('simple', $1)) AS score FROM memory_chunks WHERE agent_id = $2 AND tsv @@ plainto_tsquery('simple', $3) - AND (user_id IS NULL OR user_id = $4) - ORDER BY score DESC LIMIT $5` - args = []any{query, agentID, query, userID, limit} + AND (user_id IS NULL OR user_id = $4)%s + ORDER BY score DESC LIMIT $%d`, tc, limitN) + args = append([]any{query, agentID, query, userID}, tcArgs...) + args = append(args, limit) } else { - q = `SELECT path, start_line, end_line, text, user_id, + // fixed params: $1=query, $2=agentID, $3=query + // tenant clause at $4 (if filtered), then LIMIT at $4 or $5 + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return nil, err + } + limitN := 4 + len(tcArgs) + q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id, ts_rank(tsv, plainto_tsquery('simple', $1)) AS score FROM memory_chunks WHERE agent_id = $2 AND tsv @@ plainto_tsquery('simple', $3) - AND user_id IS NULL - ORDER BY score DESC LIMIT $4` - args = []any{query, agentID, query, limit} + AND user_id IS NULL%s + ORDER BY score DESC LIMIT $%d`, tc, limitN) + args = append([]any{query, agentID, query}, tcArgs...) + args = append(args, limit) } rows, err := s.db.QueryContext(ctx, q, args...) @@ -120,21 +137,39 @@ func (s *PGMemoryStore) vectorSearch(ctx context.Context, embedding []float32, a var args []any if userID != "" { - q = `SELECT path, start_line, end_line, text, user_id, + // fixed params: $1=vec, $2=agentID, $3=userID + // tenant clause at $4, then ORDER vec at $4+len(tcArgs), LIMIT after + tc, tcArgs, err := tenantClauseN(ctx, 4) + if err != nil { + return nil, err + } + orderN := 4 + len(tcArgs) + limitN := orderN + 1 + q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id, 1 - (embedding <=> $1::vector) AS score FROM memory_chunks WHERE agent_id = $2 AND embedding IS NOT NULL - AND (user_id IS NULL OR user_id = $3) - ORDER BY embedding <=> $4::vector LIMIT $5` - args = []any{vecStr, agentID, userID, vecStr, limit} + AND (user_id IS NULL OR user_id = $3)%s + ORDER BY embedding <=> $%d::vector LIMIT $%d`, tc, orderN, limitN) + args = append([]any{vecStr, agentID, userID}, tcArgs...) + args = append(args, vecStr, limit) } else { - q = `SELECT path, start_line, end_line, text, user_id, + // fixed params: $1=vec, $2=agentID + // tenant clause at $3, then ORDER vec at $3+len(tcArgs), LIMIT after + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } + orderN := 3 + len(tcArgs) + limitN := orderN + 1 + q = fmt.Sprintf(`SELECT path, start_line, end_line, text, user_id, 1 - (embedding <=> $1::vector) AS score FROM memory_chunks WHERE agent_id = $2 AND embedding IS NOT NULL - AND user_id IS NULL - ORDER BY embedding <=> $3::vector LIMIT $4` - args = []any{vecStr, agentID, vecStr, limit} + AND user_id IS NULL%s + ORDER BY embedding <=> $%d::vector LIMIT $%d`, tc, orderN, limitN) + args = append([]any{vecStr, agentID}, tcArgs...) + args = append(args, vecStr, limit) } rows, err := s.db.QueryContext(ctx, q, args...) diff --git a/internal/store/pg/pairing.go b/internal/store/pg/pairing.go index dc040b3b..aedde459 100644 --- a/internal/store/pg/pairing.go +++ b/internal/store/pg/pairing.go @@ -1,6 +1,7 @@ package pg import ( + "context" "crypto/rand" "database/sql" "encoding/json" @@ -35,20 +36,22 @@ func (s *PGPairingStore) SetOnRequest(cb func(code, senderID, channel, chatID st s.onRequest = cb } -func (s *PGPairingStore) RequestPairing(senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) { - // Prune expired - s.db.Exec("DELETE FROM pairing_requests WHERE expires_at < $1", time.Now()) +func (s *PGPairingStore) RequestPairing(ctx context.Context, senderID, channel, chatID, accountID string, metadata map[string]string) (string, error) { + tid := tenantIDForInsert(ctx) - // Check max pending + // Prune expired + s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < $1", time.Now()) + + // Check max pending (per tenant) var count int64 - s.db.QueryRow("SELECT COUNT(*) FROM pairing_requests WHERE account_id = $1", accountID).Scan(&count) + s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM pairing_requests WHERE account_id = $1 AND tenant_id = $2", accountID, tid).Scan(&count) if count >= maxPendingPerAccount { return "", fmt.Errorf("max pending pairing requests (%d) exceeded", maxPendingPerAccount) } - // Check existing + // Check existing (per tenant) var existingCode string - err := s.db.QueryRow("SELECT code FROM pairing_requests WHERE sender_id = $1 AND channel = $2", senderID, channel).Scan(&existingCode) + err := s.db.QueryRowContext(ctx, "SELECT code FROM pairing_requests WHERE sender_id = $1 AND channel = $2 AND tenant_id = $3", senderID, channel, tid).Scan(&existingCode) if err == nil { return existingCode, nil } @@ -60,10 +63,10 @@ func (s *PGPairingStore) RequestPairing(senderID, channel, chatID, accountID str code := generatePairingCode() now := time.Now() - _, err = s.db.Exec( - `INSERT INTO pairing_requests (id, code, sender_id, channel, chat_id, account_id, expires_at, created_at, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL), now, metaJSON, + _, err = s.db.ExecContext(ctx, + `INSERT INTO pairing_requests (id, code, sender_id, channel, chat_id, account_id, expires_at, created_at, metadata, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + uuid.Must(uuid.NewV7()), code, senderID, channel, chatID, accountID, now.Add(codeTTL), now, metaJSON, tid, ) if err != nil { return "", fmt.Errorf("create pairing request: %w", err) @@ -74,30 +77,34 @@ func (s *PGPairingStore) RequestPairing(senderID, channel, chatID, accountID str return code, nil } -func (s *PGPairingStore) ApprovePairing(code, approvedBy string) (*store.PairedDeviceData, error) { +// ApprovePairing looks up by code (globally unique random token) and creates paired device. +// The approver's tenant context determines paired_devices.tenant_id. +func (s *PGPairingStore) ApprovePairing(ctx context.Context, code, approvedBy string) (*store.PairedDeviceData, error) { // Prune expired - s.db.Exec("DELETE FROM pairing_requests WHERE expires_at < $1", time.Now()) + s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < $1", time.Now()) var reqID uuid.UUID var senderID, channel, chatID string var metaJSON []byte - err := s.db.QueryRow( - "SELECT id, sender_id, channel, chat_id, COALESCE(metadata, '{}') FROM pairing_requests WHERE code = $1", code, - ).Scan(&reqID, &senderID, &channel, &chatID, &metaJSON) + var reqTenantID uuid.UUID + // Code lookup is cross-tenant (random token, approver is WS/HTTP user) + err := s.db.QueryRowContext(ctx, + "SELECT id, sender_id, channel, chat_id, COALESCE(metadata, '{}'), tenant_id FROM pairing_requests WHERE code = $1", code, + ).Scan(&reqID, &senderID, &channel, &chatID, &metaJSON, &reqTenantID) if err != nil { return nil, fmt.Errorf("pairing code %s not found or expired", code) } // Remove from pending - s.db.Exec("DELETE FROM pairing_requests WHERE id = $1", reqID) + s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE id = $1", reqID) - // Add to paired (with expiry for defense-in-depth) + // Add to paired — use the request's tenant (the channel that initiated pairing) now := time.Now() expiresAt := now.Add(pairedDeviceTTL) - _, err = s.db.Exec( - `INSERT INTO paired_devices (id, sender_id, channel, chat_id, paired_by, paired_at, metadata, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - uuid.Must(uuid.NewV7()), senderID, channel, chatID, approvedBy, now, metaJSON, expiresAt, + _, err = s.db.ExecContext(ctx, + `INSERT INTO paired_devices (id, sender_id, channel, chat_id, paired_by, paired_at, metadata, expires_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + uuid.Must(uuid.NewV7()), senderID, channel, chatID, approvedBy, now, metaJSON, expiresAt, reqTenantID, ) if err != nil { return nil, fmt.Errorf("create paired device: %w", err) @@ -118,8 +125,9 @@ func (s *PGPairingStore) ApprovePairing(code, approvedBy string) (*store.PairedD }, nil } -func (s *PGPairingStore) DenyPairing(code string) error { - result, err := s.db.Exec("DELETE FROM pairing_requests WHERE code = $1", code) +func (s *PGPairingStore) DenyPairing(ctx context.Context, code string) error { + // Code lookup is cross-tenant (random token) + result, err := s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE code = $1", code) if err != nil { return err } @@ -130,8 +138,9 @@ func (s *PGPairingStore) DenyPairing(code string) error { return nil } -func (s *PGPairingStore) RevokePairing(senderID, channel string) error { - result, err := s.db.Exec("DELETE FROM paired_devices WHERE sender_id = $1 AND channel = $2", senderID, channel) +func (s *PGPairingStore) RevokePairing(ctx context.Context, senderID, channel string) error { + tid := tenantIDForInsert(ctx) + result, err := s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE sender_id = $1 AND channel = $2 AND tenant_id = $3", senderID, channel, tid) if err != nil { return err } @@ -142,11 +151,12 @@ func (s *PGPairingStore) RevokePairing(senderID, channel string) error { return nil } -func (s *PGPairingStore) IsPaired(senderID, channel string) (bool, error) { +func (s *PGPairingStore) IsPaired(ctx context.Context, senderID, channel string) (bool, error) { + tid := tenantIDForInsert(ctx) var count int64 - err := s.db.QueryRow( - "SELECT COUNT(*) FROM paired_devices WHERE sender_id = $1 AND channel = $2 AND (expires_at IS NULL OR expires_at > NOW())", - senderID, channel, + err := s.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM paired_devices WHERE sender_id = $1 AND channel = $2 AND tenant_id = $3 AND (expires_at IS NULL OR expires_at > NOW())", + senderID, channel, tid, ).Scan(&count) if err != nil { return false, fmt.Errorf("pairing check query: %w", err) @@ -154,12 +164,15 @@ func (s *PGPairingStore) IsPaired(senderID, channel string) (bool, error) { return count > 0, nil } -func (s *PGPairingStore) ListPending() []store.PairingRequestData { - // Prune expired - s.db.Exec("DELETE FROM pairing_requests WHERE expires_at < $1", time.Now()) +func (s *PGPairingStore) ListPending(ctx context.Context) []store.PairingRequestData { + tid := tenantIDForInsert(ctx) - rows, err := s.db.Query( - "SELECT code, sender_id, channel, chat_id, account_id, created_at, expires_at, COALESCE(metadata, '{}') FROM pairing_requests ORDER BY created_at DESC") + // Prune expired + s.db.ExecContext(ctx, "DELETE FROM pairing_requests WHERE expires_at < $1", time.Now()) + + rows, err := s.db.QueryContext(ctx, + `SELECT code, sender_id, channel, chat_id, account_id, created_at, expires_at, COALESCE(metadata, '{}') + FROM pairing_requests WHERE tenant_id = $1 ORDER BY created_at DESC`, tid) if err != nil { return nil } @@ -186,11 +199,15 @@ func (s *PGPairingStore) ListPending() []store.PairingRequestData { return result } -func (s *PGPairingStore) ListPaired() []store.PairedDeviceData { - // Prune expired paired devices - s.db.Exec("DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < NOW()") +func (s *PGPairingStore) ListPaired(ctx context.Context) []store.PairedDeviceData { + tid := tenantIDForInsert(ctx) - rows, err := s.db.Query("SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}') FROM paired_devices ORDER BY paired_at DESC") + // Prune expired paired devices + s.db.ExecContext(ctx, "DELETE FROM paired_devices WHERE expires_at IS NOT NULL AND expires_at < NOW()") + + rows, err := s.db.QueryContext(ctx, + `SELECT sender_id, channel, chat_id, paired_by, paired_at, COALESCE(metadata, '{}') + FROM paired_devices WHERE tenant_id = $1 ORDER BY paired_at DESC`, tid) if err != nil { return nil } diff --git a/internal/store/pg/pending_message_store.go b/internal/store/pg/pending_message_store.go index 4736a480..ec340fa4 100644 --- a/internal/store/pg/pending_message_store.go +++ b/internal/store/pg/pending_message_store.go @@ -27,25 +27,26 @@ func (s *PGPendingMessageStore) AppendBatch(ctx context.Context, msgs []store.Pe return nil } - // Build multi-row INSERT: VALUES ($1,$2,...,$10), ($11,$12,...,$20), ... - const cols = 10 + // Build multi-row INSERT: VALUES ($1,$2,...,$11), ($12,$13,...,$22), ... + const cols = 11 placeholders := make([]string, len(msgs)) args := make([]any, 0, len(msgs)*cols) now := time.Now() + tid := tenantIDForInsert(ctx) for i := range msgs { if msgs[i].ID == uuid.Nil { msgs[i].ID = uuid.Must(uuid.NewV7()) } base := i * cols - placeholders[i] = fmt.Sprintf("($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d)", - base+1, base+2, base+3, base+4, base+5, base+6, base+7, base+8, base+9, base+10) + placeholders[i] = fmt.Sprintf("($%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d,$%d)", + base+1, base+2, base+3, base+4, base+5, base+6, base+7, base+8, base+9, base+10, base+11) args = append(args, msgs[i].ID, msgs[i].ChannelName, msgs[i].HistoryKey, - msgs[i].Sender, msgs[i].SenderID, msgs[i].Body, msgs[i].PlatformMsgID, msgs[i].IsSummary, now, now) + msgs[i].Sender, msgs[i].SenderID, msgs[i].Body, msgs[i].PlatformMsgID, msgs[i].IsSummary, now, now, tid) } _, err := s.db.ExecContext(ctx, - `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at) + `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at, tenant_id) VALUES `+strings.Join(placeholders, ","), args..., ) @@ -53,12 +54,16 @@ func (s *PGPendingMessageStore) AppendBatch(ctx context.Context, msgs []store.Pe } func (s *PGPendingMessageStore) ListByKey(ctx context.Context, channelName, historyKey string) ([]store.PendingMessage, error) { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, `SELECT id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at FROM channel_pending_messages - WHERE channel_name = $1 AND history_key = $2 + WHERE channel_name = $1 AND history_key = $2`+tClause+` ORDER BY created_at ASC`, - channelName, historyKey, + append([]any{channelName, historyKey}, tArgs...)..., ) if err != nil { return nil, err @@ -77,9 +82,13 @@ func (s *PGPendingMessageStore) ListByKey(ctx context.Context, channelName, hist } func (s *PGPendingMessageStore) DeleteByKey(ctx context.Context, channelName, historyKey string) error { - _, err := s.db.ExecContext(ctx, - `DELETE FROM channel_pending_messages WHERE channel_name = $1 AND history_key = $2`, - channelName, historyKey, + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM channel_pending_messages WHERE channel_name = $1 AND history_key = $2`+tClause, + append([]any{channelName, historyKey}, tArgs...)..., ) return err } @@ -123,9 +132,9 @@ func (s *PGPendingMessageStore) Compact(ctx context.Context, deleteIDs []uuid.UU } now := time.Now() _, err = tx.ExecContext(ctx, - `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, - summary.ID, summary.ChannelName, summary.HistoryKey, summary.Sender, summary.SenderID, summary.Body, summary.PlatformMsgID, true, now, now, + `INSERT INTO channel_pending_messages (id, channel_name, history_key, sender, sender_id, body, platform_msg_id, is_summary, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + summary.ID, summary.ChannelName, summary.HistoryKey, summary.Sender, summary.SenderID, summary.Body, summary.PlatformMsgID, true, now, now, tenantIDForInsert(ctx), ) if err != nil { return fmt.Errorf("compact insert summary: %w", err) @@ -136,9 +145,10 @@ func (s *PGPendingMessageStore) Compact(ctx context.Context, deleteIDs []uuid.UU func (s *PGPendingMessageStore) DeleteStale(ctx context.Context, olderThan time.Duration) (int64, error) { cutoff := time.Now().Add(-olderThan) + tid := tenantIDForInsert(ctx) result, err := s.db.ExecContext(ctx, - `DELETE FROM channel_pending_messages WHERE updated_at < $1`, - cutoff, + `DELETE FROM channel_pending_messages WHERE updated_at < $1 AND tenant_id = $2`, + cutoff, tid, ) if err != nil { return 0, err @@ -147,6 +157,14 @@ func (s *PGPendingMessageStore) DeleteStale(ctx context.Context, olderThan time. } func (s *PGPendingMessageStore) ListGroups(ctx context.Context) ([]store.PendingMessageGroup, error) { + tClause, tArgs, err := tenantClauseN(ctx, 1) + if err != nil { + return nil, err + } + var where string + if tClause != "" { + where = ` WHERE m.tenant_id = $1` + } rows, err := s.db.QueryContext(ctx, `SELECT channel_name, history_key, COUNT(*) AS message_count, @@ -165,9 +183,10 @@ func (s *PGPendingMessageStore) ListGroups(ctx context.Context) ([]store.Pending ) ) AS has_summary, MAX(created_at) AS last_activity - FROM channel_pending_messages m + FROM channel_pending_messages m`+where+` GROUP BY channel_name, history_key ORDER BY last_activity DESC`, + tArgs..., ) if err != nil { return nil, err @@ -186,16 +205,30 @@ func (s *PGPendingMessageStore) ListGroups(ctx context.Context) ([]store.Pending } func (s *PGPendingMessageStore) CountAll(ctx context.Context) (int64, error) { + tClause, tArgs, err := tenantClauseN(ctx, 1) + if err != nil { + return 0, err + } var count int64 - err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM channel_pending_messages`).Scan(&count) + var query string + if tClause != "" { + query = `SELECT COUNT(*) FROM channel_pending_messages WHERE tenant_id = $1` + } else { + query = `SELECT COUNT(*) FROM channel_pending_messages` + } + err = s.db.QueryRowContext(ctx, query, tArgs...).Scan(&count) return count, err } func (s *PGPendingMessageStore) CountByKey(ctx context.Context, channelName, historyKey string) (int, error) { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return 0, err + } var count int - err := s.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM channel_pending_messages WHERE channel_name = $1 AND history_key = $2`, - channelName, historyKey, + err = s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM channel_pending_messages WHERE channel_name = $1 AND history_key = $2`+tClause, + append([]any{channelName, historyKey}, tArgs...)..., ).Scan(&count) return count, err } @@ -216,11 +249,22 @@ func (s *PGPendingMessageStore) ResolveGroupTitles(ctx context.Context, groups [ args = append(args, g.ChannelName, g.HistoryKey) } + tenantFilter := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + argIdx := len(args) + 1 + tenantFilter = fmt.Sprintf(" AND tenant_id = $%d", argIdx) + args = append(args, tid) + } + rows, err := s.db.QueryContext(ctx, - `SELECT session_key, metadata->>'chat_title' - FROM sessions - WHERE metadata->>'chat_title' != '' - AND (`+strings.Join(conditions, " OR ")+`)`, + "SELECT session_key, metadata->>'chat_title'"+ + " FROM sessions"+ + " WHERE metadata->>'chat_title' != ''"+ + " AND ("+strings.Join(conditions, " OR ")+")"+tenantFilter, args..., ) if err != nil { diff --git a/internal/store/pg/providers.go b/internal/store/pg/providers.go index 4db4aa26..18b10c35 100644 --- a/internal/store/pg/providers.go +++ b/internal/store/pg/providers.go @@ -50,21 +50,28 @@ func (s *PGProviderStore) CreateProvider(ctx context.Context, p *store.LLMProvid now := time.Now() p.CreatedAt = now p.UpdatedAt = now + tid := tenantIDForInsert(ctx) + p.TenantID = tid _, err := s.db.ExecContext(ctx, - `INSERT INTO llm_providers (id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, - p.ID, p.Name, p.DisplayName, p.ProviderType, p.APIBase, apiKey, p.Enabled, settings, now, now, + `INSERT INTO llm_providers (id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + p.ID, p.Name, p.DisplayName, p.ProviderType, p.APIBase, apiKey, p.Enabled, settings, now, now, tid, ) return err } func (s *PGProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store.LLMProviderData, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } var p store.LLMProviderData var apiKey string - err := s.db.QueryRowContext(ctx, - `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at - FROM llm_providers WHERE id = $1`, id, - ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt) + err = s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id + FROM llm_providers WHERE id = $1`+tClause, + append([]any{id}, tArgs...)..., + ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt, &p.TenantID) if err != nil { return nil, fmt.Errorf("provider not found: %s", id) } @@ -73,12 +80,17 @@ func (s *PGProviderStore) GetProvider(ctx context.Context, id uuid.UUID) (*store } func (s *PGProviderStore) GetProviderByName(ctx context.Context, name string) (*store.LLMProviderData, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } var p store.LLMProviderData var apiKey string - err := s.db.QueryRowContext(ctx, - `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at - FROM llm_providers WHERE name = $1`, name, - ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt) + err = s.db.QueryRowContext(ctx, + `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id + FROM llm_providers WHERE name = $1`+tClause, + append([]any{name}, tArgs...)..., + ).Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt, &p.TenantID) if err != nil { return nil, fmt.Errorf("provider not found: %s", name) } @@ -87,9 +99,13 @@ func (s *PGProviderStore) GetProviderByName(ctx context.Context, name string) (* } func (s *PGProviderStore) ListProviders(ctx context.Context) ([]store.LLMProviderData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at - FROM llm_providers ORDER BY name`) + tClause, tArgs, err := tenantClauseN(ctx, 1) + if err != nil { + return nil, err + } + q := `SELECT id, name, display_name, provider_type, api_base, api_key, enabled, settings, created_at, updated_at, tenant_id + FROM llm_providers WHERE true` + tClause + ` ORDER BY name` + rows, err := s.db.QueryContext(ctx, q, tArgs...) if err != nil { return nil, err } @@ -99,7 +115,7 @@ func (s *PGProviderStore) ListProviders(ctx context.Context) ([]store.LLMProvide for rows.Next() { var p store.LLMProviderData var apiKey string - if err := rows.Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Name, &p.DisplayName, &p.ProviderType, &p.APIBase, &apiKey, &p.Enabled, &p.Settings, &p.CreatedAt, &p.UpdatedAt, &p.TenantID); err != nil { continue } p.APIKey = s.decryptKey(apiKey, p.Name) @@ -118,11 +134,25 @@ func (s *PGProviderStore) UpdateProvider(ctx context.Context, id uuid.UUID, upda updates["api_key"] = encrypted } } - return execMapUpdate(ctx, s.db, "llm_providers", id, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "llm_providers", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + return execMapUpdateWhereTenant(ctx, s.db, "llm_providers", updates, id, tid) } func (s *PGProviderStore) DeleteProvider(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, "DELETE FROM llm_providers WHERE id = $1", id) + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM llm_providers WHERE id = $1"+tClause, + append([]any{id}, tArgs...)..., + ) return err } diff --git a/internal/store/pg/secure_cli.go b/internal/store/pg/secure_cli.go index 5ed8e6eb..462d173b 100644 --- a/internal/store/pg/secure_cli.go +++ b/internal/store/pg/secure_cli.go @@ -50,23 +50,37 @@ func (s *PGSecureCLIStore) Create(ctx context.Context, b *store.SecureCLIBinary) b.CreatedAt = now b.UpdatedAt = now + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, `INSERT INTO secure_cli_binaries (id, binary_name, binary_path, description, encrypted_env, - deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + deny_args, deny_verbose, timeout_seconds, tips, agent_id, enabled, created_by, created_at, updated_at, tenant_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, b.ID, b.BinaryName, nilStr(derefStr(b.BinaryPath)), b.Description, envBytes, jsonOrEmptyArray(b.DenyArgs), jsonOrEmptyArray(b.DenyVerbose), b.TimeoutSeconds, b.Tips, nilUUID(b.AgentID), b.Enabled, - b.CreatedBy, now, now, + b.CreatedBy, now, now, tenantID, ) return err } func (s *PGSecureCLIStore) Get(ctx context.Context, id uuid.UUID) (*store.SecureCLIBinary, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries WHERE id = $1`, id) + return s.scanRow(row) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } row := s.db.QueryRowContext(ctx, - `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries WHERE id = $1`, id) + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries WHERE id = $1 AND tenant_id = $2`, id, tenantID) return s.scanRow(row) } @@ -179,17 +193,42 @@ func (s *PGSecureCLIStore) Update(ctx context.Context, id uuid.UUID, updates map } } updates["updated_at"] = time.Now() - return execMapUpdate(ctx, s.db, "secure_cli_binaries", id, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "secure_cli_binaries", id, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "secure_cli_binaries", updates, id, tid) } func (s *PGSecureCLIStore) Delete(ctx context.Context, id uuid.UUID) error { - _, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_binaries WHERE id = $1", id) + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_binaries WHERE id = $1", id) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + _, err := s.db.ExecContext(ctx, "DELETE FROM secure_cli_binaries WHERE id = $1 AND tenant_id = $2", id, tid) return err } func (s *PGSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries ORDER BY binary_name, agent_id NULLS LAST`) + query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + query += ` WHERE tenant_id = $1` + qArgs = append(qArgs, tenantID) + } + query += ` ORDER BY binary_name, agent_id NULLS LAST` + rows, err := s.db.QueryContext(ctx, query, qArgs...) if err != nil { return nil, err } @@ -197,10 +236,24 @@ func (s *PGSecureCLIStore) List(ctx context.Context) ([]store.SecureCLIBinary, e } func (s *PGSecureCLIStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ([]store.SecureCLIBinary, error) { + if store.IsCrossTenant(ctx) { + rows, err := s.db.QueryContext(ctx, + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries + WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true + ORDER BY binary_name, agent_id NULLS LAST`, agentID) + if err != nil { + return nil, err + } + return s.scanRows(rows) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } rows, err := s.db.QueryContext(ctx, `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries - WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true - ORDER BY binary_name, agent_id NULLS LAST`, agentID) + WHERE (agent_id = $1 OR agent_id IS NULL) AND enabled = true AND tenant_id = $2 + ORDER BY binary_name, agent_id NULLS LAST`, agentID, tenantID) if err != nil { return nil, err } @@ -212,15 +265,35 @@ func (s *PGSecureCLIStore) ListByAgent(ctx context.Context, agentID uuid.UUID) ( func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string, agentID *uuid.UUID) (*store.SecureCLIBinary, error) { var row *sql.Row if agentID != nil { - row = s.db.QueryRowContext(ctx, - `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries - WHERE binary_name = $1 AND (agent_id = $2 OR agent_id IS NULL) AND enabled = true - ORDER BY agent_id NULLS LAST LIMIT 1`, binaryName, *agentID) + if store.IsCrossTenant(ctx) { + row = s.db.QueryRowContext(ctx, + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries + WHERE binary_name = $1 AND (agent_id = $2 OR agent_id IS NULL) AND enabled = true + ORDER BY agent_id NULLS LAST LIMIT 1`, binaryName, *agentID) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, nil + } + row = s.db.QueryRowContext(ctx, + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries + WHERE binary_name = $1 AND (agent_id = $2 OR agent_id IS NULL) AND enabled = true AND tenant_id = $3 + ORDER BY agent_id NULLS LAST LIMIT 1`, binaryName, *agentID, tid) + } } else { - row = s.db.QueryRowContext(ctx, - `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries - WHERE binary_name = $1 AND agent_id IS NULL AND enabled = true - LIMIT 1`, binaryName) + if store.IsCrossTenant(ctx) { + row = s.db.QueryRowContext(ctx, + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries + WHERE binary_name = $1 AND agent_id IS NULL AND enabled = true LIMIT 1`, binaryName) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, nil + } + row = s.db.QueryRowContext(ctx, + `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries + WHERE binary_name = $1 AND agent_id IS NULL AND enabled = true AND tenant_id = $2 LIMIT 1`, binaryName, tid) + } } b, err := s.scanRow(row) if err == sql.ErrNoRows { @@ -230,9 +303,18 @@ func (s *PGSecureCLIStore) LookupByBinary(ctx context.Context, binaryName string } func (s *PGSecureCLIStore) ListEnabled(ctx context.Context) ([]store.SecureCLIBinary, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT `+secureCLISelectCols+` FROM secure_cli_binaries - WHERE enabled = true ORDER BY binary_name`) + query := `SELECT ` + secureCLISelectCols + ` FROM secure_cli_binaries WHERE enabled = true` + var qArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + query += ` AND tenant_id = $1` + qArgs = append(qArgs, tenantID) + } + query += ` ORDER BY binary_name` + rows, err := s.db.QueryContext(ctx, query, qArgs...) if err != nil { return nil, err } diff --git a/internal/store/pg/sessions.go b/internal/store/pg/sessions.go index d7a53730..3ea7c243 100644 --- a/internal/store/pg/sessions.go +++ b/internal/store/pg/sessions.go @@ -1,6 +1,7 @@ package pg import ( + "context" "database/sql" "encoding/json" "log/slog" @@ -58,17 +59,27 @@ func (s *PGSessionStore) migrateLegacyWSKeys() { } } -func (s *PGSessionStore) GetOrCreate(key string) *store.SessionData { +// sessionCacheKey prefixes session key with tenant UUID to prevent cross-tenant cache collisions. +// Two tenants with the same agent_key produce different cache keys. +func sessionCacheKey(ctx context.Context, key string) string { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + return tid.String() + ":" + key +} + +func (s *PGSessionStore) GetOrCreate(ctx context.Context, key string) *store.SessionData { s.mu.Lock() defer s.mu.Unlock() - if cached, ok := s.cache[key]; ok { + if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return cached } - data := s.loadFromDB(key) + data := s.loadFromDB(ctx, key) if data != nil { - s.cache[key] = data + s.cache[sessionCacheKey(ctx, key)] = data return data } @@ -89,30 +100,54 @@ func (s *PGSessionStore) GetOrCreate(key string) *store.SessionData { data.TeamID = teamID } } - s.cache[key] = data + s.cache[sessionCacheKey(ctx, key)] = data msgsJSON, _ := json.Marshal([]providers.Message{}) s.db.Exec( - `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, team_id) - VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (session_key) DO NOTHING`, - uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, teamID, + `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, team_id, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (tenant_id, session_key) DO NOTHING`, + uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, teamID, tenantIDForInsert(ctx), ) return data } -func (s *PGSessionStore) AddMessage(key string, msg providers.Message) { +// Get returns the session if it exists (cache or DB), nil otherwise. Never creates. +func (s *PGSessionStore) Get(ctx context.Context, key string) *store.SessionData { + s.mu.RLock() + if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + s.mu.RUnlock() + return cached + } + s.mu.RUnlock() + s.mu.Lock() defer s.mu.Unlock() - data := s.getOrInit(key) + // Double-check after acquiring write lock + if cached, ok := s.cache[sessionCacheKey(ctx, key)]; ok { + return cached + } + + data := s.loadFromDB(ctx, key) + if data != nil { + s.cache[sessionCacheKey(ctx, key)] = data + } + return data +} + +func (s *PGSessionStore) AddMessage(ctx context.Context, key string, msg providers.Message) { + s.mu.Lock() + defer s.mu.Unlock() + + data := s.getOrInit(ctx, key) data.Messages = append(data.Messages, msg) data.Updated = time.Now() } -func (s *PGSessionStore) GetHistory(key string) []providers.Message { +func (s *PGSessionStore) GetHistory(ctx context.Context, key string) []providers.Message { s.mu.RLock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { msgs := make([]providers.Message, len(data.Messages)) copy(msgs, data.Messages) s.mu.RUnlock() @@ -125,62 +160,62 @@ func (s *PGSessionStore) GetHistory(key string) []providers.Message { defer s.mu.Unlock() // Double-check after acquiring write lock - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { msgs := make([]providers.Message, len(data.Messages)) copy(msgs, data.Messages) return msgs } - data := s.loadFromDB(key) + data := s.loadFromDB(ctx, key) if data == nil { return nil } - s.cache[key] = data + s.cache[sessionCacheKey(ctx, key)] = data msgs := make([]providers.Message, len(data.Messages)) copy(msgs, data.Messages) return msgs } -func (s *PGSessionStore) GetSummary(key string) string { +func (s *PGSessionStore) GetSummary(ctx context.Context, key string) string { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data.Summary } return "" } -func (s *PGSessionStore) SetSummary(key, summary string) { +func (s *PGSessionStore) SetSummary(ctx context.Context, key, summary string) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.Summary = summary data.Updated = time.Now() } } -func (s *PGSessionStore) GetLabel(key string) string { +func (s *PGSessionStore) GetLabel(ctx context.Context, key string) string { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data.Label } return "" } -func (s *PGSessionStore) SetLabel(key, label string) { +func (s *PGSessionStore) SetLabel(ctx context.Context, key, label string) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.Label = label data.Updated = time.Now() } } -func (s *PGSessionStore) GetSessionMetadata(key string) map[string]string { +func (s *PGSessionStore) GetSessionMetadata(ctx context.Context, key string) map[string]string { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok && data.Metadata != nil { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok && data.Metadata != nil { out := make(map[string]string, len(data.Metadata)) maps.Copy(out, data.Metadata) return out @@ -188,10 +223,10 @@ func (s *PGSessionStore) GetSessionMetadata(key string) map[string]string { return nil } -func (s *PGSessionStore) SetSessionMetadata(key string, metadata map[string]string) { +func (s *PGSessionStore) SetSessionMetadata(ctx context.Context, key string, metadata map[string]string) { s.mu.Lock() defer s.mu.Unlock() - data := s.getOrInit(key) + data := s.getOrInit(ctx, key) if data.Metadata == nil { data.Metadata = make(map[string]string) } @@ -199,10 +234,10 @@ func (s *PGSessionStore) SetSessionMetadata(key string, metadata map[string]stri data.Updated = time.Now() } -func (s *PGSessionStore) SetAgentInfo(key string, agentUUID uuid.UUID, userID string) { +func (s *PGSessionStore) SetAgentInfo(ctx context.Context, key string, agentUUID uuid.UUID, userID string) { s.mu.Lock() defer s.mu.Unlock() - data := s.getOrInit(key) + data := s.getOrInit(ctx, key) if agentUUID != uuid.Nil { data.AgentUUID = agentUUID } @@ -211,10 +246,10 @@ func (s *PGSessionStore) SetAgentInfo(key string, agentUUID uuid.UUID, userID st } } -func (s *PGSessionStore) UpdateMetadata(key, model, provider, channel string) { +func (s *PGSessionStore) UpdateMetadata(ctx context.Context, key, model, provider, channel string) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { if model != "" { data.Model = model } diff --git a/internal/store/pg/sessions_list.go b/internal/store/pg/sessions_list.go index 05e63005..9e6192cc 100644 --- a/internal/store/pg/sessions_list.go +++ b/internal/store/pg/sessions_list.go @@ -1,7 +1,7 @@ package pg import ( - "database/sql" + "context" "encoding/json" "fmt" "strings" @@ -41,6 +41,12 @@ func buildSessionFilter(opts store.SessionListOpts, tableAlias string) (string, args = append(args, opts.UserID) idx++ } + if opts.TenantID != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("%stenant_id = $%d", prefix, idx)) + args = append(args, opts.TenantID) + idx++ + } + _ = idx // consumed if len(conditions) == 0 { return "", nil @@ -48,17 +54,34 @@ func buildSessionFilter(opts store.SessionListOpts, tableAlias string) (string, return " WHERE " + strings.Join(conditions, " AND "), args } -func (s *PGSessionStore) List(agentID string) []store.SessionInfo { - var rows *sql.Rows - var err error +func (s *PGSessionStore) List(ctx context.Context, agentID string) []store.SessionInfo { + var conditions []string + var args []any + idx := 1 + if agentID != "" { - prefix := "agent:" + agentID + ":%" - rows, err = s.db.Query( - "SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions WHERE session_key LIKE $1 ORDER BY updated_at DESC", prefix) - } else { - rows, err = s.db.Query( - "SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions ORDER BY updated_at DESC") + conditions = append(conditions, fmt.Sprintf("session_key LIKE $%d", idx)) + args = append(args, "agent:"+agentID+":%") + idx++ } + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", idx)) + args = append(args, tid) + idx++ + } + } + _ = idx + + where := "" + if len(conditions) > 0 { + where = " WHERE " + strings.Join(conditions, " AND ") + } + + rows, err := s.db.QueryContext(ctx, + "SELECT session_key, messages, created_at, updated_at, label, channel, user_id, COALESCE(metadata, '{}') FROM sessions"+where+" ORDER BY updated_at DESC", + args...) if err != nil { return nil } @@ -94,7 +117,7 @@ func (s *PGSessionStore) List(agentID string) []store.SessionInfo { return result } -func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionListResult { +func (s *PGSessionStore) ListPaged(ctx context.Context, opts store.SessionListOpts) store.SessionListResult { limit := opts.Limit if limit <= 0 { limit = 20 @@ -106,7 +129,7 @@ func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionList // Count total var total int countQ := "SELECT COUNT(*) FROM sessions" + where - if err := s.db.QueryRow(countQ, whereArgs...).Scan(&total); err != nil { + if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil { return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: 0} } @@ -116,7 +139,7 @@ func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionList FROM sessions%s ORDER BY updated_at DESC LIMIT $%d OFFSET $%d`, where, nextIdx, nextIdx+1) selectArgs := append(append([]any{}, whereArgs...), limit, offset) - rows, err := s.db.Query(selectQ, selectArgs...) + rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...) if err != nil { return store.SessionListResult{Sessions: []store.SessionInfo{}, Total: total} } @@ -154,7 +177,7 @@ func (s *PGSessionStore) ListPaged(opts store.SessionListOpts) store.SessionList } // ListPagedRich returns enriched session info for API responses (includes model, tokens, agent name). -func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.SessionListRichResult { +func (s *PGSessionStore) ListPagedRich(ctx context.Context, opts store.SessionListOpts) store.SessionListRichResult { limit := opts.Limit if limit <= 0 { limit = 20 @@ -166,7 +189,7 @@ func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.Session // Count total var total int countQ := "SELECT COUNT(*) FROM sessions s" + where - if err := s.db.QueryRow(countQ, whereArgs...).Scan(&total); err != nil { + if err := s.db.QueryRowContext(ctx, countQ, whereArgs...).Scan(&total); err != nil { return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: 0} } @@ -185,7 +208,7 @@ func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.Session %s ORDER BY s.updated_at DESC LIMIT $%d OFFSET $%d`, richCols, where, nextIdx, nextIdx+1) selectArgs := append(append([]any{}, whereArgs...), limit, offset) - rows, err := s.db.Query(selectQ, selectArgs...) + rows, err := s.db.QueryContext(ctx, selectQ, selectArgs...) if err != nil { return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}, Total: total} } @@ -238,9 +261,9 @@ func (s *PGSessionStore) ListPagedRich(opts store.SessionListOpts) store.Session return store.SessionListRichResult{Sessions: result, Total: total} } -func (s *PGSessionStore) Save(key string) error { +func (s *PGSessionStore) Save(ctx context.Context, key string) error { s.mu.RLock() - data, ok := s.cache[key] + data, ok := s.cache[sessionCacheKey(ctx, key)] if !ok { s.mu.RUnlock() return nil @@ -258,7 +281,7 @@ func (s *PGSessionStore) Save(key string) error { metaJSON, _ = json.Marshal(snapshot.Metadata) } - _, err := s.db.Exec( + _, err := s.db.ExecContext(ctx, `UPDATE sessions SET messages = $1, summary = $2, model = $3, provider = $4, channel = $5, input_tokens = $6, output_tokens = $7, compaction_count = $8, @@ -266,30 +289,33 @@ func (s *PGSessionStore) Save(key string) error { label = $11, spawned_by = $12, spawn_depth = $13, agent_id = $14, user_id = $15, metadata = $16, updated_at = $17, team_id = $18 - WHERE session_key = $19`, + WHERE session_key = $19 AND tenant_id = $20`, msgsJSON, nilStr(snapshot.Summary), nilStr(snapshot.Model), nilStr(snapshot.Provider), nilStr(snapshot.Channel), snapshot.InputTokens, snapshot.OutputTokens, snapshot.CompactionCount, snapshot.MemoryFlushCompactionCount, snapshot.MemoryFlushAt, nilStr(snapshot.Label), nilStr(snapshot.SpawnedBy), snapshot.SpawnDepth, nilSessionUUID(snapshot.AgentUUID), nilStr(snapshot.UserID), metaJSON, snapshot.Updated, snapshot.TeamID, - key, + key, tenantIDForInsert(ctx), ) return err } -func (s *PGSessionStore) LastUsedChannel(agentID string) (string, string) { +func (s *PGSessionStore) LastUsedChannel(ctx context.Context, agentID string) (string, string) { prefix := "agent:" + agentID + ":%" + tid := tenantIDForInsert(ctx) var sessionKey string - err := s.db.QueryRow( + err := s.db.QueryRowContext(ctx, `SELECT session_key FROM sessions WHERE session_key LIKE $1 AND session_key NOT LIKE $2 AND session_key NOT LIKE $3 + AND tenant_id = $4 ORDER BY updated_at DESC LIMIT 1`, prefix, "agent:"+agentID+":cron:%", "agent:"+agentID+":subagent:%", + tid, ).Scan(&sessionKey) if err != nil { return "", "" @@ -303,15 +329,15 @@ func (s *PGSessionStore) LastUsedChannel(agentID string) (string, string) { // --- helpers --- -func (s *PGSessionStore) getOrInit(key string) *store.SessionData { - if data, ok := s.cache[key]; ok { +func (s *PGSessionStore) getOrInit(ctx context.Context, key string) *store.SessionData { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data } // Try loading from DB first to avoid overwriting existing messages - data := s.loadFromDB(key) + data := s.loadFromDB(ctx, key) if data != nil { - s.cache[key] = data + s.cache[sessionCacheKey(ctx, key)] = data return data } @@ -323,18 +349,18 @@ func (s *PGSessionStore) getOrInit(key string) *store.SessionData { Created: now, Updated: now, } - s.cache[key] = data + s.cache[sessionCacheKey(ctx, key)] = data msgsJSON, _ := json.Marshal([]providers.Message{}) s.db.Exec( - `INSERT INTO sessions (id, session_key, messages, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5) ON CONFLICT (session_key) DO NOTHING`, - uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, + `INSERT INTO sessions (id, session_key, messages, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (tenant_id, session_key) DO NOTHING`, + uuid.Must(uuid.NewV7()), key, msgsJSON, now, now, tenantIDForInsert(ctx), ) return data } -func (s *PGSessionStore) loadFromDB(key string) *store.SessionData { +func (s *PGSessionStore) loadFromDB(ctx context.Context, key string) *store.SessionData { var sessionKey string var msgsJSON []byte var summary, model, provider, channel, label, spawnedBy, userID *string @@ -345,13 +371,14 @@ func (s *PGSessionStore) loadFromDB(key string) *store.SessionData { var createdAt, updatedAt time.Time var metaJSON *[]byte - err := s.db.QueryRow( + tid := tenantIDForInsert(ctx) + err := s.db.QueryRowContext(ctx, `SELECT session_key, messages, summary, model, provider, channel, input_tokens, output_tokens, compaction_count, memory_flush_compaction_count, memory_flush_at, label, spawned_by, spawn_depth, agent_id, user_id, COALESCE(metadata, '{}'), created_at, updated_at, team_id - FROM sessions WHERE session_key = $1`, key, + FROM sessions WHERE session_key = $1 AND tenant_id = $2`, key, tid, ).Scan(&sessionKey, &msgsJSON, &summary, &model, &provider, &channel, &inputTokens, &outputTokens, &compactionCount, &memoryFlushCompactionCount, &memoryFlushAt, diff --git a/internal/store/pg/sessions_ops.go b/internal/store/pg/sessions_ops.go index 5033b6de..f835c871 100644 --- a/internal/store/pg/sessions_ops.go +++ b/internal/store/pg/sessions_ops.go @@ -1,15 +1,16 @@ package pg import ( + "context" "time" "github.com/nextlevelbuilder/goclaw/internal/providers" ) -func (s *PGSessionStore) TruncateHistory(key string, keepLast int) { +func (s *PGSessionStore) TruncateHistory(ctx context.Context, key string, keepLast int) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { if keepLast <= 0 { data.Messages = []providers.Message{} } else if len(data.Messages) > keepLast { @@ -19,28 +20,28 @@ func (s *PGSessionStore) TruncateHistory(key string, keepLast int) { } } -func (s *PGSessionStore) SetHistory(key string, msgs []providers.Message) { +func (s *PGSessionStore) SetHistory(ctx context.Context, key string, msgs []providers.Message) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.Messages = msgs data.Updated = time.Now() } } -func (s *PGSessionStore) Reset(key string) { +func (s *PGSessionStore) Reset(ctx context.Context, key string) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.Messages = []providers.Message{} data.Summary = "" data.Updated = time.Now() } } -func (s *PGSessionStore) Delete(key string) error { +func (s *PGSessionStore) Delete(ctx context.Context, key string) error { s.mu.Lock() - delete(s.cache, key) + delete(s.cache, sessionCacheKey(ctx, key)) s.mu.Unlock() // Clean up associated media files before deleting from DB. @@ -48,6 +49,7 @@ func (s *PGSessionStore) Delete(key string) error { s.OnDelete(key) } - _, err := s.db.Exec("DELETE FROM sessions WHERE session_key = $1", key) + tid := tenantIDForInsert(ctx) + _, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE session_key = $1 AND tenant_id = $2", key, tid) return err } diff --git a/internal/store/pg/sessions_tokens.go b/internal/store/pg/sessions_tokens.go index 2aace97c..45fd7c76 100644 --- a/internal/store/pg/sessions_tokens.go +++ b/internal/store/pg/sessions_tokens.go @@ -1,90 +1,93 @@ package pg -import "time" +import ( + "context" + "time" +) -func (s *PGSessionStore) AccumulateTokens(key string, input, output int64) { +func (s *PGSessionStore) AccumulateTokens(ctx context.Context, key string, input, output int64) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.InputTokens += input data.OutputTokens += output } } -func (s *PGSessionStore) IncrementCompaction(key string) { +func (s *PGSessionStore) IncrementCompaction(ctx context.Context, key string) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.CompactionCount++ } } -func (s *PGSessionStore) GetCompactionCount(key string) int { +func (s *PGSessionStore) GetCompactionCount(ctx context.Context, key string) int { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data.CompactionCount } return 0 } -func (s *PGSessionStore) GetMemoryFlushCompactionCount(key string) int { +func (s *PGSessionStore) GetMemoryFlushCompactionCount(ctx context.Context, key string) int { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data.MemoryFlushCompactionCount } return -1 } -func (s *PGSessionStore) SetMemoryFlushDone(key string) { +func (s *PGSessionStore) SetMemoryFlushDone(ctx context.Context, key string) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.MemoryFlushCompactionCount = data.CompactionCount data.MemoryFlushAt = time.Now().UnixMilli() } } -func (s *PGSessionStore) SetSpawnInfo(key, spawnedBy string, depth int) { +func (s *PGSessionStore) SetSpawnInfo(ctx context.Context, key, spawnedBy string, depth int) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.SpawnedBy = spawnedBy data.SpawnDepth = depth } } -func (s *PGSessionStore) SetContextWindow(key string, cw int) { +func (s *PGSessionStore) SetContextWindow(ctx context.Context, key string, cw int) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.ContextWindow = cw } } -func (s *PGSessionStore) GetContextWindow(key string) int { +func (s *PGSessionStore) GetContextWindow(ctx context.Context, key string) int { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data.ContextWindow } return 0 } -func (s *PGSessionStore) SetLastPromptTokens(key string, tokens, msgCount int) { +func (s *PGSessionStore) SetLastPromptTokens(ctx context.Context, key string, tokens, msgCount int) { s.mu.Lock() defer s.mu.Unlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { data.LastPromptTokens = tokens data.LastMessageCount = msgCount } } -func (s *PGSessionStore) GetLastPromptTokens(key string) (int, int) { +func (s *PGSessionStore) GetLastPromptTokens(ctx context.Context, key string) (int, int) { s.mu.RLock() defer s.mu.RUnlock() - if data, ok := s.cache[key]; ok { + if data, ok := s.cache[sessionCacheKey(ctx, key)]; ok { return data.LastPromptTokens, data.LastMessageCount } return 0, 0 diff --git a/internal/store/pg/skills.go b/internal/store/pg/skills.go index 4b860069..fbb5f84d 100644 --- a/internal/store/pg/skills.go +++ b/internal/store/pg/skills.go @@ -1,6 +1,7 @@ package pg import ( + "context" "database/sql" "encoding/json" "log/slog" @@ -27,22 +28,29 @@ type PGSkillStore struct { cache map[string]*store.SkillInfo version atomic.Int64 - // List cache: cached result of ListSkills() with version + TTL validation - listCache []store.SkillInfo - listVer int64 - listTime time.Time + // List cache: per-tenant cached result of ListSkills() with version + TTL validation. + // Key is tenant UUID; uuid.Nil = cross-tenant (system admin). + listCache map[uuid.UUID]*listCacheEntry ttl time.Duration // Embedding provider for vector-based skill search embProvider store.EmbeddingProvider } +// listCacheEntry holds per-tenant cached skill list with version + TTL. +type listCacheEntry struct { + skills []store.SkillInfo + ver int64 + time time.Time +} + func NewPGSkillStore(db *sql.DB, baseDir string) *PGSkillStore { return &PGSkillStore{ - db: db, - baseDir: baseDir, - cache: make(map[string]*store.SkillInfo), - ttl: defaultSkillsCacheTTL, + db: db, + baseDir: baseDir, + cache: make(map[string]*store.SkillInfo), + listCache: make(map[uuid.UUID]*listCacheEntry), + ttl: defaultSkillsCacheTTL, } } @@ -50,12 +58,17 @@ func (s *PGSkillStore) Version() int64 { return s.version.Load() } func (s *PGSkillStore) BumpVersion() { s.version.Store(time.Now().UnixMilli()) } func (s *PGSkillStore) Dirs() []string { return []string{s.baseDir} } -func (s *PGSkillStore) ListSkills() []store.SkillInfo { +func (s *PGSkillStore) ListSkills(ctx context.Context) []store.SkillInfo { currentVer := s.version.Load() + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil && !store.IsCrossTenant(ctx) { + tid = store.MasterTenantID + } + // Check per-tenant cache s.mu.RLock() - if s.listCache != nil && s.listVer == currentVer && time.Since(s.listTime) < s.ttl { - result := s.listCache + if entry := s.listCache[tid]; entry != nil && entry.ver == currentVer && time.Since(entry.time) < s.ttl { + result := entry.skills s.mu.RUnlock() return result } @@ -64,8 +77,19 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo { // Cache miss or TTL expired → query DB // Returns active + archived + system skills. Archived skills are shown dimmed in the UI // so admins can see missing deps and re-activate after installing them. - rows, err := s.db.Query( - `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, frontmatter, file_path FROM skills WHERE status IN ('active', 'archived') OR is_system = true ORDER BY name`) + // Tenant filter: system skills visible globally, custom skills scoped to tenant. + var rows *sql.Rows + var err error + if store.IsCrossTenant(ctx) { + rows, err = s.db.QueryContext(ctx, + `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, frontmatter, file_path + FROM skills WHERE status IN ('active', 'archived') OR is_system = true ORDER BY name`) + } else { + rows, err = s.db.QueryContext(ctx, + `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, frontmatter, file_path + FROM skills WHERE (status IN ('active', 'archived') OR is_system = true) AND (is_system = true OR tenant_id = $1) + ORDER BY name`, tid) + } if err != nil { return nil } @@ -100,9 +124,7 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo { } s.mu.Lock() - s.listCache = result - s.listVer = currentVer - s.listTime = time.Now() + s.listCache[tid] = &listCacheEntry{skills: result, ver: currentVer, time: time.Now()} s.mu.Unlock() return result @@ -110,8 +132,8 @@ func (s *PGSkillStore) ListSkills() []store.SkillInfo { // ListAllSkills returns all enabled skills regardless of status (for admin operations like rescan-deps). // Disabled skills are excluded — no point scanning or updating them. -func (s *PGSkillStore) ListAllSkills() []store.SkillInfo { - rows, err := s.db.Query( +func (s *PGSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo { + rows, err := s.db.QueryContext(ctx, `SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path FROM skills WHERE enabled = true AND status != 'deleted' ORDER BY name`) if err != nil { return nil diff --git a/internal/store/pg/skills_content.go b/internal/store/pg/skills_content.go index e041cdc5..6e157176 100644 --- a/internal/store/pg/skills_content.go +++ b/internal/store/pg/skills_content.go @@ -1,6 +1,7 @@ package pg import ( + "context" "fmt" "os" "regexp" @@ -15,13 +16,22 @@ import ( // skillFrontmatterRe matches YAML frontmatter (--- delimited) at the start of a file. var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---\n?`) -func (s *PGSkillStore) LoadSkill(name string) (string, bool) { +func (s *PGSkillStore) LoadSkill(ctx context.Context, name string) (string, bool) { var slug string var version int var filePath *string - err := s.db.QueryRow( - "SELECT slug, version, file_path FROM skills WHERE slug = $1 AND status = 'active'", name, - ).Scan(&slug, &version, &filePath) + // Tenant filter: system skills visible globally, custom skills scoped to tenant. + q := "SELECT slug, version, file_path FROM skills WHERE slug = $1 AND status = 'active'" + args := []any{name} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = true OR tenant_id = $2)" + args = append(args, tid) + } + err := s.db.QueryRowContext(ctx, q, args...).Scan(&slug, &version, &filePath) if err != nil { return "", false } @@ -33,14 +43,14 @@ func (s *PGSkillStore) LoadSkill(name string) (string, bool) { return content, true } -func (s *PGSkillStore) LoadForContext(allowList []string) string { - skills := s.FilterSkills(allowList) +func (s *PGSkillStore) LoadForContext(ctx context.Context, allowList []string) string { + skills := s.FilterSkills(ctx, allowList) if len(skills) == 0 { return "" } var parts []string for _, sk := range skills { - content, ok := s.LoadSkill(sk.Name) + content, ok := s.LoadSkill(ctx, sk.Name) if !ok { continue } @@ -60,8 +70,8 @@ func (s *PGSkillStore) LoadForContext(allowList []string) string { return result.String() } -func (s *PGSkillStore) BuildSummary(allowList []string) string { - skills := s.FilterSkills(allowList) +func (s *PGSkillStore) BuildSummary(ctx context.Context, allowList []string) string { + skills := s.FilterSkills(ctx, allowList) if len(skills) == 0 { return "" } @@ -78,7 +88,7 @@ func (s *PGSkillStore) BuildSummary(allowList []string) string { return result.String() } -func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) { +func (s *PGSkillStore) GetSkill(ctx context.Context, name string) (*store.SkillInfo, bool) { var id uuid.UUID var skillName, slug, visibility string var desc *string @@ -86,9 +96,17 @@ func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) { var version int var isSystem bool var filePath *string - err := s.db.QueryRow( - "SELECT id, name, slug, description, visibility, tags, version, is_system, file_path FROM skills WHERE slug = $1 AND status = 'active'", name, - ).Scan(&id, &skillName, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &filePath) + q := "SELECT id, name, slug, description, visibility, tags, version, is_system, file_path FROM skills WHERE slug = $1 AND status = 'active'" + args := []any{name} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = true OR tenant_id = $2)" + args = append(args, tid) + } + err := s.db.QueryRowContext(ctx, q, args...).Scan(&id, &skillName, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &filePath) if err != nil { return nil, false } @@ -99,8 +117,8 @@ func (s *PGSkillStore) GetSkill(name string) (*store.SkillInfo, bool) { return &info, true } -func (s *PGSkillStore) FilterSkills(allowList []string) []store.SkillInfo { - all := s.ListSkills() +func (s *PGSkillStore) FilterSkills(ctx context.Context, allowList []string) []store.SkillInfo { + all := s.ListSkills(ctx) var filtered []store.SkillInfo if allowList == nil { // No allowList → return all enabled skills (for agent injection) @@ -128,7 +146,8 @@ func (s *PGSkillStore) FilterSkills(allowList []string) []store.SkillInfo { // GetSkillByID returns a SkillInfo for any skill by UUID, regardless of status or enabled flag. // Used by admin operations (e.g. toggle) that need full skill info. -func (s *PGSkillStore) GetSkillByID(id uuid.UUID) (store.SkillInfo, bool) { +// Tenant filter: system skills visible globally, custom skills scoped to tenant. +func (s *PGSkillStore) GetSkillByID(ctx context.Context, id uuid.UUID) (store.SkillInfo, bool) { var name, slug, visibility, status string var desc *string var tags []string @@ -136,11 +155,18 @@ func (s *PGSkillStore) GetSkillByID(id uuid.UUID) (store.SkillInfo, bool) { var isSystem, enabled bool var depsRaw []byte var filePath *string - err := s.db.QueryRow( - `SELECT name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path - FROM skills WHERE id = $1`, - id, - ).Scan(&name, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &status, &enabled, &depsRaw, &filePath) + q := `SELECT name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path + FROM skills WHERE id = $1` + args := []any{id} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = true OR tenant_id = $2)" + args = append(args, tid) + } + err := s.db.QueryRowContext(ctx, q, args...).Scan(&name, &slug, &desc, &visibility, pq.Array(&tags), &version, &isSystem, &status, &enabled, &depsRaw, &filePath) if err != nil { return store.SkillInfo{}, false } @@ -156,10 +182,19 @@ func (s *PGSkillStore) GetSkillByID(id uuid.UUID) (store.SkillInfo, bool) { // GetSkillOwnerID returns the owner_id for a skill by UUID. // Returns ("", false) if the skill does not exist. -func (s *PGSkillStore) GetSkillOwnerID(id uuid.UUID) (string, bool) { +func (s *PGSkillStore) GetSkillOwnerID(ctx context.Context, id uuid.UUID) (string, bool) { + q := "SELECT owner_id FROM skills WHERE id = $1" + args := []any{id} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = true OR tenant_id = $2)" + args = append(args, tid) + } var ownerID string - err := s.db.QueryRow("SELECT owner_id FROM skills WHERE id = $1", id).Scan(&ownerID) - if err != nil { + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil { return "", false } return ownerID, true @@ -167,10 +202,19 @@ func (s *PGSkillStore) GetSkillOwnerID(id uuid.UUID) (string, bool) { // GetSkillOwnerIDBySlug returns the owner_id for a skill by slug. // Returns ("", false) if the skill does not exist or is archived. -func (s *PGSkillStore) GetSkillOwnerIDBySlug(slug string) (string, bool) { +func (s *PGSkillStore) GetSkillOwnerIDBySlug(ctx context.Context, slug string) (string, bool) { + q := "SELECT owner_id FROM skills WHERE slug = $1 AND status = 'active'" + args := []any{slug} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = true OR tenant_id = $2)" + args = append(args, tid) + } var ownerID string - err := s.db.QueryRow("SELECT owner_id FROM skills WHERE slug = $1 AND status = 'active'", slug).Scan(&ownerID) - if err != nil { + if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil { return "", false } return ownerID, true diff --git a/internal/store/pg/skills_crud.go b/internal/store/pg/skills_crud.go index b6cd7dea..8c79a856 100644 --- a/internal/store/pg/skills_crud.go +++ b/internal/store/pg/skills_crud.go @@ -29,9 +29,9 @@ type SkillCreateParams struct { func (s *PGSkillStore) CreateSkill(name, slug string, description *string, ownerID, visibility string, version int, filePath string, fileSize int64, fileHash *string) error { id := store.GenNewID() _, err := s.db.Exec( - `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, file_path, file_size, file_hash, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, NOW(), NOW())`, - id, name, slug, description, ownerID, visibility, version, filePath, fileSize, fileHash, + `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, file_path, file_size, file_hash, tenant_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, $11, NOW(), NOW())`, + id, name, slug, description, ownerID, visibility, version, filePath, fileSize, fileHash, store.MasterTenantID, ) if err == nil { s.BumpVersion() @@ -39,42 +39,68 @@ func (s *PGSkillStore) CreateSkill(name, slug string, description *string, owner return err } -func (s *PGSkillStore) UpdateSkill(id uuid.UUID, updates map[string]any) error { - if err := execMapUpdate(context.Background(), s.db, "skills", id, updates); err != nil { +func (s *PGSkillStore) UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error { + var err error + if store.IsCrossTenant(ctx) { + err = execMapUpdate(ctx, s.db, "skills", id, updates) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } else { + err = execMapUpdateWhereTenant(ctx, s.db, "skills", updates, id, tid) + } + } + if err != nil { return err } s.BumpVersion() return nil } -func (s *PGSkillStore) DeleteSkill(id uuid.UUID) error { +func (s *PGSkillStore) DeleteSkill(ctx context.Context, id uuid.UUID) error { // Reject deletion of system skills var isSystem bool - if err := s.db.QueryRow("SELECT is_system FROM skills WHERE id = $1", id).Scan(&isSystem); err != nil { + if err := s.db.QueryRowContext(ctx, "SELECT is_system FROM skills WHERE id = $1", id).Scan(&isSystem); err != nil { return fmt.Errorf("check skill: %w", err) } if isSystem { return fmt.Errorf("cannot delete system skill") } - tx, err := s.db.Begin() + // Tenant-scoped delete: only delete skill owned by this tenant. + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + var skillTenantID uuid.UUID + if err := s.db.QueryRowContext(ctx, "SELECT tenant_id FROM skills WHERE id = $1", id).Scan(&skillTenantID); err != nil { + return fmt.Errorf("skill not found") + } + if skillTenantID != tid { + return fmt.Errorf("skill not found") + } + } + + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // Cascade: remove all agent grants for this skill - if _, err := tx.Exec("DELETE FROM skill_agent_grants WHERE skill_id = $1", id); err != nil { + if _, err := tx.ExecContext(ctx, "DELETE FROM skill_agent_grants WHERE skill_id = $1", id); err != nil { return fmt.Errorf("delete skill grants: %w", err) } // Cascade: remove all user grants for this skill - if _, err := tx.Exec("DELETE FROM skill_user_grants WHERE skill_id = $1", id); err != nil { + if _, err := tx.ExecContext(ctx, "DELETE FROM skill_user_grants WHERE skill_id = $1", id); err != nil { return fmt.Errorf("delete skill user grants: %w", err) } // Soft-delete the skill (use 'deleted' status, distinct from 'archived' which means missing deps) - if _, err := tx.Exec("UPDATE skills SET status = 'deleted' WHERE id = $1", id); err != nil { + if _, err := tx.ExecContext(ctx, "UPDATE skills SET status = 'deleted' WHERE id = $1", id); err != nil { return fmt.Errorf("delete skill: %w", err) } @@ -132,12 +158,18 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara return uuid.Nil, fmt.Errorf("get next version: %w", err) } + // Resolve tenant_id: default to MasterTenantID if no tenant in context. + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + id := store.GenNewID() var returnedID uuid.UUID err = tx.QueryRowContext(ctx, - `INSERT INTO skills (id, name, slug, description, owner_id, visibility, version, status, frontmatter, file_path, file_size, file_hash, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, $11, NOW(), NOW()) - ON CONFLICT (slug) DO UPDATE SET + `INSERT INTO skills (id, name, slug, description, owner_id, tenant_id, visibility, version, status, frontmatter, file_path, file_size, file_hash, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'active', $9, $10, $11, $12, NOW(), NOW()) + ON CONFLICT (tenant_id, slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, version = EXCLUDED.version, frontmatter = EXCLUDED.frontmatter, file_path = EXCLUDED.file_path, @@ -145,7 +177,7 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara visibility = CASE WHEN skills.status IN ('archived', 'deleted') THEN 'private' ELSE skills.visibility END, status = 'active', updated_at = NOW() RETURNING id`, - id, p.Name, p.Slug, p.Description, p.OwnerID, p.Visibility, version, + id, p.Name, p.Slug, p.Description, p.OwnerID, tenantID, p.Visibility, version, fmJSON, p.FilePath, p.FileSize, p.FileHash, ).Scan(&returnedID) if err != nil { @@ -168,10 +200,18 @@ func (s *PGSkillStore) CreateSkillManaged(ctx context.Context, p SkillCreatePara } // GetSkillFilePath returns the filesystem path and version for a skill by UUID. -func (s *PGSkillStore) GetSkillFilePath(id uuid.UUID) (filePath string, slug string, version int, ok bool) { - err := s.db.QueryRow( - "SELECT file_path, slug, version FROM skills WHERE id = $1 AND status = 'active'", id, - ).Scan(&filePath, &slug, &version) +func (s *PGSkillStore) GetSkillFilePath(ctx context.Context, id uuid.UUID) (filePath string, slug string, version int, ok bool) { + q := "SELECT file_path, slug, version FROM skills WHERE id = $1 AND status = 'active'" + args := []any{id} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + tid = store.MasterTenantID + } + q += " AND (is_system = true OR tenant_id = $2)" + args = append(args, tid) + } + err := s.db.QueryRowContext(ctx, q, args...).Scan(&filePath, &slug, &version) return filePath, slug, version, err == nil } @@ -209,11 +249,19 @@ func (s *PGSkillStore) GetNextVersionLocked(ctx context.Context, slug string) (i } // ToggleSkill enables or disables a skill by UUID. -func (s *PGSkillStore) ToggleSkill(id uuid.UUID, enabled bool) error { - _, err := s.db.Exec( - `UPDATE skills SET enabled = $1, updated_at = NOW() WHERE id = $2`, - enabled, id, - ) +func (s *PGSkillStore) ToggleSkill(ctx context.Context, id uuid.UUID, enabled bool) error { + q := `UPDATE skills SET enabled = $1, updated_at = NOW() WHERE id = $2` + args := []any{enabled, id} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + } + + _, err := s.db.ExecContext(ctx, q, args...) if err == nil { s.BumpVersion() } diff --git a/internal/store/pg/skills_embedding.go b/internal/store/pg/skills_embedding.go index 41af1fc0..468ad105 100644 --- a/internal/store/pg/skills_embedding.go +++ b/internal/store/pg/skills_embedding.go @@ -22,15 +22,27 @@ func (s *PGSkillStore) SearchByEmbedding(ctx context.Context, embedding []float3 } vecStr := vectorToString(embedding) - rows, err := s.db.QueryContext(ctx, - `SELECT name, slug, COALESCE(description, ''), version, file_path, - 1 - (embedding <=> $1::vector) AS score - FROM skills - WHERE status = 'active' AND enabled = true AND embedding IS NOT NULL - AND visibility != 'private' - ORDER BY embedding <=> $2::vector - LIMIT $3`, - vecStr, vecStr, limit, + // $1=vec, $2=vec → tenant at $3 (if needed), ORDER vec at $3+len(tcArgs), LIMIT after + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } + tenantCond := "" + if tc != "" { + tenantCond = fmt.Sprintf(" AND (is_system = true OR tenant_id = $%d)", 3) + } + orderN := 3 + len(tcArgs) + limitN := orderN + 1 + q := fmt.Sprintf(`SELECT name, slug, COALESCE(description, ''), version, file_path, + 1 - (embedding <=> $1::vector) AS score + FROM skills + WHERE status = 'active' AND enabled = true AND embedding IS NOT NULL + AND visibility != 'private'%s + ORDER BY embedding <=> $%d::vector + LIMIT $%d`, tenantCond, orderN, limitN) + + rows, err := s.db.QueryContext(ctx, q, + append(append([]any{vecStr}, tcArgs...), vecStr, limit)..., ) if err != nil { return nil, fmt.Errorf("embedding skill search: %w", err) diff --git a/internal/store/pg/skills_grants.go b/internal/store/pg/skills_grants.go index ed0a3c7d..f5273b4e 100644 --- a/internal/store/pg/skills_grants.go +++ b/internal/store/pg/skills_grants.go @@ -2,6 +2,7 @@ package pg import ( "context" + "fmt" "log/slog" "time" @@ -18,10 +19,10 @@ func (s *PGSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.U return err } _, err := s.db.ExecContext(ctx, - `INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at) - VALUES ($1, $2, $3, $4, $5, $6) + `INSERT INTO skill_agent_grants (id, skill_id, agent_id, pinned_version, granted_by, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (skill_id, agent_id) DO UPDATE SET pinned_version = EXCLUDED.pinned_version`, - store.GenNewID(), skillID, agentID, version, grantedBy, time.Now(), + store.GenNewID(), skillID, agentID, version, grantedBy, time.Now(), tenantIDForInsert(ctx), ) if err != nil { return err @@ -43,8 +44,13 @@ func (s *PGSkillStore) GrantToAgent(ctx context.Context, skillID, agentID uuid.U // RevokeFromAgent revokes a skill grant from an agent. // Auto-demotes visibility from 'internal' back to 'private' when no agent grants remain. func (s *PGSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID uuid.UUID) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM skill_agent_grants WHERE skill_id = $1 AND agent_id = $2", skillID, agentID) + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM skill_agent_grants WHERE skill_id = $1 AND agent_id = $2"+tClause, + append([]any{skillID, agentID}, tArgs...)...) if err != nil { return err } @@ -67,8 +73,13 @@ func (s *PGSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID uui // ListAgentGrants returns all skill grants for an agent. func (s *PGSkillStore) ListAgentGrants(ctx context.Context, agentID uuid.UUID) ([]SkillGrantInfo, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } rows, err := s.db.QueryContext(ctx, - "SELECT skill_id, pinned_version, granted_by FROM skill_agent_grants WHERE agent_id = $1", agentID) + "SELECT skill_id, pinned_version, granted_by FROM skill_agent_grants WHERE agent_id = $1"+tClause, + append([]any{agentID}, tArgs...)...) if err != nil { return nil, err } @@ -95,35 +106,60 @@ func (s *PGSkillStore) GrantToUser(ctx context.Context, skillID uuid.UUID, userI return err } _, err := s.db.ExecContext(ctx, - `INSERT INTO skill_user_grants (id, skill_id, user_id, granted_by, created_at) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO skill_user_grants (id, skill_id, user_id, granted_by, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (skill_id, user_id) DO NOTHING`, - store.GenNewID(), skillID, userID, grantedBy, time.Now(), + store.GenNewID(), skillID, userID, grantedBy, time.Now(), tenantIDForInsert(ctx), ) return err } // RevokeFromUser revokes a skill grant from a user. func (s *PGSkillStore) RevokeFromUser(ctx context.Context, skillID uuid.UUID, userID string) error { - _, err := s.db.ExecContext(ctx, - "DELETE FROM skill_user_grants WHERE skill_id = $1 AND user_id = $2", skillID, userID) + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + "DELETE FROM skill_user_grants WHERE skill_id = $1 AND user_id = $2"+tClause, + append([]any{skillID, userID}, tArgs...)...) return err } // ListAccessible returns skills accessible to a given agent+user combination. // Access logic: public → all, private → owner only, internal → check grants. +// System skills (is_system=true) are always visible regardless of tenant. func (s *PGSkillStore) ListAccessible(ctx context.Context, agentID uuid.UUID, userID string) ([]store.SkillInfo, error) { + // tenant filter: system skills bypass it, tenant-owned skills are filtered + tc, tcArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return nil, err + } + tenantCond := "" + if tc != "" { + // tc is " AND tenant_id = $3"; we need it as an OR condition inside the WHERE + tenantCond = fmt.Sprintf(" AND (s.is_system = true OR s.tenant_id = $%d)", 3) + _ = tc // tcArgs carries the value + } + // LEFT JOIN skill_tenant_configs to exclude per-tenant disabled skills. + // stc.enabled = false → skill explicitly disabled for this tenant. + stcJoin := "" + stcFilter := "" + if len(tcArgs) > 0 { + stcJoin = fmt.Sprintf(" LEFT JOIN skill_tenant_configs stc ON s.id = stc.skill_id AND stc.tenant_id = $%d", 3) + stcFilter = " AND (stc.enabled IS NULL OR stc.enabled = true)" + } rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT s.name, s.slug, s.description, s.version, s.file_path FROM skills s LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = $1 - LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND sug.user_id = $2 - WHERE s.status = 'active' AND ( + LEFT JOIN skill_user_grants sug ON s.id = sug.skill_id AND sug.user_id = $2`+stcJoin+` + WHERE s.status = 'active'`+tenantCond+stcFilter+` AND ( s.is_system = true OR s.visibility = 'public' OR (s.visibility = 'private' AND s.owner_id = $2) OR (s.visibility = 'internal' AND (sag.id IS NOT NULL OR sug.id IS NOT NULL)) ) - ORDER BY s.name`, agentID, userID) + ORDER BY s.name`, append([]any{agentID, userID}, tcArgs...)...) if err != nil { return nil, err } @@ -166,6 +202,14 @@ type SkillWithGrantStatus struct { // ListWithGrantStatus returns all active skills with grant status for a specific agent. func (s *PGSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid.UUID) ([]SkillWithGrantStatus, error) { + tc, tcArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } + tenantCond := "" + if tc != "" { + tenantCond = fmt.Sprintf(" AND (s.is_system = true OR s.tenant_id = $%d)", 2) + } rows, err := s.db.QueryContext(ctx, `SELECT s.id, s.name, s.slug, COALESCE(s.description, ''), s.visibility, s.version, (sag.id IS NOT NULL) AS granted, @@ -173,8 +217,8 @@ func (s *PGSkillStore) ListWithGrantStatus(ctx context.Context, agentID uuid.UUI s.is_system FROM skills s LEFT JOIN skill_agent_grants sag ON s.id = sag.skill_id AND sag.agent_id = $1 - WHERE s.status = 'active' - ORDER BY s.name`, agentID) + WHERE s.status = 'active'`+tenantCond+` + ORDER BY s.name`, append([]any{agentID}, tcArgs...)...) if err != nil { return nil, err } diff --git a/internal/store/pg/snapshot.go b/internal/store/pg/snapshot.go index 06646cfd..40125058 100644 --- a/internal/store/pg/snapshot.go +++ b/internal/store/pg/snapshot.go @@ -7,6 +7,8 @@ import ( "strings" "time" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -19,7 +21,7 @@ func NewPGSnapshotStore(db *sql.DB) *PGSnapshotStore { return &PGSnapshotStore{db: db} } -const snapshotFieldCount = 21 +const snapshotFieldCount = 22 // maxBatchRows limits each INSERT to stay under PG's 65535 param limit (65535 / 21 ≈ 3120). const maxBatchRows = 3000 @@ -38,6 +40,11 @@ func (s *PGSnapshotStore) UpsertSnapshots(ctx context.Context, snapshots []store } func (s *PGSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.UsageSnapshot) error { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + var vals []string var args []any for i, snap := range snapshots { @@ -53,6 +60,7 @@ func (s *PGSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.Usa snap.TotalCost, snap.RequestCount, snap.LLMCallCount, snap.ToolCallCount, snap.ErrorCount, snap.UniqueUsers, snap.AvgDurationMS, snap.MemoryDocs, snap.MemoryChunks, snap.KGEntities, snap.KGRelations, + tenantID, ) } @@ -61,9 +69,10 @@ func (s *PGSnapshotStore) upsertBatch(ctx context.Context, snapshots []store.Usa input_tokens, output_tokens, cache_read_tokens, cache_create_tokens, thinking_tokens, total_cost, request_count, llm_call_count, tool_call_count, error_count, unique_users, avg_duration_ms, - memory_docs, memory_chunks, kg_entities, kg_relations + memory_docs, memory_chunks, kg_entities, kg_relations, + tenant_id ) VALUES ` + strings.Join(vals, ", ") + ` - ON CONFLICT (bucket_hour, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), provider, model, channel) + ON CONFLICT (bucket_hour, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), provider, model, channel, tenant_id) DO UPDATE SET input_tokens = EXCLUDED.input_tokens, output_tokens = EXCLUDED.output_tokens, @@ -92,7 +101,7 @@ func (s *PGSnapshotStore) GetTimeSeries(ctx context.Context, q store.SnapshotQue bucketExpr = "date_trunc('day', bucket_hour)" } - where, args := buildSnapshotWhere(q) + where, args := buildSnapshotWhere(ctx, q) query := fmt.Sprintf(`SELECT bucket_time, @@ -187,7 +196,7 @@ func (s *PGSnapshotStore) GetBreakdown(ctx context.Context, q store.SnapshotQuer extraFilter = " AND provider != '' AND model != ''" } - where, args := buildSnapshotWhere(q) + where, args := buildSnapshotWhere(ctx, q) if where == "" { where = " WHERE 1=1" } @@ -250,11 +259,20 @@ func (s *PGSnapshotStore) GetLatestBucket(ctx context.Context) (*time.Time, erro } // buildSnapshotWhere builds a dynamic WHERE clause from SnapshotQuery filters. -func buildSnapshotWhere(q store.SnapshotQuery) (string, []any) { +func buildSnapshotWhere(ctx context.Context, q store.SnapshotQuery) (string, []any) { var conds []string var args []any idx := 1 + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conds = append(conds, fmt.Sprintf("tenant_id = $%d", idx)) + args = append(args, tenantID) + idx++ + } + } + if !q.From.IsZero() { conds = append(conds, fmt.Sprintf("bucket_hour >= $%d", idx)) args = append(args, q.From) diff --git a/internal/store/pg/teams.go b/internal/store/pg/teams.go index d7617b5a..c16e8bf2 100644 --- a/internal/store/pg/teams.go +++ b/internal/store/pg/teams.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "time" "github.com/google/uuid" @@ -48,38 +49,78 @@ func (s *PGTeamStore) CreateTeam(ctx context.Context, team *store.TeamData) erro settings = json.RawMessage(`{}`) } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } + _, err := s.db.ExecContext(ctx, - `INSERT INTO agent_teams (id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + `INSERT INTO agent_teams (id, name, lead_agent_id, description, status, settings, created_by, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, team.ID, team.Name, team.LeadAgentID, team.Description, - team.Status, settings, team.CreatedBy, now, now, + team.Status, settings, team.CreatedBy, now, now, tenantID, ) return err } func (s *PGTeamStore) GetTeam(ctx context.Context, teamID uuid.UUID) (*store.TeamData, error) { + if store.IsCrossTenant(ctx) { + row := s.db.QueryRowContext(ctx, + `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = $1`, teamID) + return scanTeamRow(row) + } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } row := s.db.QueryRowContext(ctx, - `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = $1`, teamID) + `SELECT `+teamSelectCols+` FROM agent_teams WHERE id = $1 AND tenant_id = $2`, teamID, tenantID) return scanTeamRow(row) } func (s *PGTeamStore) UpdateTeam(ctx context.Context, teamID uuid.UUID, updates map[string]any) error { - return execMapUpdate(ctx, s.db, "agent_teams", teamID, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "agent_teams", teamID, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "agent_teams", updates, teamID, tid) } func (s *PGTeamStore) DeleteTeam(ctx context.Context, teamID uuid.UUID) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = $1`, teamID) + if store.IsCrossTenant(ctx) { + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = $1`, teamID) + return err + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for delete") + } + _, err := s.db.ExecContext(ctx, `DELETE FROM agent_teams WHERE id = $1 AND tenant_id = $2`, teamID, tid) return err } func (s *PGTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) { + var tenantFilter string + var queryArgs []any + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + tenantFilter = " WHERE t.tenant_id = $1" + queryArgs = append(queryArgs, tenantID) + } + rows, err := s.db.QueryContext(ctx, `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at, COALESCE(a.agent_key, '') AS lead_agent_key, COALESCE(a.display_name, '') AS lead_display_name FROM agent_teams t - LEFT JOIN agents a ON a.id = t.lead_agent_id - ORDER BY t.created_at`) + LEFT JOIN agents a ON a.id = t.lead_agent_id`+tenantFilter+` + ORDER BY t.created_at`, queryArgs...) if err != nil { return nil, err } @@ -148,10 +189,10 @@ func (s *PGTeamStore) ListTeams(ctx context.Context) ([]store.TeamData, error) { func (s *PGTeamStore) AddMember(ctx context.Context, teamID, agentID uuid.UUID, role string) error { _, err := s.db.ExecContext(ctx, - `INSERT INTO agent_team_members (team_id, agent_id, role, joined_at) - VALUES ($1, $2, $3, $4) + `INSERT INTO agent_team_members (team_id, agent_id, role, joined_at, tenant_id) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (team_id, agent_id) DO UPDATE SET role = EXCLUDED.role`, - teamID, agentID, role, time.Now(), + teamID, agentID, role, time.Now(), tenantIDForInsert(ctx), ) return err } @@ -165,16 +206,27 @@ func (s *PGTeamStore) RemoveMember(ctx context.Context, teamID, agentID uuid.UUI } func (s *PGTeamStore) ListMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT m.team_id, m.agent_id, m.role, m.joined_at, + q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at, COALESCE(a.agent_key, '') AS agent_key, COALESCE(a.display_name, '') AS display_name, COALESCE(a.frontmatter, '') AS frontmatter, COALESCE(a.other_config->>'emoji', '') AS emoji FROM agent_team_members m JOIN agents a ON a.id = m.agent_id - WHERE m.team_id = $1 AND a.status = 'active' - ORDER BY m.joined_at`, teamID) + JOIN agent_teams at2 ON at2.id = m.team_id + WHERE m.team_id = $1 AND a.status = 'active'` + args := []any{teamID} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND at2.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + } + q += ` ORDER BY m.joined_at` + + rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } @@ -195,20 +247,31 @@ func (s *PGTeamStore) ListMembers(ctx context.Context, teamID uuid.UUID) ([]stor } func (s *PGTeamStore) ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) { - rows, err := s.db.QueryContext(ctx, - `SELECT m.team_id, m.agent_id, m.role, m.joined_at, + q := `SELECT m.team_id, m.agent_id, m.role, m.joined_at, COALESCE(a.agent_key, '') AS agent_key, COALESCE(a.display_name, '') AS display_name, COALESCE(a.frontmatter, '') AS frontmatter, COALESCE(a.other_config->>'emoji', '') AS emoji FROM agent_team_members m JOIN agents a ON a.id = m.agent_id + JOIN agent_teams at2 ON at2.id = m.team_id WHERE m.team_id = $1 AND a.status = 'active' AND m.role != $2 AND NOT EXISTS ( - SELECT 1 FROM team_tasks t - WHERE t.owner_agent_id = m.agent_id AND t.team_id = $1 AND t.status = $3 - ) - ORDER BY m.joined_at`, teamID, store.TeamRoleLead, store.TeamTaskStatusInProgress) + SELECT 1 FROM team_tasks tt + WHERE tt.owner_agent_id = m.agent_id AND tt.team_id = $1 AND tt.status = $3 + )` + args := []any{teamID, store.TeamRoleLead, store.TeamTaskStatusInProgress} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND at2.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + } + q += ` ORDER BY m.joined_at` + + rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } @@ -229,16 +292,24 @@ func (s *PGTeamStore) ListIdleMembers(ctx context.Context, teamID uuid.UUID) ([] } func (s *PGTeamStore) GetTeamForAgent(ctx context.Context, agentID uuid.UUID) (*store.TeamData, error) { - row := s.db.QueryRowContext(ctx, - `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at + q := `SELECT t.id, t.name, t.lead_agent_id, t.description, t.status, t.settings, t.created_by, t.created_at, t.updated_at FROM agent_teams t WHERE ( t.lead_agent_id = $1 OR EXISTS (SELECT 1 FROM agent_team_members m WHERE m.team_id = t.id AND m.agent_id = $1) - ) AND t.status = $2 - ORDER BY (t.lead_agent_id = $1) DESC - LIMIT 1`, agentID, store.TeamStatusActive) + ) AND t.status = $2` + args := []any{agentID, store.TeamStatusActive} + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND t.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + } + q += ` ORDER BY (t.lead_agent_id = $1) DESC LIMIT 1` + + row := s.db.QueryRowContext(ctx, q, args...) d, err := scanTeamRow(row) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -250,13 +321,24 @@ func (s *PGTeamStore) KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit if limit <= 0 { limit = 100 } - rows, err := s.db.QueryContext(ctx, - `SELECT DISTINCT s.user_id + q := `SELECT DISTINCT s.user_id FROM sessions s JOIN agent_team_members m ON m.agent_id = s.agent_id - WHERE m.team_id = $1 AND s.user_id != '' - ORDER BY s.user_id - LIMIT $2`, teamID, limit) + JOIN agent_teams at2 ON at2.id = m.team_id + WHERE m.team_id = $1 AND s.user_id != ''` + args := []any{teamID} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += fmt.Sprintf(" AND at2.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + } + q += fmt.Sprintf(" ORDER BY s.user_id LIMIT $%d", len(args)+1) + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, err } @@ -273,6 +355,110 @@ func (s *PGTeamStore) KnownUserIDs(ctx context.Context, teamID uuid.UUID, limit return users, rows.Err() } +// ============================================================ +// Team user grants +// ============================================================ + +func (s *PGTeamStore) GrantTeamAccess(ctx context.Context, teamID uuid.UUID, userID, role, grantedBy string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO team_user_grants (id, team_id, user_id, role, granted_by, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (team_id, user_id) DO UPDATE SET role = EXCLUDED.role, granted_by = EXCLUDED.granted_by`, + store.GenNewID(), teamID, userID, role, grantedBy, time.Now(), tenantIDForInsert(ctx), + ) + return err +} + +func (s *PGTeamStore) RevokeTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) error { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, + `DELETE FROM team_user_grants WHERE team_id = $1 AND user_id = $2`+tClause, + append([]any{teamID, userID}, tArgs...)...) + return err +} + +func (s *PGTeamStore) ListTeamGrants(ctx context.Context, teamID uuid.UUID) ([]store.TeamUserGrant, error) { + tClause, tArgs, err := tenantClauseN(ctx, 2) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, team_id, user_id, role, COALESCE(granted_by, ''), created_at + FROM team_user_grants WHERE team_id = $1`+tClause+` ORDER BY created_at DESC`, + append([]any{teamID}, tArgs...)...) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []store.TeamUserGrant + for rows.Next() { + var g store.TeamUserGrant + if err := rows.Scan(&g.ID, &g.TeamID, &g.UserID, &g.Role, &g.GrantedBy, &g.CreatedAt); err != nil { + return nil, err + } + result = append(result, g) + } + return result, rows.Err() +} + +func (s *PGTeamStore) ListUserTeams(ctx context.Context, userID string) ([]store.TeamData, error) { + baseQuery := `SELECT ` + teamSelectCols + ` + FROM agent_teams t + WHERE t.status = $1 + AND EXISTS (SELECT 1 FROM team_user_grants g WHERE g.team_id = t.id AND g.user_id = $2)` + args := []any{store.TeamStatusActive, userID} + + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, nil + } + baseQuery += ` AND t.tenant_id = $3` + args = append(args, tenantID) + } + baseQuery += ` ORDER BY t.created_at DESC` + + rows, err := s.db.QueryContext(ctx, baseQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var teams []store.TeamData + for rows.Next() { + var d store.TeamData + var desc sql.NullString + if err := rows.Scan( + &d.ID, &d.Name, &d.LeadAgentID, &desc, &d.Status, + &d.Settings, &d.CreatedBy, &d.CreatedAt, &d.UpdatedAt, + ); err != nil { + return nil, err + } + if desc.Valid { + d.Description = desc.String + } + teams = append(teams, d) + } + return teams, rows.Err() +} + +func (s *PGTeamStore) HasTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, error) { + tClause, tArgs, err := tenantClauseN(ctx, 3) + if err != nil { + return false, err + } + var exists bool + err = s.db.QueryRowContext(ctx, + `SELECT EXISTS(SELECT 1 FROM team_user_grants WHERE team_id = $1 AND user_id = $2`+tClause+`)`, + append([]any{teamID, userID}, tArgs...)..., + ).Scan(&exists) + return exists, err +} + // ============================================================ // Scan helpers // ============================================================ diff --git a/internal/store/pg/teams_delegation.go b/internal/store/pg/teams_delegation.go deleted file mode 100644 index 8d64aa10..00000000 --- a/internal/store/pg/teams_delegation.go +++ /dev/null @@ -1,172 +0,0 @@ -package pg - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/store" -) - -// ============================================================ -// Delegation History -// ============================================================ - -func (s *PGTeamStore) SaveDelegationHistory(ctx context.Context, record *store.DelegationHistoryData) error { - if record.ID == uuid.Nil { - record.ID = store.GenNewID() - } - now := time.Now() - record.CreatedAt = now - - metadata, _ := json.Marshal(record.Metadata) - if len(metadata) == 0 { - metadata = []byte(`{}`) - } - - _, err := s.db.ExecContext(ctx, - `INSERT INTO delegation_history (id, source_agent_id, target_agent_id, team_id, team_task_id, user_id, task, mode, status, result, error, iterations, trace_id, duration_ms, metadata, created_at, completed_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, - record.ID, record.SourceAgentID, record.TargetAgentID, - record.TeamID, record.TeamTaskID, - record.UserID, record.Task, record.Mode, record.Status, - record.Result, record.Error, record.Iterations, - record.TraceID, record.DurationMS, metadata, now, record.CompletedAt, - ) - return err -} - -func (s *PGTeamStore) ListDelegationHistory(ctx context.Context, opts store.DelegationHistoryListOpts) ([]store.DelegationHistoryData, int, error) { - where := "WHERE 1=1" - args := []any{} - argN := 0 - - nextArg := func(v any) string { - argN++ - args = append(args, v) - return fmt.Sprintf("$%d", argN) - } - - if opts.SourceAgentID != nil { - where += " AND d.source_agent_id = " + nextArg(*opts.SourceAgentID) - } - if opts.TargetAgentID != nil { - where += " AND d.target_agent_id = " + nextArg(*opts.TargetAgentID) - } - if opts.TeamID != nil { - where += " AND d.team_id = " + nextArg(*opts.TeamID) - } - if opts.UserID != "" { - where += " AND d.user_id = " + nextArg(opts.UserID) - } - if opts.Status != "" { - where += " AND d.status = " + nextArg(opts.Status) - } - - // Count total - var total int - countSQL := "SELECT COUNT(*) FROM delegation_history d " + where - if err := s.db.QueryRowContext(ctx, countSQL, args...).Scan(&total); err != nil { - return nil, 0, err - } - - // Fetch rows - limit := opts.Limit - if limit <= 0 || limit > 200 { - limit = 50 - } - offset := max(opts.Offset, 0) - - query := fmt.Sprintf( - `SELECT d.id, d.source_agent_id, d.target_agent_id, d.team_id, d.team_task_id, - d.user_id, d.task, d.mode, d.status, d.result, d.error, d.iterations, - d.trace_id, d.duration_ms, d.metadata, d.created_at, d.completed_at, - COALESCE(sa.agent_key, '') AS source_agent_key, - COALESCE(ta.agent_key, '') AS target_agent_key - FROM delegation_history d - LEFT JOIN agents sa ON sa.id = d.source_agent_id - LEFT JOIN agents ta ON ta.id = d.target_agent_id - %s - ORDER BY d.created_at DESC - LIMIT %s OFFSET %s`, - where, nextArg(limit), nextArg(offset)) - - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, 0, err - } - defer rows.Close() - - var records []store.DelegationHistoryData - for rows.Next() { - var d store.DelegationHistoryData - var result, errStr sql.NullString - var completedAt sql.NullTime - var metadata json.RawMessage - if err := rows.Scan( - &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.TeamID, &d.TeamTaskID, - &d.UserID, &d.Task, &d.Mode, &d.Status, &result, &errStr, &d.Iterations, - &d.TraceID, &d.DurationMS, &metadata, &d.CreatedAt, &completedAt, - &d.SourceAgentKey, &d.TargetAgentKey, - ); err != nil { - return nil, 0, err - } - if result.Valid { - d.Result = &result.String - } - if errStr.Valid { - d.Error = &errStr.String - } - if completedAt.Valid { - d.CompletedAt = &completedAt.Time - } - if len(metadata) > 0 && string(metadata) != "{}" { - _ = json.Unmarshal(metadata, &d.Metadata) - } - records = append(records, d) - } - return records, total, rows.Err() -} - -func (s *PGTeamStore) GetDelegationHistory(ctx context.Context, id uuid.UUID) (*store.DelegationHistoryData, error) { - var d store.DelegationHistoryData - var result, errStr sql.NullString - var completedAt sql.NullTime - var metadata json.RawMessage - - err := s.db.QueryRowContext(ctx, - `SELECT d.id, d.source_agent_id, d.target_agent_id, d.team_id, d.team_task_id, - d.user_id, d.task, d.mode, d.status, d.result, d.error, d.iterations, - d.trace_id, d.duration_ms, d.metadata, d.created_at, d.completed_at, - COALESCE(sa.agent_key, '') AS source_agent_key, - COALESCE(ta.agent_key, '') AS target_agent_key - FROM delegation_history d - LEFT JOIN agents sa ON sa.id = d.source_agent_id - LEFT JOIN agents ta ON ta.id = d.target_agent_id - WHERE d.id = $1`, id).Scan( - &d.ID, &d.SourceAgentID, &d.TargetAgentID, &d.TeamID, &d.TeamTaskID, - &d.UserID, &d.Task, &d.Mode, &d.Status, &result, &errStr, &d.Iterations, - &d.TraceID, &d.DurationMS, &metadata, &d.CreatedAt, &completedAt, - &d.SourceAgentKey, &d.TargetAgentKey, - ) - if err != nil { - return nil, err - } - if result.Valid { - d.Result = &result.String - } - if errStr.Valid { - d.Error = &errStr.String - } - if completedAt.Valid { - d.CompletedAt = &completedAt.Time - } - if len(metadata) > 0 && string(metadata) != "{}" { - _ = json.Unmarshal(metadata, &d.Metadata) - } - return &d, nil -} diff --git a/internal/store/pg/teams_tasks.go b/internal/store/pg/teams_tasks.go index 8657e459..d879ce0c 100644 --- a/internal/store/pg/teams_tasks.go +++ b/internal/store/pg/teams_tasks.go @@ -42,10 +42,20 @@ const maxListTasksRows = 30 // ============================================================ func (s *PGTeamStore) ListTaskScopes(ctx context.Context, teamID uuid.UUID) ([]store.ScopeEntry, error) { + args := []any{teamID} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT channel, chat_id FROM team_tasks - WHERE team_id = $1 AND channel IS NOT NULL AND channel != '' - ORDER BY channel, chat_id`, teamID) + WHERE team_id = $1 AND channel IS NOT NULL AND channel != ''`+tenantWhere+` + ORDER BY channel, chat_id`, args...) if err != nil { return nil, err } @@ -53,11 +63,11 @@ func (s *PGTeamStore) ListTaskScopes(ctx context.Context, teamID uuid.UUID) ([]s var scopes []store.ScopeEntry for rows.Next() { - var s store.ScopeEntry - if err := rows.Scan(&s.Channel, &s.ChatID); err != nil { + var sc store.ScopeEntry + if err := rows.Scan(&sc.Channel, &sc.ChatID); err != nil { return nil, err } - scopes = append(scopes, s) + scopes = append(scopes, sc) } return scopes, rows.Err() } @@ -117,8 +127,8 @@ func (s *PGTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData) // INSERT with all fields in one statement. _, err = tx.ExecContext(ctx, `INSERT INTO team_tasks (id, team_id, subject, description, status, owner_agent_id, blocked_by, priority, result, user_id, channel, - task_type, task_number, identifier, created_by_agent_id, parent_id, chat_id, metadata, locked_at, lock_expires_at, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`, + task_type, task_number, identifier, created_by_agent_id, parent_id, chat_id, metadata, locked_at, lock_expires_at, created_at, updated_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)`, task.ID, task.TeamID, task.Subject, task.Description, task.Status, task.OwnerAgentID, pq.Array(task.BlockedBy), task.Priority, task.Result, @@ -129,7 +139,7 @@ func (s *PGTeamStore) CreateTask(ctx context.Context, task *store.TeamTaskData) sql.NullString{String: task.ChatID, Valid: task.ChatID != ""}, metaJSON, task.LockedAt, task.LockExpiresAt, - now, now, + now, now, tenantIDForInsert(ctx), ) if err != nil { return err @@ -171,8 +181,18 @@ func (s *PGTeamStore) UpdateTask(ctx context.Context, taskID uuid.UUID, updates updates["blocked_by"] = pq.Array(v) } updates["updated_at"] = time.Now() - if err := execMapUpdate(ctx, s.db, "team_tasks", taskID, updates); err != nil { - return err + var updateErr error + if store.IsCrossTenant(ctx) { + updateErr = execMapUpdate(ctx, s.db, "team_tasks", taskID, updates) + } else { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + updateErr = execMapUpdateWhereTenant(ctx, s.db, "team_tasks", updates, taskID, tid) + } + if updateErr != nil { + return updateErr } // Re-embed when subject changes. @@ -207,12 +227,26 @@ func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy s // Scope filter: always bind $4/$5 but only enforce when non-empty. scopeWhere := "AND ($4 = '' OR COALESCE(t.channel,'') = $4) AND ($5 = '' OR COALESCE(t.chat_id,'') = $5)" + // Base args: $1=teamID, $2=userID, $3=limit+1, $4=channel, $5=chatID, $6=offset + // Tenant clause appended as $7 when needed (after offset which stays at $6). + args := []any{teamID, userID, limit + 1, channel, chatID, offset} + + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = fmt.Sprintf(" AND t.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } + rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` - WHERE t.team_id = $1 AND ($2 = '' OR t.user_id = $2) `+statusWhere+` `+scopeWhere+` + WHERE t.team_id = $1 AND ($2 = '' OR t.user_id = $2) `+statusWhere+` `+scopeWhere+tenantWhere+` ORDER BY `+orderClause+` - LIMIT $3 OFFSET $6`, teamID, userID, limit+1, channel, chatID, offset) + LIMIT $3 OFFSET $6`, args...) if err != nil { return nil, err } @@ -221,10 +255,20 @@ func (s *PGTeamStore) ListTasks(ctx context.Context, teamID uuid.UUID, orderBy s } func (s *PGTeamStore) GetTask(ctx context.Context, taskID uuid.UUID) (*store.TeamTaskData, error) { + args := []any{taskID} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = fmt.Sprintf(" AND t.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` - WHERE t.id = $1`, taskID) + WHERE t.id = $1`+tenantWhere, args...) if err != nil { return nil, err } @@ -243,10 +287,20 @@ func (s *PGTeamStore) GetTasksByIDs(ctx context.Context, ids []uuid.UUID) ([]sto if len(ids) == 0 { return nil, nil } + args := []any{pq.Array(ids)} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = fmt.Sprintf(" AND t.tenant_id = $%d", len(args)+1) + args = append(args, tid) + } rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` - WHERE t.id = ANY($1)`, pq.Array(ids)) + WHERE t.id = ANY($1)`+tenantWhere, args...) if err != nil { return nil, err } @@ -287,12 +341,23 @@ func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query s if s.embProvider != nil { ftsLimit = limit * 2 // fetch more for hybrid merge } + // Base args: $1=teamID, $2=tsq, $3=ftsLimit, $4=userID + ftsArgs := []any{teamID, tsq, ftsLimit, userID} + ftsTenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + ftsTenantWhere = fmt.Sprintf(" AND t.tenant_id = $%d", len(ftsArgs)+1) + ftsArgs = append(ftsArgs, tid) + } rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` - WHERE t.team_id = $1 AND t.tsv @@ to_tsquery('simple', $2) AND ($4 = '' OR t.user_id = $4) + WHERE t.team_id = $1 AND t.tsv @@ to_tsquery('simple', $2) AND ($4 = '' OR t.user_id = $4)`+ftsTenantWhere+` ORDER BY ts_rank(t.tsv, to_tsquery('simple', $2)) DESC - LIMIT $3`, teamID, tsq, ftsLimit, userID) + LIMIT $3`, ftsArgs...) if err != nil { return nil, err } @@ -335,9 +400,19 @@ func (s *PGTeamStore) SearchTasks(ctx context.Context, teamID uuid.UUID, query s } func (s *PGTeamStore) DeleteTask(ctx context.Context, taskID, teamID uuid.UUID) error { + args := []any{taskID, teamID} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required") + } + tenantWhere = fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } res, err := s.db.ExecContext(ctx, - `DELETE FROM team_tasks WHERE id = $1 AND team_id = $2 AND status IN ('completed','failed','cancelled')`, - taskID, teamID) + `DELETE FROM team_tasks WHERE id = $1 AND team_id = $2 AND status IN ('completed','failed','cancelled')`+tenantWhere, + args...) if err != nil { return err } @@ -352,11 +427,20 @@ func (s *PGTeamStore) DeleteTasks(ctx context.Context, taskIDs []uuid.UUID, team if len(taskIDs) == 0 { return nil, nil } + args := []any{pq.Array(taskIDs), teamID} + tenantWhere := "" + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + tenantWhere = fmt.Sprintf(" AND tenant_id = $%d", len(args)+1) + args = append(args, tid) + } rows, err := s.db.QueryContext(ctx, `DELETE FROM team_tasks - WHERE id = ANY($1) AND team_id = $2 AND status IN ('completed','failed','cancelled') - RETURNING id`, - taskIDs, teamID) + WHERE id = ANY($1) AND team_id = $2 AND status IN ('completed','failed','cancelled')`+tenantWhere+` + RETURNING id`, args...) if err != nil { return nil, err } diff --git a/internal/store/pg/teams_tasks_activity.go b/internal/store/pg/teams_tasks_activity.go index 0cde2567..775e81ac 100644 --- a/internal/store/pg/teams_tasks_activity.go +++ b/internal/store/pg/teams_tasks_activity.go @@ -21,29 +21,30 @@ func (s *PGTeamStore) AddTaskComment(ctx context.Context, comment *store.TeamTas } comment.CreatedAt = time.Now() _, err := s.db.ExecContext(ctx, - `INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, created_at) - VALUES ($1, $2, $3, $4, $5, $6)`, + `INSERT INTO team_task_comments (id, task_id, agent_id, user_id, content, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, comment.ID, comment.TaskID, comment.AgentID, sql.NullString{String: comment.UserID, Valid: comment.UserID != ""}, - comment.Content, comment.CreatedAt, + comment.Content, comment.CreatedAt, tenantIDForInsert(ctx), ) if err != nil { return err } // Increment denormalized comment count. _, _ = s.db.ExecContext(ctx, - `UPDATE team_tasks SET comment_count = comment_count + 1 WHERE id = $1`, comment.TaskID) + `UPDATE team_tasks SET comment_count = comment_count + 1 WHERE id = $1 AND tenant_id = $2`, comment.TaskID, tenantIDForInsert(ctx)) return nil } func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskCommentData, error) { + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at, COALESCE(a.agent_key, '') AS agent_key FROM team_task_comments c LEFT JOIN agents a ON a.id = c.agent_id - WHERE c.task_id = $1 - ORDER BY c.created_at ASC`, taskID) + WHERE c.task_id = $1 AND c.tenant_id = $2 + ORDER BY c.created_at ASC`, taskID, tid) if err != nil { return nil, err } @@ -69,14 +70,15 @@ func (s *PGTeamStore) ListTaskComments(ctx context.Context, taskID uuid.UUID) ([ // ListRecentTaskComments returns the N most recent comments for a task (DESC order). // Used by dispatch to include context without fetching all comments. func (s *PGTeamStore) ListRecentTaskComments(ctx context.Context, taskID uuid.UUID, limit int) ([]store.TeamTaskCommentData, error) { + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT c.id, c.task_id, c.agent_id, c.user_id, c.content, c.created_at, COALESCE(a.agent_key, '') AS agent_key FROM team_task_comments c LEFT JOIN agents a ON a.id = c.agent_id - WHERE c.task_id = $1 + WHERE c.task_id = $1 AND c.tenant_id = $3 ORDER BY c.created_at DESC - LIMIT $2`, taskID, limit) + LIMIT $2`, taskID, limit, tid) if err != nil { return nil, err } @@ -116,19 +118,20 @@ func (s *PGTeamStore) RecordTaskEvent(ctx context.Context, event *store.TeamTask } event.CreatedAt = time.Now() _, err := s.db.ExecContext(ctx, - `INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, - event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt, + `INSERT INTO team_task_events (id, task_id, event_type, actor_type, actor_id, data, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + event.ID, event.TaskID, event.EventType, event.ActorType, event.ActorID, event.Data, event.CreatedAt, tenantIDForInsert(ctx), ) return err } func (s *PGTeamStore) ListTaskEvents(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskEventData, error) { + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, event_type, actor_type, actor_id, data, created_at FROM team_task_events - WHERE task_id = $1 - ORDER BY created_at ASC`, taskID) + WHERE task_id = $1 AND tenant_id = $2 + ORDER BY created_at ASC`, taskID, tid) if err != nil { return nil, err } @@ -151,14 +154,15 @@ func (s *PGTeamStore) ListTeamEvents(ctx context.Context, teamID uuid.UUID, limi if limit <= 0 || limit > 100 { limit = 50 } + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT e.id, e.task_id, e.event_type, e.actor_type, e.actor_id, e.data, e.created_at FROM team_task_events e JOIN team_tasks t ON t.id = e.task_id - WHERE t.team_id = $1 + WHERE t.team_id = $1 AND t.tenant_id = $4 ORDER BY e.created_at DESC LIMIT $2 OFFSET $3`, - teamID, limit, offset) + teamID, limit, offset, tid) if err != nil { return nil, err } @@ -190,13 +194,13 @@ func (s *PGTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskA att.Metadata = json.RawMessage(`{}`) } res, err := s.db.ExecContext(ctx, - `INSERT INTO team_task_attachments (id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `INSERT INTO team_task_attachments (id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (task_id, path) DO NOTHING`, att.ID, att.TaskID, att.TeamID, att.ChatID, att.Path, att.FileSize, att.MimeType, att.CreatedByAgentID, sql.NullString{String: att.CreatedBySenderID, Valid: att.CreatedBySenderID != ""}, - att.Metadata, att.CreatedAt, + att.Metadata, att.CreatedAt, tenantIDForInsert(ctx), ) if err != nil { return err @@ -204,7 +208,7 @@ func (s *PGTeamStore) AttachFileToTask(ctx context.Context, att *store.TeamTaskA // Increment denormalized count only if a row was actually inserted (not conflict). if n, _ := res.RowsAffected(); n > 0 { _, _ = s.db.ExecContext(ctx, - `UPDATE team_tasks SET attachment_count = attachment_count + 1 WHERE id = $1`, att.TaskID) + `UPDATE team_tasks SET attachment_count = attachment_count + 1 WHERE id = $1 AND tenant_id = $2`, att.TaskID, tenantIDForInsert(ctx)) } return nil } @@ -214,10 +218,11 @@ func (s *PGTeamStore) GetAttachment(ctx context.Context, attachmentID uuid.UUID) var agentID *uuid.UUID var senderID sql.NullString var metadata json.RawMessage + tid := tenantIDForInsert(ctx) err := s.db.QueryRowContext(ctx, `SELECT id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at - FROM team_task_attachments WHERE id = $1`, attachmentID, + FROM team_task_attachments WHERE id = $1 AND tenant_id = $2`, attachmentID, tid, ).Scan(&a.ID, &a.TaskID, &a.TeamID, &a.ChatID, &a.Path, &a.FileSize, &a.MimeType, &agentID, &senderID, &metadata, &a.CreatedAt) if err != nil { @@ -232,12 +237,13 @@ func (s *PGTeamStore) GetAttachment(ctx context.Context, attachmentID uuid.UUID) } func (s *PGTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID) ([]store.TeamTaskAttachmentData, error) { + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT id, task_id, team_id, chat_id, path, file_size, mime_type, created_by_agent_id, created_by_sender_id, metadata, created_at FROM team_task_attachments - WHERE task_id = $1 - ORDER BY created_at ASC`, taskID) + WHERE task_id = $1 AND tenant_id = $2 + ORDER BY created_at ASC`, taskID, tid) if err != nil { return nil, err } @@ -264,9 +270,10 @@ func (s *PGTeamStore) ListTaskAttachments(ctx context.Context, taskID uuid.UUID) } func (s *PGTeamStore) DetachFileFromTask(ctx context.Context, taskID uuid.UUID, path string) error { + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, - `DELETE FROM team_task_attachments WHERE task_id = $1 AND path = $2`, - taskID, path, + `DELETE FROM team_task_attachments WHERE task_id = $1 AND path = $2 AND tenant_id = $3`, + taskID, path, tid, ) if err != nil { return err @@ -274,7 +281,7 @@ func (s *PGTeamStore) DetachFileFromTask(ctx context.Context, taskID uuid.UUID, // Decrement denormalized count only if a row was actually deleted. if n, _ := res.RowsAffected(); n > 0 { _, _ = s.db.ExecContext(ctx, - `UPDATE team_tasks SET attachment_count = GREATEST(attachment_count - 1, 0) WHERE id = $1`, taskID) + `UPDATE team_tasks SET attachment_count = GREATEST(attachment_count - 1, 0) WHERE id = $1 AND tenant_id = $2`, taskID, tid) } return nil } diff --git a/internal/store/pg/teams_tasks_embedding.go b/internal/store/pg/teams_tasks_embedding.go index 38d916a0..ee21a027 100644 --- a/internal/store/pg/teams_tasks_embedding.go +++ b/internal/store/pg/teams_tasks_embedding.go @@ -115,15 +115,17 @@ func (s *PGTeamStore) SearchTasksByEmbedding(ctx context.Context, teamID uuid.UU limit = 5 } vecStr := vectorToString(embedding) + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` WHERE t.team_id = $1 AND t.embedding IS NOT NULL AND ($4 = '' OR t.user_id = $4) + AND t.tenant_id = $5 ORDER BY t.embedding <=> $2::vector LIMIT $3`, - teamID, vecStr, limit, userID) + teamID, vecStr, limit, userID, tid) if err != nil { return nil, err } diff --git a/internal/store/pg/teams_tasks_followup.go b/internal/store/pg/teams_tasks_followup.go index 97a3a9cf..72bf8a6c 100644 --- a/internal/store/pg/teams_tasks_followup.go +++ b/internal/store/pg/teams_tasks_followup.go @@ -16,11 +16,12 @@ import ( func (s *PGTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.UUID, followupAt time.Time, max int, message, channel, chatID string) error { now := time.Now() + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET followup_at = $1, followup_max = $2, followup_message = $3, followup_channel = $4, followup_chat_id = $5, updated_at = $6 - WHERE id = $7 AND team_id = $8 AND status = $9`, + WHERE id = $7 AND team_id = $8 AND status = $9 AND tenant_id = $10`, followupAt, max, message, channel, chatID, now, - taskID, teamID, store.TeamTaskStatusInProgress, + taskID, teamID, store.TeamTaskStatusInProgress, tid, ) if err != nil { return err @@ -36,10 +37,11 @@ func (s *PGTeamStore) SetTaskFollowup(ctx context.Context, taskID, teamID uuid.U } func (s *PGTeamStore) ClearTaskFollowup(ctx context.Context, taskID uuid.UUID) error { + tid := tenantIDForInsert(ctx) _, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, updated_at = $1 - WHERE id = $2`, - time.Now(), taskID, + WHERE id = $2 AND tenant_id = $3`, + time.Now(), taskID, tid, ) return err } @@ -67,22 +69,24 @@ func (s *PGTeamStore) ListAllFollowupDueTasks(ctx context.Context) ([]store.Team } func (s *PGTeamStore) IncrementFollowupCount(ctx context.Context, taskID uuid.UUID, nextAt *time.Time) error { + tid := tenantIDForInsert(ctx) _, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET followup_count = followup_count + 1, followup_at = $1, updated_at = $2 - WHERE id = $3`, - nextAt, time.Now(), taskID, + WHERE id = $3 AND tenant_id = $4`, + nextAt, time.Now(), taskID, tid, ) return err } func (s *PGTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID string) (int, error) { + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, updated_at = NOW() WHERE followup_channel = $1 AND followup_chat_id = $2 - AND followup_at IS NOT NULL AND status = $3`, - channel, chatID, store.TeamTaskStatusInProgress, + AND followup_at IS NOT NULL AND status = $3 AND tenant_id = $4`, + channel, chatID, store.TeamTaskStatusInProgress, tid, ) if err != nil { return 0, err @@ -92,6 +96,7 @@ func (s *PGTeamStore) ClearFollowupByScope(ctx context.Context, channel, chatID } func (s *PGTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid.UUID, channel, chatID string, followupAt time.Time, max int, message string) (int, error) { + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET followup_at = $4, followup_max = $5, followup_message = $6, @@ -99,12 +104,13 @@ func (s *PGTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid WHERE team_id = $1 AND status = $7 AND followup_at IS NULL + AND tenant_id = $8 AND ( (COALESCE(channel,'') = $2 AND COALESCE(chat_id,'') = $3) OR followup_channel = $2 OR (COALESCE(channel,'') IN ('', 'system', 'delegate') AND COALESCE(chat_id,'') = '') )`, - teamID, channel, chatID, followupAt, max, message, store.TeamTaskStatusInProgress, + teamID, channel, chatID, followupAt, max, message, store.TeamTaskStatusInProgress, tid, ) if err != nil { return 0, err @@ -114,6 +120,7 @@ func (s *PGTeamStore) SetFollowupForActiveTasks(ctx context.Context, teamID uuid } func (s *PGTeamStore) HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID, excludeAgentID uuid.UUID) (bool, error) { + tid := tenantIDForInsert(ctx) var exists bool err := s.db.QueryRowContext(ctx, `SELECT EXISTS( @@ -122,9 +129,10 @@ func (s *PGTeamStore) HasActiveMemberTasks(ctx context.Context, teamID uuid.UUID AND owner_agent_id IS NOT NULL AND owner_agent_id != $2 AND status IN ($3, $4, $5) + AND tenant_id = $6 )`, teamID, excludeAgentID, - store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, store.TeamTaskStatusBlocked, + store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, store.TeamTaskStatusBlocked, tid, ).Scan(&exists) return exists, err } diff --git a/internal/store/pg/teams_tasks_lifecycle.go b/internal/store/pg/teams_tasks_lifecycle.go index d474eeee..70885423 100644 --- a/internal/store/pg/teams_tasks_lifecycle.go +++ b/internal/store/pg/teams_tasks_lifecycle.go @@ -14,11 +14,12 @@ import ( func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error { now := time.Now() lockExpires := now.Add(taskLockDuration) + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET status = $1, owner_agent_id = $2, locked_at = $3, lock_expires_at = $4, updated_at = $3 - WHERE id = $5 AND status = $6 AND owner_agent_id IS NULL AND team_id = $7`, + WHERE id = $5 AND status = $6 AND owner_agent_id IS NULL AND team_id = $7 AND tenant_id = $8`, store.TeamTaskStatusInProgress, agentID, now, lockExpires, - taskID, store.TeamTaskStatusPending, teamID, + taskID, store.TeamTaskStatusPending, teamID, tid, ) if err != nil { return err @@ -36,11 +37,12 @@ func (s *PGTeamStore) ClaimTask(ctx context.Context, taskID, agentID, teamID uui func (s *PGTeamStore) AssignTask(ctx context.Context, taskID, agentID, teamID uuid.UUID) error { now := time.Now() lockExpires := now.Add(taskLockDuration) + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET status = $1, owner_agent_id = $2, locked_at = $3, lock_expires_at = $4, updated_at = $3 - WHERE id = $5 AND team_id = $6 AND status = $7`, + WHERE id = $5 AND team_id = $6 AND status = $7 AND tenant_id = $8`, store.TeamTaskStatusInProgress, agentID, now, lockExpires, - taskID, teamID, store.TeamTaskStatusPending, + taskID, teamID, store.TeamTaskStatusPending, tid, ) if err != nil { return err @@ -62,14 +64,15 @@ func (s *PGTeamStore) CompleteTask(ctx context.Context, taskID, teamID uuid.UUID } defer tx.Rollback() + tid := tenantIDForInsert(ctx) res, err := tx.ExecContext(ctx, `UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL, followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, progress_percent = NULL, updated_at = $3 - WHERE id = $4 AND status = $5 AND team_id = $6`, + WHERE id = $4 AND status = $5 AND team_id = $6 AND tenant_id = $7`, store.TeamTaskStatusCompleted, result, time.Now(), - taskID, store.TeamTaskStatusInProgress, teamID, + taskID, store.TeamTaskStatusInProgress, teamID, tid, ) if err != nil { return err @@ -96,14 +99,15 @@ func (s *PGTeamStore) CancelTask(ctx context.Context, taskID, teamID uuid.UUID, defer tx.Rollback() now := time.Now() + tid := tenantIDForInsert(ctx) res, err := tx.ExecContext(ctx, `UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL, followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, progress_percent = NULL, updated_at = $3 - WHERE id = $4 AND status NOT IN ($5, $1) AND team_id = $6`, + WHERE id = $4 AND status NOT IN ($5, $1) AND team_id = $6 AND tenant_id = $7`, store.TeamTaskStatusCancelled, reason, now, - taskID, store.TeamTaskStatusCompleted, teamID, + taskID, store.TeamTaskStatusCompleted, teamID, tid, ) if err != nil { return err @@ -130,14 +134,15 @@ func (s *PGTeamStore) FailTask(ctx context.Context, taskID, teamID uuid.UUID, er defer tx.Rollback() now := time.Now() + tid := tenantIDForInsert(ctx) res, err := tx.ExecContext(ctx, `UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL, followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, progress_percent = NULL, updated_at = $3 - WHERE id = $4 AND status = $5 AND team_id = $6`, + WHERE id = $4 AND status = $5 AND team_id = $6 AND tenant_id = $7`, store.TeamTaskStatusFailed, "FAILED: "+errMsg, now, - taskID, store.TeamTaskStatusInProgress, teamID, + taskID, store.TeamTaskStatusInProgress, teamID, tid, ) if err != nil { return err @@ -165,12 +170,13 @@ func (s *PGTeamStore) FailPendingTask(ctx context.Context, taskID, teamID uuid.U defer tx.Rollback() now := time.Now() + tid := tenantIDForInsert(ctx) res, err := tx.ExecContext(ctx, `UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL, progress_percent = NULL, updated_at = $3 - WHERE id = $4 AND status IN ($5, $6) AND team_id = $7`, + WHERE id = $4 AND status IN ($5, $6) AND team_id = $7 AND tenant_id = $8`, store.TeamTaskStatusFailed, "FAILED: "+errMsg, now, - taskID, store.TeamTaskStatusPending, store.TeamTaskStatusBlocked, teamID, + taskID, store.TeamTaskStatusPending, store.TeamTaskStatusBlocked, teamID, tid, ) if err != nil { return err @@ -191,24 +197,27 @@ func (s *PGTeamStore) FailPendingTask(ctx context.Context, taskID, teamID uuid.U // unblockDependentTasks removes taskID from blocked_by arrays and transitions blocked→pending // when all blockers are resolved. Must be called within a transaction. +// Scoped by tenant_id to prevent cross-tenant unblocking. func unblockDependentTasks(ctx context.Context, tx *sql.Tx, taskID uuid.UUID) error { + tid := tenantIDForInsert(ctx) _, err := tx.ExecContext(ctx, `UPDATE team_tasks SET blocked_by = array_remove(blocked_by, $1), status = CASE WHEN status = 'blocked' AND array_length(array_remove(blocked_by, $1), 1) IS NULL THEN 'pending' ELSE status END, updated_at = $2 - WHERE $1 = ANY(blocked_by)`, - taskID, time.Now(), + WHERE $1 = ANY(blocked_by) AND tenant_id = $3`, + taskID, time.Now(), tid, ) return err } func (s *PGTeamStore) ReviewTask(ctx context.Context, taskID, teamID uuid.UUID) error { + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET status = $1, updated_at = $2 - WHERE id = $3 AND status = $4 AND team_id = $5`, + WHERE id = $3 AND status = $4 AND team_id = $5 AND tenant_id = $6`, store.TeamTaskStatusInReview, time.Now(), - taskID, store.TeamTaskStatusInProgress, teamID, + taskID, store.TeamTaskStatusInProgress, teamID, tid, ) if err != nil { return err @@ -231,14 +240,15 @@ func (s *PGTeamStore) ApproveTask(ctx context.Context, taskID, teamID uuid.UUID, defer tx.Rollback() now := time.Now() + tid := tenantIDForInsert(ctx) res, err := tx.ExecContext(ctx, `UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL, followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, progress_percent = NULL, updated_at = $2 - WHERE id = $3 AND status = $4 AND team_id = $5`, + WHERE id = $3 AND status = $4 AND team_id = $5 AND tenant_id = $6`, store.TeamTaskStatusCompleted, now, - taskID, store.TeamTaskStatusInReview, teamID, + taskID, store.TeamTaskStatusInReview, teamID, tid, ) if err != nil { return err @@ -265,14 +275,15 @@ func (s *PGTeamStore) RejectTask(ctx context.Context, taskID, teamID uuid.UUID, defer tx.Rollback() now := time.Now() + tid := tenantIDForInsert(ctx) res, err := tx.ExecContext(ctx, `UPDATE team_tasks SET status = $1, result = $2, locked_at = NULL, lock_expires_at = NULL, followup_at = NULL, followup_count = 0, followup_message = NULL, followup_channel = NULL, followup_chat_id = NULL, progress_percent = NULL, updated_at = $3 - WHERE id = $4 AND status = $5 AND team_id = $6`, + WHERE id = $4 AND status = $5 AND team_id = $6 AND tenant_id = $7`, store.TeamTaskStatusCancelled, reason, now, - taskID, store.TeamTaskStatusInReview, teamID, + taskID, store.TeamTaskStatusInReview, teamID, tid, ) if err != nil { return err diff --git a/internal/store/pg/teams_tasks_progress.go b/internal/store/pg/teams_tasks_progress.go index 56e682ef..df0a97cb 100644 --- a/internal/store/pg/teams_tasks_progress.go +++ b/internal/store/pg/teams_tasks_progress.go @@ -21,11 +21,12 @@ func (s *PGTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uui // Also renews lock_expires_at as a heartbeat. now := time.Now() lockExpires := now.Add(taskLockDuration) + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET progress_percent = $1, progress_step = $2, lock_expires_at = $3, updated_at = $4 - WHERE id = $5 AND status = $6 AND team_id = $7`, + WHERE id = $5 AND status = $6 AND team_id = $7 AND tenant_id = $8`, percent, step, lockExpires, now, - taskID, store.TeamTaskStatusInProgress, teamID, + taskID, store.TeamTaskStatusInProgress, teamID, tid, ) if err != nil { return err @@ -46,11 +47,12 @@ func (s *PGTeamStore) UpdateTaskProgress(ctx context.Context, taskID, teamID uui func (s *PGTeamStore) RenewTaskLock(ctx context.Context, taskID, teamID uuid.UUID) error { now := time.Now() lockExpires := now.Add(taskLockDuration) + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET lock_expires_at = $1, updated_at = $2 - WHERE id = $3 AND team_id = $4 AND status = $5`, + WHERE id = $3 AND team_id = $4 AND status = $5 AND tenant_id = $6`, lockExpires, now, - taskID, teamID, store.TeamTaskStatusInProgress, + taskID, teamID, store.TeamTaskStatusInProgress, tid, ) if err != nil { return err @@ -84,7 +86,7 @@ func (s *PGTeamStore) RecoverAllStaleTasks(ctx context.Context) ([]store.Recover AND COALESCE((tm.settings->>'version')::int, 0) >= 2 AND t.status = $3 AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $2 - RETURNING t.id, t.team_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, + RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress, ) if err != nil { @@ -106,7 +108,7 @@ func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.Recover WHERE t.team_id = tm.id AND tm.status = 'active' AND COALESCE((tm.settings->>'version')::int, 0) >= 2 AND t.status = $3 - RETURNING t.id, t.team_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, + RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, store.TeamTaskStatusPending, now, store.TeamTaskStatusInProgress, ) if err != nil { @@ -120,17 +122,19 @@ func (s *PGTeamStore) ForceRecoverAllTasks(ctx context.Context) ([]store.Recover // Used by DispatchUnblockedTasks after task completion. func (s *PGTeamStore) ListRecoverableTasks(ctx context.Context, teamID uuid.UUID) ([]store.TeamTaskData, error) { now := time.Now() + tid := tenantIDForInsert(ctx) rows, err := s.db.QueryContext(ctx, `SELECT `+taskSelectCols+` `+taskJoinClause+` WHERE t.team_id = $1 + AND t.tenant_id = $6 AND ( t.status = $2 OR (t.status = $3 AND t.lock_expires_at IS NOT NULL AND t.lock_expires_at < $4) ) ORDER BY t.priority DESC, t.created_at LIMIT $5`, - teamID, store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows) + teamID, store.TeamTaskStatusPending, store.TeamTaskStatusInProgress, now, maxListTasksRows, tid) if err != nil { return nil, err } @@ -148,7 +152,7 @@ func (s *PGTeamStore) MarkAllStaleTasks(ctx context.Context, olderThan time.Time WHERE t.team_id = tm.id AND tm.status = 'active' AND COALESCE((tm.settings->>'version')::int, 0) >= 2 AND t.status = $3 AND t.updated_at < $4 - RETURNING t.id, t.team_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, + RETURNING t.id, t.team_id, t.tenant_id, t.task_number, t.subject, COALESCE(t.channel, ''), COALESCE(t.chat_id, '')`, store.TeamTaskStatusStale, now, store.TeamTaskStatusPending, olderThan, ) if err != nil { @@ -166,7 +170,7 @@ func scanRecoveredTaskInfoRows(rows interface { var out []store.RecoveredTaskInfo for rows.Next() { var info store.RecoveredTaskInfo - if err := rows.Scan(&info.ID, &info.TeamID, &info.TaskNumber, &info.Subject, &info.Channel, &info.ChatID); err != nil { + if err := rows.Scan(&info.ID, &info.TeamID, &info.TenantID, &info.TaskNumber, &info.Subject, &info.Channel, &info.ChatID); err != nil { return nil, err } out = append(out, info) @@ -179,12 +183,13 @@ func scanRecoveredTaskInfoRows(rows interface { func (s *PGTeamStore) ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error { now := time.Now() + tid := tenantIDForInsert(ctx) res, err := s.db.ExecContext(ctx, `UPDATE team_tasks SET status = $1, locked_at = NULL, lock_expires_at = NULL, result = NULL, progress_percent = NULL, progress_step = NULL, updated_at = $2 - WHERE id = $3 AND team_id = $4 AND status IN ($5, $6)`, + WHERE id = $3 AND team_id = $4 AND status IN ($5, $6) AND tenant_id = $7`, store.TeamTaskStatusPending, now, - taskID, teamID, store.TeamTaskStatusStale, store.TeamTaskStatusFailed, + taskID, teamID, store.TeamTaskStatusStale, store.TeamTaskStatusFailed, tid, ) if err != nil { return err diff --git a/internal/store/pg/tenant_configs.go b/internal/store/pg/tenant_configs.go new file mode 100644 index 00000000..7ae29f34 --- /dev/null +++ b/internal/store/pg/tenant_configs.go @@ -0,0 +1,126 @@ +package pg + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +// PGBuiltinToolTenantConfigStore implements store.BuiltinToolTenantConfigStore. +type PGBuiltinToolTenantConfigStore struct { + db *sql.DB +} + +func NewPGBuiltinToolTenantConfigStore(db *sql.DB) *PGBuiltinToolTenantConfigStore { + return &PGBuiltinToolTenantConfigStore{db: db} +} + +func (s *PGBuiltinToolTenantConfigStore) ListDisabled(ctx context.Context, tenantID uuid.UUID) ([]string, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT tool_name FROM builtin_tool_tenant_configs WHERE tenant_id = $1 AND enabled = false`, + tenantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + return names, rows.Err() +} + +func (s *PGBuiltinToolTenantConfigStore) ListAll(ctx context.Context, tenantID uuid.UUID) (map[string]bool, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT tool_name, enabled FROM builtin_tool_tenant_configs WHERE tenant_id = $1`, + tenantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + result := make(map[string]bool) + for rows.Next() { + var name string + var enabled bool + if err := rows.Scan(&name, &enabled); err != nil { + return nil, err + } + result[name] = enabled + } + return result, rows.Err() +} + +func (s *PGBuiltinToolTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, toolName string, enabled bool) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO builtin_tool_tenant_configs (tool_name, tenant_id, enabled, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (tool_name, tenant_id) DO UPDATE SET enabled = $3, updated_at = $4`, + toolName, tenantID, enabled, time.Now(), + ) + return err +} + +func (s *PGBuiltinToolTenantConfigStore) Delete(ctx context.Context, tenantID uuid.UUID, toolName string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM builtin_tool_tenant_configs WHERE tool_name = $1 AND tenant_id = $2`, + toolName, tenantID, + ) + return err +} + +// PGSkillTenantConfigStore implements store.SkillTenantConfigStore. +type PGSkillTenantConfigStore struct { + db *sql.DB +} + +func NewPGSkillTenantConfigStore(db *sql.DB) *PGSkillTenantConfigStore { + return &PGSkillTenantConfigStore{db: db} +} + +func (s *PGSkillTenantConfigStore) ListDisabledSkillIDs(ctx context.Context, tenantID uuid.UUID) ([]uuid.UUID, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT skill_id FROM skill_tenant_configs WHERE tenant_id = $1 AND enabled = false`, + tenantID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (s *PGSkillTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO skill_tenant_configs (skill_id, tenant_id, enabled, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (skill_id, tenant_id) DO UPDATE SET enabled = $3, updated_at = $4`, + skillID, tenantID, enabled, time.Now(), + ) + return err +} + +func (s *PGSkillTenantConfigStore) Delete(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM skill_tenant_configs WHERE skill_id = $1 AND tenant_id = $2`, + skillID, tenantID, + ) + return err +} diff --git a/internal/store/pg/tenant_store.go b/internal/store/pg/tenant_store.go new file mode 100644 index 00000000..7aafb994 --- /dev/null +++ b/internal/store/pg/tenant_store.go @@ -0,0 +1,229 @@ +package pg + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// PGTenantStore implements store.TenantStore backed by Postgres. +type PGTenantStore struct { + db *sql.DB +} + +// NewPGTenantStore creates a new PostgreSQL-backed tenant store. +func NewPGTenantStore(db *sql.DB) *PGTenantStore { + return &PGTenantStore{db: db} +} + +// ============================================================ +// Tenant CRUD +// ============================================================ + +func (s *PGTenantStore) CreateTenant(ctx context.Context, tenant *store.TenantData) error { + if tenant.ID == uuid.Nil { + tenant.ID = store.GenNewID() + } + now := time.Now() + tenant.CreatedAt = now + tenant.UpdatedAt = now + + settings := tenant.Settings + if len(settings) == 0 { + settings = json.RawMessage(`{}`) + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO tenants (id, name, slug, status, settings, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + tenant.ID, tenant.Name, tenant.Slug, tenant.Status, settings, now, now, + ) + return err +} + +func (s *PGTenantStore) GetTenant(ctx context.Context, id uuid.UUID) (*store.TenantData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, name, slug, status, settings, created_at, updated_at + FROM tenants WHERE id = $1`, id) + return scanTenantRow(row) +} + +func (s *PGTenantStore) GetTenantBySlug(ctx context.Context, slug string) (*store.TenantData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, name, slug, status, settings, created_at, updated_at + FROM tenants WHERE slug = $1`, slug) + return scanTenantRow(row) +} + +func (s *PGTenantStore) ListTenants(ctx context.Context) ([]store.TenantData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, slug, status, settings, created_at, updated_at + FROM tenants ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + + var tenants []store.TenantData + for rows.Next() { + d, err := scanTenantRows(rows) + if err != nil { + return nil, err + } + tenants = append(tenants, *d) + } + return tenants, rows.Err() +} + +func (s *PGTenantStore) UpdateTenant(ctx context.Context, id uuid.UUID, updates map[string]any) error { + return execMapUpdate(ctx, s.db, "tenants", id, updates) +} + +// ============================================================ +// Tenant-user membership +// ============================================================ + +func (s *PGTenantStore) AddUser(ctx context.Context, tenantID uuid.UUID, userID, role string) error { + now := time.Now() + _, err := s.db.ExecContext(ctx, + `INSERT INTO tenant_users (id, tenant_id, user_id, role, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role, updated_at = EXCLUDED.updated_at`, + store.GenNewID(), tenantID, userID, role, now, now, + ) + return err +} + +func (s *PGTenantStore) GetTenantUser(ctx context.Context, id uuid.UUID) (*store.TenantUserData, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at + FROM tenant_users WHERE id = $1`, id) + var d store.TenantUserData + if err := row.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + return &d, nil +} + +func (s *PGTenantStore) CreateTenantUserReturning(ctx context.Context, tenantID uuid.UUID, userID, displayName, role string) (*store.TenantUserData, error) { + now := time.Now() + var dn *string + if displayName != "" { + dn = &displayName + } + row := s.db.QueryRowContext(ctx, + `INSERT INTO tenant_users (id, tenant_id, user_id, display_name, role, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (tenant_id, user_id) DO UPDATE SET + display_name = COALESCE(EXCLUDED.display_name, tenant_users.display_name), + updated_at = EXCLUDED.updated_at + RETURNING id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at`, + store.GenNewID(), tenantID, userID, dn, role, now, now, + ) + var d store.TenantUserData + if err := row.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + return &d, nil +} + +func (s *PGTenantStore) RemoveUser(ctx context.Context, tenantID uuid.UUID, userID string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM tenant_users WHERE tenant_id = $1 AND user_id = $2`, + tenantID, userID, + ) + return err +} + +func (s *PGTenantStore) GetUserRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) { + var role string + err := s.db.QueryRowContext(ctx, + `SELECT role FROM tenant_users WHERE tenant_id = $1 AND user_id = $2`, + tenantID, userID, + ).Scan(&role) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return role, err +} + +func (s *PGTenantStore) ListUsers(ctx context.Context, tenantID uuid.UUID) ([]store.TenantUserData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at + FROM tenant_users WHERE tenant_id = $1 ORDER BY created_at`, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTenantUserRows(rows) +} + +func (s *PGTenantStore) ListUserTenants(ctx context.Context, userID string) ([]store.TenantUserData, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, tenant_id, user_id, display_name, role, metadata, created_at, updated_at + FROM tenant_users WHERE user_id = $1 ORDER BY created_at`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanTenantUserRows(rows) +} + +func (s *PGTenantStore) ResolveUserTenant(ctx context.Context, userID string) (uuid.UUID, error) { + var tenantID uuid.UUID + err := s.db.QueryRowContext(ctx, + `SELECT tenant_id FROM tenant_users WHERE user_id = $1 ORDER BY created_at LIMIT 1`, + userID, + ).Scan(&tenantID) + if errors.Is(err, sql.ErrNoRows) { + return store.MasterTenantID, nil + } + if err != nil { + return uuid.Nil, err + } + return tenantID, nil +} + +// ============================================================ +// Scan helpers +// ============================================================ + +func scanTenantRow(row *sql.Row) (*store.TenantData, error) { + var d store.TenantData + err := row.Scan(&d.ID, &d.Name, &d.Slug, &d.Status, &d.Settings, &d.CreatedAt, &d.UpdatedAt) + if err != nil { + return nil, err + } + return &d, nil +} + +type tenantRowScanner interface { + Scan(dest ...any) error +} + +func scanTenantRows(row tenantRowScanner) (*store.TenantData, error) { + var d store.TenantData + err := row.Scan(&d.ID, &d.Name, &d.Slug, &d.Status, &d.Settings, &d.CreatedAt, &d.UpdatedAt) + if err != nil { + return nil, err + } + return &d, nil +} + +func scanTenantUserRows(rows *sql.Rows) ([]store.TenantUserData, error) { + var result []store.TenantUserData + for rows.Next() { + var d store.TenantUserData + if err := rows.Scan(&d.ID, &d.TenantID, &d.UserID, &d.DisplayName, &d.Role, &d.Metadata, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + result = append(result, d) + } + return result, rows.Err() +} diff --git a/internal/store/pg/tracing.go b/internal/store/pg/tracing.go index c34af61f..bb99c2e7 100644 --- a/internal/store/pg/tracing.go +++ b/internal/store/pg/tracing.go @@ -26,24 +26,35 @@ func (s *PGTracingStore) CreateTrace(ctx context.Context, trace *store.TraceData if trace.ID == uuid.Nil { trace.ID = store.GenNewID() } + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } _, err := s.db.ExecContext(ctx, `INSERT INTO traces (id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, duration_ms, name, channel, input_preview, output_preview, total_input_tokens, total_output_tokens, total_cost, span_count, llm_call_count, tool_call_count, - status, error, metadata, tags, team_id, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)`, + status, error, metadata, tags, team_id, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)`, trace.ID, nilUUID(trace.ParentTraceID), nilUUID(trace.AgentID), nilStr(trace.UserID), nilStr(trace.SessionKey), nilStr(trace.RunID), trace.StartTime, nilTime(trace.EndTime), nilInt(trace.DurationMS), nilStr(trace.Name), nilStr(trace.Channel), nilStr(trace.InputPreview), nilStr(trace.OutputPreview), trace.TotalInputTokens, trace.TotalOutputTokens, trace.TotalCost, trace.SpanCount, trace.LLMCallCount, trace.ToolCallCount, - trace.Status, nilStr(trace.Error), jsonOrEmpty(trace.Metadata), pqStringArray(trace.Tags), nilUUID(trace.TeamID), trace.CreatedAt, + trace.Status, nilStr(trace.Error), jsonOrEmpty(trace.Metadata), pqStringArray(trace.Tags), nilUUID(trace.TeamID), trace.CreatedAt, tenantID, ) return err } func (s *PGTracingStore) UpdateTrace(ctx context.Context, traceID uuid.UUID, updates map[string]any) error { - return execMapUpdate(ctx, s.db, "traces", traceID, updates) + if store.IsCrossTenant(ctx) { + return execMapUpdate(ctx, s.db, "traces", traceID, updates) + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return fmt.Errorf("tenant_id required for update") + } + return execMapUpdateWhereTenant(ctx, s.db, "traces", updates, traceID, tid) } func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*store.TraceData, error) { @@ -55,13 +66,22 @@ func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*stor var metadata *[]byte var tags []byte - err := s.db.QueryRowContext(ctx, - `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, + query := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, duration_ms, name, channel, input_preview, output_preview, total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count, status, error, metadata, tags, team_id, created_at - FROM traces WHERE id = $1`, traceID, - ).Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime, + FROM traces WHERE id = $1` + qArgs := []any{traceID} + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return nil, sql.ErrNoRows + } + query += ` AND tenant_id = $2` + qArgs = append(qArgs, tenantID) + } + + err := s.db.QueryRowContext(ctx, query, qArgs...).Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime, &durationMS, &name, &channel, &inputPreview, &outputPreview, &d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount, &d.Status, &errStr, &metadata, &tags, &teamID, &d.CreatedAt) @@ -91,11 +111,21 @@ func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*stor return &d, nil } -func buildTraceWhere(opts store.TraceListOpts) (string, []any) { +func buildTraceWhere(ctx context.Context, opts store.TraceListOpts) (string, []any) { var conditions []string var args []any argIdx := 1 + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID == uuid.Nil { + return " WHERE 1=0", nil // fail-closed: no tenant = no results + } + conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx)) + args = append(args, tenantID) + argIdx++ + } + if opts.AgentID != nil { conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx)) args = append(args, *opts.AgentID) @@ -130,14 +160,14 @@ func buildTraceWhere(opts store.TraceListOpts) (string, []any) { } func (s *PGTracingStore) CountTraces(ctx context.Context, opts store.TraceListOpts) (int, error) { - where, args := buildTraceWhere(opts) + where, args := buildTraceWhere(ctx, opts) var count int err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM traces"+where, args...).Scan(&count) return count, err } func (s *PGTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpts) ([]store.TraceData, error) { - where, args := buildTraceWhere(opts) + where, args := buildTraceWhere(ctx, opts) q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time, duration_ms, name, channel, input_preview, output_preview, @@ -203,9 +233,20 @@ func (s *PGTracingStore) ListChildTraces(ctx context.Context, parentTraceID uuid duration_ms, name, channel, input_preview, output_preview, total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count, status, error, metadata, tags, team_id, created_at - FROM traces WHERE parent_trace_id = $1 ORDER BY created_at` + FROM traces WHERE parent_trace_id = $1` + qArgs := []any{parentTraceID} - rows, err := s.db.QueryContext(ctx, q, parentTraceID) + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return nil, fmt.Errorf("tenant_id required") + } + q += " AND tenant_id = $2" + qArgs = append(qArgs, tid) + } + q += " ORDER BY created_at" + + rows, err := s.db.QueryContext(ctx, q, qArgs...) if err != nil { return nil, err } @@ -256,18 +297,22 @@ func (s *PGTracingStore) CreateSpan(ctx context.Context, span *store.SpanData) e if span.ID == uuid.Nil { span.ID = store.GenNewID() } + tenantID := span.TenantID + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } _, err := s.db.ExecContext(ctx, `INSERT INTO spans (id, trace_id, parent_span_id, agent_id, span_type, name, start_time, end_time, duration_ms, status, error, level, model, provider, input_tokens, output_tokens, finish_reason, model_params, tool_name, tool_call_id, input_preview, output_preview, - metadata, team_id, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)`, + metadata, team_id, created_at, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)`, span.ID, span.TraceID, span.ParentSpanID, span.AgentID, span.SpanType, nilStr(span.Name), span.StartTime, nilTime(span.EndTime), nilInt(span.DurationMS), span.Status, nilStr(span.Error), span.Level, nilStr(span.Model), nilStr(span.Provider), nilInt(span.InputTokens), nilInt(span.OutputTokens), nilStr(span.FinishReason), jsonOrNull(span.ModelParams), nilStr(span.ToolName), nilStr(span.ToolCallID), nilStr(span.InputPreview), nilStr(span.OutputPreview), - jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, + jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, tenantID, ) return err } @@ -349,7 +394,7 @@ func (s *PGTracingStore) BatchCreateSpans(ctx context.Context, spans []store.Spa } // Build multi-row INSERT - const cols = 25 + const cols = 26 valueGroups := make([]string, len(spans)) args := make([]any, 0, len(spans)*cols) @@ -358,6 +403,10 @@ func (s *PGTracingStore) BatchCreateSpans(ctx context.Context, spans []store.Spa span.ID = store.GenNewID() spans[i].ID = span.ID } + tenantID := span.TenantID + if tenantID == uuid.Nil { + tenantID = store.MasterTenantID + } base := i * cols placeholders := make([]string, cols) for j := range cols { @@ -370,7 +419,7 @@ func (s *PGTracingStore) BatchCreateSpans(ctx context.Context, spans []store.Spa span.StartTime, nilTime(span.EndTime), nilInt(span.DurationMS), span.Status, nilStr(span.Error), span.Level, nilStr(span.Model), nilStr(span.Provider), nilInt(span.InputTokens), nilInt(span.OutputTokens), nilStr(span.FinishReason), jsonOrNull(span.ModelParams), nilStr(span.ToolName), nilStr(span.ToolCallID), nilStr(span.InputPreview), nilStr(span.OutputPreview), - jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, + jsonOrNull(span.Metadata), nilUUID(span.TeamID), span.CreatedAt, tenantID, ) } @@ -378,7 +427,7 @@ func (s *PGTracingStore) BatchCreateSpans(ctx context.Context, spans []store.Spa start_time, end_time, duration_ms, status, error, level, model, provider, input_tokens, output_tokens, finish_reason, model_params, tool_name, tool_call_id, input_preview, output_preview, - metadata, team_id, created_at) + metadata, team_id, created_at, tenant_id) VALUES ` + strings.Join(valueGroups, ", ") _, err := s.db.ExecContext(ctx, q, args...) @@ -423,12 +472,21 @@ func (s *PGTracingStore) BatchUpdateTraceAggregates(ctx context.Context, traceID func (s *PGTracingStore) GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error) { start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) end := start.AddDate(0, 1, 0) + + q := `SELECT COALESCE(SUM(total_cost), 0) FROM traces + WHERE agent_id = $1 AND created_at >= $2 AND created_at < $3 AND parent_trace_id IS NULL` + qArgs := []any{agentID, start, end} + + if !store.IsCrossTenant(ctx) { + tid := store.TenantIDFromContext(ctx) + if tid != uuid.Nil { + q += " AND tenant_id = $4" + qArgs = append(qArgs, tid) + } + } + var cost float64 - err := s.db.QueryRowContext(ctx, - `SELECT COALESCE(SUM(total_cost), 0) FROM traces - WHERE agent_id = $1 AND created_at >= $2 AND created_at < $3 AND parent_trace_id IS NULL`, - agentID, start, end, - ).Scan(&cost) + err := s.db.QueryRowContext(ctx, q, qArgs...).Scan(&cost) return cost, err } @@ -440,6 +498,15 @@ func (s *PGTracingStore) GetCostSummary(ctx context.Context, opts store.CostSumm // Only root traces (not delegations) conditions = append(conditions, "parent_trace_id IS NULL") + if !store.IsCrossTenant(ctx) { + tenantID := store.TenantIDFromContext(ctx) + if tenantID != uuid.Nil { + conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx)) + args = append(args, tenantID) + argIdx++ + } + } + if opts.AgentID != nil { conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx)) args = append(args, *opts.AgentID) diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index e597d017..eae7047c 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -62,6 +62,7 @@ var ValidProviderTypes = map[string]bool{ // LLMProviderData represents an LLM provider configuration. type LLMProviderData struct { BaseModel + TenantID uuid.UUID `json:"tenant_id,omitempty"` Name string `json:"name"` DisplayName string `json:"display_name,omitempty"` ProviderType string `json:"provider_type"` diff --git a/internal/store/session_store.go b/internal/store/session_store.go index 51074d49..80ac9797 100644 --- a/internal/store/session_store.go +++ b/internal/store/session_store.go @@ -1,6 +1,7 @@ package store import ( + "context" "time" "github.com/google/uuid" @@ -52,11 +53,12 @@ type SessionInfo struct { // SessionListOpts holds pagination options for ListPaged. type SessionListOpts struct { - AgentID string - Channel string // optional: filter by channel prefix ("ws", "telegram", etc.) - UserID string // optional: filter by user_id - Limit int - Offset int + AgentID string + Channel string // optional: filter by channel prefix ("ws", "telegram", etc.) + UserID string // optional: filter by user_id + TenantID uuid.UUID // optional: filter by tenant (uuid.Nil = no filter) + Limit int + Offset int } // SessionListResult is the paginated result of ListPaged. @@ -86,34 +88,36 @@ type SessionListRichResult struct { // SessionStore manages conversation sessions. type SessionStore interface { - GetOrCreate(key string) *SessionData - AddMessage(key string, msg providers.Message) - GetHistory(key string) []providers.Message - GetSummary(key string) string - SetSummary(key, summary string) - GetLabel(key string) string - SetLabel(key, label string) - SetAgentInfo(key string, agentUUID uuid.UUID, userID string) - UpdateMetadata(key, model, provider, channel string) - AccumulateTokens(key string, input, output int64) - IncrementCompaction(key string) - GetCompactionCount(key string) int - GetMemoryFlushCompactionCount(key string) int - SetMemoryFlushDone(key string) - GetSessionMetadata(key string) map[string]string - SetSessionMetadata(key string, metadata map[string]string) - SetSpawnInfo(key, spawnedBy string, depth int) - SetContextWindow(key string, cw int) - GetContextWindow(key string) int - SetLastPromptTokens(key string, tokens, msgCount int) - GetLastPromptTokens(key string) (tokens, msgCount int) - TruncateHistory(key string, keepLast int) - SetHistory(key string, msgs []providers.Message) - Reset(key string) - Delete(key string) error - List(agentID string) []SessionInfo - ListPaged(opts SessionListOpts) SessionListResult - ListPagedRich(opts SessionListOpts) SessionListRichResult - Save(key string) error - LastUsedChannel(agentID string) (channel, chatID string) + GetOrCreate(ctx context.Context, key string) *SessionData + // Get returns the session if it exists (cache or DB), nil otherwise. Never creates. + Get(ctx context.Context, key string) *SessionData + AddMessage(ctx context.Context, key string, msg providers.Message) + GetHistory(ctx context.Context, key string) []providers.Message + GetSummary(ctx context.Context, key string) string + SetSummary(ctx context.Context, key, summary string) + GetLabel(ctx context.Context, key string) string + SetLabel(ctx context.Context, key, label string) + SetAgentInfo(ctx context.Context, key string, agentUUID uuid.UUID, userID string) + UpdateMetadata(ctx context.Context, key, model, provider, channel string) + AccumulateTokens(ctx context.Context, key string, input, output int64) + IncrementCompaction(ctx context.Context, key string) + GetCompactionCount(ctx context.Context, key string) int + GetMemoryFlushCompactionCount(ctx context.Context, key string) int + SetMemoryFlushDone(ctx context.Context, key string) + GetSessionMetadata(ctx context.Context, key string) map[string]string + SetSessionMetadata(ctx context.Context, key string, metadata map[string]string) + SetSpawnInfo(ctx context.Context, key, spawnedBy string, depth int) + SetContextWindow(ctx context.Context, key string, cw int) + GetContextWindow(ctx context.Context, key string) int + SetLastPromptTokens(ctx context.Context, key string, tokens, msgCount int) + GetLastPromptTokens(ctx context.Context, key string) (tokens, msgCount int) + TruncateHistory(ctx context.Context, key string, keepLast int) + SetHistory(ctx context.Context, key string, msgs []providers.Message) + Reset(ctx context.Context, key string) + Delete(ctx context.Context, key string) error + List(ctx context.Context, agentID string) []SessionInfo + ListPaged(ctx context.Context, opts SessionListOpts) SessionListResult + ListPagedRich(ctx context.Context, opts SessionListOpts) SessionListRichResult + Save(ctx context.Context, key string) error + LastUsedChannel(ctx context.Context, agentID string) (channel, chatID string) } diff --git a/internal/store/skill_store.go b/internal/store/skill_store.go index c637150d..93b0226a 100644 --- a/internal/store/skill_store.go +++ b/internal/store/skill_store.go @@ -37,12 +37,12 @@ type SkillSearchResult struct { // SkillStore manages skill discovery and loading. // Backed by Postgres (PGSkillStore) or filesystem (FileSkillStore). type SkillStore interface { - ListSkills() []SkillInfo - LoadSkill(name string) (string, bool) - LoadForContext(allowList []string) string - BuildSummary(allowList []string) string - GetSkill(name string) (*SkillInfo, bool) - FilterSkills(allowList []string) []SkillInfo + ListSkills(ctx context.Context) []SkillInfo + LoadSkill(ctx context.Context, name string) (string, bool) + LoadForContext(ctx context.Context, allowList []string) string + BuildSummary(ctx context.Context, allowList []string) string + GetSkill(ctx context.Context, name string) (*SkillInfo, bool) + FilterSkills(ctx context.Context, allowList []string) []SkillInfo Version() int64 BumpVersion() Dirs() []string diff --git a/internal/store/stores.go b/internal/store/stores.go index 58c0909a..93de55c7 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -14,7 +14,6 @@ type Stores struct { Providers ProviderStore Tracing TracingStore MCP MCPServerStore - CustomTools CustomToolStore ChannelInstances ChannelInstanceStore ConfigSecrets ConfigSecretsStore AgentLinks AgentLinkStore @@ -28,5 +27,8 @@ type Stores struct { SecureCLI SecureCLIStore APIKeys APIKeyStore Heartbeats HeartbeatStore - ConfigPermissions ConfigPermissionStore + ConfigPermissions ConfigPermissionStore + Tenants TenantStore + BuiltinToolTenantCfgs BuiltinToolTenantConfigStore + SkillTenantCfgs SkillTenantConfigStore } diff --git a/internal/store/team_store.go b/internal/store/team_store.go index 18494ab3..60195ce0 100644 --- a/internal/store/team_store.go +++ b/internal/store/team_store.go @@ -13,6 +13,7 @@ import ( type RecoveredTaskInfo struct { ID uuid.UUID TeamID uuid.UUID + TenantID uuid.UUID TaskNumber int Subject string Channel string // task's origin channel for notification routing @@ -171,41 +172,17 @@ type TeamTaskAttachmentData struct { CreatedBySenderID string `json:"created_by_sender_id,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` CreatedAt time.Time `json:"created_at"` + DownloadURL string `json:"download_url,omitempty"` // signed URL, populated at delivery time } -// DelegationHistoryData represents a persisted delegation record. -type DelegationHistoryData struct { - BaseModel - SourceAgentID uuid.UUID `json:"source_agent_id"` - TargetAgentID uuid.UUID `json:"target_agent_id"` - TeamID *uuid.UUID `json:"team_id,omitempty"` - TeamTaskID *uuid.UUID `json:"team_task_id,omitempty"` - UserID string `json:"user_id,omitempty"` - Task string `json:"task"` - Mode string `json:"mode"` - Status string `json:"status"` - Result *string `json:"result,omitempty"` - Error *string `json:"error,omitempty"` - Iterations int `json:"iterations"` - TraceID *uuid.UUID `json:"trace_id,omitempty"` - DurationMS int `json:"duration_ms"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - - // Joined fields - SourceAgentKey string `json:"source_agent_key,omitempty"` - TargetAgentKey string `json:"target_agent_key,omitempty"` -} - -// DelegationHistoryListOpts configures delegation history queries. -type DelegationHistoryListOpts struct { - SourceAgentID *uuid.UUID - TargetAgentID *uuid.UUID - TeamID *uuid.UUID - UserID string - Status string // "completed", "failed", "" = all - Limit int - Offset int +// TeamUserGrant represents a user's access grant to a team. +type TeamUserGrant struct { + ID uuid.UUID `json:"id"` + TeamID uuid.UUID `json:"team_id"` + UserID string `json:"user_id"` + Role string `json:"role"` + GrantedBy string `json:"granted_by,omitempty"` + CreatedAt time.Time `json:"created_at"` } // ScopeEntry represents a unique channel+chatID scope across tasks. @@ -349,8 +326,10 @@ type TeamStore interface { // ResetTaskStatus resets a stale or failed task back to pending for retry. ResetTaskStatus(ctx context.Context, taskID, teamID uuid.UUID) error - // Delegation history - SaveDelegationHistory(ctx context.Context, record *DelegationHistoryData) error - ListDelegationHistory(ctx context.Context, opts DelegationHistoryListOpts) ([]DelegationHistoryData, int, error) - GetDelegationHistory(ctx context.Context, id uuid.UUID) (*DelegationHistoryData, error) + // Team user grants + GrantTeamAccess(ctx context.Context, teamID uuid.UUID, userID, role, grantedBy string) error + RevokeTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) error + ListTeamGrants(ctx context.Context, teamID uuid.UUID) ([]TeamUserGrant, error) + ListUserTeams(ctx context.Context, userID string) ([]TeamData, error) + HasTeamAccess(ctx context.Context, teamID uuid.UUID, userID string) (bool, error) } diff --git a/internal/store/tenant_config_store.go b/internal/store/tenant_config_store.go new file mode 100644 index 00000000..0a78180a --- /dev/null +++ b/internal/store/tenant_config_store.go @@ -0,0 +1,43 @@ +package store + +import ( + "context" + + "github.com/google/uuid" +) + +// BuiltinToolTenantConfig represents a per-tenant override for a builtin tool. +type BuiltinToolTenantConfig struct { + ToolName string `json:"tool_name"` + TenantID uuid.UUID `json:"tenant_id"` + Enabled *bool `json:"enabled,omitempty"` // nil = use default, false = disabled, true = enabled +} + +// BuiltinToolTenantConfigStore manages per-tenant builtin tool overrides. +type BuiltinToolTenantConfigStore interface { + // ListDisabled returns tool names disabled for a tenant. + ListDisabled(ctx context.Context, tenantID uuid.UUID) ([]string, error) + // ListAll returns all tenant overrides (tool_name → enabled) for a tenant. + ListAll(ctx context.Context, tenantID uuid.UUID) (map[string]bool, error) + // Set creates or updates a tenant tool config. + Set(ctx context.Context, tenantID uuid.UUID, toolName string, enabled bool) error + // Delete removes a tenant tool config (reverts to default). + Delete(ctx context.Context, tenantID uuid.UUID, toolName string) error +} + +// SkillTenantConfig represents a per-tenant override for a skill. +type SkillTenantConfig struct { + SkillID uuid.UUID `json:"skill_id"` + TenantID uuid.UUID `json:"tenant_id"` + Enabled bool `json:"enabled"` +} + +// SkillTenantConfigStore manages per-tenant skill visibility. +type SkillTenantConfigStore interface { + // ListDisabledSkillIDs returns skill IDs disabled for a tenant. + ListDisabledSkillIDs(ctx context.Context, tenantID uuid.UUID) ([]uuid.UUID, error) + // Set creates or updates a tenant skill config. + Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error + // Delete removes a tenant skill config (reverts to default). + Delete(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID) error +} diff --git a/internal/store/tenant_store.go b/internal/store/tenant_store.go new file mode 100644 index 00000000..a4761fb3 --- /dev/null +++ b/internal/store/tenant_store.go @@ -0,0 +1,81 @@ +package store + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// MasterTenantID is the fixed UUID v7 for the default/master tenant. +// All existing data defaults to this tenant during migration. +var MasterTenantID = uuid.MustParse("0193a5b0-7000-7000-8000-000000000001") + +// Tenant status constants. +const ( + TenantStatusActive = "active" + TenantStatusSuspended = "suspended" + TenantStatusArchived = "archived" +) + +// Tenant role constants (hierarchy: owner > admin > operator > member > viewer). +const ( + TenantRoleOwner = "owner" + TenantRoleAdmin = "admin" + TenantRoleOperator = "operator" + TenantRoleMember = "member" + TenantRoleViewer = "viewer" +) + +// TenantData represents a tenant in the database. +type TenantData struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Status string `json:"status"` + Settings json.RawMessage `json:"settings,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TenantUserData represents a user's membership in a tenant. +type TenantUserData struct { + ID uuid.UUID `json:"id"` + TenantID uuid.UUID `json:"tenant_id"` + UserID string `json:"user_id"` + DisplayName *string `json:"display_name,omitempty"` + Role string `json:"role"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TenantStore manages tenants and tenant-user membership. +type TenantStore interface { + // Tenant CRUD + CreateTenant(ctx context.Context, tenant *TenantData) error + GetTenant(ctx context.Context, id uuid.UUID) (*TenantData, error) + GetTenantBySlug(ctx context.Context, slug string) (*TenantData, error) + ListTenants(ctx context.Context) ([]TenantData, error) + UpdateTenant(ctx context.Context, id uuid.UUID, updates map[string]any) error + + // Tenant-user membership + AddUser(ctx context.Context, tenantID uuid.UUID, userID, role string) error + RemoveUser(ctx context.Context, tenantID uuid.UUID, userID string) error + GetUserRole(ctx context.Context, tenantID uuid.UUID, userID string) (string, error) + ListUsers(ctx context.Context, tenantID uuid.UUID) ([]TenantUserData, error) + ListUserTenants(ctx context.Context, userID string) ([]TenantUserData, error) + + // ResolveUserTenant returns the tenant_id for a user. + // If user belongs to multiple tenants, returns the first (by created_at). + // If no membership, returns MasterTenantID (backward compat). + ResolveUserTenant(ctx context.Context, userID string) (uuid.UUID, error) + + // GetTenantUser returns a single tenant_user by primary key. + GetTenantUser(ctx context.Context, id uuid.UUID) (*TenantUserData, error) + + // CreateTenantUserReturning creates a tenant_user and returns the row. + // On conflict (tenant_id, user_id), updates role/display_name and returns existing row. + CreateTenantUserReturning(ctx context.Context, tenantID uuid.UUID, userID, displayName, role string) (*TenantUserData, error) +} diff --git a/internal/store/tracing_store.go b/internal/store/tracing_store.go index 55a707e9..1f97a80a 100644 --- a/internal/store/tracing_store.go +++ b/internal/store/tracing_store.go @@ -93,6 +93,7 @@ type SpanData struct { OutputPreview string `json:"output_preview,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` TeamID *uuid.UUID `json:"team_id,omitempty"` + TenantID uuid.UUID `json:"tenant_id"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/tasks/task_ticker.go b/internal/tasks/task_ticker.go index e2d7fa88..97ec1f16 100644 --- a/internal/tasks/task_ticker.go +++ b/internal/tasks/task_ticker.go @@ -140,9 +140,10 @@ func (t *TaskTicker) recoverAll(forceRecover bool) { // ============================================================ type taskScope struct { - TeamID uuid.UUID - Channel string // from task's origin channel - ChatID string + TeamID uuid.UUID + TenantID uuid.UUID + Channel string // from task's origin channel + ChatID string } // notifyLeaders sends a batched system message per (teamID, channel, chatID) scope to the leader. @@ -154,7 +155,7 @@ func (t *TaskTicker) notifyLeaders(ctx context.Context, tasks []store.RecoveredT // Group by (team_id, channel, chat_id) → one message per scope. byScope := map[taskScope][]store.RecoveredTaskInfo{} for _, task := range tasks { - key := taskScope{TeamID: task.TeamID, Channel: task.Channel, ChatID: task.ChatID} + key := taskScope{TeamID: task.TeamID, TenantID: task.TenantID, Channel: task.Channel, ChatID: task.ChatID} byScope[key] = append(byScope[key], task) } @@ -205,6 +206,7 @@ func (t *TaskTicker) notifyLeaders(ctx context.Context, tasks []store.RecoveredT ChatID: chatID, AgentID: lead.AgentKey, UserID: team.CreatedBy, + TenantID: scope.TenantID, Content: content, }) { slog.Warn("task_ticker: inbound buffer full, notification dropped", @@ -225,15 +227,12 @@ func (t *TaskTicker) broadcastStaleEvents(ctx context.Context, tasks []store.Rec continue } seen[task.TeamID] = true - t.msgBus.Broadcast(bus.Event{ - Name: protocol.EventTeamTaskStale, - Payload: protocol.TeamTaskEventPayload{ - TeamID: task.TeamID.String(), - Status: store.TeamTaskStatusStale, - Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), - ActorType: "system", - ActorID: "task_ticker", - }, + bus.BroadcastForTenant(t.msgBus, protocol.EventTeamTaskStale, task.TenantID, protocol.TeamTaskEventPayload{ + TeamID: task.TeamID.String(), + Status: store.TeamTaskStatusStale, + Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), + ActorType: "system", + ActorID: "task_ticker", }) } } diff --git a/internal/tools/announce_queue.go b/internal/tools/announce_queue.go index 1166c892..6b7cce6f 100644 --- a/internal/tools/announce_queue.go +++ b/internal/tools/announce_queue.go @@ -6,6 +6,8 @@ import ( "sync" "time" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/bus" ) @@ -28,6 +30,7 @@ type AnnounceMetadata struct { OriginLocalKey string // composite key with topic/thread suffix for routing OriginUserID string OriginSessionKey string // exact parent session key (WS uses non-standard format) + OriginTenantID uuid.UUID // parent tenant for announce routing ParentAgent string OriginTraceID string // parent trace UUID for announce linking OriginRootSpanID string // parent agent's root span UUID diff --git a/internal/tools/boundary_test.go b/internal/tools/boundary_test.go index d60476b2..9001e50c 100644 --- a/internal/tools/boundary_test.go +++ b/internal/tools/boundary_test.go @@ -259,6 +259,66 @@ func TestCheckDeniedPath(t *testing.T) { } } +func TestResolvePathWithAllowed_TenantScoping(t *testing.T) { + // Simulate: tenant workspace is a subdirectory of global workspace. + // Paths outside tenant workspace but inside global should be BLOCKED. + globalWs := t.TempDir() + tenantWs := filepath.Join(globalWs, "tenants", "acme") + otherTenantWs := filepath.Join(globalWs, "tenants", "evil") + if err := os.MkdirAll(tenantWs, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(otherTenantWs, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(otherTenantWs, "secret.txt"), []byte("secret"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tenantWs, "ok.txt"), []byte("ok"), 0644); err != nil { + t.Fatal(err) + } + + // Using tenant workspace as base: should allow files inside tenant workspace + _, err := resolvePathWithAllowed("ok.txt", tenantWs, true, nil) + if err != nil { + t.Fatalf("expected success for file in tenant workspace, got: %v", err) + } + + // Using tenant workspace as base: should BLOCK files in other tenant's workspace + _, err = resolvePathWithAllowed(filepath.Join(otherTenantWs, "secret.txt"), tenantWs, true, nil) + if err == nil { + t.Fatal("expected error for path in another tenant's workspace, got nil") + } + + // Using GLOBAL workspace as base (the bug): would wrongly allow cross-tenant access + _, err = resolvePathWithAllowed(filepath.Join(otherTenantWs, "secret.txt"), globalWs, true, nil) + if err != nil { + t.Fatal("global workspace allows all children (demonstrates why tenant scoping matters)") + } +} + +func TestResolvePathWithAllowed_TeamWorkspaceAccess(t *testing.T) { + // Agent workspace and team workspace are separate directories. + // Team workspace should be accessible via allowed prefixes. + agentWs := t.TempDir() + teamWs := t.TempDir() + if err := os.WriteFile(filepath.Join(teamWs, "shared.txt"), []byte("shared"), 0644); err != nil { + t.Fatal(err) + } + + // Without team workspace in allowed: should BLOCK + _, err := resolvePathWithAllowed(filepath.Join(teamWs, "shared.txt"), agentWs, true, nil) + if err == nil { + t.Fatal("expected error without team workspace in allowed prefixes, got nil") + } + + // With team workspace in allowed: should ALLOW + _, err = resolvePathWithAllowed(filepath.Join(teamWs, "shared.txt"), agentWs, true, []string{teamWs}) + if err != nil { + t.Fatalf("expected success with team workspace in allowed prefixes, got: %v", err) + } +} + func TestIsPathInside(t *testing.T) { tests := []struct { child, parent string diff --git a/internal/tools/context_file_interceptor_test.go b/internal/tools/context_file_interceptor_test.go index 21abf50e..3c37c0a2 100644 --- a/internal/tools/context_file_interceptor_test.go +++ b/internal/tools/context_file_interceptor_test.go @@ -45,6 +45,12 @@ func (s *stubAgentStore) DeleteUserContextFile(_ context.Context, _ uuid.UUID, _ func (s *stubAgentStore) Create(_ context.Context, _ *store.AgentData) error { return nil } func (s *stubAgentStore) GetByKey(_ context.Context, _ string) (*store.AgentData, error) { return nil, nil } func (s *stubAgentStore) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) { return nil, nil } +func (s *stubAgentStore) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) { + return nil, nil +} +func (s *stubAgentStore) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.AgentData, error) { + return nil, nil +} func (s *stubAgentStore) GetDefault(_ context.Context) (*store.AgentData, error) { return nil, nil } func (s *stubAgentStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil } func (s *stubAgentStore) Delete(_ context.Context, _ uuid.UUID) error { return nil } diff --git a/internal/tools/context_keys.go b/internal/tools/context_keys.go index e38240c6..a6881ec7 100644 --- a/internal/tools/context_keys.go +++ b/internal/tools/context_keys.go @@ -174,10 +174,9 @@ func RestrictFromCtx(ctx context.Context) (bool, bool) { } func effectiveRestrict(ctx context.Context, toolDefault bool) bool { - if v, ok := RestrictFromCtx(ctx); ok { - return v - } - return toolDefault + // Multi-tenant security: always restrict agents to their workspace. + // Agents must not access files outside their tenant-scoped workspace. + return true } // --- Parent agent model (for subagent inheritance) --- @@ -392,11 +391,14 @@ func PendingTeamDispatchFromCtx(ctx context.Context) *PendingTeamDispatch { func InjectTeamDispatch(ctx context.Context, postTurn PostTurnProcessor) (context.Context, func()) { ptd := NewPendingTeamDispatch() ctx = WithPendingTeamDispatch(ctx, ptd) + // Detach from caller's cancel/deadline but keep values (tenant_id, user_id, etc.) + // so post-turn dispatch isn't aborted when the HTTP request or WS handler returns. + detached := context.WithoutCancel(ctx) drain := func() { ptd.ReleaseTeamLock() if postTurn != nil { for teamID, taskIDs := range ptd.Drain() { - if err := postTurn.ProcessPendingTasks(context.Background(), teamID, taskIDs); err != nil { + if err := postTurn.ProcessPendingTasks(detached, teamID, taskIDs); err != nil { slog.Warn("post_turn: dispatch failed", "team_id", teamID, "error", err) } } diff --git a/internal/tools/cron.go b/internal/tools/cron.go index 20efcf87..7eaab3ad 100644 --- a/internal/tools/cron.go +++ b/internal/tools/cron.go @@ -156,17 +156,17 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *Result { case "status": return t.handleStatus() case "list": - return t.handleList(args, agentID, userID) + return t.handleList(ctx, args, agentID, userID) case "add": return t.handleAdd(ctx, args, agentID, userID) case "update": - return t.handleUpdate(args, agentID, userID) + return t.handleUpdate(ctx, args, agentID, userID) case "remove": - return t.handleRemove(args, agentID, userID) + return t.handleRemove(ctx, args, agentID, userID) case "run": - return t.handleRun(args, agentID, userID) + return t.handleRun(ctx, args, agentID, userID) case "runs": - return t.handleRuns(args, agentID, userID) + return t.handleRuns(ctx, args, agentID, userID) default: return ErrorResult(fmt.Sprintf("unknown action: %s", action)) } @@ -178,9 +178,9 @@ func (t *CronTool) handleStatus() *Result { return NewResult(string(data)) } -func (t *CronTool) handleList(args map[string]any, agentID, userID string) *Result { +func (t *CronTool) handleList(ctx context.Context, args map[string]any, agentID, userID string) *Result { includeDisabled, _ := args["includeDisabled"].(bool) - jobs := t.cronStore.ListJobs(includeDisabled, agentID, userID) + jobs := t.cronStore.ListJobs(ctx, includeDisabled, agentID, userID) result := map[string]any{ "jobs": jobs, @@ -288,7 +288,7 @@ func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID, agentID = explicit } - job, err := t.cronStore.AddJob(name, schedule, message, deliver, channel, to, agentID, userID) + job, err := t.cronStore.AddJob(ctx, name, schedule, message, deliver, channel, to, agentID, userID) if err != nil { return ErrorResult(fmt.Sprintf("failed to create cron job: %v", err)) } @@ -296,7 +296,7 @@ func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID, // Set wake_heartbeat if requested (triggers heartbeat after cron job completes) if wh, _ := jobObj["wake_heartbeat"].(bool); wh { wakeTrue := true - if updated, uErr := t.cronStore.UpdateJob(job.ID, store.CronJobPatch{WakeHeartbeat: &wakeTrue}); uErr == nil { + if updated, uErr := t.cronStore.UpdateJob(ctx, job.ID, store.CronJobPatch{WakeHeartbeat: &wakeTrue}); uErr == nil { job = updated } } @@ -307,8 +307,8 @@ func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID, // checkJobOwnership validates that the job belongs to the current agent+user scope. // When agentID/userID is empty, all jobs are accessible. -func (t *CronTool) checkJobOwnership(jobID, agentID, userID string) (*store.CronJob, *Result) { - job, ok := t.cronStore.GetJob(jobID) +func (t *CronTool) checkJobOwnership(ctx context.Context, jobID, agentID, userID string) (*store.CronJob, *Result) { + job, ok := t.cronStore.GetJob(ctx, jobID) if !ok { return nil, ErrorResult(fmt.Sprintf("job %s not found", jobID)) } @@ -324,13 +324,13 @@ func (t *CronTool) checkJobOwnership(jobID, agentID, userID string) (*store.Cron return job, nil } -func (t *CronTool) handleUpdate(args map[string]any, agentID, userID string) *Result { +func (t *CronTool) handleUpdate(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobID := resolveJobID(args) if jobID == "" { return ErrorResult("jobId is required for update action") } - if _, errResult := t.checkJobOwnership(jobID, agentID, userID); errResult != nil { + if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil { return errResult } @@ -351,7 +351,7 @@ func (t *CronTool) handleUpdate(args map[string]any, agentID, userID string) *Re } } - job, err := t.cronStore.UpdateJob(jobID, patch) + job, err := t.cronStore.UpdateJob(ctx, jobID, patch) if err != nil { return ErrorResult(fmt.Sprintf("failed to update cron job: %v", err)) } @@ -360,17 +360,17 @@ func (t *CronTool) handleUpdate(args map[string]any, agentID, userID string) *Re return NewResult(string(data)) } -func (t *CronTool) handleRemove(args map[string]any, agentID, userID string) *Result { +func (t *CronTool) handleRemove(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobID := resolveJobID(args) if jobID == "" { return ErrorResult("jobId is required for remove action") } - if _, errResult := t.checkJobOwnership(jobID, agentID, userID); errResult != nil { + if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil { return errResult } - if err := t.cronStore.RemoveJob(jobID); err != nil { + if err := t.cronStore.RemoveJob(ctx, jobID); err != nil { return ErrorResult(fmt.Sprintf("failed to remove cron job: %v", err)) } @@ -378,20 +378,20 @@ func (t *CronTool) handleRemove(args map[string]any, agentID, userID string) *Re return NewResult(string(data)) } -func (t *CronTool) handleRun(args map[string]any, agentID, userID string) *Result { +func (t *CronTool) handleRun(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobID := resolveJobID(args) if jobID == "" { return ErrorResult("jobId is required for run action") } - if _, errResult := t.checkJobOwnership(jobID, agentID, userID); errResult != nil { + if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil { return errResult } runMode, _ := args["runMode"].(string) force := runMode == "force" - ran, reason, err := t.cronStore.RunJob(jobID, force) + ran, reason, err := t.cronStore.RunJob(ctx, jobID, force) if err != nil { return ErrorResult(fmt.Sprintf("failed to run cron job: %v", err)) } @@ -407,12 +407,12 @@ func (t *CronTool) handleRun(args map[string]any, agentID, userID string) *Resul return NewResult(string(data)) } -func (t *CronTool) handleRuns(args map[string]any, agentID, userID string) *Result { +func (t *CronTool) handleRuns(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobID := resolveJobID(args) // Validate ownership if a specific job is requested if jobID != "" { - if _, errResult := t.checkJobOwnership(jobID, agentID, userID); errResult != nil { + if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil { return errResult } } @@ -422,7 +422,7 @@ func (t *CronTool) handleRuns(args map[string]any, agentID, userID string) *Resu limit = int(v) } - entries, total := t.cronStore.GetRunLog(jobID, limit, 0) + entries, total := t.cronStore.GetRunLog(ctx, jobID, limit, 0) result := map[string]any{ "entries": entries, diff --git a/internal/tools/dynamic_loader.go b/internal/tools/dynamic_loader.go deleted file mode 100644 index 3e504c2b..00000000 --- a/internal/tools/dynamic_loader.go +++ /dev/null @@ -1,95 +0,0 @@ -package tools - -import ( - "context" - "log/slog" - "sync" - - "github.com/google/uuid" - - "github.com/nextlevelbuilder/goclaw/internal/store" -) - -// DynamicToolLoader loads custom tools from the database and registers them into tool registries. -type DynamicToolLoader struct { - store store.CustomToolStore - workspace string - mu sync.Mutex - // Track names of globally registered custom tools (for reload/unregister). - globalNames map[string]bool -} - -// NewDynamicToolLoader creates a loader for custom tools from the database. -func NewDynamicToolLoader(s store.CustomToolStore, workspace string) *DynamicToolLoader { - return &DynamicToolLoader{ - store: s, - workspace: workspace, - globalNames: make(map[string]bool), - } -} - -// LoadGlobal loads all global custom tools (agent_id IS NULL) and registers them into the registry. -// Skips tools whose names collide with existing built-in or MCP tools. -func (l *DynamicToolLoader) LoadGlobal(ctx context.Context, reg *Registry) error { - l.mu.Lock() - defer l.mu.Unlock() - - defs, err := l.store.ListGlobal(ctx) - if err != nil { - return err - } - - registered := 0 - for _, def := range defs { - if _, exists := reg.Get(def.Name); exists { - slog.Warn("custom_tools: skipping global tool (name collision with built-in/MCP)", - "tool", def.Name) - continue - } - reg.Register(NewDynamicTool(def, l.workspace)) - l.globalNames[def.Name] = true - registered++ - } - - if registered > 0 { - slog.Info("custom_tools: loaded global tools", "count", registered) - } - return nil -} - -// LoadForAgent loads per-agent custom tools and returns a cloned registry with them added. -// Returns nil if the agent has no custom tools (caller should use global registry). -func (l *DynamicToolLoader) LoadForAgent(ctx context.Context, globalReg *Registry, agentID uuid.UUID) (*Registry, error) { - defs, err := l.store.ListByAgent(ctx, agentID) - if err != nil { - return nil, err - } - if len(defs) == 0 { - return nil, nil // no per-agent tools - } - - clone := globalReg.Clone() - for _, def := range defs { - clone.Register(NewDynamicTool(def, l.workspace)) - } - - slog.Debug("custom_tools: loaded per-agent tools", "agent_id", agentID, "count", len(defs)) - return clone, nil -} - -// ReloadGlobal unregisters all previously loaded global tools and re-loads from DB. -// Used on cache invalidation events. -func (l *DynamicToolLoader) ReloadGlobal(ctx context.Context, reg *Registry) { - l.mu.Lock() - // Unregister old global custom tools - for name := range l.globalNames { - reg.Unregister(name) - } - l.globalNames = make(map[string]bool) - l.mu.Unlock() - - // Re-load (LoadGlobal acquires its own lock) - if err := l.LoadGlobal(ctx, reg); err != nil { - slog.Warn("custom_tools: failed to reload global tools", "error", err) - } -} diff --git a/internal/tools/dynamic_tool.go b/internal/tools/dynamic_tool.go deleted file mode 100644 index 58e19ae1..00000000 --- a/internal/tools/dynamic_tool.go +++ /dev/null @@ -1,133 +0,0 @@ -package tools - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os/exec" - "strings" - "time" - - "github.com/nextlevelbuilder/goclaw/internal/store" -) - -// DynamicTool wraps a CustomToolDef from the database and implements the Tool interface. -// Command templates use {{.key}} placeholders — all LLM-provided values are shell-escaped. -type DynamicTool struct { - def store.CustomToolDef - workspace string - params map[string]any -} - -// NewDynamicTool creates a Tool from a DB-stored custom tool definition. -func NewDynamicTool(def store.CustomToolDef, workspace string) *DynamicTool { - var params map[string]any - if len(def.Parameters) > 0 { - json.Unmarshal(def.Parameters, ¶ms) - } - if params == nil { - params = map[string]any{ - "type": "object", - "properties": map[string]any{}, - } - } - return &DynamicTool{def: def, workspace: workspace, params: params} -} - -func (t *DynamicTool) Name() string { return t.def.Name } -func (t *DynamicTool) Description() string { return t.def.Description } -func (t *DynamicTool) Parameters() map[string]any { return t.params } - -func (t *DynamicTool) Execute(ctx context.Context, args map[string]any) *Result { - // Render command template with shell-escaped args - command := renderCommand(t.def.Command, args) - - // Check deny patterns (uses all defaults for dynamic tools — no per-agent override) - for _, pattern := range DefaultDenyPatterns() { - if pattern.MatchString(command) { - return ErrorResult(fmt.Sprintf("command denied by safety policy: matches pattern %s", pattern.String())) - } - } - - // Timeout - timeout := time.Duration(t.def.TimeoutSeconds) * time.Second - if timeout <= 0 { - timeout = 60 * time.Second - } - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - // Working directory — per-user workspace from context, fallback to tool's workspace. - // Explicit WorkingDir on the tool definition still overrides everything. - cwd := ToolWorkspaceFromCtx(ctx) - if cwd == "" { - cwd = t.workspace - } - if t.def.WorkingDir != "" { - cwd = t.def.WorkingDir - } - - cmd := exec.CommandContext(ctx, "sh", "-c", command) - cmd.Dir = cwd - - // Decrypt and apply env vars - if len(t.def.Env) > 0 { - var envMap map[string]string - if json.Unmarshal(t.def.Env, &envMap) == nil { - for k, v := range envMap { - cmd.Env = append(cmd.Env, k+"="+v) - } - } - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - - var result string - if stdout.Len() > 0 { - result = stdout.String() - } - if stderr.Len() > 0 { - if result != "" { - result += "\n" - } - result += "STDERR:\n" + stderr.String() - } - - if err != nil { - if ctx.Err() == context.DeadlineExceeded { - return ErrorResult(fmt.Sprintf("command timed out after %s", timeout)) - } - if result == "" { - result = err.Error() - } - return ErrorResult(result) - } - - if result == "" { - result = "(command completed with no output)" - } - - return SilentResult(result) -} - -// renderCommand replaces {{.key}} placeholders with shell-escaped arg values. -// Uses simple string replacement (NOT Go text/template) for security. -func renderCommand(tmpl string, args map[string]any) string { - result := tmpl - for key, val := range args { - placeholder := "{{." + key + "}}" - escaped := shellEscape(fmt.Sprint(val)) - result = strings.ReplaceAll(result, placeholder, escaped) - } - return result -} - -// shellEscape wraps a value in single quotes, escaping embedded single quotes. -func shellEscape(s string) string { - return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" -} diff --git a/internal/tools/media_provider_chain.go b/internal/tools/media_provider_chain.go index 9e9d04f0..c706af19 100644 --- a/internal/tools/media_provider_chain.go +++ b/internal/tools/media_provider_chain.go @@ -84,7 +84,7 @@ func ResolveMediaProviderChain( } // 3. Hardcoded default chain — use first available provider - return buildDefaultChain(defaultPriority, defaultModels, registry) + return buildDefaultChain(ctx, defaultPriority, defaultModels, registry) } // parseChainSettings parses the settings JSON into a chain, handling both new @@ -116,13 +116,14 @@ func parseChainSettings(raw []byte, defaultModels map[string]string) []MediaProv // buildDefaultChain creates a chain from the hardcoded priority list, // including only providers that are currently registered. func buildDefaultChain( + ctx context.Context, priority []string, defaultModels map[string]string, registry *providers.Registry, ) []MediaProviderEntry { var chain []MediaProviderEntry for _, name := range priority { - if _, err := registry.Get(name); err == nil { + if _, err := registry.Get(ctx, name); err == nil { entry := MediaProviderEntry{ Provider: name, Model: defaultModels[name], @@ -162,7 +163,7 @@ func ExecuteWithChain( var lastErr error for _, entry := range chain { - p, err := registry.Get(entry.Provider) + p, err := registry.Get(ctx, entry.Provider) if err != nil { slog.Warn("media_chain: provider not found, skipping", "provider", entry.Provider, "error", err) diff --git a/internal/tools/read_audio_resolve.go b/internal/tools/read_audio_resolve.go index 63cab3cf..2adc823a 100644 --- a/internal/tools/read_audio_resolve.go +++ b/internal/tools/read_audio_resolve.go @@ -48,9 +48,17 @@ func (t *ReadAudioTool) resolveAudioFile(ctx context.Context, mediaID string) (p ref = &refs[len(refs)-1] } - p, err := t.mediaLoader.LoadPath(ref.ID) - if err != nil { - return "", "", fmt.Errorf("audio file not found: %v", err) + // Prefer persisted workspace path; fall back to legacy .media/ lookup. + p := ref.Path + if p == "" { + var err error + if t.mediaLoader == nil { + return "", "", fmt.Errorf("no media storage configured") + } + p, err = t.mediaLoader.LoadPath(ref.ID) + if err != nil { + return "", "", fmt.Errorf("audio file not found: %v", err) + } } mime = ref.MimeType @@ -99,7 +107,7 @@ func (t *ReadAudioTool) callProvider(ctx context.Context, cp credentialProvider, } // Other providers: try standard Chat API with base64 audio as image_url (best effort). - p, err := t.registry.Get(providerName) + p, err := t.registry.Get(ctx, providerName) if err != nil { return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err) } diff --git a/internal/tools/read_document_resolve.go b/internal/tools/read_document_resolve.go index 67269a09..8c899718 100644 --- a/internal/tools/read_document_resolve.go +++ b/internal/tools/read_document_resolve.go @@ -13,10 +13,6 @@ import ( // resolveDocumentFile finds the document file path from context MediaRefs. func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID string) (path, mime string, err error) { - if t.mediaLoader == nil { - return "", "", fmt.Errorf("no media storage configured — cannot access document files") - } - refs := MediaDocRefsFromCtx(ctx) if len(refs) == 0 { return "", "", fmt.Errorf("no documents available in this conversation. The user may not have sent a document.") @@ -39,9 +35,17 @@ func (t *ReadDocumentTool) resolveDocumentFile(ctx context.Context, mediaID stri ref = &refs[len(refs)-1] } - p, err := t.mediaLoader.LoadPath(ref.ID) - if err != nil { - return "", "", fmt.Errorf("document file not found: %v", err) + // Prefer persisted workspace path; fall back to legacy .media/ lookup. + p := ref.Path + if p == "" { + var err error + if t.mediaLoader == nil { + return "", "", fmt.Errorf("no media storage configured") + } + p, err = t.mediaLoader.LoadPath(ref.ID) + if err != nil { + return "", "", fmt.Errorf("document file not found: %v", err) + } } // Determine MIME type: prefer ref's stored MIME, fall back to extension. @@ -75,7 +79,7 @@ func (t *ReadDocumentTool) callProvider(ctx context.Context, cp credentialProvid } // Other providers: use standard Chat API with document as base64 image_url. - p, err := t.registry.Get(providerName) + p, err := t.registry.Get(ctx, providerName) if err != nil { return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err) } diff --git a/internal/tools/read_image.go b/internal/tools/read_image.go index d0b92aaf..5b4fee4d 100644 --- a/internal/tools/read_image.go +++ b/internal/tools/read_image.go @@ -125,7 +125,7 @@ func (t *ReadImageTool) callProvider(ctx context.Context, cp credentialProvider, images, _ := params["images"].([]providers.ImageContent) // Get the full provider for Chat() access - p, err := t.registry.Get(providerName) + p, err := t.registry.Get(ctx, providerName) if err != nil { return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err) } diff --git a/internal/tools/read_video_resolve.go b/internal/tools/read_video_resolve.go index 85bb3682..8a67d925 100644 --- a/internal/tools/read_video_resolve.go +++ b/internal/tools/read_video_resolve.go @@ -37,9 +37,17 @@ func (t *ReadVideoTool) resolveVideoFile(ctx context.Context, mediaID string) (p ref = &refs[len(refs)-1] } - p, err := t.mediaLoader.LoadPath(ref.ID) - if err != nil { - return "", "", fmt.Errorf("video file not found: %v", err) + // Prefer persisted workspace path; fall back to legacy .media/ lookup. + p := ref.Path + if p == "" { + var err error + if t.mediaLoader == nil { + return "", "", fmt.Errorf("no media storage configured") + } + p, err = t.mediaLoader.LoadPath(ref.ID) + if err != nil { + return "", "", fmt.Errorf("video file not found: %v", err) + } } mime = ref.MimeType @@ -70,7 +78,7 @@ func (t *ReadVideoTool) callProvider(ctx context.Context, cp credentialProvider, } // Other providers: try standard Chat API with base64 as image_url (best effort). - p, err := t.registry.Get(providerName) + p, err := t.registry.Get(ctx, providerName) if err != nil { return nil, nil, fmt.Errorf("provider %q not available: %w", providerName, err) } diff --git a/internal/tools/sessions.go b/internal/tools/sessions.go index 6c93fe91..42c8ae80 100644 --- a/internal/tools/sessions.go +++ b/internal/tools/sessions.go @@ -59,7 +59,7 @@ func (t *SessionsListTool) Execute(ctx context.Context, args map[string]any) *Re } agentID := resolveAgentIDString(ctx) - sessions := t.sessions.List(agentID) + sessions := t.sessions.List(ctx, agentID) // Filter by active_minutes if activeMinutes > 0 { @@ -148,7 +148,7 @@ func (t *SessionStatusTool) Execute(ctx context.Context, args map[string]any) *R return ErrorResult("access denied: session belongs to a different agent") } - data := t.sessions.GetOrCreate(sessionKey) + data := t.sessions.GetOrCreate(ctx, sessionKey) var lines []string lines = append(lines, fmt.Sprintf("Session: %s", data.Key)) diff --git a/internal/tools/sessions_history.go b/internal/tools/sessions_history.go index 5ae5823f..0b6855dd 100644 --- a/internal/tools/sessions_history.go +++ b/internal/tools/sessions_history.go @@ -76,7 +76,7 @@ func (t *SessionsHistoryTool) Execute(ctx context.Context, args map[string]any) return ErrorResult("access denied: session belongs to a different agent") } - history := t.sessions.GetHistory(sessionKey) + history := t.sessions.GetHistory(ctx, sessionKey) if history == nil { return SilentResult(`{"session_key":"` + sessionKey + `","messages":[],"count":0}`) } diff --git a/internal/tools/sessions_send.go b/internal/tools/sessions_send.go index c9134843..b8ea77f7 100644 --- a/internal/tools/sessions_send.go +++ b/internal/tools/sessions_send.go @@ -72,10 +72,10 @@ func (t *SessionsSendTool) Execute(ctx context.Context, args map[string]any) *Re // Resolve by label if needed if sessionKey == "" && label != "" { - sessions := t.sessions.List(agentID) + sessions := t.sessions.List(ctx, agentID) for _, s := range sessions { // Check if label matches by loading session data - data := t.sessions.GetOrCreate(s.Key) + data := t.sessions.GetOrCreate(ctx, s.Key) if data.Label == label { sessionKey = s.Key break @@ -98,6 +98,7 @@ func (t *SessionsSendTool) Execute(ctx context.Context, args map[string]any) *Re ChatID: sessionKey, Content: message, PeerKind: "direct", + TenantID: store.TenantIDFromContext(ctx), }) return SilentResult(fmt.Sprintf(`{"status":"accepted","session_key":"%s"}`, sessionKey)) diff --git a/internal/tools/shell.go b/internal/tools/shell.go index 706b5179..73272357 100644 --- a/internal/tools/shell.go +++ b/internal/tools/shell.go @@ -198,14 +198,23 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *Result { } } - // Use per-user workspace from context if available, fallback to struct field + // Use per-user workspace from context if available, fallback to struct field. + // The context workspace is tenant-scoped; t.workspace is the global (master) workspace. cwd := ToolWorkspaceFromCtx(ctx) if cwd == "" { cwd = t.workspace } if wd, _ := args["working_dir"].(string); wd != "" { if effectiveRestrict(ctx, t.restrict) { - resolved, err := resolvePath(wd, t.workspace, true) + // Validate working_dir against the tenant-scoped workspace (not the + // global workspace) so non-master tenants can't escape their scope. + // Also allow team workspace as a valid target (same as filesystem tools). + wsBase := ToolWorkspaceFromCtx(ctx) + if wsBase == "" { + wsBase = t.workspace + } + allowed := allowedWithTeamWorkspace(ctx, nil) + resolved, err := resolvePathWithAllowed(wd, wsBase, true, allowed) if err != nil { return ErrorResult(err.Error()) } diff --git a/internal/tools/skill_manage.go b/internal/tools/skill_manage.go index 1d088c17..7f9e8eec 100644 --- a/internal/tools/skill_manage.go +++ b/internal/tools/skill_manage.go @@ -13,6 +13,7 @@ import ( "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/skills" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/store/pg" @@ -22,13 +23,21 @@ import ( // Complements publish_skill (directory-based) with a content-string interface // so agents can create/patch/delete skills without pre-writing files to disk. type SkillManageTool struct { - skills *pg.PGSkillStore - base string // skills-store/ base directory - loader *skills.Loader // cache invalidation + skills *pg.PGSkillStore + base string // skills-store/ base directory (master tenant) + dataDir string // parent data dir for tenant-scoped skill paths + loader *skills.Loader // cache invalidation } -func NewSkillManageTool(skills *pg.PGSkillStore, baseDir string, loader *skills.Loader) *SkillManageTool { - return &SkillManageTool{skills: skills, base: baseDir, loader: loader} +func NewSkillManageTool(skills *pg.PGSkillStore, baseDir, dataDir string, loader *skills.Loader) *SkillManageTool { + return &SkillManageTool{skills: skills, base: baseDir, dataDir: dataDir, loader: loader} +} + +// tenantSkillsDir returns the skills-store directory scoped to the calling agent's tenant. +func (t *SkillManageTool) tenantSkillsDir(ctx context.Context) string { + tid := store.TenantIDFromContext(ctx) + slug := store.TenantSlugFromContext(ctx) + return config.TenantSkillsStoreDir(t.dataDir, tid, slug) } func (t *SkillManageTool) Name() string { return "skill_manage" } @@ -124,9 +133,9 @@ func (t *SkillManageTool) executeCreate(ctx context.Context, args map[string]any return ErrorResult(fmt.Sprintf("cannot manage system skill %q", slug)) } - // Version + destination + // Version + destination (tenant-scoped) version := t.skills.GetNextVersion(slug) - destDir := filepath.Join(t.base, slug, fmt.Sprintf("%d", version)) + destDir := filepath.Join(t.tenantSkillsDir(ctx), slug, fmt.Sprintf("%d", version)) if err := os.MkdirAll(destDir, 0755); err != nil { return ErrorResult(fmt.Sprintf("failed to create skill directory: %v", err)) } @@ -217,7 +226,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any) return ErrorResult("find is required for action=patch") } - info, ok := t.skills.GetSkill(slug) + info, ok := t.skills.GetSkill(ctx, slug) if !ok { return ErrorResult(fmt.Sprintf("skill %q not found or archived", slug)) } @@ -227,7 +236,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any) // Ownership check: only the skill owner can patch userID := store.UserIDFromContext(ctx) - if ownerID, found := t.skills.GetSkillOwnerIDBySlug(slug); found && ownerID != userID { + if ownerID, found := t.skills.GetSkillOwnerIDBySlug(ctx, slug); found && ownerID != userID { return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug)) } @@ -254,7 +263,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any) return ErrorResult(fmt.Sprintf("failed to lock version: %v", lockErr)) } defer commitLock() //nolint:errcheck - destDir := filepath.Join(t.base, slug, fmt.Sprintf("%d", newVer)) + destDir := filepath.Join(t.tenantSkillsDir(ctx), slug, fmt.Sprintf("%d", newVer)) if err := os.MkdirAll(destDir, 0755); err != nil { return ErrorResult(fmt.Sprintf("failed to create new version directory: %v", err)) } @@ -281,7 +290,7 @@ func (t *SkillManageTool) executePatch(ctx context.Context, args map[string]any) if err != nil { return ErrorResult(fmt.Sprintf("invalid skill ID in database: %v", err)) } - if err := t.skills.UpdateSkill(skillID, map[string]any{ + if err := t.skills.UpdateSkill(ctx, skillID, map[string]any{ "version": newVer, "file_path": destDir, "file_size": fileSize, @@ -307,7 +316,7 @@ func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any return ErrorResult("slug is required for action=delete") } - info, ok := t.skills.GetSkill(slug) + info, ok := t.skills.GetSkill(ctx, slug) if !ok { return ErrorResult(fmt.Sprintf("skill %q not found or already archived", slug)) } @@ -317,17 +326,18 @@ func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any // Ownership check: only the skill owner can delete deleteUserID := store.UserIDFromContext(ctx) - if ownerID, found := t.skills.GetSkillOwnerIDBySlug(slug); found && ownerID != deleteUserID { + if ownerID, found := t.skills.GetSkillOwnerIDBySlug(ctx, slug); found && ownerID != deleteUserID { return ErrorResult(fmt.Sprintf("cannot manage skill %q: you are not the owner", slug)) } // Soft-delete on disk: move to .trash/. - trashDir := filepath.Join(t.base, ".trash") + skillsDir := t.tenantSkillsDir(ctx) + trashDir := filepath.Join(skillsDir, ".trash") if err := os.MkdirAll(trashDir, 0755); err != nil { slog.Warn("skill_manage: failed to create .trash dir", "error", err) } else { timestamp := fmt.Sprintf("%d", time.Now().Unix()) - src := filepath.Join(t.base, slug) + src := filepath.Join(skillsDir, slug) dst := filepath.Join(trashDir, slug+"."+timestamp) if err := os.Rename(src, dst); err != nil { // Cross-device rename fails on some setups — log and continue (DB archive is primary) @@ -340,7 +350,7 @@ func (t *SkillManageTool) executeDelete(ctx context.Context, args map[string]any if err != nil { return ErrorResult(fmt.Sprintf("invalid skill ID in database: %v", err)) } - if err := t.skills.DeleteSkill(skillID); err != nil { + if err := t.skills.DeleteSkill(ctx, skillID); err != nil { return ErrorResult(fmt.Sprintf("failed to archive skill in database: %v", err)) } diff --git a/internal/tools/skill_search.go b/internal/tools/skill_search.go index 37f0f636..8d659e52 100644 --- a/internal/tools/skill_search.go +++ b/internal/tools/skill_search.go @@ -33,7 +33,7 @@ type SkillSearchTool struct { func NewSkillSearchTool(loader *skills.Loader) *SkillSearchTool { idx := skills.NewIndex() t := &SkillSearchTool{index: idx, loader: loader} - t.rebuildIndex() + t.rebuildIndex(store.WithCrossTenant(context.Background())) return t } @@ -49,18 +49,18 @@ func (t *SkillSearchTool) SetSkillAccessStore(sas store.SkillAccessStore) { } // rebuildIndex refreshes the BM25 index from the current skill set. -func (t *SkillSearchTool) rebuildIndex() { - allSkills := t.loader.ListSkills() +func (t *SkillSearchTool) rebuildIndex(ctx context.Context) { + allSkills := t.loader.ListSkills(ctx) t.index.Build(allSkills) t.lastVersion = t.loader.Version() slog.Info("skill_search index rebuilt", "docs", len(allSkills), "version", t.lastVersion) } // ensureIndex rebuilds the BM25 index if skills have changed since last build. -func (t *SkillSearchTool) ensureIndex() { +func (t *SkillSearchTool) ensureIndex(ctx context.Context) { current := t.loader.Version() if current > t.lastVersion { - t.rebuildIndex() + t.rebuildIndex(ctx) } } @@ -99,7 +99,7 @@ func (t *SkillSearchTool) Execute(ctx context.Context, args map[string]any) *Res } // Lazy rebuild: check if skills changed since last index build - t.ensureIndex() + t.ensureIndex(ctx) // BM25 search (always available) bm25Results := t.index.Search(query, maxResults*2) diff --git a/internal/tools/subagent.go b/internal/tools/subagent.go index a52c01a5..3e2ea0dd 100644 --- a/internal/tools/subagent.go +++ b/internal/tools/subagent.go @@ -62,6 +62,7 @@ type SubagentTask struct { CreatedAt int64 `json:"createdAt"` CompletedAt int64 `json:"completedAt,omitempty"` Media []bus.MediaFile `json:"-"` // media files from tool results + OriginTenantID uuid.UUID `json:"-"` // parent's tenant for announce routing OriginTraceID uuid.UUID `json:"-"` // parent trace for announce linking OriginRootSpanID uuid.UUID `json:"-"` // parent agent's root span ID cancelFunc context.CancelFunc `json:"-"` // per-task context cancel @@ -274,6 +275,7 @@ func (sm *SubagentManager) Spawn( OriginLocalKey: ToolLocalKeyFromCtx(ctx), OriginUserID: store.UserIDFromContext(ctx), OriginSessionKey: ToolSessionKeyFromCtx(ctx), + OriginTenantID: store.TenantIDFromContext(ctx), OriginTraceID: tracing.TraceIDFromContext(ctx), OriginRootSpanID: tracing.ParentSpanIDFromContext(ctx), CreatedAt: time.Now().UnixMilli(), diff --git a/internal/tools/subagent_exec.go b/internal/tools/subagent_exec.go index 9c35f400..74316884 100644 --- a/internal/tools/subagent_exec.go +++ b/internal/tools/subagent_exec.go @@ -40,6 +40,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call OriginLocalKey: task.OriginLocalKey, OriginUserID: task.OriginUserID, OriginSessionKey: task.OriginSessionKey, + OriginTenantID: task.OriginTenantID, ParentAgent: task.ParentID, OriginTraceID: task.OriginTraceID.String(), OriginRootSpanID: task.OriginRootSpanID.String(), @@ -75,6 +76,7 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call ChatID: task.OriginChatID, Content: announceContent, UserID: task.OriginUserID, + TenantID: task.OriginTenantID, Metadata: announceMeta, Media: task.Media, }) @@ -167,7 +169,7 @@ func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) activeProvider := sm.provider if sm.providerReg != nil { if parentProviderName := ParentProviderFromCtx(ctx); parentProviderName != "" { - if p, err := sm.providerReg.Get(parentProviderName); err == nil { + if p, err := sm.providerReg.Get(ctx, parentProviderName); err == nil { activeProvider = p } } diff --git a/internal/tools/subagent_spawn_tool.go b/internal/tools/subagent_spawn_tool.go index 5cecff88..6a309303 100644 --- a/internal/tools/subagent_spawn_tool.go +++ b/internal/tools/subagent_spawn_tool.go @@ -5,6 +5,9 @@ import ( "fmt" "strings" "time" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" ) // SpawnTool spawns subagent clones to handle tasks in the background. @@ -97,6 +100,15 @@ func (t *SpawnTool) executeSpawn(ctx context.Context, args map[string]any) *Resu agentKey, agentKey)) } + // Validate tenant isolation: non-cross-tenant callers must have a tenant in context. + // Self-clone subagents inherit caller's context (WithoutCancel), so tenant propagates automatically. + if !store.IsCrossTenant(ctx) { + callerTenant := store.TenantIDFromContext(ctx) + if callerTenant == uuid.Nil { + return ErrorResult("spawn requires tenant context: no tenant ID found in request context") + } + } + task, _ := args["task"].(string) if task == "" { return ErrorResult("task parameter is required") diff --git a/internal/tools/subagent_tracing.go b/internal/tools/subagent_tracing.go index 2de91f89..1f5f3254 100644 --- a/internal/tools/subagent_tracing.go +++ b/internal/tools/subagent_tracing.go @@ -43,6 +43,10 @@ func (sm *SubagentManager) emitLLMSpanStart(ctx context.Context, start time.Time span.ParentSpanID = &parentID } span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx) + span.TenantID = store.TenantIDFromContext(ctx) + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } if collector.Verbose() && len(messages) > 0 { if b, err := json.Marshal(messages); err == nil { span.InputPreview = truncate(string(b), 100000) @@ -131,6 +135,10 @@ func (sm *SubagentManager) emitToolSpanStart(ctx context.Context, start time.Tim span.ParentSpanID = &parentID } span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx) + span.TenantID = store.TenantIDFromContext(ctx) + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } collector.EmitSpan(span) return spanID } @@ -198,6 +206,10 @@ func (sm *SubagentManager) emitSubagentSpanStart(ctx context.Context, spanID uui span.ParentSpanID = &parentSpanID } span.TeamID = tracing.TraceTeamIDPtrFromContext(ctx) + span.TenantID = store.TenantIDFromContext(ctx) + if span.TenantID == uuid.Nil { + span.TenantID = store.MasterTenantID + } collector.EmitSpan(span) } diff --git a/internal/tools/team_tasks_followup.go b/internal/tools/team_tasks_followup.go index 274a7df2..e2a09378 100644 --- a/internal/tools/team_tasks_followup.go +++ b/internal/tools/team_tasks_followup.go @@ -117,8 +117,11 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) * if task.TeamID != team.ID { return ErrorResult("task does not belong to your team") } - if task.Status != store.TeamTaskStatusStale && task.Status != store.TeamTaskStatusFailed { - return ErrorResult(fmt.Sprintf("retry only works on stale or failed tasks (current status: %s)", task.Status)) + switch task.Status { + case store.TeamTaskStatusStale, store.TeamTaskStatusFailed, store.TeamTaskStatusCompleted: + // OK — can retry/reopen these statuses + default: + return ErrorResult(fmt.Sprintf("retry only works on completed, stale, or failed tasks (current status: %s)", task.Status)) } if task.OwnerAgentID == nil { return ErrorResult("task has no assignee — assign it first via update") @@ -133,7 +136,7 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) * return ErrorResult("failed to retry task: " + err.Error()) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), TaskNumber: task.TaskNumber, @@ -151,5 +154,6 @@ func (t *TeamTasksTool) executeRetry(ctx context.Context, args map[string]any) * // Dispatch immediately (retry is an explicit action, not during a turn). t.manager.dispatchTaskToAgent(ctx, task, team.ID, *task.OwnerAgentID) - return NewResult(fmt.Sprintf("Task %s retried and dispatched to %s.", taskID, t.manager.agentKeyFromID(ctx, *task.OwnerAgentID))) + assignee := t.manager.agentKeyFromID(ctx, *task.OwnerAgentID) + return NewResult(fmt.Sprintf("Task #%d \"%s\" (id: %s) retried and dispatched to %s. The assignee will receive the task with your recent comments.", task.TaskNumber, task.Subject, taskID, assignee)) } diff --git a/internal/tools/team_tasks_lifecycle.go b/internal/tools/team_tasks_lifecycle.go index 06f6f711..2d38a2eb 100644 --- a/internal/tools/team_tasks_lifecycle.go +++ b/internal/tools/team_tasks_lifecycle.go @@ -25,7 +25,7 @@ func (t *TeamTasksTool) executeClaim(ctx context.Context, args map[string]any) * } ownerKey := t.manager.agentKeyFromID(ctx, agentID) - t.manager.broadcastTeamEvent(protocol.EventTeamTaskClaimed, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskClaimed, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), Status: store.TeamTaskStatusInProgress, @@ -82,7 +82,7 @@ func (t *TeamTasksTool) executeComplete(ctx context.Context, args map[string]any taskNumber = completedTask.TaskNumber taskSubject = completedTask.Subject } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskCompleted, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCompleted, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), TaskNumber: taskNumber, @@ -135,7 +135,7 @@ func (t *TeamTasksTool) executeCancel(ctx context.Context, args map[string]any) return ErrorResult("failed to cancel task: " + err.Error()) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCancelled, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), Status: store.TeamTaskStatusCancelled, @@ -181,7 +181,7 @@ func (t *TeamTasksTool) executeReview(ctx context.Context, args map[string]any) } ownerKey := t.manager.agentKeyFromID(ctx, agentID) - t.manager.broadcastTeamEvent(protocol.EventTeamTaskReviewed, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskReviewed, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), Status: store.TeamTaskStatusInReview, @@ -245,7 +245,7 @@ func (t *TeamTasksTool) executeApprove(ctx context.Context, args map[string]any) newStatus = approved.Status } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskApproved, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskApproved, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), Subject: task.Subject, @@ -313,7 +313,7 @@ func (t *TeamTasksTool) executeReject(ctx context.Context, args map[string]any) return ErrorResult("failed to reject task: " + err.Error()) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskRejected, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), Subject: task.Subject, diff --git a/internal/tools/team_tasks_mutations.go b/internal/tools/team_tasks_mutations.go index 863434eb..513e0e3a 100644 --- a/internal/tools/team_tasks_mutations.go +++ b/internal/tools/team_tasks_mutations.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "log/slog" + "path/filepath" "strings" "time" "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tracing" "github.com/nextlevelbuilder/goclaw/pkg/protocol" @@ -98,7 +100,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) if assigneeKey == "" { return ErrorResult("assignee is required — specify which team member should handle this task") } - assigneeID, err := t.manager.resolveAgentByKey(assigneeKey) + assigneeID, err := t.manager.resolveAgentByKey(ctx, assigneeKey) if err != nil { return ErrorResult(fmt.Sprintf("assignee %q not found: %v", assigneeKey, err)) } @@ -140,10 +142,11 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) wsChat = "" } - // Compute the team workspace directory so member agents write files to the - // shared team folder instead of their own personal workspace. + // Compute the team workspace directory (tenant-scoped) so member agents + // write files to the shared team folder instead of their own personal workspace. taskMeta := make(map[string]any) - if teamWsDir, err := WorkspaceDir(t.manager.dataDir, team.ID, wsChat); err == nil { + tenantBase := config.TenantWorkspace(t.manager.dataDir, store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)) + if teamWsDir, err := WorkspaceDir(tenantBase, team.ID, wsChat); err == nil { taskMeta["team_workspace"] = teamWsDir } // Auto-collect media files from current run to team workspace. @@ -231,7 +234,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) } agentKey := t.manager.agentKeyFromID(ctx, agentID) - t.manager.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: task.ID.String(), Subject: subject, @@ -253,7 +256,7 @@ func (t *TeamTasksTool) executeCreate(ctx context.Context, args map[string]any) if err := t.manager.teamStore.AssignTask(ctx, task.ID, assigneeID, team.ID); err != nil { slog.Warn("executeCreate: fallback assign failed", "task_id", task.ID, "error", err) } else { - t.manager.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: task.ID.String(), TaskNumber: task.TaskNumber, @@ -314,7 +317,7 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) return ErrorResult("failed to add comment: " + err.Error()) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskCommented, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), TaskNumber: task.TaskNumber, @@ -328,7 +331,19 @@ func (t *TeamTasksTool) executeComment(ctx context.Context, args map[string]any) ActorID: t.manager.agentKeyFromID(ctx, agentID), }) - return NewResult(fmt.Sprintf("Comment added to task %s.", taskID)) + isLead := agentID == team.LeadAgentID + msg := fmt.Sprintf("Comment added to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID) + switch { + case isLead && task.Status == store.TeamTaskStatusInProgress: + msg += " Note: the assignee is currently working and won't see this comment during execution. To redirect, wait for completion then use retry action with this task_id." + case isLead && task.Status == store.TeamTaskStatusCompleted: + msg += " Task is completed. Use retry action with this task_id to reopen and re-dispatch with your feedback." + case isLead && task.Status == store.TeamTaskStatusFailed: + msg += " Task failed. Use retry action with this task_id to re-dispatch with your feedback." + case !isLead && task.OwnerAgentID != nil && *task.OwnerAgentID == agentID: + msg += " Your comment will be included in the task report sent to the leader. Continue working on the task." + } + return NewResult(msg) } func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any) *Result { @@ -376,7 +391,7 @@ func (t *TeamTasksTool) executeProgress(ctx context.Context, args map[string]any if task.OwnerAgentID != nil { ownerKey = t.manager.agentKeyFromID(ctx, *task.OwnerAgentID) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskProgress, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), TaskNumber: task.TaskNumber, @@ -410,6 +425,14 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any) return ErrorResult("path is required for attach action") } + // Resolve to absolute path within team workspace. + if !filepath.IsAbs(filePath) { + if ws := ToolTeamWorkspaceFromCtx(ctx); ws != "" { + filePath = filepath.Join(ws, filePath) + } + } + filePath = filepath.Clean(filePath) + // Verify task belongs to team. task, err := t.manager.teamStore.GetTask(ctx, taskID) if err != nil { @@ -430,7 +453,7 @@ func (t *TeamTasksTool) executeAttach(ctx context.Context, args map[string]any) return ErrorResult("failed to attach file: " + err.Error()) } - return NewResult(fmt.Sprintf("File attached to task %s.", taskID)) + return NewResult(fmt.Sprintf("File attached to task #%d \"%s\" (id: %s).", task.TaskNumber, task.Subject, taskID)) } func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) *Result { @@ -511,7 +534,7 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) return ErrorResult("failed to update task: " + err.Error()) } - t.manager.broadcastTeamEvent(protocol.EventTeamTaskUpdated, protocol.TeamTaskEventPayload{ + t.manager.broadcastTeamEvent(ctx, protocol.EventTeamTaskUpdated, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: taskID.String(), Subject: task.Subject, @@ -524,5 +547,5 @@ func (t *TeamTasksTool) executeUpdate(ctx context.Context, args map[string]any) ActorID: t.manager.agentKeyFromID(ctx, agentID), }) - return NewResult(fmt.Sprintf("Task %s updated.", taskID)) + return NewResult(fmt.Sprintf("Task #%d \"%s\" updated (id: %s).", task.TaskNumber, task.Subject, taskID)) } diff --git a/internal/tools/team_tasks_read.go b/internal/tools/team_tasks_read.go index cacf5c5d..1b711a66 100644 --- a/internal/tools/team_tasks_read.go +++ b/internal/tools/team_tasks_read.go @@ -188,6 +188,13 @@ func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *R tasks = tasks[:listPageSize] } + // Pre-warm agent cache to avoid N+1 queries for display names. + agentKeys := make([]string, 0, len(tasks)*2) + for _, task := range tasks { + agentKeys = append(agentKeys, task.OwnerAgentKey, task.CreatedByAgentKey) + } + t.manager.preWarmAgentKeyCache(ctx, agentKeys) + items := make([]taskListItem, 0, len(tasks)) for _, task := range tasks { items = append(items, t.toListItem(ctx, task)) @@ -261,12 +268,24 @@ func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Re } } + // Pre-warm cache for task owner + creator display names. + t.manager.preWarmAgentKeyCache(ctx, []string{task.OwnerAgentKey, task.CreatedByAgentKey}) + detail := t.toDetailItem(ctx, task) // Load and slim comments/events/attachments resp := map[string]any{"task": detail} if comments, _ := t.manager.teamStore.ListTaskComments(ctx, taskID); len(comments) > 0 { + // Pre-warm agent cache to avoid N+1 queries for comment agent keys. + commentAgentIDs := make([]uuid.UUID, 0, len(comments)) + for _, c := range comments { + if c.AgentID != nil { + commentAgentIDs = append(commentAgentIDs, *c.AgentID) + } + } + t.manager.preWarmAgentIDCache(ctx, commentAgentIDs) + slim := make([]slimComment, 0, len(comments)) for _, c := range comments { key := "" diff --git a/internal/tools/team_tool_cache.go b/internal/tools/team_tool_cache.go index f116ea86..d2521129 100644 --- a/internal/tools/team_tool_cache.go +++ b/internal/tools/team_tool_cache.go @@ -104,6 +104,20 @@ func (m *TeamToolManager) InvalidateAgentCache() { m.agentKeyCache.Range(func(k, _ any) bool { m.agentKeyCache.Delete(k); return true }) } +// agentKeyCacheKey builds a tenant-scoped cache key for agentKeyCache. +// Agent keys (e.g. "my-agent") are unique per-tenant, not globally, +// so the cache key must include tenant to prevent cross-tenant pollution. +func agentKeyCacheKey(ctx context.Context, key string) string { + if store.IsCrossTenant(ctx) { + return "*:" + key + } + tid := store.TenantIDFromContext(ctx) + if tid == uuid.Nil { + return "*:" + key + } + return tid.String() + ":" + key +} + // cachedGetAgentByID returns agent data from cache or DB with TTL. func (m *TeamToolManager) cachedGetAgentByID(ctx context.Context, id uuid.UUID) (*store.AgentData, error) { if entry, ok := m.agentCache.Load(id); ok { @@ -120,18 +134,19 @@ func (m *TeamToolManager) cachedGetAgentByID(ctx context.Context, id uuid.UUID) now := time.Now() e := &agentCacheEntry{agent: ag, cachedAt: now} m.agentCache.Store(id, e) - m.agentKeyCache.Store(ag.AgentKey, e) + m.agentKeyCache.Store(agentKeyCacheKey(ctx, ag.AgentKey), e) return ag, nil } // cachedGetAgentByKey returns agent data from cache or DB with TTL. func (m *TeamToolManager) cachedGetAgentByKey(ctx context.Context, key string) (*store.AgentData, error) { - if entry, ok := m.agentKeyCache.Load(key); ok { + ck := agentKeyCacheKey(ctx, key) + if entry, ok := m.agentKeyCache.Load(ck); ok { ce := entry.(*agentCacheEntry) if time.Since(ce.cachedAt) < teamCacheTTL { return ce.agent, nil } - m.agentKeyCache.Delete(key) + m.agentKeyCache.Delete(ck) } ag, err := m.agentStore.GetByKey(ctx, key) if err != nil { @@ -139,7 +154,7 @@ func (m *TeamToolManager) cachedGetAgentByKey(ctx context.Context, key string) ( } now := time.Now() e := &agentCacheEntry{agent: ag, cachedAt: now} - m.agentKeyCache.Store(key, e) + m.agentKeyCache.Store(ck, e) m.agentCache.Store(ag.ID, e) return ag, nil } @@ -155,9 +170,87 @@ func (m *TeamToolManager) cachedListMembers(ctx context.Context, teamID uuid.UUI return m.teamStore.ListMembers(ctx, teamID) } +// preWarmAgentKeyCache batch-fetches agents by key and populates the cache. +// Reduces N+1 queries when rendering task lists with agent display names. +func (m *TeamToolManager) preWarmAgentKeyCache(ctx context.Context, keys []string) { + // Deduplicate and filter already-cached keys. + unique := make(map[string]bool) + var missing []string + for _, k := range keys { + if k == "" || unique[k] { + continue + } + unique[k] = true + ck := agentKeyCacheKey(ctx, k) + if entry, ok := m.agentKeyCache.Load(ck); ok { + ce := entry.(*agentCacheEntry) + if time.Since(ce.cachedAt) < teamCacheTTL { + continue // still valid + } + } + missing = append(missing, k) + } + if len(missing) == 0 { + return + } + + agents, err := m.agentStore.GetByKeys(ctx, missing) + if err != nil { + slog.Debug("preWarmAgentKeyCache: batch fetch failed", "error", err) + return + } + now := time.Now() + for i := range agents { + ag := &agents[i] + e := &agentCacheEntry{agent: ag, cachedAt: now} + ck := agentKeyCacheKey(ctx, ag.AgentKey) + m.agentKeyCache.Store(ck, e) + m.agentCache.Store(ag.ID, e) + } +} + +// preWarmAgentIDCache batch-fetches agents by UUID and populates the cache. +// Reduces N+1 queries when rendering task comments with agent keys. +func (m *TeamToolManager) preWarmAgentIDCache(ctx context.Context, ids []uuid.UUID) { + // Deduplicate and filter already-cached IDs. + seen := make(map[uuid.UUID]bool) + var missing []uuid.UUID + for _, id := range ids { + if id == uuid.Nil || seen[id] { + continue + } + seen[id] = true + if entry, ok := m.agentCache.Load(id); ok { + ce := entry.(*agentCacheEntry) + if time.Since(ce.cachedAt) < teamCacheTTL { + continue + } + } + missing = append(missing, id) + } + if len(missing) == 0 { + return + } + + agents, err := m.agentStore.GetByIDs(ctx, missing) + if err != nil { + slog.Debug("preWarmAgentIDCache: batch fetch failed", "error", err) + return + } + now := time.Now() + for i := range agents { + ag := &agents[i] + e := &agentCacheEntry{agent: ag, cachedAt: now} + m.agentCache.Store(ag.ID, e) + ck := agentKeyCacheKey(ctx, ag.AgentKey) + m.agentKeyCache.Store(ck, e) + } +} + // resolveAgentByKey looks up an agent by key and returns its UUID. -func (m *TeamToolManager) resolveAgentByKey(key string) (uuid.UUID, error) { - ag, err := m.cachedGetAgentByKey(context.Background(), key) +// Uses the caller's tenant-scoped context for isolation. +func (m *TeamToolManager) resolveAgentByKey(ctx context.Context, key string) (uuid.UUID, error) { + ag, err := m.cachedGetAgentByKey(ctx, key) if err != nil { return uuid.Nil, fmt.Errorf("agent %q not found: %w", key, err) } diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index cc6ccf4d..aed8c5fd 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -166,6 +166,7 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T ChatID: teamID.String(), Content: content, UserID: originUserID, + TenantID: store.TenantIDFromContext(ctx), AgentID: ag.AgentKey, Metadata: meta, }) { @@ -304,7 +305,7 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui continue } dispatched[ownerID] = true - m.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ + m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: task.ID.String(), TaskNumber: task.TaskNumber, diff --git a/internal/tools/team_tool_helpers.go b/internal/tools/team_tool_helpers.go index 7e533829..fbb42a0a 100644 --- a/internal/tools/team_tool_helpers.go +++ b/internal/tools/team_tool_helpers.go @@ -13,14 +13,12 @@ import ( ) // broadcastTeamEvent sends a real-time event via the message bus for team activity visibility. -func (m *TeamToolManager) broadcastTeamEvent(name string, payload any) { +// Includes tenant_id from context for proper WS event filtering. +func (m *TeamToolManager) broadcastTeamEvent(ctx context.Context, name string, payload any) { if m.msgBus == nil { return } - m.msgBus.Broadcast(bus.Event{ - Name: name, - Payload: payload, - }) + bus.BroadcastForTenant(m.msgBus, name, store.TenantIDFromContext(ctx), payload) } // resolveTeamRole returns the calling agent's role in the team. @@ -194,7 +192,7 @@ func (m *TeamToolManager) createEscalationTask(ctx context.Context, team *store. return ErrorResult("failed to create escalation task: " + err.Error()) } - m.broadcastTeamEvent(protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{ + m.broadcastTeamEvent(ctx, protocol.EventTeamTaskCreated, protocol.TeamTaskEventPayload{ TeamID: team.ID.String(), TaskID: task.ID.String(), Subject: subject, diff --git a/internal/tools/team_tool_validation.go b/internal/tools/team_tool_validation.go index d7b3c469..390f8bd4 100644 --- a/internal/tools/team_tool_validation.go +++ b/internal/tools/team_tool_validation.go @@ -59,7 +59,7 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U if err := m.teamStore.FailPendingTask(ctx, taskID, teamID, errMsg); err != nil { slog.Warn("post_turn: FailPendingTask error", "task_id", taskID, "error", err) } - m.broadcastTeamEvent(protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{ + m.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: taskID.String(), Status: store.TeamTaskStatusFailed, @@ -91,7 +91,7 @@ func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.U slog.Warn("post_turn: assign failed", "task_id", task.ID, "error", err) continue } - m.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ + m.broadcastTeamEvent(ctx, protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: task.ID.String(), TaskNumber: task.TaskNumber, @@ -133,7 +133,7 @@ func (m *TeamToolManager) failCycledTasks(ctx context.Context, teamID uuid.UUID, if err := m.teamStore.FailPendingTask(ctx, id, teamID, cycleDesc); err != nil { slog.Warn("post_turn: FailPendingTask (cycle) error", "task_id", id, "error", err) } - m.broadcastTeamEvent(protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{ + m.broadcastTeamEvent(ctx, protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: id.String(), Status: store.TeamTaskStatusFailed, @@ -177,6 +177,7 @@ func (m *TeamToolManager) notifyLeaderCycleError(ctx context.Context, teamID uui ChatID: chatID, AgentID: leadAgent.AgentKey, UserID: team.CreatedBy, + TenantID: store.TenantIDFromContext(ctx), Content: content, }) } diff --git a/internal/tools/workspace_dir.go b/internal/tools/workspace_dir.go index fe06ddf7..6bb27243 100644 --- a/internal/tools/workspace_dir.go +++ b/internal/tools/workspace_dir.go @@ -16,11 +16,12 @@ const ( ) // WorkspaceDir returns the disk directory for a team workspace scope. -// - chatID="" → team root: {dataDir}/teams/{teamID}/ (shared mode) -// - chatID="x" → per-chat: {dataDir}/teams/{teamID}/{chatID}/ (isolated mode) +// - chatID="" → team root: {baseDir}/teams/{teamID}/ (shared mode) +// - chatID="x" → per-chat: {baseDir}/teams/{teamID}/{chatID}/ (isolated mode) +// baseDir should already be tenant-scoped (via config.TenantDataDir for non-master tenants). // Creates directory with 0750 if not exists. -func WorkspaceDir(dataDir string, teamID uuid.UUID, chatID string) (string, error) { - dir := filepath.Join(dataDir, "teams", teamID.String()) +func WorkspaceDir(baseDir string, teamID uuid.UUID, chatID string) (string, error) { + dir := filepath.Join(baseDir, "teams", teamID.String()) if chatID != "" { dir = filepath.Join(dir, chatID) } diff --git a/internal/tools/workspace_interceptor.go b/internal/tools/workspace_interceptor.go index 44a3ca9b..c0ca08bd 100644 --- a/internal/tools/workspace_interceptor.go +++ b/internal/tools/workspace_interceptor.go @@ -128,7 +128,7 @@ func (w *WorkspaceInterceptor) AfterWrite(ctx context.Context, path string, acti } // Broadcast workspace file change event for real-time UI updates. - w.teamMgr.broadcastTeamEvent(protocol.EventWorkspaceFileChanged, map[string]string{ + w.teamMgr.broadcastTeamEvent(ctx, protocol.EventWorkspaceFileChanged, map[string]string{ "team_id": teamIDStr, "channel": "", "chat_id": chatID, @@ -151,11 +151,7 @@ func (w *WorkspaceInterceptor) AfterWrite(ctx context.Context, path string, acti return } - // Compute relative path from workspace root for storage. - relPath, err := filepath.Rel(filepath.Clean(teamWs), filepath.Clean(path)) - if err != nil { - relPath = fileName - } + absPath := filepath.Clean(path) switch action { case "write": @@ -173,24 +169,79 @@ func (w *WorkspaceInterceptor) AfterWrite(ctx context.Context, path string, acti TaskID: taskID, TeamID: teamID, ChatID: chatID, - Path: relPath, + Path: absPath, FileSize: fileSize, MimeType: mimeType, CreatedByAgentID: &agentID, } if err := w.teamMgr.teamStore.AttachFileToTask(ctx, att); err != nil { - slog.Warn("workspace: auto-attach failed", "task_id", taskIDStr, "path", relPath, "error", err) + slog.Warn("workspace: auto-attach failed", "task_id", taskIDStr, "path", absPath, "error", err) } else { - slog.Debug("workspace: auto-attached file to task", "task_id", taskIDStr, "path", relPath) + slog.Debug("workspace: auto-attached file to task", "task_id", taskIDStr, "path", absPath) } case "delete": - if err := w.teamMgr.teamStore.DetachFileFromTask(ctx, taskID, relPath); err != nil { - slog.Warn("workspace: auto-detach failed", "task_id", taskIDStr, "path", relPath, "error", err) + if err := w.teamMgr.teamStore.DetachFileFromTask(ctx, taskID, absPath); err != nil { + slog.Warn("workspace: auto-detach failed", "task_id", taskIDStr, "path", absPath, "error", err) } } } +// AutoAttachWorkspaceFile attaches a file inside the team workspace to the +// current task. No-op when the file is outside workspace or no task context exists. +// Safe to call redundantly — DB uses ON CONFLICT DO NOTHING for dedup. +func AutoAttachWorkspaceFile(ctx context.Context, teamStore store.TeamStore, workspace, absPath string) { + if teamStore == nil || workspace == "" { + return + } + cleanWs := filepath.Clean(workspace) + cleanPath := filepath.Clean(absPath) + if !strings.HasPrefix(cleanPath, cleanWs) { + return + } + taskIDStr := TeamTaskIDFromCtx(ctx) + teamIDStr := ToolTeamIDFromCtx(ctx) + if taskIDStr == "" || teamIDStr == "" { + return + } + taskID, err := uuid.Parse(taskIDStr) + if err != nil { + return + } + teamID, err := uuid.Parse(teamIDStr) + if err != nil { + return + } + + var fileSize int64 + if info, err := os.Stat(cleanPath); err == nil { + fileSize = info.Size() + } + + chatID := ToolChatIDFromCtx(ctx) + if chatID == "" { + chatID = store.UserIDFromContext(ctx) + } + agentID := store.AgentIDFromContext(ctx) + + att := &store.TeamTaskAttachmentData{ + TaskID: taskID, + TeamID: teamID, + ChatID: chatID, + Path: cleanPath, + FileSize: fileSize, + MimeType: mimeFromExt(filepath.Ext(cleanPath)), + } + if agentID != uuid.Nil { + att.CreatedByAgentID = &agentID + } + if err := teamStore.AttachFileToTask(ctx, att); err != nil { + slog.Warn("workspace: auto-attach media failed", "task_id", taskIDStr, "path", cleanPath, "error", err) + } else { + slog.Debug("workspace: auto-attached media to task", "task_id", taskIDStr, "path", cleanPath) + } +} + // mimeFromExt returns a MIME type for a file extension using the stdlib registry. func mimeFromExt(ext string) string { if t := mime.TypeByExtension(ext); t != "" { diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index 14f18775..80e0c495 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 25 +const RequiredSchemaVersion uint = 27 diff --git a/migrations/000026_api_key_user_binding.down.sql b/migrations/000026_api_key_user_binding.down.sql new file mode 100644 index 00000000..4df19e39 --- /dev/null +++ b/migrations/000026_api_key_user_binding.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS team_user_grants; +DROP INDEX IF EXISTS idx_api_keys_owner_id; +ALTER TABLE api_keys DROP COLUMN IF EXISTS owner_id; +-- Note: handoff_routes and delegation_history are not recreated (dead code removed) diff --git a/migrations/000026_api_key_user_binding.up.sql b/migrations/000026_api_key_user_binding.up.sql new file mode 100644 index 00000000..25a91f19 --- /dev/null +++ b/migrations/000026_api_key_user_binding.up.sql @@ -0,0 +1,21 @@ +-- API key owner binding for identity enforcement +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS owner_id VARCHAR(255); +COMMENT ON COLUMN api_keys.owner_id IS 'User who owns this key. When set, auth via this key forces user_id = owner_id.'; +CREATE INDEX IF NOT EXISTS idx_api_keys_owner_id ON api_keys(owner_id) WHERE owner_id IS NOT NULL; + +-- Team user grants for access control +CREATE TABLE IF NOT EXISTS team_user_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'viewer', + granted_by VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(team_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_team_user_grants_user ON team_user_grants(user_id); +CREATE INDEX IF NOT EXISTS idx_team_user_grants_team ON team_user_grants(team_id); + +-- Drop unused legacy tables +DROP TABLE IF EXISTS handoff_routes; +DROP TABLE IF EXISTS delegation_history; diff --git a/migrations/000027_tenant_foundation.down.sql b/migrations/000027_tenant_foundation.down.sql new file mode 100644 index 00000000..000c4130 --- /dev/null +++ b/migrations/000027_tenant_foundation.down.sql @@ -0,0 +1,112 @@ +-- Rollback: Plan 2 Tenant Foundation + +-- Phase I rollback: Restore original UNIQUE constraints +DROP INDEX IF EXISTS idx_agents_tenant_agent_key_active; +CREATE UNIQUE INDEX idx_agents_agent_key_active ON agents(agent_key) WHERE deleted_at IS NULL; + +DROP INDEX IF EXISTS idx_sessions_tenant_session_key; +ALTER TABLE sessions ADD CONSTRAINT sessions_session_key_key UNIQUE (session_key); + +DROP INDEX IF EXISTS idx_skills_tenant_slug; +ALTER TABLE skills ADD CONSTRAINT skills_slug_key UNIQUE (slug); + +DROP INDEX IF EXISTS idx_mcp_servers_tenant_name; +ALTER TABLE mcp_servers ADD CONSTRAINT mcp_servers_name_key UNIQUE (name); + +DROP INDEX IF EXISTS idx_channel_contacts_tenant_type_sender; +ALTER TABLE channel_contacts ADD CONSTRAINT channel_contacts_channel_type_sender_id_key UNIQUE (channel_type, sender_id); + +-- Restore llm_providers global UNIQUE(name) +DROP INDEX IF EXISTS idx_llm_providers_tenant_name; +ALTER TABLE llm_providers ADD CONSTRAINT llm_providers_name_key UNIQUE (name); + +-- Restore config_secrets PK(key) +ALTER TABLE config_secrets DROP CONSTRAINT IF EXISTS config_secrets_pkey; +ALTER TABLE config_secrets ADD PRIMARY KEY (key); + +-- Restore paired_devices global UNIQUE(sender_id, channel) +DROP INDEX IF EXISTS idx_paired_devices_tenant_sender_channel; +ALTER TABLE paired_devices ADD CONSTRAINT paired_devices_sender_id_channel_key UNIQUE (sender_id, channel); + +-- Restore channel_instances global UNIQUE(name) +DROP INDEX IF EXISTS idx_channel_instances_tenant_name; +ALTER TABLE channel_instances ADD CONSTRAINT channel_instances_name_key UNIQUE (name); + +-- Restore original usage_snapshots unique index (without tenant_id) +DROP INDEX IF EXISTS idx_usage_snapshots_unique; +CREATE UNIQUE INDEX idx_usage_snapshots_unique ON usage_snapshots ( + bucket_hour, + COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'), + provider, model, channel +); + +-- Drop new tables +DROP TABLE IF EXISTS mcp_user_credentials; +DROP TABLE IF EXISTS skill_tenant_configs; +DROP TABLE IF EXISTS builtin_tool_tenant_configs; +DROP TABLE IF EXISTS tenant_users; + +-- Drop tenant_id from all tables (reverse order) +ALTER TABLE team_task_attachments DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE team_task_events DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE team_task_comments DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agent_team_members DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE embedding_cache DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE spans DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE team_tasks DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE mcp_agent_grants DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE skill_agent_grants DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agent_context_files DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE secure_cli_binaries DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE config_secrets DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE llm_providers DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE channel_contacts DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE channel_pending_messages DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE paired_devices DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE pairing_requests DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE team_user_grants DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agent_teams DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE mcp_access_requests DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE mcp_user_grants DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE mcp_servers DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE usage_snapshots DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE activity_logs DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE traces DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE cron_jobs DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE skill_user_grants DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE skills DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE kg_relations DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE kg_entities DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE memory_chunks DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE memory_documents DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE channel_instances DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agent_links DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agent_config_permissions DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE user_agent_overrides DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE user_agent_profiles DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE user_context_files DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agent_shares DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE api_keys DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE sessions DROP COLUMN IF EXISTS tenant_id; +ALTER TABLE agents DROP COLUMN IF EXISTS tenant_id; + +-- Recreate custom_tools table (restore from 000001 schema) +CREATE TABLE IF NOT EXISTS custom_tools ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + parameters JSONB DEFAULT '{}', + command TEXT NOT NULL, + working_dir TEXT DEFAULT '', + timeout_seconds INT DEFAULT 30, + env BYTEA, + agent_id UUID REFERENCES agents(id) ON DELETE CASCADE, + enabled BOOLEAN DEFAULT true, + created_by VARCHAR(255), + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Drop tenants table last (FKs already removed) +DROP TABLE IF EXISTS tenants; diff --git a/migrations/000027_tenant_foundation.up.sql b/migrations/000027_tenant_foundation.up.sql new file mode 100644 index 00000000..995f6b02 --- /dev/null +++ b/migrations/000027_tenant_foundation.up.sql @@ -0,0 +1,358 @@ +-- Plan 2: Tenant Foundation +-- Master tenant UUID v7: 0193a5b0-7000-7000-8000-000000000001 + +-- ============================================================ +-- Phase A: Create tenants + tenant_users tables +-- ============================================================ + +CREATE TABLE tenants ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT 'active', + settings JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_tenants_slug ON tenants(slug); +CREATE INDEX idx_tenants_status ON tenants(status) WHERE status = 'active'; + +-- Seed master tenant +INSERT INTO tenants (id, name, slug, status) +VALUES ('0193a5b0-7000-7000-8000-000000000001', 'Master', 'master', 'active'); + +CREATE TABLE tenant_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + display_name VARCHAR(255), + role VARCHAR(20) NOT NULL DEFAULT 'member', + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, user_id) +); + +CREATE INDEX idx_tenant_users_user ON tenant_users(user_id); +CREATE INDEX idx_tenant_users_tenant ON tenant_users(tenant_id); + +-- ============================================================ +-- Phase B: ALTER ADD tenant_id to 30 tables +-- All default to master tenant UUID (PG 11+ metadata-only op) +-- ============================================================ + +-- Core tables +ALTER TABLE agents ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE sessions ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +-- api_keys: NULLABLE (NULL = system-level cross-tenant key) +ALTER TABLE api_keys ADD COLUMN tenant_id UUID REFERENCES tenants(id); + +-- Agent ecosystem +ALTER TABLE agent_shares ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE user_context_files ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE user_agent_profiles ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE user_agent_overrides ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE agent_config_permissions ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE agent_links ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE channel_instances ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Memory + KG +ALTER TABLE memory_documents ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE memory_chunks ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE kg_entities ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE kg_relations ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Skills +ALTER TABLE skills ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE skill_user_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Cron +ALTER TABLE cron_jobs ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Tracing + Activity +ALTER TABLE traces ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE activity_logs ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE usage_snapshots ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- MCP +ALTER TABLE mcp_servers ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE mcp_user_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE mcp_access_requests ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Teams +ALTER TABLE agent_teams ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE team_user_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Pairing + Channels +ALTER TABLE pairing_requests ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE paired_devices ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE channel_pending_messages ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE channel_contacts ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- LLM Providers + Config Secrets +ALTER TABLE llm_providers ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE config_secrets ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Other +ALTER TABLE secure_cli_binaries ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Grant tables +ALTER TABLE agent_context_files ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE skill_agent_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE mcp_agent_grants ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Tasks + Tracing +ALTER TABLE team_tasks ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE spans ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Cache +ALTER TABLE embedding_cache ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- Team activity tables +ALTER TABLE agent_team_members ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE team_task_comments ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE team_task_events ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); +ALTER TABLE team_task_attachments ADD COLUMN tenant_id UUID NOT NULL DEFAULT '0193a5b0-7000-7000-8000-000000000001' REFERENCES tenants(id); + +-- ============================================================ +-- Phase C: Drop defaults (force explicit tenant_id for new rows) +-- ============================================================ + +ALTER TABLE agents ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE sessions ALTER COLUMN tenant_id DROP DEFAULT; +-- api_keys: no default to drop (nullable, no DEFAULT set) +ALTER TABLE agent_shares ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE user_context_files ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE user_agent_profiles ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE user_agent_overrides ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE agent_config_permissions ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE agent_links ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE channel_instances ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE memory_documents ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE memory_chunks ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE kg_entities ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE kg_relations ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE skills ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE skill_user_grants ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE cron_jobs ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE traces ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE activity_logs ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE usage_snapshots ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE mcp_servers ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE mcp_user_grants ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE mcp_access_requests ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE agent_teams ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE team_user_grants ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE pairing_requests ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE paired_devices ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE channel_pending_messages ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE channel_contacts ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE llm_providers ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE config_secrets ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE secure_cli_binaries ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE agent_context_files ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE skill_agent_grants ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE mcp_agent_grants ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE team_tasks ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE spans ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE embedding_cache ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE agent_team_members ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE team_task_comments ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE team_task_events ALTER COLUMN tenant_id DROP DEFAULT; +ALTER TABLE team_task_attachments ALTER COLUMN tenant_id DROP DEFAULT; + +-- ============================================================ +-- Phase D: Indexes +-- ============================================================ + +-- Per-table tenant indexes +CREATE INDEX idx_agents_tenant ON agents(tenant_id); +CREATE INDEX idx_sessions_tenant ON sessions(tenant_id); +CREATE INDEX idx_api_keys_tenant ON api_keys(tenant_id) WHERE tenant_id IS NOT NULL; +CREATE INDEX idx_agent_shares_tenant ON agent_shares(tenant_id); +CREATE INDEX idx_user_context_files_tenant ON user_context_files(tenant_id); +CREATE INDEX idx_user_agent_profiles_tenant ON user_agent_profiles(tenant_id); +CREATE INDEX idx_user_agent_overrides_tenant ON user_agent_overrides(tenant_id); +CREATE INDEX idx_agent_config_permissions_tenant ON agent_config_permissions(tenant_id); +CREATE INDEX idx_agent_links_tenant ON agent_links(tenant_id); +CREATE INDEX idx_channel_instances_tenant ON channel_instances(tenant_id); +CREATE INDEX idx_memory_documents_tenant ON memory_documents(tenant_id); +CREATE INDEX idx_memory_chunks_tenant ON memory_chunks(tenant_id); +CREATE INDEX idx_kg_entities_tenant ON kg_entities(tenant_id); +CREATE INDEX idx_kg_relations_tenant ON kg_relations(tenant_id); +CREATE INDEX idx_skills_tenant ON skills(tenant_id); +CREATE INDEX idx_skill_user_grants_tenant ON skill_user_grants(tenant_id); +CREATE INDEX idx_cron_jobs_tenant ON cron_jobs(tenant_id); +CREATE INDEX idx_traces_tenant ON traces(tenant_id); +CREATE INDEX idx_activity_logs_tenant ON activity_logs(tenant_id); +CREATE INDEX idx_usage_snapshots_tenant ON usage_snapshots(tenant_id); +CREATE INDEX idx_mcp_servers_tenant ON mcp_servers(tenant_id); +CREATE INDEX idx_mcp_user_grants_tenant ON mcp_user_grants(tenant_id); +CREATE INDEX idx_mcp_access_requests_tenant ON mcp_access_requests(tenant_id); +CREATE INDEX idx_agent_teams_tenant ON agent_teams(tenant_id); +CREATE INDEX idx_team_user_grants_tenant ON team_user_grants(tenant_id); +CREATE INDEX idx_pairing_requests_tenant ON pairing_requests(tenant_id); +CREATE INDEX idx_paired_devices_tenant ON paired_devices(tenant_id); +CREATE INDEX idx_channel_pending_messages_tenant ON channel_pending_messages(tenant_id); +CREATE INDEX idx_channel_contacts_tenant ON channel_contacts(tenant_id); +CREATE INDEX idx_llm_providers_tenant ON llm_providers(tenant_id); +CREATE INDEX idx_config_secrets_tenant ON config_secrets(tenant_id); +CREATE INDEX idx_secure_cli_binaries_tenant ON secure_cli_binaries(tenant_id); +CREATE INDEX idx_agent_context_files_tenant ON agent_context_files(tenant_id); +CREATE INDEX idx_skill_agent_grants_tenant ON skill_agent_grants(tenant_id); +CREATE INDEX idx_mcp_agent_grants_tenant ON mcp_agent_grants(tenant_id); +CREATE INDEX idx_team_tasks_tenant ON team_tasks(tenant_id); +CREATE INDEX idx_spans_tenant ON spans(tenant_id); +CREATE INDEX idx_embedding_cache_tenant ON embedding_cache(tenant_id); +CREATE INDEX idx_agent_team_members_tenant ON agent_team_members(tenant_id); +CREATE INDEX idx_team_task_comments_tenant ON team_task_comments(tenant_id); +CREATE INDEX idx_team_task_events_tenant ON team_task_events(tenant_id); +CREATE INDEX idx_team_task_attachments_tenant ON team_task_attachments(tenant_id); + +-- Composite indexes for Plan 3 query performance +CREATE INDEX idx_agents_tenant_active ON agents(tenant_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_sessions_tenant_user ON sessions(tenant_id, user_id); +CREATE INDEX idx_traces_tenant_time ON traces(tenant_id, created_at DESC); + +-- ============================================================ +-- Phase E: Seed master tenant owner from existing agents +-- ============================================================ + +INSERT INTO tenant_users (tenant_id, user_id, role) +SELECT DISTINCT '0193a5b0-7000-7000-8000-000000000001'::uuid, owner_id, 'owner' +FROM agents +WHERE owner_id IS NOT NULL AND owner_id != '' +LIMIT 1 +ON CONFLICT (tenant_id, user_id) DO NOTHING; + +-- ============================================================ +-- Phase F: DROP custom_tools table (dead code — agent loop never wired) +-- ============================================================ + +DROP TABLE IF EXISTS custom_tools; + +-- ============================================================ +-- Phase G: Per-tenant builtin tool config overrides +-- ============================================================ + +CREATE TABLE builtin_tool_tenant_configs ( + tool_name VARCHAR(100) NOT NULL REFERENCES builtin_tools(name) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + enabled BOOLEAN, + settings JSONB, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (tool_name, tenant_id) +); + +CREATE INDEX idx_builtin_tool_tenant_configs_tenant ON builtin_tool_tenant_configs(tenant_id); + +-- ============================================================ +-- Phase H: Per-tenant skill config (disable system skills) +-- ============================================================ + +CREATE TABLE skill_tenant_configs ( + skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (skill_id, tenant_id) +); + +CREATE INDEX idx_skill_tenant_configs_tenant ON skill_tenant_configs(tenant_id); + +-- ============================================================ +-- Phase J: MCP per-user credentials +-- ============================================================ + +CREATE TABLE mcp_user_credentials ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + server_id UUID NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + api_key TEXT, + headers BYTEA, + env BYTEA, + tenant_id UUID NOT NULL REFERENCES tenants(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(server_id, user_id, tenant_id) +); + +CREATE INDEX idx_mcp_user_credentials_tenant ON mcp_user_credentials(tenant_id); +CREATE INDEX idx_mcp_user_credentials_server ON mcp_user_credentials(server_id); + +-- ============================================================ +-- Phase I: Update UNIQUE constraints to include tenant_id +-- Allows same name/key/slug across different tenants. +-- ============================================================ + +-- agents.agent_key: (agent_key) → (tenant_id, agent_key) +-- Old constraint replaced by partial index in migration 23. +DROP INDEX IF EXISTS idx_agents_agent_key_active; +CREATE UNIQUE INDEX idx_agents_tenant_agent_key_active ON agents(tenant_id, agent_key) WHERE deleted_at IS NULL; + +-- sessions.session_key: globally unique → (tenant_id, session_key) +ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_session_key_key; +DROP INDEX IF EXISTS sessions_session_key_key; +CREATE UNIQUE INDEX idx_sessions_tenant_session_key ON sessions(tenant_id, session_key); + +-- skills.slug: globally unique → (tenant_id, slug) +ALTER TABLE skills DROP CONSTRAINT IF EXISTS skills_slug_key; +DROP INDEX IF EXISTS skills_slug_key; +CREATE UNIQUE INDEX idx_skills_tenant_slug ON skills(tenant_id, slug); + +-- mcp_servers.name: globally unique → (tenant_id, name) +ALTER TABLE mcp_servers DROP CONSTRAINT IF EXISTS mcp_servers_name_key; +DROP INDEX IF EXISTS mcp_servers_name_key; +CREATE UNIQUE INDEX idx_mcp_servers_tenant_name ON mcp_servers(tenant_id, name); + +-- channel_contacts: (channel_type, sender_id) → (tenant_id, channel_type, sender_id) +ALTER TABLE channel_contacts DROP CONSTRAINT IF EXISTS channel_contacts_channel_type_sender_id_key; +DROP INDEX IF EXISTS channel_contacts_channel_type_sender_id_key; +CREATE UNIQUE INDEX idx_channel_contacts_tenant_type_sender ON channel_contacts(tenant_id, channel_type, sender_id); + +-- llm_providers.name: globally unique → (tenant_id, name) +ALTER TABLE llm_providers DROP CONSTRAINT IF EXISTS llm_providers_name_key; +DROP INDEX IF EXISTS llm_providers_name_key; +CREATE UNIQUE INDEX idx_llm_providers_tenant_name ON llm_providers(tenant_id, name); + +-- config_secrets.key: PK (key) → PK (key, tenant_id) +ALTER TABLE config_secrets DROP CONSTRAINT IF EXISTS config_secrets_pkey; +ALTER TABLE config_secrets ADD PRIMARY KEY (key, tenant_id); + +-- paired_devices: UNIQUE (sender_id, channel) → (tenant_id, sender_id, channel) +ALTER TABLE paired_devices DROP CONSTRAINT IF EXISTS paired_devices_sender_id_channel_key; +DROP INDEX IF EXISTS paired_devices_sender_id_channel_key; +CREATE UNIQUE INDEX idx_paired_devices_tenant_sender_channel ON paired_devices(tenant_id, sender_id, channel); + +-- channel_instances.name: globally unique → (tenant_id, name) +ALTER TABLE channel_instances DROP CONSTRAINT IF EXISTS channel_instances_name_key; +DROP INDEX IF EXISTS channel_instances_name_key; +CREATE UNIQUE INDEX idx_channel_instances_tenant_name ON channel_instances(tenant_id, name); + +-- usage_snapshots: add tenant_id to unique conflict index for per-tenant aggregation +DROP INDEX IF EXISTS idx_usage_snapshots_unique; +CREATE UNIQUE INDEX idx_usage_snapshots_unique ON usage_snapshots ( + bucket_hour, + COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid), + provider, model, channel, + tenant_id +); + +-- Cleanup: strip leaked gateway tokens from session media URLs. +-- Old code embedded ?token=GATEWAY_TOKEN in markdown image URLs stored in session messages. +-- New code stores clean paths; frontend adds auth at render time. +UPDATE sessions +SET messages = regexp_replace(messages::text, '\?token=[a-f0-9]+', '', 'g')::jsonb +WHERE messages::text LIKE '%?token=%'; + +-- ============================================================ +-- Phase K: Migrate remaining UUID v4 defaults to v7 +-- ============================================================ + +ALTER TABLE kg_entities ALTER COLUMN id SET DEFAULT uuid_generate_v7(); +ALTER TABLE kg_relations ALTER COLUMN id SET DEFAULT uuid_generate_v7(); +ALTER TABLE channel_contacts ALTER COLUMN id SET DEFAULT uuid_generate_v7(); +ALTER TABLE team_user_grants ALTER COLUMN id SET DEFAULT uuid_generate_v7(); +ALTER TABLE tenant_users ALTER COLUMN id SET DEFAULT uuid_generate_v7(); +ALTER TABLE mcp_user_credentials ALTER COLUMN id SET DEFAULT uuid_generate_v7(); diff --git a/pkg/browser/actions.go b/pkg/browser/actions.go index 83c27c5b..fffe6996 100644 --- a/pkg/browser/actions.go +++ b/pkg/browser/actions.go @@ -12,7 +12,7 @@ import ( // Click clicks an element by ref. func (m *Manager) Click(ctx context.Context, targetID, ref string, opts ClickOpts) error { - _, el, err := m.getPageAndResolve(targetID, ref) + _, el, err := m.getPageAndResolve(ctx, targetID, ref) if err != nil { return err } @@ -34,7 +34,7 @@ func (m *Manager) Click(ctx context.Context, targetID, ref string, opts ClickOpt // Type types text into an element by ref. func (m *Manager) Type(ctx context.Context, targetID, ref, text string, opts TypeOpts) error { - page, el, err := m.getPageAndResolve(targetID, ref) + page, el, err := m.getPageAndResolve(ctx, targetID, ref) if err != nil { return err } @@ -63,8 +63,9 @@ func (m *Manager) Type(ctx context.Context, targetID, ref, text string, opts Typ // Press presses a keyboard key. func (m *Manager) Press(ctx context.Context, targetID, key string) error { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { return err @@ -76,7 +77,7 @@ func (m *Manager) Press(ctx context.Context, targetID, key string) error { // Hover hovers over an element by ref. func (m *Manager) Hover(ctx context.Context, targetID, ref string) error { - _, el, err := m.getPageAndResolve(targetID, ref) + _, el, err := m.getPageAndResolve(ctx, targetID, ref) if err != nil { return err } @@ -86,8 +87,9 @@ func (m *Manager) Hover(ctx context.Context, targetID, ref string) error { // Wait waits for a condition on a page. func (m *Manager) Wait(ctx context.Context, targetID string, opts WaitOpts) error { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { return err @@ -148,8 +150,9 @@ func (m *Manager) Wait(ctx context.Context, targetID string, opts WaitOpts) erro // Evaluate runs JavaScript on a page. func (m *Manager) Evaluate(ctx context.Context, targetID, js string) (string, error) { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { return "", err diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go index bedcdaac..338c4ec4 100644 --- a/pkg/browser/browser.go +++ b/pkg/browser/browser.go @@ -12,14 +12,16 @@ import ( // Manager handles the Chrome browser lifecycle and page management. type Manager struct { - mu sync.Mutex - browser *rod.Browser - refs *RefStore - pages map[string]*rod.Page // targetID → page - console map[string][]ConsoleMessage // targetID → console messages - headless bool - remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher - logger *slog.Logger + mu sync.Mutex + browser *rod.Browser + refs *RefStore + pages map[string]*rod.Page // targetID → page + console map[string][]ConsoleMessage // targetID → console messages + tenantCtxs map[string]*rod.Browser // tenantID → incognito browser context + pageTenants map[string]string // targetID → tenantID (for filtering) + headless bool + remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher + logger *slog.Logger } // Option configures a Manager. @@ -44,10 +46,12 @@ func WithLogger(l *slog.Logger) Option { // New creates a Manager with options. func New(opts ...Option) *Manager { m := &Manager{ - refs: NewRefStore(), - pages: make(map[string]*rod.Page), - console: make(map[string][]ConsoleMessage), - logger: slog.Default(), + refs: NewRefStore(), + pages: make(map[string]*rod.Page), + console: make(map[string][]ConsoleMessage), + tenantCtxs: make(map[string]*rod.Browser), + pageTenants: make(map[string]string), + logger: slog.Default(), } for _, o := range opts { o(m) @@ -68,9 +72,11 @@ func (m *Manager) Start(ctx context.Context) error { } // Connection dead — clean up and reconnect m.logger.Info("browser connection lost, reconnecting") + m.closeTenantContextsLocked() m.browser = nil m.pages = make(map[string]*rod.Page) m.console = make(map[string][]ConsoleMessage) + m.pageTenants = make(map[string]string) m.refs = NewRefStore() } @@ -118,6 +124,8 @@ func (m *Manager) Stop(ctx context.Context) error { return nil } + m.closeTenantContextsLocked() + var err error if m.remoteURL == "" { // Local Chrome — close the browser process @@ -128,9 +136,49 @@ func (m *Manager) Stop(ctx context.Context) error { m.browser = nil m.pages = make(map[string]*rod.Page) m.console = make(map[string][]ConsoleMessage) + m.pageTenants = make(map[string]string) return err } +// closeTenantContextsLocked closes all incognito browser contexts. Must be called with mu held. +func (m *Manager) closeTenantContextsLocked() { + for tid, ctx := range m.tenantCtxs { + if err := ctx.Close(); err != nil { + m.logger.Warn("failed to close tenant browser context", "tenant", tid, "error", err) + } + } + m.tenantCtxs = make(map[string]*rod.Browser) +} + +// MasterTenantID is the well-known master tenant UUID string. +// Pages opened without a tenant context or by the master tenant use the main browser directly. +const MasterTenantID = "0193a5b0-7000-7000-8000-000000000001" + +// tenantBrowserLocked returns an isolated incognito browser context for the given tenant. +// Master tenant and empty string use the main browser (no isolation needed). +// Must be called with mu held. +func (m *Manager) tenantBrowserLocked(tenantID string) (*rod.Browser, error) { + if m.browser == nil { + return nil, fmt.Errorf("browser not running") + } + // Master tenant or no tenant: use main browser + if tenantID == "" || tenantID == MasterTenantID { + return m.browser, nil + } + // Return existing incognito context + if ctx, ok := m.tenantCtxs[tenantID]; ok { + return ctx, nil + } + // Create new incognito context for this tenant + incognito, err := m.browser.Incognito() + if err != nil { + return nil, fmt.Errorf("create incognito context for tenant %s: %w", tenantID, err) + } + m.tenantCtxs[tenantID] = incognito + m.logger.Info("created incognito browser context", "tenant", tenantID) + return incognito, nil +} + // Status returns current browser status. func (m *Manager) Status() *StatusInfo { m.mu.Lock() diff --git a/pkg/browser/browser_page.go b/pkg/browser/browser_page.go index 2f5f429f..20cc56a4 100644 --- a/pkg/browser/browser_page.go +++ b/pkg/browser/browser_page.go @@ -10,8 +10,9 @@ import ( // Snapshot takes an accessibility snapshot of a page. func (m *Manager) Snapshot(ctx context.Context, targetID string, opts SnapshotOptions) (*SnapshotResult, error) { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { @@ -39,8 +40,9 @@ func (m *Manager) Snapshot(ctx context.Context, targetID string, opts SnapshotOp // Screenshot captures a page screenshot as PNG bytes. func (m *Manager) Screenshot(ctx context.Context, targetID string, fullPage bool) ([]byte, error) { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { @@ -57,8 +59,9 @@ func (m *Manager) Screenshot(ctx context.Context, targetID string, fullPage bool // Navigate navigates a page to a URL. func (m *Manager) Navigate(ctx context.Context, targetID, url string) error { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { diff --git a/pkg/browser/browser_remote.go b/pkg/browser/browser_remote.go index c636f3af..a2a339f9 100644 --- a/pkg/browser/browser_remote.go +++ b/pkg/browser/browser_remote.go @@ -1,6 +1,7 @@ package browser import ( + "context" "encoding/json" "fmt" "net" @@ -16,9 +17,11 @@ import ( // reconnectLocked re-establishes the CDP connection to a remote Chrome. // Must be called with m.mu held. Only works when remoteURL is set. func (m *Manager) reconnectLocked() error { + m.closeTenantContextsLocked() m.browser = nil m.pages = make(map[string]*rod.Page) m.console = make(map[string][]ConsoleMessage) + m.pageTenants = make(map[string]string) m.refs = NewRefStore() controlURL, err := resolveRemoteCDP(m.remoteURL) @@ -87,6 +90,29 @@ func (m *Manager) getPage(targetID string) (*rod.Page, error) { return pages[0], nil } +// getPageForTenant wraps getPage with tenant ownership validation. +// If tenantID is set and the page belongs to a different tenant, access is denied. +// Must be called with m.mu held. +func (m *Manager) getPageForTenant(targetID, tenantID string) (*rod.Page, error) { + page, err := m.getPage(targetID) + if err != nil { + return nil, err + } + // If no tenant context or master tenant, allow access to all pages + if tenantID == "" || tenantID == MasterTenantID { + return page, nil + } + // Check ownership: page must belong to this tenant + resolvedTID := targetID + if targetID == "" { + resolvedTID = string(page.TargetID) + } + if owner, ok := m.pageTenants[resolvedTID]; ok && owner != tenantID { + return nil, fmt.Errorf("tab not found: %s", targetID) + } + return page, nil +} + // setupConsoleListener attaches a console message listener to a page via Rod's EachEvent. func (m *Manager) setupConsoleListener(page *rod.Page, targetID string) { go page.EachEvent(func(e *proto.RuntimeConsoleAPICalled) { @@ -146,10 +172,11 @@ func (m *Manager) resolveElement(page *rod.Page, targetID, ref string) (*rod.Ele return el, nil } -// getPageAndResolve is a helper that locks, gets page, and resolves an element. -func (m *Manager) getPageAndResolve(targetID, ref string) (*rod.Page, *rod.Element, error) { +// getPageAndResolve is a helper that locks, gets page with tenant check, and resolves an element. +func (m *Manager) getPageAndResolve(ctx context.Context, targetID, ref string) (*rod.Page, *rod.Element, error) { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) m.mu.Unlock() if err != nil { return nil, nil, err diff --git a/pkg/browser/browser_tabs.go b/pkg/browser/browser_tabs.go index bc067fc2..08c138e1 100644 --- a/pkg/browser/browser_tabs.go +++ b/pkg/browser/browser_tabs.go @@ -8,7 +8,7 @@ import ( "github.com/go-rod/rod/lib/proto" ) -// ListTabs returns all open tabs. +// ListTabs returns open tabs filtered by the caller's tenant context. func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { m.mu.Lock() defer m.mu.Unlock() @@ -17,14 +17,27 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { return nil, fmt.Errorf("browser not running") } - pages, err := m.browser.Pages() + tenantID := tenantIDFromCtx(ctx) + + // Use tenant-scoped browser context for page listing + b, err := m.tenantBrowserLocked(tenantID) + if err != nil { + return nil, err + } + + pages, err := b.Pages() if err != nil { if m.remoteURL != "" { if reconnErr := m.reconnectLocked(); reconnErr != nil { return nil, fmt.Errorf("list pages: %w (reconnect also failed: %v)", err, reconnErr) } m.logger.Info("auto-reconnected to remote Chrome") - pages, err = m.browser.Pages() + // Re-acquire tenant browser after reconnect (incognito contexts were reset) + b, err = m.tenantBrowserLocked(tenantID) + if err != nil { + return nil, err + } + pages, err = b.Pages() if err != nil { return nil, fmt.Errorf("list pages after reconnect: %w", err) } @@ -41,6 +54,9 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { } tid := string(p.TargetID) m.pages[tid] = p + if tenantID != "" { + m.pageTenants[tid] = tenantID + } tabs = append(tabs, TabInfo{ TargetID: tid, URL: info.URL, @@ -51,15 +67,18 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { } // OpenTab opens a new tab with the given URL. +// Pages are created within the tenant's incognito browser context for isolation. func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { m.mu.Lock() defer m.mu.Unlock() - if m.browser == nil { - return nil, fmt.Errorf("browser not running") + tenantID := tenantIDFromCtx(ctx) + b, err := m.tenantBrowserLocked(tenantID) + if err != nil { + return nil, err } - page, err := m.browser.Page(proto.TargetCreateTarget{URL: url}) + page, err := b.Page(proto.TargetCreateTarget{URL: url}) if err != nil { return nil, fmt.Errorf("open tab: %w", err) } @@ -70,6 +89,9 @@ func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { info, _ := page.Info() tid := string(page.TargetID) m.pages[tid] = page + if tenantID != "" { + m.pageTenants[tid] = tenantID + } // Set up console listener m.setupConsoleListener(page, tid) @@ -84,10 +106,11 @@ func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { // FocusTab activates a tab. func (m *Manager) FocusTab(ctx context.Context, targetID string) error { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() defer m.mu.Unlock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) if err != nil { return err } @@ -98,24 +121,34 @@ func (m *Manager) FocusTab(ctx context.Context, targetID string) error { // CloseTab closes a tab. func (m *Manager) CloseTab(ctx context.Context, targetID string) error { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() defer m.mu.Unlock() - page, err := m.getPage(targetID) + page, err := m.getPageForTenant(targetID, tenantID) if err != nil { return err } delete(m.pages, targetID) delete(m.console, targetID) + delete(m.pageTenants, targetID) return page.Close() } // ConsoleMessages returns captured console messages for a tab. -func (m *Manager) ConsoleMessages(targetID string) []ConsoleMessage { +func (m *Manager) ConsoleMessages(ctx context.Context, targetID string) []ConsoleMessage { + tenantID := tenantIDFromCtx(ctx) m.mu.Lock() defer m.mu.Unlock() + // Validate tenant ownership + if tenantID != "" && tenantID != MasterTenantID { + if owner, ok := m.pageTenants[targetID]; ok && owner != tenantID { + return []ConsoleMessage{} + } + } + msgs := m.console[targetID] if msgs == nil { return []ConsoleMessage{} diff --git a/pkg/browser/browser_tenant.go b/pkg/browser/browser_tenant.go new file mode 100644 index 00000000..cc7a73bc --- /dev/null +++ b/pkg/browser/browser_tenant.go @@ -0,0 +1,20 @@ +package browser + +import "context" + +// browserTenantKey is a context key for passing tenant ID to browser operations. +type browserTenantKey struct{} + +// WithTenantID returns a context with the browser tenant ID set. +// This is used to isolate browser pages per tenant via incognito contexts. +func WithTenantID(ctx context.Context, tenantID string) context.Context { + return context.WithValue(ctx, browserTenantKey{}, tenantID) +} + +// tenantIDFromCtx extracts the tenant ID from context. +func tenantIDFromCtx(ctx context.Context) string { + if v, ok := ctx.Value(browserTenantKey{}).(string); ok { + return v + } + return "" +} diff --git a/pkg/browser/tool.go b/pkg/browser/tool.go index c26ee125..08b6efba 100644 --- a/pkg/browser/tool.go +++ b/pkg/browser/tool.go @@ -8,6 +8,7 @@ import ( "path/filepath" "time" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/tools" ) @@ -137,6 +138,11 @@ func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *tools.R return tools.ErrorResult("action is required") } + // Propagate tenant ID from store context to browser context for page isolation. + if tid := store.TenantIDFromContext(ctx); tid.String() != "00000000-0000-0000-0000-000000000000" { + ctx = WithTenantID(ctx, tid.String()) + } + // Auto-start browser for actions that need it switch action { case "open", "snapshot", "screenshot", "navigate", "act", "tabs": @@ -165,7 +171,7 @@ func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *tools.R case "navigate": return t.handleNavigate(ctx, args) case "console": - return t.handleConsole(args) + return t.handleConsole(ctx, args) case "act": return t.handleAct(ctx, args) default: @@ -279,9 +285,9 @@ func (t *BrowserTool) handleNavigate(ctx context.Context, args map[string]any) * return tools.NewResult(fmt.Sprintf("Navigated to %s", url)) } -func (t *BrowserTool) handleConsole(args map[string]any) *tools.Result { +func (t *BrowserTool) handleConsole(ctx context.Context, args map[string]any) *tools.Result { targetID, _ := args["targetId"].(string) - msgs := t.manager.ConsoleMessages(targetID) + msgs := t.manager.ConsoleMessages(ctx, targetID) return jsonResult(msgs) } diff --git a/pkg/protocol/errors.go b/pkg/protocol/errors.go index bdff63ed..bfe6e4fa 100644 --- a/pkg/protocol/errors.go +++ b/pkg/protocol/errors.go @@ -13,6 +13,7 @@ const ( ErrNotFound = "NOT_FOUND" ErrAlreadyExists = "ALREADY_EXISTS" ErrResourceExhausted = "RESOURCE_EXHAUSTED" - ErrFailedPrecondition = "FAILED_PRECONDITION" - ErrInternal = "INTERNAL" + ErrFailedPrecondition = "FAILED_PRECONDITION" + ErrInternal = "INTERNAL" + ErrTenantAccessRevoked = "TENANT_ACCESS_REVOKED" ) diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go index 14ac156f..c295b278 100644 --- a/pkg/protocol/events.go +++ b/pkg/protocol/events.go @@ -97,6 +97,9 @@ const ( // Zalo Personal QR login events (client-scoped, not broadcast). EventZaloPersonalQRCode = "zalo.personal.qr.code" EventZaloPersonalQRDone = "zalo.personal.qr.done" + + // Tenant access revocation — forces affected user's UI to logout. + EventTenantAccessRevoked = "tenant.access.revoked" ) // Agent event subtypes (in payload.type) diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index a26bb693..cd1eecbe 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -156,12 +156,6 @@ const ( MethodTeamsEventsList = "teams.events.list" ) -// Delegation history -const ( - MethodDelegationsList = "delegations.list" - MethodDelegationsGet = "delegations.get" -) - // API key management const ( MethodAPIKeysList = "api_keys.list" diff --git a/ui/web/index.html b/ui/web/index.html index 8a30c15f..4c4c0245 100644 --- a/ui/web/index.html +++ b/ui/web/index.html @@ -4,6 +4,9 @@ + + + GoClaw Dashboard diff --git a/ui/web/public/favicon.svg b/ui/web/public/favicon.svg new file mode 100644 index 00000000..5a23afe8 --- /dev/null +++ b/ui/web/public/favicon.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/web/public/goclaw-icon.svg b/ui/web/public/goclaw-icon.svg new file mode 100644 index 00000000..797941f5 --- /dev/null +++ b/ui/web/public/goclaw-icon.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/web/src/api/http-client.ts b/ui/web/src/api/http-client.ts index a6018859..2b73dc7b 100644 --- a/ui/web/src/api/http-client.ts +++ b/ui/web/src/api/http-client.ts @@ -111,6 +111,9 @@ export class HttpClient { if (userId) h["X-GoClaw-User-Id"] = userId; const senderID = this.getSenderID(); if (senderID) h["X-GoClaw-Sender-Id"] = senderID; + // Tenant scope: narrow cross-tenant admin to a specific tenant + const tenantScope = localStorage.getItem("goclaw:tenant_id"); + if (tenantScope) h["X-GoClaw-Tenant-Id"] = tenantScope; return h; } @@ -130,10 +133,10 @@ export class HttpClient { } if (!res.ok) { - if (res.status === 401) { + const err = await res.json().catch(() => ({ error: res.statusText })); + if (res.status === 401 || err.code === "TENANT_ACCESS_REVOKED") { this.onAuthFailure?.(); } - const err = await res.json().catch(() => ({ error: res.statusText })); throw new ApiError( err.code ?? "HTTP_ERROR", err.error ?? err.message ?? res.statusText, diff --git a/ui/web/src/api/protocol.ts b/ui/web/src/api/protocol.ts index e194dc03..6f0bb046 100644 --- a/ui/web/src/api/protocol.ts +++ b/ui/web/src/api/protocol.ts @@ -167,6 +167,16 @@ export const Methods = { CONFIG_PERMISSIONS_GRANT: "config.permissions.grant", CONFIG_PERMISSIONS_REVOKE: "config.permissions.revoke", + // Tenants (multi-tenant) + TENANTS_MINE: "tenants.mine", + TENANTS_LIST: "tenants.list", + TENANTS_GET: "tenants.get", + TENANTS_CREATE: "tenants.create", + TENANTS_UPDATE: "tenants.update", + TENANTS_USERS_LIST: "tenants.users.list", + TENANTS_USERS_ADD: "tenants.users.add", + TENANTS_USERS_REMOVE: "tenants.users.remove", + // Phase 3+ - NICE TO HAVE LOGS_TAIL: "logs.tail", } as const; diff --git a/ui/web/src/api/ws-client.ts b/ui/web/src/api/ws-client.ts index 07a5832b..eb289b49 100644 --- a/ui/web/src/api/ws-client.ts +++ b/ui/web/src/api/ws-client.ts @@ -23,6 +23,15 @@ export class WsClient { private intentionalClose = false; private connectGeneration = 0; + /** Server-assigned role from connect response. */ + role: "admin" | "operator" | "viewer" | "" = ""; + + /** Tenant fields from connect response. */ + tenantId = ""; + tenantName = ""; + tenantSlug = ""; + crossTenant = false; + private readonly maxReconnectDelay = 30_000; private readonly baseReconnectDelay = 1_000; private readonly defaultTimeout = 30_000; @@ -193,11 +202,17 @@ export class WsClient { status?: string; pairing_code?: string; sender_id?: string; + tenant_id?: string; + tenant_name?: string; + tenant_slug?: string; + cross_tenant?: boolean; }>("connect", { token: this.getToken(), user_id: this.getUserId(), sender_id: this.getSenderID(), locale: localStorage.getItem("goclaw:language") || "en", + tenant_hint: localStorage.getItem("goclaw:tenant_hint") || "", + tenant_id: localStorage.getItem("goclaw:tenant_id") || "", protocolVersion: PROTOCOL_VERSION, }); if (this.connectGeneration !== generation) return; @@ -218,9 +233,21 @@ export class WsClient { } this.authenticated = true; + this.role = (res?.role as "admin" | "operator" | "viewer") ?? ""; + this.tenantId = res?.tenant_id ?? ""; + this.tenantName = res?.tenant_name ?? ""; + this.tenantSlug = res?.tenant_slug ?? ""; + this.crossTenant = res?.cross_tenant ?? false; this.onStateChange("connected"); - } catch { + } catch (e) { if (this.connectGeneration === generation) { + // Tenant access revoked → force logout instead of reconnect + if (e instanceof ApiError && e.code === "TENANT_ACCESS_REVOKED") { + this.intentionalClose = true; + this.ws?.close(); + this.onAuthFailure?.(); + return; + } this.ws?.close(); } } @@ -252,7 +279,7 @@ export class WsClient { pending.resolve(frame.payload); } else { const err = frame.error as ErrorShape; - if (err.code === "UNAUTHORIZED") { + if (err.code === "UNAUTHORIZED" || err.code === "TENANT_ACCESS_REVOKED") { this.onAuthFailure?.(); } pending.reject( diff --git a/ui/web/src/components/chat/active-run-zone.tsx b/ui/web/src/components/chat/active-run-zone.tsx index ace4534f..cc9e66a2 100644 --- a/ui/web/src/components/chat/active-run-zone.tsx +++ b/ui/web/src/components/chat/active-run-zone.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { Bot } from "lucide-react"; import { ActivityIndicator } from "./activity-indicator"; import { BlockReplyBubble } from "./block-reply-bubble"; @@ -15,7 +16,7 @@ interface ActiveRunZoneProps { blockReplies: ChatMessage[]; } -export function ActiveRunZone({ +export const ActiveRunZone = memo(function ActiveRunZone({ isRunning, activity, thinkingText, @@ -69,4 +70,4 @@ export function ActiveRunZone({ ); -} +}); diff --git a/ui/web/src/components/chat/activity-indicator.tsx b/ui/web/src/components/chat/activity-indicator.tsx index abff3717..62aabfd9 100644 --- a/ui/web/src/components/chat/activity-indicator.tsx +++ b/ui/web/src/components/chat/activity-indicator.tsx @@ -34,7 +34,7 @@ export function ActivityIndicator({ activity, isRunning }: ActivityIndicatorProp function getPhaseConfig(activity: RunActivity) { switch (activity.phase) { case "thinking": - return { icon: Brain, color: "text-purple-500", label: "Thinking..." }; + return { icon: Brain, color: "text-orange-500", label: "Thinking..." }; case "tool_exec": return { icon: Wrench, diff --git a/ui/web/src/components/chat/message-bubble.tsx b/ui/web/src/components/chat/message-bubble.tsx index 24fe6a27..e391b4ff 100644 --- a/ui/web/src/components/chat/message-bubble.tsx +++ b/ui/web/src/components/chat/message-bubble.tsx @@ -51,7 +51,7 @@ export function MessageBubble({ message }: MessageBubbleProps) { /* Normal message bubble — assistant uses full width, user capped at 85% */

{hasThinking && ( @@ -73,7 +73,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
)} {message.timestamp && ( -
+
{new Date(message.timestamp).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
)} diff --git a/ui/web/src/components/chat/rich-content.tsx b/ui/web/src/components/chat/rich-content.tsx index 35cf9d10..3a70ec4e 100644 --- a/ui/web/src/components/chat/rich-content.tsx +++ b/ui/web/src/components/chat/rich-content.tsx @@ -264,7 +264,7 @@ export function RichContent({ content, role }: RichContentProps) { // If no special blocks found, render as plain markdown (fast path) const first = blocks[0]; if (blocks.length === 1 && first?.type === "markdown") { - return ; + return ; } return ( @@ -278,7 +278,7 @@ export function RichContent({ content, role }: RichContentProps) { case "video-notice": return ; case "markdown": - return ; + return ; case "file": return ; case "reply": diff --git a/ui/web/src/components/chat/streaming-text.tsx b/ui/web/src/components/chat/streaming-text.tsx index b85fef9b..45d9c6bc 100644 --- a/ui/web/src/components/chat/streaming-text.tsx +++ b/ui/web/src/components/chat/streaming-text.tsx @@ -1,10 +1,11 @@ +import { memo } from "react"; import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; interface StreamingTextProps { text: string; } -export function StreamingText({ text }: StreamingTextProps) { +export const StreamingText = memo(function StreamingText({ text }: StreamingTextProps) { if (!text) return ; return ( @@ -13,7 +14,7 @@ export function StreamingText({ text }: StreamingTextProps) {
); -} +}); function ThinkingIndicator() { return ( diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index c7ae94c9..f8857409 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -26,6 +26,7 @@ import { Contact, KeyRound, FileText, + Building2, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { SidebarGroup } from "./sidebar-group"; @@ -34,6 +35,8 @@ import { ConnectionStatus } from "./connection-status"; import { ROUTES } from "@/lib/constants"; import { cn } from "@/lib/utils"; import { usePendingPairingsCount } from "@/hooks/use-pending-pairings-count"; +import { useAuthStore } from "@/stores/use-auth-store"; +import { useTenants } from "@/hooks/use-tenants"; interface SidebarProps { collapsed: boolean; @@ -43,6 +46,9 @@ interface SidebarProps { export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { const { t } = useTranslation("sidebar"); const { pendingCount } = usePendingPairingsCount(); + const role = useAuthStore((s) => s.role); + const { isCrossTenant } = useTenants(); + const isAdmin = role === "admin"; return (