diff --git a/apps/backend/internal/agent/handlers/shell_handlers.go b/apps/backend/internal/agent/handlers/shell_handlers.go index 890a1cd359..78c73a39ed 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers.go +++ b/apps/backend/internal/agent/handlers/shell_handlers.go @@ -569,7 +569,9 @@ func (h *ShellHandlers) wsUserShellStop(ctx context.Context, msg *ws.Message) (* // GetOrEnsureExecutionForEnvironment where the check normally runs. Without // this, an empty task_id plus a foreign task_environment_id stopped another // user's terminal. - if err := h.lifecycleMgr.CheckEnvironmentAccess(ctx, req.TaskEnvironmentID); err != nil { + // Exec scope, not read: this path tears a PTY down. A caller who may only + // list terminals must not be able to stop one. + if err := h.lifecycleMgr.CheckEnvironmentExecAccess(ctx, req.TaskEnvironmentID); err != nil { return nil, errors.New("task environment not found") } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager.go b/apps/backend/internal/agent/runtime/lifecycle/manager.go index df909ce4d0..c543b1b1cc 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager.go @@ -83,11 +83,23 @@ type Manager struct { // surfaces (opt-in auth). Nil = no scoping. See SetSessionAccessChecker. sessionAccessCheck func(ctx context.Context, sessionID string) error + // sessionExecCheck enforces the session.exec scope on surfaces that hand + // the caller a shell, a file write, or a port preview. Reading a + // transcript and running a command in the worktree are different + // permissions, so they get different checks. + sessionExecCheck func(ctx context.Context, sessionID string) error + // environmentAccessCheck is the environment-keyed sibling of // sessionAccessCheck, used by the terminal environment-shell route which // resolves executions by environment ID. Nil = no scoping. environmentAccessCheck func(ctx context.Context, environmentID string) error + // environmentExecCheck enforces session.exec on the environment-keyed + // surfaces that tear down or hand out a PTY. environmentAccessCheck is the + // read-level sibling used by the SSR terminal lists: listing terminals and + // destroying one are different permissions on the same identifier. + environmentExecCheck func(ctx context.Context, environmentID string) error + // taskAccessCheck is the task-keyed sibling of sessionAccessCheck, used by // the task-keyed SSR terminal list which reads terminal rows by task ID // without resolving an execution at all. Nil = no scoping. @@ -454,6 +466,22 @@ func (m *Manager) SetSessionAccessChecker(check func(ctx context.Context, sessio m.sessionAccessCheck = check } +// SetSessionExecAccessChecker installs the session.exec check used by the +// terminal, shell, file-write, VS Code and port-preview surfaces. +func (m *Manager) SetSessionExecAccessChecker(check func(ctx context.Context, sessionID string) error) { + m.sessionExecCheck = check +} + +// CheckSessionExecAccess authorizes an execution-capable session operation. +// It falls back to the read check when no exec checker is wired, so an +// unwired build is no more permissive than before this scope existed. +func (m *Manager) CheckSessionExecAccess(ctx context.Context, sessionID string) error { + if m.sessionExecCheck == nil { + return m.CheckSessionAccess(ctx, sessionID) + } + return m.sessionExecCheck(ctx, sessionID) +} + // SetAttachmentReader wires the backend attachment reader used by prompt // dispatch. Claimed descriptors are streamed into the active agentctl // session immediately before an ACP prompt is sent. @@ -469,6 +497,22 @@ func (m *Manager) SetEnvironmentAccessChecker(check func(ctx context.Context, en m.environmentAccessCheck = check } +// SetEnvironmentExecAccessChecker installs the session.exec check for the +// environment-keyed surfaces that destroy or open a PTY. +func (m *Manager) SetEnvironmentExecAccessChecker(check func(ctx context.Context, environmentID string) error) { + m.environmentExecCheck = check +} + +// CheckEnvironmentExecAccess authorizes an execution-capable environment +// operation. It falls back to the read check when no exec checker is wired, +// so an unwired build is no more permissive than before this scope existed. +func (m *Manager) CheckEnvironmentExecAccess(ctx context.Context, taskEnvironmentID string) error { + if m.environmentExecCheck == nil { + return m.CheckEnvironmentAccess(ctx, taskEnvironmentID) + } + return m.environmentExecCheck(ctx, taskEnvironmentID) +} + // SetTaskAccessChecker installs the per-user task visibility check used by // the task-keyed SSR terminal route. The checker must return nil for contexts // without a request identity (internal callers). @@ -598,3 +642,15 @@ func (m *Manager) DockerClientProvider() func() *docker.Client { return dockerExec.Client() } } + +// execAccessCheck returns the check that execution-capable surfaces must pass. +// +// It prefers the session.exec checker and falls back to the plain access check +// when no scoped checker is wired. The fallback is never weaker than the +// behavior before session.exec existed; production always wires both. +func (m *Manager) execAccessCheck() func(ctx context.Context, sessionID string) error { + if m.sessionExecCheck != nil { + return m.sessionExecCheck + } + return m.sessionAccessCheck +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go index 3d7ee926be..3f09d6e4c9 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go @@ -41,7 +41,11 @@ func (m *Manager) ResolveSessionRuntime(ctx context.Context, sessionID string) ( if sessionID == "" { return "", fmt.Errorf("session_id is required") } - if check := m.sessionAccessCheck; check != nil { + // Execution surfaces require session.exec, not mere reach. This is the + // chokepoint every workspace-oriented handler (shell, files, ports, VS + // Code, LSP) goes through, so gating it here covers them all rather than + // relying on each handler to remember. + if check := m.execAccessCheck(); check != nil { if err := check(ctx, sessionID); err != nil { return "", err } @@ -81,7 +85,11 @@ func (m *Manager) GetOrEnsureExecution(ctx context.Context, sessionID string) (* // Per-user workspace scoping (opt-in auth): user-facing session surfaces // funnel through here; internal callers pass a ctx without an identity // and are unaffected. - if check := m.sessionAccessCheck; check != nil { + // Execution surfaces require session.exec, not mere reach. This is the + // chokepoint every workspace-oriented handler (shell, files, ports, VS + // Code, LSP) goes through, so gating it here covers them all rather than + // relying on each handler to remember. + if check := m.execAccessCheck(); check != nil { if err := check(ctx, sessionID); err != nil { return nil, err } @@ -446,7 +454,11 @@ func (m *Manager) IsAgentCommandConfigured(executionID string) bool { func (m *Manager) EnsurePassthroughExecution(ctx context.Context, sessionID string) (*AgentExecution, error) { // Per-user scoping (opt-in auth) — before the cache short-circuit so a // cached execution cannot be reached by a non-owner. - if check := m.sessionAccessCheck; check != nil { + // Execution surfaces require session.exec, not mere reach. This is the + // chokepoint every workspace-oriented handler (shell, files, ports, VS + // Code, LSP) goes through, so gating it here covers them all rather than + // relying on each handler to remember. + if check := m.execAccessCheck(); check != nil { if err := check(ctx, sessionID); err != nil { return nil, err } diff --git a/apps/backend/internal/agent/settings/handlers/agent_settings_org_scope_test.go b/apps/backend/internal/agent/settings/handlers/agent_settings_org_scope_test.go new file mode 100644 index 0000000000..0e72e6a874 --- /dev/null +++ b/apps/backend/internal/agent/settings/handlers/agent_settings_org_scope_test.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/common/httpmw" + "github.com/kandev/kandev/internal/common/logger" +) + +// mutatingSettingsRoutes is every state-changing agent-settings route. Agents, +// agent profiles and the runtimes behind them are org configuration, so each +// one is gated on org.config.manage. +var mutatingSettingsRoutes = []struct { + method string + path string +}{ + {http.MethodPost, "/api/v1/agents"}, + {http.MethodPost, "/api/v1/agents/tui"}, + {http.MethodPatch, "/api/v1/agents/tui/agent-1/mcp"}, + {http.MethodPatch, "/api/v1/agents/agent-1"}, + {http.MethodDelete, "/api/v1/agents/agent-1"}, + {http.MethodPost, "/api/v1/agents/agent-1/profiles"}, + {http.MethodPost, "/api/v1/agent-install/agent-1"}, + {http.MethodPost, "/api/v1/agent-update/agent-1"}, + {http.MethodPatch, "/api/v1/agent-profiles/profile-1"}, + {http.MethodDelete, "/api/v1/agent-profiles/profile-1"}, + {http.MethodPost, "/api/v1/agent-profiles/profile-1/duplicate"}, + {http.MethodPost, "/api/v1/agent-profiles/profile-1/mcp-config"}, +} + +func settingsRouterAs(t *testing.T, identity authn.Identity) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("NewLogger: %v", err) + } + router := gin.New() + router.Use(func(c *gin.Context) { + authn.SetOnGin(c, identity) + c.Next() + }) + NewHandlers(nil, nil, log, "test-interlock").registerHTTP(router) + return router +} + +// TestAgentSettingsMutationsRequireOrgConfigManage pins the authorization gate +// on every mutating route. The interlock guard that used to be the only thing +// in front of these handlers answers 403 as well, so asserting the status alone +// would pass even with the scope check removed — the scope name in the body is +// what distinguishes an authorization refusal from a concurrent-edit refusal. +func TestAgentSettingsMutationsRequireOrgConfigManage(t *testing.T) { + router := settingsRouterAs(t, authn.Identity{UserID: "member-1", Role: authn.RoleMember}) + + for _, route := range mutatingSettingsRoutes { + t.Run(route.method+" "+route.path, func(t *testing.T) { + request := httptest.NewRequest(route.method, route.path, nil) + request.Header.Set(httpmw.InterimSettingsInterlockHeader, "test-interlock") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden) + } + if !strings.Contains(response.Body.String(), "org.config.manage") { + t.Fatalf("body = %s, want an org.config.manage refusal", response.Body.String()) + } + }) + } +} + +// TestAgentSettingsReadsDoNotRequireOrgConfigManage keeps the gate off the read +// surface: a member must still be able to see the agents a task can run. +func TestAgentSettingsReadsDoNotRequireOrgConfigManage(t *testing.T) { + // A real controller, not the nil one the refusal test can use: these reads + // reach the handler. + router, _, _ := newSettingsHarnessAs(t, newFakeSettingsRepo(), nil, + authn.Identity{UserID: "member-1", Role: authn.RoleMember}) + + for _, path := range []string{"/api/v1/agents", "/api/v1/agents/available"} { + t.Run(path, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, path, nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code == http.StatusForbidden || response.Code == http.StatusUnauthorized { + t.Fatalf("status = %d, want the read to pass the scope gate", response.Code) + } + }) + } +} diff --git a/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go b/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go index efa850e684..7410ac653b 100644 --- a/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go @@ -150,6 +150,7 @@ func newAgentUpdateRouter( ctrl := controller.NewController(nil, discoveryRegistry, reg, nil, log) ctrl.SetRuntimeUpdater(updater) router := gin.New() + useSyntheticSettingsIdentity(router) broadcaster := newUpdateTerminalBroadcaster() RegisterRoutes(router, ctrl, broadcaster, log, "test-interlock") return router, ctrl, broadcaster.completed diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 193eff4cc1..20ab9ec501 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -14,6 +14,7 @@ import ( "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/settings/controller" "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/common/logger" ws "github.com/kandev/kandev/pkg/websocket" @@ -54,34 +55,40 @@ func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, hub Broadca func (h *Handlers) registerHTTP(router *gin.Engine) { api := router.Group("/api/v1") + // Agents, agent profiles and the runtimes behind them are org configuration: + // org.config.manage is the scope that names exactly this surface. Reads stay + // open to any identity; every mutation is gated. h.interlock is a + // concurrent-edit guard, not an authorization check, so it does not stand in + // for this. + cfg := authz.RequireOrgScope(authz.ScopeOrgConfigManage) api.GET("/agents/discovery", h.httpDiscoverAgents) api.GET("/agents/available", h.httpListAvailableAgents) api.GET("/agents", h.httpListAgents) - api.POST("/agents", h.interlock, h.httpCreateAgent) - api.POST("/agents/tui", h.interlock, h.httpCreateCustomTUIAgent) + api.POST("/agents", cfg, h.interlock, h.httpCreateAgent) + api.POST("/agents/tui", cfg, h.interlock, h.httpCreateCustomTUIAgent) api.GET("/agents/tui/mcp-strategies", h.httpListMCPStrategies) - api.PATCH("/agents/tui/:id/mcp", h.interlock, h.httpUpdateCustomTUIAgentMCP) + api.PATCH("/agents/tui/:id/mcp", cfg, h.interlock, h.httpUpdateCustomTUIAgentMCP) api.GET("/agents/:id", h.httpGetAgent) - api.PATCH("/agents/:id", h.interlock, h.httpUpdateAgent) - api.DELETE("/agents/:id", h.interlock, h.httpDeleteAgent) - api.POST("/agents/:id/profiles", h.interlock, h.httpCreateProfile) + api.PATCH("/agents/:id", cfg, h.interlock, h.httpUpdateAgent) + api.DELETE("/agents/:id", cfg, h.interlock, h.httpDeleteAgent) + api.POST("/agents/:id/profiles", cfg, h.interlock, h.httpCreateProfile) api.GET("/agents/:id/logo", h.httpGetAgentLogo) api.GET("/agent-models/:agentName", h.httpGetAgentModels) api.POST("/agent-models/:agentName/resolve", h.httpResolveAgentModelConfig) api.POST("/agent-command-preview/:agentName", h.httpPreviewAgentCommand) - api.POST("/agent-install/:agentName", h.interlock, h.httpInstallAgent) + api.POST("/agent-install/:agentName", cfg, h.interlock, h.httpInstallAgent) api.GET("/agent-update/status", h.httpListAgentUpdateStatuses) api.GET("/agent-update/:agentName/preview", h.httpPreviewAgentUpdate) api.GET("/agent-install/jobs", h.httpListInstallJobs) api.GET("/agent-install/jobs/:id", h.httpGetInstallJob) - api.POST("/agent-update/:agentName", h.interlock, h.httpUpdateAgentRuntime) + api.POST("/agent-update/:agentName", cfg, h.interlock, h.httpUpdateAgentRuntime) api.GET("/agent-update/jobs", h.httpListAgentUpdateJobs) api.GET("/agent-update/jobs/:id", h.httpGetAgentUpdateJob) - api.PATCH("/agent-profiles/:id", h.interlock, h.httpUpdateProfile) - api.DELETE("/agent-profiles/:id", h.interlock, h.httpDeleteProfile) - api.POST("/agent-profiles/:id/duplicate", h.interlock, h.httpDuplicateProfile) + api.PATCH("/agent-profiles/:id", cfg, h.interlock, h.httpUpdateProfile) + api.DELETE("/agent-profiles/:id", cfg, h.interlock, h.httpDeleteProfile) + api.POST("/agent-profiles/:id/duplicate", cfg, h.interlock, h.httpDuplicateProfile) api.GET("/agent-profiles/:id/mcp-config", h.httpGetProfileMcpConfig) - api.POST("/agent-profiles/:id/mcp-config", h.interlock, h.httpUpdateProfileMcpConfig) + api.POST("/agent-profiles/:id/mcp-config", cfg, h.interlock, h.httpUpdateProfileMcpConfig) } func (h *Handlers) httpDiscoverAgents(c *gin.Context) { diff --git a/apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go b/apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go index 268ec96df7..6342d2a190 100644 --- a/apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go +++ b/apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go @@ -18,6 +18,7 @@ func TestRegisterRoutesRejectsMutationWithoutInterimSettingsInterlock(t *testing } router := gin.New() + useSyntheticSettingsIdentity(router) NewHandlers(nil, nil, log, "test-interlock").registerHTTP(router) request := httptest.NewRequest(http.MethodPost, "/api/v1/agents", strings.NewReader("{")) @@ -38,6 +39,7 @@ func TestRegisterRoutesProtectsEveryStateChangingAgentSettingsRoute(t *testing.T } router := gin.New() + useSyntheticSettingsIdentity(router) NewHandlers(nil, nil, log, "test-interlock").registerHTTP(router) requests := []struct { method string diff --git a/apps/backend/internal/agent/settings/handlers/model_config_handlers_test.go b/apps/backend/internal/agent/settings/handlers/model_config_handlers_test.go index 4abc569af5..94fa5028a8 100644 --- a/apps/backend/internal/agent/settings/handlers/model_config_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/model_config_handlers_test.go @@ -25,6 +25,7 @@ func newModelConfigRouter(t *testing.T) (*gin.Engine, *registry.Registry) { ctrl := controller.NewController(nil, nil, reg, nil, log) ctrl.SetHostUtility(hostutility.NewManager(reg, "127.0.0.1", 0, nil, log)) router := gin.New() + useSyntheticSettingsIdentity(router) RegisterRoutes(router, ctrl, nil, log, "test-interlock") return router, reg } diff --git a/apps/backend/internal/agent/settings/handlers/profile_duplicate_handlers_test.go b/apps/backend/internal/agent/settings/handlers/profile_duplicate_handlers_test.go index 9ed760b670..249057d3c4 100644 --- a/apps/backend/internal/agent/settings/handlers/profile_duplicate_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/profile_duplicate_handlers_test.go @@ -21,6 +21,7 @@ import ( "github.com/kandev/kandev/internal/agent/settings/dto" "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/common/logger" ws "github.com/kandev/kandev/pkg/websocket" @@ -419,6 +420,27 @@ func (h *workspaceDuplicateHub) BroadcastToWorkspaceOrDrop(workspaceID string, m h.workspaceMsgs[workspaceID] = append(h.workspaceMsgs[workspaceID], msg) } +// syntheticSettingsIdentity is the single-user admin identity the auth +// middleware installs when auth is disabled. +func syntheticSettingsIdentity() authn.Identity { + return authn.Identity{UserID: "default", Role: authn.RoleAdmin, Synthetic: true} +} + +// useSettingsIdentity puts an identity on every request. The agent and +// agent-profile mutations are gated on org.config.manage, so a router built +// without the production middleware chain has no identity at all and every +// mutation answers 401 before reaching the handler under test. +func useSettingsIdentity(router *gin.Engine, identity authn.Identity) { + router.Use(func(c *gin.Context) { + authn.SetOnGin(c, identity) + c.Next() + }) +} + +func useSyntheticSettingsIdentity(router *gin.Engine) { + useSettingsIdentity(router, syntheticSettingsIdentity()) +} + // newSettingsHarness wires the settings handlers to the given repo and hub and // returns the router plus the controller and agent registry, so a test can // register a deterministic agent implementation or attach a dependency checker. @@ -426,6 +448,18 @@ func newSettingsHarness( t *testing.T, repo store.Repository, hub Broadcaster, +) (*gin.Engine, *controller.Controller, *registry.Registry) { + t.Helper() + return newSettingsHarnessAs(t, repo, hub, syntheticSettingsIdentity()) +} + +// newSettingsHarnessAs is newSettingsHarness with the caller's identity, for +// tests that assert what a non-admin may and may not reach. +func newSettingsHarnessAs( + t *testing.T, + repo store.Repository, + hub Broadcaster, + identity authn.Identity, ) (*gin.Engine, *controller.Controller, *registry.Registry) { t.Helper() gin.SetMode(gin.TestMode) @@ -443,6 +477,7 @@ func newSettingsHarness( } ctrl := controller.NewController(repo, discoveryRegistry, reg, nil, log) router := gin.New() + useSettingsIdentity(router, identity) // Register through the production entry point so the install/update job // stores are wired from the hub exactly as they are at startup — with a nil // hub they stay unavailable, which is itself a behaviour under test. diff --git a/apps/backend/internal/auth/authn/identity.go b/apps/backend/internal/auth/authn/identity.go index 8447ed8239..1857182b2e 100644 --- a/apps/backend/internal/auth/authn/identity.go +++ b/apps/backend/internal/auth/authn/identity.go @@ -20,6 +20,9 @@ const ( RoleAdmin Role = "admin" // RoleMember is a regular authenticated user. RoleMember Role = "member" + // RoleGuest holds no org scopes and reaches only workspaces it is an + // explicit member of, including none of the org-visible ones. + RoleGuest Role = "guest" ) // GinContextKey is the gin-context key holding the request Identity. @@ -41,6 +44,16 @@ type Identity struct { SessionID string // TokenID is set when the identity came from a personal access token. TokenID string + // OrgID is the caller's tenant. It comes from the user record and from + // nowhere else: no route, payload, header or WS frame may name an org, so + // the tenant is a total function of the authenticated identity and no + // request can move a caller between tenants. + OrgID string + // Instance marks the operator tier, which manages organizations, feature + // toggles and instance-wide configuration. An operator holds NO org scopes + // and reaches no org's workspaces: it is an administration tier, not a + // visibility one. + Instance bool } // IsAdmin reports whether the identity carries the admin role. diff --git a/apps/backend/internal/auth/httpapi/handlers.go b/apps/backend/internal/auth/httpapi/handlers.go index e7b71291e9..cf97dcdabe 100644 --- a/apps/backend/internal/auth/httpapi/handlers.go +++ b/apps/backend/internal/auth/httpapi/handlers.go @@ -32,6 +32,7 @@ import ( "github.com/kandev/kandev/internal/auth" "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/auth/hostnames" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/logger" ) @@ -67,12 +68,12 @@ func RegisterRoutes(router *gin.Engine, svc *auth.Service, resolver *hostnames.R // RequireAdmin allows the synthetic admin, so RequireRealIdentity must // precede it to keep these locked while auth is disabled. - admin := group.Group("", authn.RequireRealIdentity(), authn.RequireAdmin()) + admin := group.Group("", authn.RequireRealIdentity(), authz.RequireOrgScope(authz.ScopeOrgMembersManage)) admin.GET("/invites", h.listInvites) admin.POST("/invites", h.createInvite) admin.DELETE("/invites/:id", h.deleteInvite) - users := router.Group("/api/v1/users", authn.RequireRealIdentity(), authn.RequireAdmin()) + users := router.Group("/api/v1/users", authn.RequireRealIdentity(), authz.RequireOrgScope(authz.ScopeOrgMembersManage)) users.GET("", h.listUsers) users.POST("", h.createUser) users.PATCH("/:id", h.updateUser) @@ -235,6 +236,10 @@ func (h *Handlers) writeAuthError(c *gin.Context, err error) { c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()}) case errors.Is(err, auth.ErrInvalidCredentials): c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + // 403, not 401: the credential was accepted, so the browser must not + // treat this as a session challenge and loop back to the sign-in form. + case errors.Is(err, auth.ErrOrgUnavailable): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "code": "org_suspended"}) case errors.Is(err, auth.ErrSetupNotAvailable): c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) case errors.Is(err, auth.ErrInviteInvalid): diff --git a/apps/backend/internal/auth/service.go b/apps/backend/internal/auth/service.go index 598ef4bb51..08fc470338 100644 --- a/apps/backend/internal/auth/service.go +++ b/apps/backend/internal/auth/service.go @@ -106,6 +106,19 @@ type Service struct { // dummyHash equalizes login timing for unknown emails. dummyHash string + + // orgStatus fails sessions and tokens closed for a suspended + // organization. Nil when organizations are off. + orgStatus OrgStatusChecker + + // adminCreated places the first admin and grants the operator tier before + // setup creates the identity that switches authentication into enabled mode. + adminCreated func(ctx context.Context, userID string) error +} + +// SetAdminCreatedHook installs the pre-commit setup callback. +func (s *Service) SetAdminCreatedHook(hook func(ctx context.Context, userID string) error) { + s.adminCreated = hook } // NewService constructs the service and computes the initial mode. @@ -208,11 +221,60 @@ func validateEmailPassword(email, password string) error { return nil } -func roleOf(user *usermodels.User) authn.Role { - if user.Role == usermodels.RoleAdmin { - return authn.RoleAdmin +// identityOf builds the request identity from the account record. The org and +// the operator tier come from the stored user and from nowhere else, so no +// caller-supplied value can influence which tenant a request belongs to. +// ErrOrgUnavailable reports a correct credential whose organization is +// suspended. It is deliberately distinct from ErrInvalidCredentials: the +// password was right, and saying otherwise would send the user to reset it. +var ErrOrgUnavailable = errors.New("your organization is currently unavailable") + +// OrgStatusChecker reports whether an organization may serve requests. Wired +// from backendapp; nil on instances without organizations. +type OrgStatusChecker func(ctx context.Context, orgID string) bool + +// SetOrgStatusChecker installs the suspended-organization gate. +func (s *Service) SetOrgStatusChecker(check OrgStatusChecker) { s.orgStatus = check } + +// orgUsable reports whether the identity's organization may serve requests. +// A suspended org fails every session and token closed, which is what makes +// suspension a real lever rather than a label. +func (s *Service) orgUsable(ctx context.Context, orgID string) bool { + if s.orgStatus == nil || orgID == "" { + return true } - return authn.RoleMember + return s.orgStatus(ctx, orgID) +} + +// callerOrgID returns the requesting user's organization, or "" when +// organizations are off. Accounts and invites created by an admin inherit it, +// which is why neither path takes an org parameter. +func callerOrgID(ctx context.Context) string { + identity, ok := authn.IdentityFromContext(ctx) + if !ok || identity.Synthetic { + return "" + } + return identity.OrgID +} + +func identityOf(user *usermodels.User, sessionID, tokenID string) authn.Identity { + return authn.Identity{ + UserID: user.ID, + Role: roleOf(user), + OrgID: user.OrgID, + Instance: user.IsOperator, + SessionID: sessionID, + TokenID: tokenID, + } +} + +// roleOf carries the STORED role through unchanged. It deliberately does not +// normalize: authz.NormalizeOrgRole maps an unrecognized role to guest, the +// least privileged one, whereas usermodels.NormalizeRole defaults to member +// for write paths. Normalizing here would turn an unknown stored value into a +// collaborator on every org-visible workspace. +func roleOf(user *usermodels.User) authn.Role { + return authn.Role(user.Role) } func truncateUserAgent(ua string) string { diff --git a/apps/backend/internal/auth/service_credentials.go b/apps/backend/internal/auth/service_credentials.go index a1d6bd1c29..e98c509f1e 100644 --- a/apps/backend/internal/auth/service_credentials.go +++ b/apps/backend/internal/auth/service_credentials.go @@ -37,6 +37,13 @@ func (s *Service) Login(ctx context.Context, email, password, userAgent, ip stri return nil, "", ErrInvalidCredentials } s.limiter.Reset(limiterKey) + // A suspended organization refuses login outright rather than handing out + // a session that fails on the very next request. The message is distinct + // from ErrInvalidCredentials on purpose: the credentials were correct, and + // telling the user their password is wrong would send them to reset it. + if !s.orgUsable(ctx, user.OrgID) { + return nil, "", ErrOrgUnavailable + } token, err := s.createSession(ctx, user.ID, userAgent, ip) if err != nil { return nil, "", err @@ -101,7 +108,10 @@ func (s *Service) ResolveSessionToken(ctx context.Context, token, ip string) (au } _ = s.store.TouchSession(ctx, sess.ID, now, now.Add(s.SessionTTL()), refreshIP) } - return authn.Identity{UserID: user.ID, Role: roleOf(user), SessionID: sess.ID}, true + if !s.orgUsable(ctx, user.OrgID) { + return authn.Identity{}, false + } + return identityOf(user, sess.ID, ""), true } // ResolveBearer authenticates an Authorization bearer credential when it is a @@ -126,7 +136,10 @@ func (s *Service) ResolveBearer(ctx context.Context, token string) (authn.Identi if record.LastUsedAt == nil || now.Sub(*record.LastUsedAt) > sessionTouchInterval { _ = s.store.TouchAPIToken(ctx, record.ID, now) } - return authn.Identity{UserID: user.ID, Role: roleOf(user), TokenID: record.ID}, true + if !s.orgUsable(ctx, user.OrgID) { + return authn.Identity{}, false + } + return identityOf(user, "", record.ID), true } // IdentityForUser resolves a stored user ID to the identity that user carries @@ -148,7 +161,10 @@ func (s *Service) IdentityForUser(ctx context.Context, userID string) (authn.Ide if err != nil { return authn.Identity{}, false } - return authn.Identity{UserID: user.ID, Role: roleOf(user)}, true + if !s.orgUsable(ctx, user.OrgID) { + return authn.Identity{}, false + } + return identityOf(user, "", ""), true } // ListSessions returns the user's sessions for the account page. diff --git a/apps/backend/internal/auth/service_invites.go b/apps/backend/internal/auth/service_invites.go index b266c85ab2..a1a3c45f27 100644 --- a/apps/backend/internal/auth/service_invites.go +++ b/apps/backend/internal/auth/service_invites.go @@ -18,9 +18,7 @@ const ( // CreateInvite mints an invite link token. Email is optional; when set, the // accept step must use the same address. Returns the raw token exactly once. func (s *Service) CreateInvite(ctx context.Context, createdBy, email, role string, ttlHours int) (*store.Invite, string, error) { - if role != usermodels.RoleAdmin { - role = usermodels.RoleMember - } + role = usermodels.NormalizeRole(role) ttl := defaultInviteTTL if ttlHours > 0 { ttl = time.Duration(ttlHours) * time.Hour @@ -36,6 +34,9 @@ func (s *Service) CreateInvite(ctx context.Context, createdBy, email, role strin TokenHash: hash, Email: normalizeEmail(email), Role: role, + // The invite joins the minting admin's organization, so accepting it + // can never place an account in a tenant the inviter cannot see. + OrgID: callerOrgID(ctx), CreatedBy: createdBy, ExpiresAt: time.Now().UTC().Add(ttl), } @@ -76,6 +77,10 @@ func (s *Service) AcceptInvite(ctx context.Context, token, email, password, disp DisplayName: displayName, Role: invite.Role, Status: usermodels.StatusActive, + // The organization comes from the invite, which recorded the minting + // admin's org. Accepting an invite is an anonymous request, so this is + // the only trustworthy source for it. + OrgID: invite.OrgID, } if err := s.users.CreateUser(ctx, user); err != nil { return nil, "", ErrEmailTaken diff --git a/apps/backend/internal/auth/service_provisioning_review_test.go b/apps/backend/internal/auth/service_provisioning_review_test.go new file mode 100644 index 0000000000..964c02224f --- /dev/null +++ b/apps/backend/internal/auth/service_provisioning_review_test.go @@ -0,0 +1,55 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/auth/store" + userstore "github.com/kandev/kandev/internal/user/store" +) + +func TestSetupPlacementFailureLeavesSetupRetryable(t *testing.T) { + fixture := newServiceFixture(t, true) + placementErr := errors.New("place setup administrator") + fixture.svc.SetAdminCreatedHook(func(context.Context, string) error { return placementErr }) + + user, token, err := fixture.svc.Setup( + context.Background(), "admin@example.com", "password123", "Admin", "browser", "127.0.0.1", + ) + if !errors.Is(err, placementErr) { + t.Fatalf("setup error = %v, want placement error", err) + } + if user != nil || token != "" { + t.Fatalf("failed setup returned user=%+v token=%q", user, token) + } + if fixture.svc.Mode() != ModeSetup { + t.Fatalf("mode after failed placement = %q, want setup", fixture.svc.Mode()) + } + + fixture.svc.SetAdminCreatedHook(func(context.Context, string) error { return nil }) + if _, retryToken, retryErr := fixture.svc.Setup( + context.Background(), "admin@example.com", "password123", "Admin", "browser", "127.0.0.1", + ); retryErr != nil || retryToken == "" { + t.Fatalf("retry setup token=%q error=%v", retryToken, retryErr) + } +} + +func TestCreateUserInOrgRollsBackAccountWhenIdentityCreationFails(t *testing.T) { + fixture := newServiceFixture(t, false) + ctx := context.Background() + const email = "first-admin@example.com" + if err := fixture.svc.store.CreateIdentity(ctx, &store.LoginIdentity{ + UserID: userstore.DefaultUserID, Provider: store.ProviderLocal, Subject: email, + }); err != nil { + t.Fatalf("seed colliding identity: %v", err) + } + + err := fixture.svc.CreateUserInOrg(ctx, "org-a", email, "password123", "First Admin") + if err == nil { + t.Fatal("CreateUserInOrg succeeded despite identity collision") + } + if user, lookupErr := fixture.svc.users.GetUserByEmail(ctx, email); lookupErr == nil || user != nil { + t.Fatalf("failed provisioning left account user=%+v error=%v", user, lookupErr) + } +} diff --git a/apps/backend/internal/auth/service_setup.go b/apps/backend/internal/auth/service_setup.go index b04116d68f..22882f858d 100644 --- a/apps/backend/internal/auth/service_setup.go +++ b/apps/backend/internal/auth/service_setup.go @@ -42,6 +42,14 @@ func (s *Service) Setup(ctx context.Context, email, password, displayName, userA if err != nil { return nil, "", err } + // Tenant placement must complete before the identity commit point below. + // If it fails, setup remains retryable instead of returning an org-less, + // non-operator session that cannot repair itself. + if s.adminCreated != nil { + if err := s.adminCreated(ctx, adminID); err != nil { + return nil, "", err + } + } identity := &store.LoginIdentity{ UserID: adminID, Provider: store.ProviderLocal, diff --git a/apps/backend/internal/auth/service_users.go b/apps/backend/internal/auth/service_users.go index 978134d588..21a6393f55 100644 --- a/apps/backend/internal/auth/service_users.go +++ b/apps/backend/internal/auth/service_users.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "time" "github.com/google/uuid" @@ -21,9 +22,7 @@ func (s *Service) AdminCreateUser(ctx context.Context, email, password, displayN if err := validateEmailPassword(email, password); err != nil { return nil, err } - if role != usermodels.RoleAdmin { - role = usermodels.RoleMember - } + role = usermodels.NormalizeRole(role) if _, err := s.users.GetUserByEmail(ctx, email); err == nil { return nil, ErrEmailTaken } @@ -33,21 +32,13 @@ func (s *Service) AdminCreateUser(ctx context.Context, email, password, displayN DisplayName: displayName, Role: role, Status: usermodels.StatusActive, + // A new account joins the creating admin's organization. There is no + // org parameter on this path on purpose: an admin can only ever create + // users in their own tenant, so the org comes from the caller's + // identity rather than from the request. + OrgID: callerOrgID(ctx), } - if err := s.users.CreateUser(ctx, user); err != nil { - return nil, ErrEmailTaken - } - hash, err := HashPassword(password) - if err != nil { - return nil, err - } - identity := &store.LoginIdentity{ - UserID: user.ID, - Provider: store.ProviderLocal, - Subject: email, - PasswordHash: hash, - } - if err := s.store.CreateIdentity(ctx, identity); err != nil { + if err := s.createLocalUser(ctx, user, password); err != nil { return nil, err } return user, nil @@ -66,9 +57,7 @@ func (s *Service) AdminSetRoleStatus(ctx context.Context, targetID, role, status if status == "" { status = target.Status } - if role != usermodels.RoleAdmin { - role = usermodels.RoleMember - } + role = usermodels.NormalizeRole(role) if status != usermodels.StatusDisabled { status = usermodels.StatusActive } @@ -144,3 +133,49 @@ func (s *Service) ensureAnotherAdmin(ctx context.Context, excludingID string) er } return ErrLastAdmin } + +// CreateUserInOrg provisions an account in a named organization. +// +// It exists for exactly one caller: the operator creating an organization's +// first administrator. Every other creation path takes the org from the +// requesting admin's identity, because an admin may only ever create accounts +// in their own tenant. +func (s *Service) CreateUserInOrg( + ctx context.Context, orgID, email, password, displayName string, +) error { + email = normalizeEmail(email) + if err := validateEmailPassword(email, password); err != nil { + return err + } + if _, err := s.users.GetUserByEmail(ctx, email); err == nil { + return ErrEmailTaken + } + user := &usermodels.User{ + ID: uuid.New().String(), + Email: email, + DisplayName: displayName, + Role: usermodels.RoleAdmin, + Status: usermodels.StatusActive, + OrgID: orgID, + } + return s.createLocalUser(ctx, user, password) +} + +func (s *Service) createLocalUser(ctx context.Context, user *usermodels.User, password string) error { + hash, err := HashPassword(password) + if err != nil { + return err + } + if err := s.users.CreateUser(ctx, user); err != nil { + return ErrEmailTaken + } + if err := s.store.CreateIdentity(ctx, &store.LoginIdentity{ + UserID: user.ID, + Provider: store.ProviderLocal, + Subject: user.Email, + PasswordHash: hash, + }); err != nil { + return errors.Join(err, s.users.DeleteUser(ctx, user.ID)) + } + return nil +} diff --git a/apps/backend/internal/auth/store/models.go b/apps/backend/internal/auth/store/models.go index d771f8dcf2..f43615b67c 100644 --- a/apps/backend/internal/auth/store/models.go +++ b/apps/backend/internal/auth/store/models.go @@ -51,10 +51,12 @@ type APIToken struct { // Invite is a tokenized invitation URL minted by an admin. type Invite struct { - ID string `db:"id" json:"id"` - TokenHash string `db:"token_sha256" json:"-"` - Email string `db:"email" json:"email"` - Role string `db:"role" json:"role"` + ID string `db:"id" json:"id"` + TokenHash string `db:"token_sha256" json:"-"` + Email string `db:"email" json:"email"` + Role string `db:"role" json:"role"` + // OrgID is the organization the accepted account will join. + OrgID string `db:"org_id" json:"org_id,omitempty"` CreatedBy string `db:"created_by" json:"created_by"` CreatedAt time.Time `db:"created_at" json:"created_at"` ExpiresAt time.Time `db:"expires_at" json:"expires_at"` diff --git a/apps/backend/internal/auth/store/schema.go b/apps/backend/internal/auth/store/schema.go index 6f391a2285..d01e2bfbf7 100644 --- a/apps/backend/internal/auth/store/schema.go +++ b/apps/backend/internal/auth/store/schema.go @@ -3,6 +3,7 @@ package store import ( "fmt" + "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/db/dialect" ) @@ -65,5 +66,12 @@ func (s *Store) initSchema() error { return fmt.Errorf("auth schema: %w", err) } } + // An invite mints a member of exactly one organization, so it carries the + // minting admin's org. CREATE TABLE IF NOT EXISTS is a no-op on an + // existing database, so the column also needs an ADD COLUMN (ADR 0027). + db.NewMigrateLogger(s.db, nil).Apply( + "auth_invites.org_id", + `ALTER TABLE auth_invites ADD COLUMN org_id TEXT NOT NULL DEFAULT ''`, + ) return nil } diff --git a/apps/backend/internal/auth/store/store.go b/apps/backend/internal/auth/store/store.go index 0ecc14dc53..6d8f6146f2 100644 --- a/apps/backend/internal/auth/store/store.go +++ b/apps/backend/internal/auth/store/store.go @@ -246,9 +246,9 @@ func (s *Store) CreateInvite(ctx context.Context, invite *Invite) error { } invite.CreatedAt = time.Now().UTC() _, err := s.db.ExecContext(ctx, s.db.Rebind(` - INSERT INTO auth_invites (id, token_sha256, email, role, created_by, created_at, expires_at, used_by, used_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `), invite.ID, invite.TokenHash, invite.Email, invite.Role, invite.CreatedBy, invite.CreatedAt, invite.ExpiresAt, invite.UsedBy, invite.UsedAt) + INSERT INTO auth_invites (id, token_sha256, email, role, org_id, created_by, created_at, expires_at, used_by, used_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), invite.ID, invite.TokenHash, invite.Email, invite.Role, invite.OrgID, invite.CreatedBy, invite.CreatedAt, invite.ExpiresAt, invite.UsedBy, invite.UsedAt) return err } diff --git a/apps/backend/internal/authz/gin.go b/apps/backend/internal/authz/gin.go new file mode 100644 index 0000000000..c9df36982d --- /dev/null +++ b/apps/backend/internal/authz/gin.go @@ -0,0 +1,48 @@ +package authz + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/auth/authn" +) + +// SubjectFromGin builds the authz subject for a gin request. A request with no +// identity, or the synthetic identity injected while authentication is +// disabled, is unscoped and holds every scope — preserving pre-auth behavior +// byte for byte. +func SubjectFromGin(c *gin.Context) Subject { + identity, ok := authn.FromGin(c) + if !ok || identity.Synthetic { + return Subject{Unscoped: true} + } + return Subject{ + UserID: identity.UserID, + OrgID: identity.OrgID, + OrgRole: NormalizeOrgRole(string(identity.Role)), + } +} + +// RequireOrgScope aborts with 403 unless the caller holds the org scope. +// Requests with no identity at all are rejected with 401. +// +// This replaces the previous per-route admin bit. Naming the capability rather +// than the role is the point: "which routes need org.config.manage" is a +// question the registry can answer, "which routes called RequireAdmin" was not. +func RequireOrgScope(scope Scope) gin.HandlerFunc { + return func(c *gin.Context) { + if _, ok := authn.FromGin(c); !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + if !SubjectOrgScopes(SubjectFromGin(c)).Has(scope) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "insufficient permissions", + "scope": string(scope), + }) + return + } + c.Next() + } +} diff --git a/apps/backend/internal/authz/registry_test.go b/apps/backend/internal/authz/registry_test.go new file mode 100644 index 0000000000..7698b0626c --- /dev/null +++ b/apps/backend/internal/authz/registry_test.go @@ -0,0 +1,140 @@ +package authz + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// scopeConstNames maps each registered scope to the Go identifier that names +// it, so the enforcement sweep can look for real call sites rather than string +// literals. +var scopeConstNames = map[Scope]string{ + ScopeOrgMembersManage: "ScopeOrgMembersManage", + ScopeOrgSettingsManage: "ScopeOrgSettingsManage", + ScopeOrgConfigManage: "ScopeOrgConfigManage", + ScopeUnitManage: "ScopeUnitManage", + ScopeWorkspaceRead: "ScopeWorkspaceRead", + ScopeWorkspaceManage: "ScopeWorkspaceManage", + ScopeTaskWrite: "ScopeTaskWrite", + ScopeSessionPrompt: "ScopeSessionPrompt", + ScopeSessionControl: "ScopeSessionControl", + ScopeSessionExec: "ScopeSessionExec", + ScopeRepositoryManage: "ScopeRepositoryManage", + ScopeSecretManage: "ScopeSecretManage", + ScopeMemberManage: "ScopeMemberManage", +} + +// TestEveryRegisteredScopeIsNamed fails when a scope is added to the registry +// without a matching entry here, which would let it slip past the enforcement +// sweep below unnoticed. +func TestEveryRegisteredScopeIsNamed(t *testing.T) { + for _, def := range Registry() { + if _, ok := scopeConstNames[def.Scope]; !ok { + t.Errorf("scope %q is registered but has no constant name entry in scopeConstNames", def.Scope) + } + } + if len(scopeConstNames) != len(registry) { + t.Errorf("scopeConstNames has %d entries, registry has %d", len(scopeConstNames), len(registry)) + } +} + +// TestEveryRegisteredScopeIsEnforced sweeps the backend for a use of each +// scope constant outside this package. A scope nobody enforces is a permission +// that silently does not exist, which is worse than no scope at all: it reads +// as protection in the registry and in the API response. +func TestEveryRegisteredScopeIsEnforced(t *testing.T) { + root := backendRoot(t) + sources := goSourcesOutsideAuthz(t, root) + if len(sources) == 0 { + t.Fatal("found no Go sources outside internal/authz; the sweep would pass vacuously") + } + + for scope, constName := range scopeConstNames { + needle := "authz." + constName + if !anyFileContains(t, sources, needle) { + t.Errorf("scope %q (authz.%s) has no enforcement call site outside internal/authz", scope, constName) + } + } +} + +// TestScopeIdentifiersAreStable pins the wire values. They appear in API +// responses and are compared with ==, so a rename is a breaking change and +// must be a deliberate one. +func TestScopeIdentifiersAreStable(t *testing.T) { + want := []string{ + "org.config.manage", "org.members.manage", "org.settings.manage", + "unit.manage", + "member.manage", "repository.manage", "secret.manage", + "session.control", "session.exec", "session.prompt", + "task.write", "workspace.manage", "workspace.read", + } + got := make([]string, 0, len(registry)) + for _, def := range Registry() { + got = append(got, string(def.Scope)) + } + if len(got) != len(want) { + t.Fatalf("registry has %d scopes, pinned list has %d: %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("scope %d = %q, want %q", i, got[i], want[i]) + } + } +} + +func backendRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + // .../apps/backend/internal/authz -> .../apps/backend + root := filepath.Dir(filepath.Dir(wd)) + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + t.Fatalf("expected go.mod at %s: %v", root, err) + } + return root +} + +func goSourcesOutsideAuthz(t *testing.T, root string) []string { + t.Helper() + authzDir := filepath.Join(root, "internal", "authz") + var files []string + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if entry.Name() == "node_modules" || entry.Name() == "bin" || path == authzDir { + return filepath.SkipDir + } + return nil + } + // Production call sites only. A scope referenced solely from a test + // would otherwise look enforced while guarding nothing. + if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") { + files = append(files, path) + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + return files +} + +func anyFileContains(t *testing.T, files []string, needle string) bool { + t.Helper() + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + if strings.Contains(string(data), needle) { + return true + } + } + return false +} diff --git a/apps/backend/internal/authz/resolve.go b/apps/backend/internal/authz/resolve.go new file mode 100644 index 0000000000..fc4e7cb2d3 --- /dev/null +++ b/apps/backend/internal/authz/resolve.go @@ -0,0 +1,136 @@ +package authz + +// Subject is the caller, as far as authorization is concerned. +type Subject struct { + UserID string + OrgRole OrgRole + // OrgID is the caller's tenant, empty when organizations are off. + OrgID string + // TenancyEnforced mirrors features.multiTenancy. When it is on, a subject + // with no org is DENIED rather than treated as org-less: an account that + // somehow escaped the migration must not become a key to every tenant. + TenancyEnforced bool + // Unscoped marks a caller that predates or bypasses per-user scoping: an + // internal caller (event bus, pollers, schedulers) or the synthetic + // identity injected while authentication is disabled. Such a caller gets + // everything, which is exactly today's behavior. + Unscoped bool +} + +// WorkspaceRef is the workspace state authorization needs. +type WorkspaceRef struct { + OwnerID string + // OrgID is the workspace's tenant, empty when organizations are off. + OrgID string + // InheritedRole is the highest role the caller holds on any unit that is + // an ancestor of the workspace's unit, including that unit itself. It is + // WorkspaceRoleNone when they are a member of none of them. + InheritedRole WorkspaceRole + // MemberRole is a direct grant recorded against this workspace alone. It + // can only raise the result, never lower it. + MemberRole WorkspaceRole +} + +// Decision is the resolved access for one (subject, workspace) pair. +type Decision struct { + Role WorkspaceRole + Scopes Set +} + +// CanRead reports whether the caller reaches the workspace at all. A false +// result must surface as 404, never 403: existence is not disclosed. +func (d Decision) CanRead() bool { return d.Scopes.Has(ScopeWorkspaceRead) } + +// Has reports whether the decision grants a scope. +func (d Decision) Has(scope Scope) bool { return d.Scopes.Has(scope) } + +// ResolveWorkspace is the only place workspace permissions are derived. +// +// Roles combine by maximum. Nothing subtracts, so no grant can lower what a +// unit already gives: narrowing is done by placing a workspace in a unit with +// fewer members, which keeps the rule to one sentence and keeps a permission +// question answerable by reading the tree. +func ResolveWorkspace(subject Subject, ref WorkspaceRef) Decision { + // The tenant boundary is checked first and is absolute. It is not a + // permission level that a role can outrank: a workspace in another org is + // not visible to anybody, including an org admin or an instance operator. + if crossOrg(subject, ref) { + return decide(WorkspaceRoleNone) + } + + switch { + case subject.Unscoped: + return decide(WorkspaceRoleOwner) + + // A workspace created before authentication was enabled has no owner and + // stays visible to everyone until the setup wizard claims it. Preserving + // this keeps single-user upgrades byte-identical. + case ref.OwnerID == "": + return decide(WorkspaceRoleOwner) + + case subject.UserID != "" && ref.OwnerID == subject.UserID: + return decide(WorkspaceRoleOwner) + + default: + return decide(highestRole(ref.InheritedRole, ref.MemberRole)) + } +} + +// highestRole returns the strongest of the roles it is given. It is the whole +// of the combining rule. +func highestRole(roles ...WorkspaceRole) WorkspaceRole { + best := WorkspaceRoleNone + for _, role := range roles { + if workspaceRoleRank(role) > workspaceRoleRank(best) { + best = role + } + } + return best +} + +// Denied is the fail-closed decision. Resolution errors return this rather +// than falling back to the org default role, which is the branch that would +// silently widen access under a transient database failure. +func Denied() Decision { return decide(WorkspaceRoleNone) } + +func decide(role WorkspaceRole) Decision { + return Decision{Role: role, Scopes: WorkspaceScopes(role)} +} + +// SubjectOrgScopes returns the org scopes a subject holds. An unscoped caller +// holds every org scope. +func SubjectOrgScopes(subject Subject) Set { + if subject.Unscoped { + return orgScopeSet() + } + return OrgScopes(subject.OrgRole) +} + +func orgScopeSet() Set { + out := NewSet() + for _, def := range registry { + if def.Kind == KindOrg { + out[def.Scope] = struct{}{} + } + } + return out +} + +// crossOrg reports whether the subject and the workspace belong to different +// tenants. Both sides must carry an org for the check to apply, so a +// single-org instance (organizations off) is unaffected. +func crossOrg(subject Subject, ref WorkspaceRef) bool { + if subject.Unscoped { + return false + } + // With tenancy on, a caller carrying no org reaches nothing. Treating them + // as org-less would make an unmigrated account a key to every tenant, + // because an empty org matches everything. + if subject.TenancyEnforced && subject.OrgID == "" { + return true + } + if subject.OrgID == "" || ref.OrgID == "" { + return false + } + return subject.OrgID != ref.OrgID +} diff --git a/apps/backend/internal/authz/resolve_test.go b/apps/backend/internal/authz/resolve_test.go new file mode 100644 index 0000000000..0d875c0d4e --- /dev/null +++ b/apps/backend/internal/authz/resolve_test.go @@ -0,0 +1,312 @@ +package authz + +import "testing" + +const ( + userAna = "user-ana" + userBruno = "user-bruno" +) + +func member(role WorkspaceRole) WorkspaceRole { return role } + +func TestResolveWorkspaceReachTable(t *testing.T) { + cases := []struct { + name string + subject Subject + ref WorkspaceRef + wantRole WorkspaceRole + wantRead bool + }{ + { + name: "owner reaches own private workspace", + subject: Subject{UserID: userAna, OrgRole: OrgRoleMember}, + ref: WorkspaceRef{OwnerID: userAna}, + wantRole: WorkspaceRoleOwner, + wantRead: true, + }, + { + name: "non-member cannot reach a private workspace", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleMember}, + ref: WorkspaceRef{OwnerID: userAna}, + wantRole: WorkspaceRoleNone, + wantRead: false, + }, + { + name: "member reaches a workspace through a unit they belong to", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleMember}, + ref: WorkspaceRef{OwnerID: userAna, InheritedRole: WorkspaceRoleCollaborator}, + wantRole: WorkspaceRoleCollaborator, + wantRead: true, + }, + { + name: "admin inherits the same role as any other member, not more", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleAdmin}, + ref: WorkspaceRef{OwnerID: userAna, InheritedRole: WorkspaceRoleCollaborator}, + wantRole: WorkspaceRoleCollaborator, + wantRead: true, + }, + { + name: "admin does NOT reach a workspace in a unit they are not in", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleAdmin}, + ref: WorkspaceRef{OwnerID: userAna}, + wantRole: WorkspaceRoleNone, + wantRead: false, + }, + { + name: "guest inherits nothing, so a shared unit does not reach them", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleGuest}, + ref: WorkspaceRef{OwnerID: userAna}, + wantRole: WorkspaceRoleNone, + wantRead: false, + }, + { + name: "guest reaches a workspace it holds a row on", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleGuest}, + ref: WorkspaceRef{OwnerID: userAna, MemberRole: member(WorkspaceRoleCollaborator)}, + wantRole: WorkspaceRoleCollaborator, + wantRead: true, + }, + { + name: "a direct viewer grant cannot lower what a unit already gives", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleMember}, + ref: WorkspaceRef{OwnerID: userAna, InheritedRole: WorkspaceRoleCollaborator, MemberRole: member(WorkspaceRoleViewer)}, + wantRole: WorkspaceRoleCollaborator, + wantRead: true, + }, + { + name: "unscoped internal caller reaches everything", + subject: Subject{Unscoped: true}, + ref: WorkspaceRef{OwnerID: userAna}, + wantRole: WorkspaceRoleOwner, + wantRead: true, + }, + { + name: "pre-auth unowned workspace stays visible to everyone", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleGuest}, + ref: WorkspaceRef{OwnerID: ""}, + wantRole: WorkspaceRoleOwner, + wantRead: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ResolveWorkspace(tc.subject, tc.ref) + if got.Role != tc.wantRole { + t.Errorf("role = %q, want %q", got.Role, tc.wantRole) + } + if got.CanRead() != tc.wantRead { + t.Errorf("CanRead() = %v, want %v", got.CanRead(), tc.wantRead) + } + }) + } +} + +// A viewer may read a transcript; a shell in the worktree is a different +// question, and collapsing the two is the mistake this test exists to catch. +func TestViewerHasNoExecOrWrite(t *testing.T) { + viewer := ResolveWorkspace( + Subject{UserID: userBruno, OrgRole: OrgRoleMember}, + WorkspaceRef{OwnerID: userAna, MemberRole: WorkspaceRoleViewer}, + ) + if !viewer.Has(ScopeWorkspaceRead) { + t.Error("viewer should hold workspace.read") + } + for _, denied := range []Scope{ScopeSessionExec, ScopeSessionPrompt, ScopeTaskWrite, ScopeSessionControl} { + if viewer.Has(denied) { + t.Errorf("viewer must not hold %q", denied) + } + } +} + +// Managing a workspace, its members, repositories and secrets belongs to the +// owner. A collaborator contributes; it does not administer. +func TestCollaboratorCannotAdminister(t *testing.T) { + collab := WorkspaceScopes(WorkspaceRoleCollaborator) + for _, denied := range []Scope{ScopeWorkspaceManage, ScopeMemberManage, ScopeSecretManage, ScopeRepositoryManage} { + if collab.Has(denied) { + t.Errorf("collaborator must not hold %q", denied) + } + } + owner := WorkspaceScopes(WorkspaceRoleOwner) + for _, granted := range []Scope{ScopeWorkspaceManage, ScopeMemberManage, ScopeSecretManage, ScopeRepositoryManage} { + if !owner.Has(granted) { + t.Errorf("owner should hold %q", granted) + } + } +} + +func TestDeniedGrantsNothing(t *testing.T) { + if Denied().CanRead() { + t.Error("Denied() must not grant workspace.read") + } + if len(Denied().Scopes) != 0 { + t.Errorf("Denied() granted %v", Denied().Scopes.List()) + } +} + +func TestOrgScopes(t *testing.T) { + admin := SubjectOrgScopes(Subject{OrgRole: OrgRoleAdmin}) + if !admin.Has(ScopeOrgMembersManage) || !admin.Has(ScopeOrgConfigManage) { + t.Errorf("admin org scopes = %v", admin.List()) + } + for _, role := range []OrgRole{OrgRoleMember, OrgRoleGuest} { + if scopes := SubjectOrgScopes(Subject{OrgRole: role}); len(scopes) != 0 { + t.Errorf("%s should hold no org scopes, got %v", role, scopes.List()) + } + } + if unscoped := SubjectOrgScopes(Subject{Unscoped: true}); !unscoped.Has(ScopeOrgMembersManage) { + t.Error("unscoped caller should hold every org scope") + } +} + +// Unknown stored values must fail closed rather than widen access. +func TestNormalizersFailClosed(t *testing.T) { + if got := NormalizeOrgRole("superuser"); got != OrgRoleGuest { + t.Errorf("NormalizeOrgRole(unknown) = %q, want guest", got) + } + if got := NormalizeOrgRole(""); got != OrgRoleGuest { + t.Errorf("NormalizeOrgRole(empty) = %q, want guest", got) + } + if got := NormalizeWorkspaceRole("superuser"); got != WorkspaceRoleNone { + t.Errorf("NormalizeWorkspaceRole(unknown) = %q, want none", got) + } +} + +func TestIsAssignableWorkspaceRole(t *testing.T) { + if IsAssignableWorkspaceRole(WorkspaceRoleOwner) { + t.Error("owner must be reached by transfer, not assignment") + } + if !IsAssignableWorkspaceRole(WorkspaceRoleCollaborator) || !IsAssignableWorkspaceRole(WorkspaceRoleViewer) { + t.Error("collaborator and viewer must be assignable") + } +} + +// The tenant boundary is absolute: it is checked before any role, so no role +// can outrank it. These cases are the whole point of organizations. +func TestCrossOrgIsAbsolute(t *testing.T) { + inOrgA := Subject{UserID: userBruno, OrgRole: OrgRoleAdmin, OrgID: "org-a"} + workspaceInB := WorkspaceRef{OwnerID: userAna, OrgID: "org-b"} + + if ResolveWorkspace(inOrgA, workspaceInB).CanRead() { + t.Error("an org-visible workspace in another org must not be readable") + } + + // Even an explicit membership row cannot cross the boundary: the tenancy + // migration drops such rows, and a stale one must not grant access. + withRow := workspaceInB + withRow.MemberRole = WorkspaceRoleCollaborator + if ResolveWorkspace(inOrgA, withRow).CanRead() { + t.Error("a stale cross-org membership row must not grant access") + } + + // Owning it does not help either: owner_id and org_id disagreeing is + // corrupt data, and the safe reading is no access. + owned := WorkspaceRef{OwnerID: userBruno, OrgID: "org-b"} + if ResolveWorkspace(inOrgA, owned).CanRead() { + t.Error("a workspace in another org must not be readable even by its owner_id") + } + + // Same org resolves normally: the tenant check is a gate in front of the + // tree, not a replacement for it, so an inherited role still applies. + sameOrg := WorkspaceRef{OwnerID: userAna, OrgID: "org-a", InheritedRole: WorkspaceRoleCollaborator} + if !ResolveWorkspace(inOrgA, sameOrg).CanRead() { + t.Error("a same-org workspace inherited through a unit should be readable") + } +} + +// With organizations off nothing carries an org, and behavior is unchanged. +func TestSingleOrgInstanceUnaffectedByTenancyCheck(t *testing.T) { + subject := Subject{UserID: userBruno, OrgRole: OrgRoleMember} + ref := WorkspaceRef{OwnerID: userAna, InheritedRole: WorkspaceRoleCollaborator} + if !ResolveWorkspace(subject, ref).CanRead() { + t.Error("org-less instance must resolve through the tree like any other") + } +} + +// A workspace with no org (created before the migration) stays reachable by +// its own org's users rather than becoming invisible to everyone. +func TestWorkspaceWithoutOrgStaysReachable(t *testing.T) { + subject := Subject{UserID: userAna, OrgRole: OrgRoleMember, OrgID: "org-a"} + ref := WorkspaceRef{OwnerID: userAna} + if !ResolveWorkspace(subject, ref).CanRead() { + t.Error("an unmigrated workspace must stay reachable by its owner") + } +} + +// An account that escaped the migration must not become a key to every tenant: +// with tenancy enforced, no org means no access. +func TestOrglessSubjectDeniedWhenTenancyEnforced(t *testing.T) { + orgless := Subject{UserID: userBruno, OrgRole: OrgRoleAdmin, TenancyEnforced: true} + for _, ref := range []WorkspaceRef{ + {OwnerID: userAna, OrgID: "org-a"}, + {OwnerID: userAna}, + {OwnerID: userBruno, OrgID: "org-a"}, + } { + if ResolveWorkspace(orgless, ref).CanRead() { + t.Errorf("org-less subject reached %+v under enforced tenancy", ref) + } + } + // Without tenancy enforced the same subject behaves as before. + legacy := Subject{UserID: userBruno, OrgRole: OrgRoleMember} + if !ResolveWorkspace(legacy, WorkspaceRef{OwnerID: userAna, InheritedRole: WorkspaceRoleCollaborator}).CanRead() { + t.Error("org-less subject must be unaffected when tenancy is off") + } +} + +// An unrecognized stored role must resolve to the LEAST privileged role, not +// to member. Identity construction carries the stored value through unchanged +// precisely so this normalization is the one that decides. +func TestUnknownStoredRoleResolvesToGuest(t *testing.T) { + unknown := Subject{UserID: userBruno, OrgRole: NormalizeOrgRole("superuser")} + orgVisible := WorkspaceRef{OwnerID: userAna} + if ResolveWorkspace(unknown, orgVisible).CanRead() { + t.Error("an unknown stored role must not reach an org-visible workspace") + } + if got := NormalizeOrgRole("superuser"); got != OrgRoleGuest { + t.Errorf("NormalizeOrgRole(unknown) = %q, want guest", got) + } +} + +// The combining rule in one test: what you inherit and what you were granted +// are unioned, and the strongest wins. A model where a grant could lower an +// inherited role would need a deny concept, which is the thing this design +// removed on purpose. +func TestRolesCombineByMaximum(t *testing.T) { + cases := []struct { + name string + inherited WorkspaceRole + direct WorkspaceRole + want WorkspaceRole + }{ + {"a direct grant raises what a unit gives", WorkspaceRoleViewer, WorkspaceRoleCollaborator, WorkspaceRoleCollaborator}, + {"a direct grant cannot lower it", WorkspaceRoleCollaborator, WorkspaceRoleViewer, WorkspaceRoleCollaborator}, + {"inheritance alone is enough", WorkspaceRoleCollaborator, WorkspaceRoleNone, WorkspaceRoleCollaborator}, + {"a grant alone is enough", WorkspaceRoleNone, WorkspaceRoleViewer, WorkspaceRoleViewer}, + {"neither reaches nothing", WorkspaceRoleNone, WorkspaceRoleNone, WorkspaceRoleNone}, + {"owner outranks everything", WorkspaceRoleViewer, WorkspaceRoleOwner, WorkspaceRoleOwner}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ResolveWorkspace( + Subject{UserID: userBruno, OrgRole: OrgRoleMember}, + WorkspaceRef{OwnerID: userAna, InheritedRole: tc.inherited, MemberRole: tc.direct}, + ) + if got.Role != tc.want { + t.Errorf("role = %q, want %q", got.Role, tc.want) + } + }) + } +} + +// An unknown stored role must not outrank a real one. Ranking the zero value +// lowest is what makes a corrupt row fail closed rather than silently win. +func TestUnknownRoleDoesNotOutrank(t *testing.T) { + got := ResolveWorkspace( + Subject{UserID: userBruno, OrgRole: OrgRoleMember}, + WorkspaceRef{OwnerID: userAna, InheritedRole: WorkspaceRoleCollaborator, MemberRole: WorkspaceRole("superuser")}, + ) + if got.Role != WorkspaceRoleCollaborator { + t.Errorf("role = %q, want collaborator: an unrecognized role must not win", got.Role) + } +} diff --git a/apps/backend/internal/authz/roles.go b/apps/backend/internal/authz/roles.go new file mode 100644 index 0000000000..71994616fc --- /dev/null +++ b/apps/backend/internal/authz/roles.go @@ -0,0 +1,139 @@ +package authz + +// OrgRole is the instance-wide role carried on the user record. It grants org +// scopes outright and supplies the default workspace role used on workspaces +// that are visible to the whole organization. +// +// The `owner` role and the `org.delete` scope described in +// docs/specs/auth/requirements/roles-and-scopes.md arrive with multi-tenancy: without an +// org entity there is nothing to own or delete, and shipping an unreachable +// role would only clutter the role picker. +type OrgRole string + +const ( + // OrgRoleAdmin manages users, org settings, and shared configuration. It + // is NOT a reach role: an admin reaches a workspace the same way anyone + // else does, through the unit tree, and never reaches one sitting in + // someone's personal unit. + OrgRoleAdmin OrgRole = "admin" + // OrgRoleMember is a regular contributor. + OrgRoleMember OrgRole = "member" + // OrgRoleGuest holds no org scopes and reaches only workspaces they are an + // explicit member of, never one reached through the unit tree. + OrgRoleGuest OrgRole = "guest" +) + +// WorkspaceRole is the per-workspace role, held either by an explicit +// workspace_members row or inherited from membership of a unit above the one +// the workspace sits in. Where both apply the stronger of the two wins. +type WorkspaceRole string + +const ( + WorkspaceRoleOwner WorkspaceRole = "owner" + WorkspaceRoleCollaborator WorkspaceRole = "collaborator" + WorkspaceRoleViewer WorkspaceRole = "viewer" + // WorkspaceRoleNone means the workspace is unreachable. + WorkspaceRoleNone WorkspaceRole = "" +) + +// NormalizeOrgRole coerces an unknown or empty stored value to the least +// privileged real role. Unknown input must never widen access. +func NormalizeOrgRole(value string) OrgRole { + switch OrgRole(value) { + case OrgRoleAdmin: + return OrgRoleAdmin + case OrgRoleMember: + return OrgRoleMember + case OrgRoleGuest: + return OrgRoleGuest + default: + return OrgRoleGuest + } +} + +// NormalizeWorkspaceRole coerces an unknown stored value to no access. +func NormalizeWorkspaceRole(value string) WorkspaceRole { + switch WorkspaceRole(value) { + case WorkspaceRoleOwner: + return WorkspaceRoleOwner + case WorkspaceRoleCollaborator: + return WorkspaceRoleCollaborator + case WorkspaceRoleViewer: + return WorkspaceRoleViewer + default: + return WorkspaceRoleNone + } +} + +// IsAssignableWorkspaceRole reports whether a role may be set through the +// member API. Owner is reached by transfer, not by assignment. +func IsAssignableWorkspaceRole(role WorkspaceRole) bool { + return role == WorkspaceRoleCollaborator || role == WorkspaceRoleViewer +} + +// orgRoleScopes maps an org role to the org scopes it grants. +var orgRoleScopes = map[OrgRole]Set{ + OrgRoleAdmin: NewSet(ScopeOrgMembersManage, ScopeOrgSettingsManage, ScopeOrgConfigManage, ScopeUnitManage), + OrgRoleMember: NewSet(), + OrgRoleGuest: NewSet(), +} + +// workspaceRoleScopes maps a workspace role to the workspace scopes it grants. +// +// session.exec is deliberately absent from viewer and present on collaborator: +// prompting an agent is bounded by that agent's own permissions, a shell in the +// worktree is not, so reading a transcript must never imply a shell. +var workspaceRoleScopes = map[WorkspaceRole]Set{ + WorkspaceRoleOwner: NewSet( + ScopeWorkspaceRead, ScopeWorkspaceManage, ScopeTaskWrite, + ScopeSessionPrompt, ScopeSessionControl, ScopeSessionExec, + ScopeRepositoryManage, ScopeSecretManage, ScopeMemberManage, + ), + WorkspaceRoleCollaborator: NewSet( + ScopeWorkspaceRead, ScopeTaskWrite, + ScopeSessionPrompt, ScopeSessionControl, ScopeSessionExec, + ), + WorkspaceRoleViewer: NewSet(ScopeWorkspaceRead), + WorkspaceRoleNone: NewSet(), +} + +// OrgScopes returns the org scopes granted by an org role. +func OrgScopes(role OrgRole) Set { return copySet(orgRoleScopes[role]) } + +// WorkspaceScopes returns the workspace scopes granted by a workspace role. +func WorkspaceScopes(role WorkspaceRole) Set { return copySet(workspaceRoleScopes[role]) } + +// AllScopes returns every scope both role tables can grant. Used by the +// synthetic (auth-disabled) identity, which must behave exactly as the +// pre-auth single user did. +func AllScopes() Set { + all := NewSet() + for _, def := range registry { + all[def.Scope] = struct{}{} + } + return all +} + +func copySet(src Set) Set { + out := make(Set, len(src)) + for scope := range src { + out[scope] = struct{}{} + } + return out +} + +// workspaceRoleRank orders the workspace roles so they can be combined by +// maximum. The zero value is the weakest, so an unknown role never outranks a +// real one. +func workspaceRoleRank(role WorkspaceRole) int { + switch role { + case WorkspaceRoleOwner: + return 3 + case WorkspaceRoleCollaborator: + return 2 + case WorkspaceRoleViewer: + return 1 + default: + return 0 + } +} diff --git a/apps/backend/internal/authz/scopes.go b/apps/backend/internal/authz/scopes.go new file mode 100644 index 0000000000..c1ff44ed62 --- /dev/null +++ b/apps/backend/internal/authz/scopes.go @@ -0,0 +1,125 @@ +// Package authz owns Kandev's permission vocabulary: the named scopes that +// guard every user-facing action, the fixed roles that grant them, and the +// single resolver that answers "what may this user do in this workspace". +// +// There is deliberately one registry. Authorization used to be a single admin +// bit applied per route; a second mechanism living alongside the first is how +// permissions drift, so every guarded action names a scope from this file and +// nothing else decides. +package authz + +import "sort" + +// Scope is a named permission. Values are stable wire identifiers: they appear +// in API responses so the UI can label and gate controls, and they are compared +// with ==, so they are never translated. +type Scope string + +// Org scopes are granted by the org role and are independent of any workspace. +const ( + ScopeOrgMembersManage Scope = "org.members.manage" + ScopeUnitManage Scope = "unit.manage" + ScopeOrgSettingsManage Scope = "org.settings.manage" + ScopeOrgConfigManage Scope = "org.config.manage" +) + +// Workspace scopes are granted by the workspace role, per workspace. +const ( + ScopeWorkspaceRead Scope = "workspace.read" + ScopeWorkspaceManage Scope = "workspace.manage" + ScopeTaskWrite Scope = "task.write" + ScopeSessionPrompt Scope = "session.prompt" + ScopeSessionControl Scope = "session.control" + ScopeSessionExec Scope = "session.exec" + ScopeRepositoryManage Scope = "repository.manage" + ScopeSecretManage Scope = "secret.manage" + ScopeMemberManage Scope = "member.manage" +) + +// Definition is a registry entry. Description is developer- and UI-facing +// English; the frontend translates by scope ID rather than displaying this. +type Definition struct { + Scope Scope `json:"scope"` + Kind Kind `json:"kind"` + Description string `json:"description"` +} + +// Kind separates scopes granted org-wide from scopes granted per workspace. +type Kind string + +const ( + KindOrg Kind = "org" + KindWorkspace Kind = "workspace" +) + +// registry is the complete set of scopes Kandev enforces. A scope that is not +// here cannot be required, and a scope here with no enforcement call site fails +// the completeness test in registry_test.go. +var registry = []Definition{ + {ScopeOrgMembersManage, KindOrg, "Invite, create, disable, and re-role users"}, + {ScopeOrgSettingsManage, KindOrg, "Change organization-wide settings and defaults"}, + {ScopeOrgConfigManage, KindOrg, "Manage executors, agent profiles, environments, editors, prompts, and notification providers"}, + {ScopeUnitManage, KindOrg, "Create, rename, move, and delete organization units, and place workspaces in them"}, + + {ScopeWorkspaceRead, KindWorkspace, "See the workspace, its board, tasks, and transcripts"}, + {ScopeWorkspaceManage, KindWorkspace, "Rename the workspace, change its defaults, move it between units, delete it"}, + {ScopeTaskWrite, KindWorkspace, "Create, edit, move, assign, archive, and delete tasks"}, + {ScopeSessionPrompt, KindWorkspace, "Start or resume a session and message an agent"}, + {ScopeSessionControl, KindWorkspace, "Stop or cancel a running agent"}, + {ScopeSessionExec, KindWorkspace, "Open a terminal, run shell commands, write files, and open port previews"}, + {ScopeRepositoryManage, KindWorkspace, "Add or remove workspace repositories"}, + {ScopeSecretManage, KindWorkspace, "Manage workspace secrets and integration credentials"}, + {ScopeMemberManage, KindWorkspace, "Add or remove workspace members and transfer ownership"}, +} + +// Registry returns every defined scope, ordered by kind then ID. +func Registry() []Definition { + out := make([]Definition, len(registry)) + copy(out, registry) + sort.Slice(out, func(i, j int) bool { + if out[i].Kind != out[j].Kind { + return out[i].Kind < out[j].Kind + } + return out[i].Scope < out[j].Scope + }) + return out +} + +// Set is an unordered collection of scopes. +type Set map[Scope]struct{} + +// NewSet builds a Set from the given scopes. +func NewSet(scopes ...Scope) Set { + set := make(Set, len(scopes)) + for _, scope := range scopes { + set[scope] = struct{}{} + } + return set +} + +// Has reports whether the set contains the scope. The zero Set holds nothing, +// which is the fail-closed default every caller depends on. +func (s Set) Has(scope Scope) bool { + _, ok := s[scope] + return ok +} + +// List returns the set's scopes sorted, for stable API responses and tests. +func (s Set) List() []Scope { + out := make([]Scope, 0, len(s)) + for scope := range s { + out = append(out, scope) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// Strings returns List as plain strings, for JSON payloads. +func (s Set) Strings() []string { + scopes := s.List() + out := make([]string, len(scopes)) + for i, scope := range scopes { + out[i] = string(scope) + } + return out +} diff --git a/apps/backend/internal/backendapp/adapters_user_directory.go b/apps/backend/internal/backendapp/adapters_user_directory.go new file mode 100644 index 0000000000..423599a9b3 --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_user_directory.go @@ -0,0 +1,72 @@ +package backendapp + +import ( + "context" + + "github.com/kandev/kandev/internal/task/service" + usermodels "github.com/kandev/kandev/internal/user/models" + userstore "github.com/kandev/kandev/internal/user/store" +) + +// directoryAccounts is the one method this adapter needs. Narrowing it from +// the full account repository is what makes the org filter testable without +// standing up an account store. +type directoryAccounts interface { + ListUsers(ctx context.Context) ([]*usermodels.User, error) + GetUser(ctx context.Context, id string) (*usermodels.User, error) +} + +// userDirectoryAdapter exposes the minimum account information workspace +// membership needs, and nothing more. +// +// It lives in backendapp rather than internal/user because the contract it +// satisfies belongs to the task service: putting it in internal/user would +// make the account package depend on the task package. +type userDirectoryAdapter struct { + accounts directoryAccounts +} + +func newUserDirectoryAdapter(accounts userstore.AccountRepository) *userDirectoryAdapter { + return &userDirectoryAdapter{accounts: accounts} +} + +// ListDirectory returns active users as ID plus display name. Email, role and +// status are deliberately dropped: a member picker needs a name, and anything +// more would hand every authenticated user a full account dump. +func (a *userDirectoryAdapter) ListDirectory(ctx context.Context, orgID string) ([]service.DirectoryUser, error) { + users, err := a.accounts.ListUsers(ctx) + if err != nil { + return nil, err + } + out := make([]service.DirectoryUser, 0, len(users)) + for _, user := range users { + if user == nil || user.Status == "disabled" { + continue + } + // Filter here rather than in the store: with organizations off every + // account carries an empty org and the comparison is a no-op, so one + // code path covers both shapes. + if orgID != "" && user.OrgID != orgID { + continue + } + name := user.DisplayName + if name == "" { + name = user.Email + } + out = append(out, service.DirectoryUser{ID: user.ID, DisplayName: name}) + } + return out, nil +} + +// LookupStatus reports whether a user exists and, if so, their status and role. +func (a *userDirectoryAdapter) LookupStatus(ctx context.Context, userID string) (string, string, bool, error) { + user, err := a.accounts.GetUser(ctx, userID) + if err != nil || user == nil { + // A missing account is an expected outcome here, not a failure: the + // caller turns it into a "user not found" validation error. + return "", "", false, nil //nolint:nilerr // absence is not an error on this path + } + return user.Status, user.Role, true, nil +} + +var _ service.UserDirectory = (*userDirectoryAdapter)(nil) diff --git a/apps/backend/internal/backendapp/adapters_user_directory_test.go b/apps/backend/internal/backendapp/adapters_user_directory_test.go new file mode 100644 index 0000000000..e1c68ad960 --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_user_directory_test.go @@ -0,0 +1,69 @@ +package backendapp + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/user/models" +) + +type stubAccounts struct{ users []*models.User } + +func (s stubAccounts) ListUsers(context.Context) ([]*models.User, error) { return s.users, nil } + +func (s stubAccounts) GetUser(_ context.Context, id string) (*models.User, error) { + for _, u := range s.users { + if u.ID == id { + return u, nil + } + } + return nil, nil +} + +// The member picker is the one place a person's name is shown to someone who +// has no other relationship with them. Listing it unscoped makes every account +// on the instance enumerable from any tenant, which is the cross-tenant leak +// this filter exists to close. +func TestDirectoryIsScopedToTheCallersOrg(t *testing.T) { + adapter := &userDirectoryAdapter{accounts: stubAccounts{users: []*models.User{ + {ID: "ada", DisplayName: "Ada", OrgID: "org-a", Status: "active"}, + {ID: "grace", DisplayName: "Grace", OrgID: "org-a", Status: "active"}, + {ID: "eve", DisplayName: "Eve", OrgID: "org-b", Status: "active"}, + {ID: "gone", DisplayName: "Gone", OrgID: "org-a", Status: "disabled"}, + }}} + + got, err := adapter.ListDirectory(context.Background(), "org-a") + if err != nil { + t.Fatalf("list directory: %v", err) + } + ids := map[string]bool{} + for _, u := range got { + ids[u.ID] = true + } + if !ids["ada"] || !ids["grace"] { + t.Fatalf("own organization missing from the directory: %+v", got) + } + if ids["eve"] { + t.Fatal("another organization's account is enumerable through the directory") + } + if ids["gone"] { + t.Fatal("a disabled account is pickable") + } +} + +// With organizations off every account carries an empty org, so the same code +// path must return the whole instance rather than nothing. +func TestDirectoryReturnsEveryoneWhenOrgsAreOff(t *testing.T) { + adapter := &userDirectoryAdapter{accounts: stubAccounts{users: []*models.User{ + {ID: "ada", DisplayName: "Ada", Status: "active"}, + {ID: "grace", DisplayName: "Grace", Status: "active"}, + }}} + + got, err := adapter.ListDirectory(context.Background(), "") + if err != nil { + t.Fatalf("list directory: %v", err) + } + if len(got) != 2 { + t.Fatalf("directory = %+v, want both accounts on a single-org instance", got) + } +} diff --git a/apps/backend/internal/backendapp/auth.go b/apps/backend/internal/backendapp/auth.go index c174edf510..1af239d550 100644 --- a/apps/backend/internal/backendapp/auth.go +++ b/apps/backend/internal/backendapp/auth.go @@ -121,7 +121,8 @@ func gatewayAuthPolicy( Session: taskSvc.AuthorizeSessionAccess, Run: runSubscriptionCheck(taskSvc, officeRepo), }, - WorkspaceOwner: newWorkspaceOwnerResolver(taskRepo), + WorkspaceOwner: newWorkspaceOwnerResolver(taskRepo), + WorkspaceReaders: taskSvc.WorkspaceReaderIDs, // The user-shell actions name a task environment and treat task_id as // optional, so the dispatch backstop needs an environment-keyed check. ActionEnvironment: taskSvc.AuthorizeEnvironmentAccess, @@ -129,7 +130,7 @@ func gatewayAuthPolicy( } // runSubscriptionCheck resolves a run's owning workspace (via its agent -// profile) and defers to the task service's workspace visibility rule. +// profile) and defers to the task service's workspace reach rule. // Unlike WorkspaceOwner (which runs on every workspace broadcast), this // runs once per run.subscribe — a rare control message — so it is not // cached. diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index f7ddf3b7ed..a6bfd28890 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -54,7 +54,7 @@ func (b bootStateBuilder) tasksPageBootData(ctx context.Context, req *http.Reque ) state := map[string]any{ "workspaces": map[string]any{ - "items": workspaceItemStates(workspaces), + "items": b.workspaceItemStates(ctx, workspaces), "activeId": nullString(activeWorkspaceID), }, } @@ -122,7 +122,7 @@ func (b bootStateBuilder) routeContextBootData(ctx context.Context, req *http.Re ) state := map[string]any{ "workspaces": map[string]any{ - "items": workspaceItemStates(workspaces), + "items": b.workspaceItemStates(ctx, workspaces), "activeId": nullString(activeWorkspaceID), }, } @@ -171,11 +171,21 @@ func workspaceIDSet(workspaces []*taskmodels.Workspace) map[string]bool { } // workspaceItemStates maps each workspace to its boot state shape. -func workspaceItemStates(workspaces []*taskmodels.Workspace) []map[string]any { +// workspaceItemStates maps each workspace to its boot state shape, including +// the caller's resolved role and scopes. +// +// The projection is threaded through here rather than left to the HTTP list +// endpoint: this is a second, independent serialization path into the same +// store, and a field added only to the DTO silently never reaches a +// first-paint render. +func (b bootStateBuilder) workspaceItemStates(ctx context.Context, workspaces []*taskmodels.Workspace) []map[string]any { + access := b.p.taskSvc.ProjectWorkspaceAccess(ctx, workspaces) items := make([]map[string]any, 0, len(workspaces)) for _, workspace := range workspaces { if workspace != nil { - items = append(items, mapWorkspaceItemState(taskdto.FromWorkspace(workspace))) + items = append(items, mapWorkspaceItemState(taskdto.FromWorkspaceWithAccess( + workspace, access.Decision(workspace.ID), access.MemberCounts[workspace.ID], + ))) } } return items @@ -694,6 +704,10 @@ func mapWorkspaceItemState(workspace taskdto.WorkspaceDTO) map[string]any { "name": workspace.Name, "description": workspace.Description, "owner_id": workspace.OwnerID, + "unit_id": workspace.UnitID, + "viewer_role": workspace.ViewerRole, + "scopes": workspace.Scopes, + "member_count": workspace.MemberCount, "default_executor_id": workspace.DefaultExecutorID, "default_environment_id": workspace.DefaultEnvironmentID, "default_agent_profile_id": workspace.DefaultAgentProfileID, diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index e7722dcc2b..4a06a5caf2 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -33,6 +33,7 @@ import ( "github.com/kandev/kandev/internal/auth" "github.com/kandev/kandev/internal/auth/authn" authhttpapi "github.com/kandev/kandev/internal/auth/httpapi" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/azuredevops" "github.com/kandev/kandev/internal/clarification" @@ -66,6 +67,8 @@ import ( officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" officetestharness "github.com/kandev/kandev/internal/office/testharness" "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/org" + "github.com/kandev/kandev/internal/orgunit" "github.com/kandev/kandev/internal/plugins" pluginstore "github.com/kandev/kandev/internal/plugins/store" "github.com/kandev/kandev/internal/profiles" @@ -1242,6 +1245,13 @@ func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, han p.log.Warn("prompt attachment routes disabled: attachment service is unavailable") } taskhandlers.RegisterWorkspaceRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) + taskhandlers.RegisterMemberRoutes(p.router, p.taskSvc, p.log) + if p.services != nil && p.services.Org != nil { + org.NewController(p.services.Org, p.log).RegisterRoutes(p.router) + } + if p.services.OrgUnits != nil { + orgunit.NewController(p.services.OrgUnits, p.log).RegisterRoutes(p.router) + } if p.services != nil { registerMentionRoutes(p.router, p.services.Mentions) } @@ -1625,17 +1635,28 @@ type lifecycleAccessAuthorizer interface { AuthorizeEnvironmentAccess(ctx context.Context, taskEnvironmentID string) error AuthorizeTaskAccess(ctx context.Context, taskID string) error AuthorizeTaskEnvironmentAccess(ctx context.Context, taskID, taskEnvironmentID string) error + AuthorizeSessionScope(ctx context.Context, sessionID string, scope authz.Scope) error + AuthorizeEnvironmentScope(ctx context.Context, taskEnvironmentID string, scope authz.Scope) error } // wireLifecycleAccessCheckers installs every per-user visibility check on the // lifecycle manager. Kept together so a surface that needs a new kind of check // has one place to add it, rather than another setter call somewhere else in // startup that nothing asserts on. -func wireLifecycleAccessCheckers(lifecycleMgr *lifecycle.Manager, authz lifecycleAccessAuthorizer) { - lifecycleMgr.SetSessionAccessChecker(authz.AuthorizeSessionAccess) - lifecycleMgr.SetEnvironmentAccessChecker(authz.AuthorizeEnvironmentAccess) - lifecycleMgr.SetTaskAccessChecker(authz.AuthorizeTaskAccess) - lifecycleMgr.SetTaskEnvironmentAccessChecker(authz.AuthorizeTaskEnvironmentAccess) +func wireLifecycleAccessCheckers(lifecycleMgr *lifecycle.Manager, access lifecycleAccessAuthorizer) { + lifecycleMgr.SetSessionAccessChecker(access.AuthorizeSessionAccess) + lifecycleMgr.SetEnvironmentAccessChecker(access.AuthorizeEnvironmentAccess) + lifecycleMgr.SetTaskAccessChecker(access.AuthorizeTaskAccess) + lifecycleMgr.SetTaskEnvironmentAccessChecker(access.AuthorizeTaskEnvironmentAccess) + // Execution surfaces (terminal, shell, file writes, VS Code, port + // previews) require session.exec, which a viewer does not hold. They are + // keyed either by session or by environment, so both slots get one. + lifecycleMgr.SetSessionExecAccessChecker(func(ctx context.Context, sessionID string) error { + return access.AuthorizeSessionScope(ctx, sessionID, authz.ScopeSessionExec) + }) + lifecycleMgr.SetEnvironmentExecAccessChecker(func(ctx context.Context, environmentID string) error { + return access.AuthorizeEnvironmentScope(ctx, environmentID, authz.ScopeSessionExec) + }) } func dockerTaskTitleProvider(taskRepo *sqliterepo.Repository, log *logger.Logger) docker.TaskTitleProvider { diff --git a/apps/backend/internal/backendapp/lifecycle_access_wiring_test.go b/apps/backend/internal/backendapp/lifecycle_access_wiring_test.go index c7ac91c646..1652733b34 100644 --- a/apps/backend/internal/backendapp/lifecycle_access_wiring_test.go +++ b/apps/backend/internal/backendapp/lifecycle_access_wiring_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/authz" ) // recordingAuthorizer records which authorizer the lifecycle manager reached @@ -31,6 +32,16 @@ func (r *recordingAuthorizer) AuthorizeTaskEnvironmentAccess(_ context.Context, return nil } +func (r *recordingAuthorizer) AuthorizeSessionScope(_ context.Context, sessionID string, scope authz.Scope) error { + r.calls = append(r.calls, "session-scope:"+sessionID+"/"+string(scope)) + return nil +} + +func (r *recordingAuthorizer) AuthorizeEnvironmentScope(_ context.Context, envID string, scope authz.Scope) error { + r.calls = append(r.calls, "environment-scope:"+envID+"/"+string(scope)) + return nil +} + // TestWireLifecycleAccessCheckers pins the hop between the task service and the // lifecycle manager. The three single-ID checkers share the signature // `func(context.Context, string) error`, so wiring session visibility into the @@ -39,10 +50,10 @@ func (r *recordingAuthorizer) AuthorizeTaskEnvironmentAccess(_ context.Context, // Checking each check reaches its own authorizer is the only thing that catches // a crossed or dropped wire. func TestWireLifecycleAccessCheckers(t *testing.T) { - authz := &recordingAuthorizer{} + access := &recordingAuthorizer{} mgr := &lifecycle.Manager{} - wireLifecycleAccessCheckers(mgr, authz) + wireLifecycleAccessCheckers(mgr, access) ctx := context.Background() if err := mgr.CheckSessionAccess(ctx, "sess-1"); err != nil { @@ -57,14 +68,28 @@ func TestWireLifecycleAccessCheckers(t *testing.T) { if err := mgr.CheckTaskEnvironmentAccess(ctx, "task-1", "env-1"); err != nil { t.Fatalf("CheckTaskEnvironmentAccess: %v", err) } + // The exec checkers fall back to the read check when unwired, so a dropped + // wire here is silent: the surface still authorizes, just at the wrong + // scope. Asserting the scope-carrying authorizer is the one reached (and + // with session.exec, not a read scope) is what catches that. + if err := mgr.CheckSessionExecAccess(ctx, "sess-1"); err != nil { + t.Fatalf("CheckSessionExecAccess: %v", err) + } + if err := mgr.CheckEnvironmentExecAccess(ctx, "env-1"); err != nil { + t.Fatalf("CheckEnvironmentExecAccess: %v", err) + } - want := []string{"session:sess-1", "environment:env-1", "task:task-1", "pair:task-1/env-1"} - if len(authz.calls) != len(want) { - t.Fatalf("calls = %v, want %v", authz.calls, want) + want := []string{ + "session:sess-1", "environment:env-1", "task:task-1", "pair:task-1/env-1", + "session-scope:sess-1/" + string(authz.ScopeSessionExec), + "environment-scope:env-1/" + string(authz.ScopeSessionExec), + } + if len(access.calls) != len(want) { + t.Fatalf("calls = %v, want %v", access.calls, want) } for i, call := range want { - if authz.calls[i] != call { - t.Errorf("call %d = %q, want %q (crossed wire)", i, authz.calls[i], call) + if access.calls[i] != call { + t.Errorf("call %d = %q, want %q (crossed wire)", i, access.calls[i], call) } } } diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 2aaf88d50c..54ca98ccb4 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -23,8 +23,10 @@ import ( "github.com/kandev/kandev/internal/auth" "github.com/kandev/kandev/internal/auth/hostnames" authhttpmw "github.com/kandev/kandev/internal/auth/httpmw" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/entityrefs" + "github.com/kandev/kandev/internal/org" "go.uber.org/zap" // Common packages @@ -323,6 +325,14 @@ func setBuildInfo(build BuildInfo) { // run initializes all services and runs the server. Returns false on fatal startup error. func run(cfg *config.Config, log *logger.Logger, cleanups *[]func() error, runCleanups func()) bool { + // Organizations without authentication is the one configuration that + // cannot work: a tenant boundary needs identities to belong to it. Refuse + // at startup rather than booting an instance whose boundary is decorative. + if err := org.ValidateStartup(cfg.Features.MultiTenancy, cfg.Features.Auth); err != nil { + log.Error("Invalid feature configuration", zap.Error(err)) + return false + } + addCleanup := func(fn func() error) { *cleanups = append(*cleanups, fn) } // 3. Create context with cancellation @@ -417,6 +427,30 @@ func startServices( //nolint:cyclop return false } warnIfExposedWithoutAuth(cfg, services.Auth, log) + taskservice.SetTenancyEnforced(cfg.Features.MultiTenancy) + if services.Org != nil && services.Org.Enabled() { + services.Auth.SetAdminCreatedHook(services.Org.ClaimSetupAdmin) + services.Org.SetFirstAdminCreator(services.Auth.CreateUserInOrg) + // Workspace fan-out needs to know which organization a user belongs + // to, so an org-visible workspace reaches its own org only. + services.Task.SetUserOrgResolver(func(ctx context.Context, userID string) (string, error) { + user, err := repos.UserAccounts.GetUser(ctx, userID) + if err != nil || user == nil { + return "", err + } + return user.OrgID, nil + }) + } + if services.Org != nil && services.Org.Enabled() { + // A suspended organization fails every session and token closed, which + // is what makes suspension a real lever rather than a label. An + // unreadable status fails closed too: an org we cannot verify does not + // get to keep serving requests. + services.Auth.SetOrgStatusChecker(func(ctx context.Context, orgID string) bool { + record, err := services.Org.Get(ctx, orgID) + return err == nil && record.Active() + }) + } hostnameStore, err := hostnames.NewStore(dbPool.Writer(), dbPool.Reader()) if err != nil { log.Error("Failed to initialize session hostname cache", zap.Error(err)) @@ -1418,6 +1452,11 @@ func wireOfficeSvcsDependencies( // Route the Office "No parent" mutation through the canonical task detach // operation so inherited workspace sharing remains valid. services.OfficeSvcs.Dashboard.SetTaskDetacher(services.Task) + // Human assignee writes from the office PATCH surface go through the task + // service, which authorizes the caller and validates the assignee. That + // route carries no :wsId, so it is not covered by the office + // workspace-scope middleware and must not write the column directly. + services.OfficeSvcs.Dashboard.SetHumanAssigneeWriter(services.Task) // Wire the reactivity pipeline so property mutations queue downstream runs. services.OfficeSvcs.Dashboard.SetReactivityApplier( officescheduler.NewDashboardReactivityAdapter(services.OfficeSvcs.Scheduler), @@ -2269,8 +2308,11 @@ func buildHTTPServer( // packages are importable: the task service's not-found sentinel becomes // the secrets sentinel (404), while raw lookup/storage errors pass through // unclassified (sanitized 500). + // Workspace secrets are a shared credential the workspace owner is + // accountable for, so they require secret.manage rather than mere reach: + // a collaborator contributes to the workspace, they do not hold its keys. secretsSvc.SetWorkspaceAuthorizer(func(ctx context.Context, workspaceID string) error { - err := services.Task.AuthorizeWorkspaceAccess(ctx, workspaceID) + err := services.Task.AuthorizeWorkspaceScope(ctx, workspaceID, authz.ScopeSecretManage) if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { return secrets.ErrWorkspaceAccessDenied } diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index e91950aed0..02ae800cb0 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + "github.com/kandev/kandev/internal/authz" "path/filepath" "strings" @@ -183,6 +184,17 @@ func provideOrchestrator( // resolves sessions through its own repo handle, so it does not inherit the // task service's authorize* checks. orchestratorSvc.SetSessionAccessChecker(taskSvc.AuthorizeSessionAccess) + orchestratorSvc.SetSessionControlChecker(func(ctx context.Context, sessionID string) error { + return taskSvc.AuthorizeSessionScope(ctx, sessionID, authz.ScopeSessionControl) + }) + // Starting, resuming, steering and dispatching a turn are writes, so they + // need session.prompt rather than mere reach. + orchestratorSvc.SetSessionPromptChecker(func(ctx context.Context, sessionID string) error { + return taskSvc.AuthorizeSessionScope(ctx, sessionID, authz.ScopeSessionPrompt) + }) + orchestratorSvc.SetTaskPromptChecker(func(ctx context.Context, taskID string) error { + return taskSvc.AuthorizeTaskScope(ctx, taskID, authz.ScopeSessionPrompt) + }) orchestratorSvc.SetTaskAccessChecker(taskSvc.AuthorizeTaskAccess) // Publish task.updated when the first session is marked primary so the diff --git a/apps/backend/internal/backendapp/orgs.go b/apps/backend/internal/backendapp/orgs.go new file mode 100644 index 0000000000..b59b2c16be --- /dev/null +++ b/apps/backend/internal/backendapp/orgs.go @@ -0,0 +1,106 @@ +package backendapp + +import ( + "context" + "errors" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/org" + userstore "github.com/kandev/kandev/internal/user/store" +) + +// orgAccountAdapter exposes the account operations the organization service +// needs without making internal/user depend on internal/org. +type orgAccountAdapter struct { + accounts userstore.AccountRepository +} + +func (a orgAccountAdapter) AssignUsersWithoutOrg(ctx context.Context, orgID string) (int64, error) { + return a.tenancy().AssignUsersWithoutOrg(ctx, orgID) +} + +func (a orgAccountAdapter) SetOperator(ctx context.Context, id string, operator bool) error { + return a.tenancy().SetOperator(ctx, id, operator) +} + +func (a orgAccountAdapter) CountOperators(ctx context.Context) (int, error) { + return a.tenancy().CountOperators(ctx) +} + +func (a orgAccountAdapter) FirstAdminID(ctx context.Context) (string, error) { + return a.tenancy().FirstAdminID(ctx) +} + +func (a orgAccountAdapter) DeleteUsersByOrg(ctx context.Context, orgID string) error { + return a.tenancy().DeleteUsersByOrg(ctx, orgID) +} + +// tenancy narrows the account repository to its tenancy operations. The +// concrete SQLite repository implements them; the interface assertion keeps +// existing AccountRepository fakes unaffected. +func (a orgAccountAdapter) tenancy() userstore.TenancyRepository { + if t, ok := a.accounts.(userstore.TenancyRepository); ok { + return t + } + return userstore.NoopTenancy{} +} + +var _ org.AccountMigrator = orgAccountAdapter{} + +type orgMigrationData interface { + AssignWorkspacesWithoutOrg(ctx context.Context, orgID string) (int64, error) + DropCrossOrgWorkspaceMembers(ctx context.Context) (int64, error) +} + +type orgWorkspaceLifecycle interface { + DeleteOrganizationWorkspaces(ctx context.Context, orgID string) error +} + +// orgDataAdapter keeps migration SQL in the repository while routing +// destructive workspace removal through the task service's full lifecycle. +type orgDataAdapter struct { + migrations orgMigrationData + workspaces orgWorkspaceLifecycle +} + +func (a orgDataAdapter) AssignWorkspacesWithoutOrg(ctx context.Context, orgID string) (int64, error) { + return a.migrations.AssignWorkspacesWithoutOrg(ctx, orgID) +} + +func (a orgDataAdapter) DropCrossOrgWorkspaceMembers(ctx context.Context) (int64, error) { + return a.migrations.DropCrossOrgWorkspaceMembers(ctx) +} + +func (a orgDataAdapter) DeleteOrgData(ctx context.Context, orgID string) error { + if a.workspaces == nil { + return errors.New("workspace deletion lifecycle is unavailable") + } + return a.workspaces.DeleteOrganizationWorkspaces(ctx, orgID) +} + +var _ org.DataMigrator = orgDataAdapter{} + +// buildOrgService constructs the organization service. It is built even when +// organizations are off so callers never branch on nil; the service itself +// reports Enabled() false and every lifecycle operation is a no-op. +func buildOrgService( + cfg *config.Config, + pool *db.Pool, + repos *Repositories, + workspaces orgWorkspaceLifecycle, + log *logger.Logger, +) (*org.Service, error) { + store, err := org.NewStore(pool) + if err != nil { + return nil, err + } + return org.NewService( + store, + orgAccountAdapter{accounts: repos.UserAccounts}, + orgDataAdapter{migrations: repos.Task, workspaces: workspaces}, + cfg.Features.MultiTenancy, + log, + ), nil +} diff --git a/apps/backend/internal/backendapp/orgs_review_test.go b/apps/backend/internal/backendapp/orgs_review_test.go new file mode 100644 index 0000000000..074f0d73d3 --- /dev/null +++ b/apps/backend/internal/backendapp/orgs_review_test.go @@ -0,0 +1,129 @@ +package backendapp + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + taskservice "github.com/kandev/kandev/internal/task/service" + userstore "github.com/kandev/kandev/internal/user/store" +) + +type orgReviewSecretDeleter struct { + workspaceIDs []string +} + +func (d *orgReviewSecretDeleter) DeleteWorkspaceSecrets(_ context.Context, workspaceID string) error { + d.workspaceIDs = append(d.workspaceIDs, workspaceID) + return nil +} + +func TestOrganizationDeletionUsesWorkspaceLifecycle(t *testing.T) { + ctx := context.Background() + raw, err := db.OpenSQLite(filepath.Join(t.TempDir(), "org-delete.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + database := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = database.Close() }) + taskRepo, cleanupTaskRepo, err := repository.Provide(database, database, nil) + if err != nil { + t.Fatalf("task repository: %v", err) + } + t.Cleanup(func() { _ = cleanupTaskRepo() }) + accounts, cleanupAccounts, err := userstore.Provide(database, database) + if err != nil { + t.Fatalf("user repository: %v", err) + } + t.Cleanup(func() { _ = cleanupAccounts() }) + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger: %v", err) + } + eventBus := bus.NewMemoryEventBus(log) + t.Cleanup(eventBus.Close) + taskSvc := taskservice.NewService(taskservice.Repos{ + Workspaces: taskRepo, + Tasks: taskRepo, + TaskRepos: taskRepo, + WorkspaceFolders: taskRepo, + Workflows: taskRepo, + Messages: taskRepo, + Attachments: taskRepo, + Turns: taskRepo, + Sessions: taskRepo, + GitSnapshots: taskRepo, + RepoEntities: taskRepo, + RepositorySets: taskRepo, + BranchPolicies: taskRepo, + RepositoryCleanup: taskRepo, + Executors: taskRepo, + Environments: taskRepo, + TaskEnvironments: taskRepo, + Reviews: taskRepo, + ResourceCleanups: taskRepo, + StatusSummaries: taskRepo, + TaskActivity: taskRepo, + SubagentContexts: taskRepo, + Usage: taskRepo, + }, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) + secretDeleter := &orgReviewSecretDeleter{} + taskSvc.SetWorkspaceSecretDeleter(secretDeleter) + + cfg := &config.Config{} + cfg.Features.MultiTenancy = true + pool := db.NewPool(database, database) + orgSvc, err := buildOrgService(cfg, pool, &Repositories{Task: taskRepo, UserAccounts: accounts}, taskSvc, log) + if err != nil { + t.Fatalf("organization service: %v", err) + } + defaultOrg, err := orgSvc.EnsureDefaultOrg(ctx, "Default organization") + if err != nil { + t.Fatalf("create default organization: %v", err) + } + doomedOrg, err := orgSvc.Create(ctx, "Doomed", "doomed") + if err != nil { + t.Fatalf("create doomed organization: %v", err) + } + if err := taskRepo.CreateWorkspace(ctx, &models.Workspace{ + ID: "ws-doomed", Name: "Doomed workspace", OrgID: doomedOrg.ID, + }); err != nil { + t.Fatalf("create doomed workspace: %v", err) + } + if err := taskRepo.CreateWorkspace(ctx, &models.Workspace{ + ID: "ws-keep", Name: "Kept workspace", OrgID: defaultOrg.ID, + }); err != nil { + t.Fatalf("create kept workspace: %v", err) + } + deletedWorkspaceID := "" + subscription, err := eventBus.Subscribe(events.WorkspaceDeleted, func(_ context.Context, event *bus.Event) error { + if data, ok := event.Data.(map[string]interface{}); ok { + deletedWorkspaceID, _ = data["id"].(string) + } + return nil + }) + if err != nil { + t.Fatalf("subscribe to workspace deletion: %v", err) + } + t.Cleanup(func() { _ = subscription.Unsubscribe() }) + + if err := orgSvc.Delete(ctx, doomedOrg.ID, doomedOrg.Slug); err != nil { + t.Fatalf("delete organization: %v", err) + } + if len(secretDeleter.workspaceIDs) != 1 || secretDeleter.workspaceIDs[0] != "ws-doomed" || deletedWorkspaceID != "ws-doomed" { + t.Fatalf("workspace lifecycle secret deletes=%v deleted event=%q", secretDeleter.workspaceIDs, deletedWorkspaceID) + } + if _, err := taskRepo.GetWorkspace(ctx, "ws-keep"); err != nil { + t.Fatalf("unrelated organization workspace was removed: %v", err) + } +} diff --git a/apps/backend/internal/backendapp/orgunits.go b/apps/backend/internal/backendapp/orgunits.go new file mode 100644 index 0000000000..6f8feeff0d --- /dev/null +++ b/apps/backend/internal/backendapp/orgunits.go @@ -0,0 +1,78 @@ +package backendapp + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/orgunit" + tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/user/store" +) + +// buildOrgUnitService creates the unit tree, wires its occupancy seam, and runs +// the one-shot placement so no workspace is left without a home. +// +// It runs after the tenancy migration, which is what assigns organization ids: +// placing a workspace before it knows its organization would put it under the +// wrong root. +func buildOrgUnitService( + pool *db.Pool, + taskRepo *tasksqlite.Repository, + accounts store.AccountRepository, + log *logger.Logger, +) (*orgunit.Service, error) { + unitStore, err := orgunit.NewStore(pool) + if err != nil { + return nil, err + } + svc := orgunit.NewService(unitStore, log) + svc.SetWorkspaceCounter(taskRepo) + + if _, err := svc.Backfill( + context.Background(), + unitAccountAdapter{accounts: accounts}, + unitPlacementAdapter{repo: taskRepo}, + ); err != nil { + return nil, fmt.Errorf("organization unit backfill: %w", err) + } + return svc, nil +} + +// unitAccountAdapter narrows the user repository to what a personal unit needs. +type unitAccountAdapter struct{ accounts store.AccountRepository } + +func (a unitAccountAdapter) ListUnitUsers(ctx context.Context) ([]orgunit.UserRef, error) { + users, err := a.accounts.ListUsers(ctx) + if err != nil { + return nil, err + } + out := make([]orgunit.UserRef, 0, len(users)) + for _, u := range users { + if u == nil { + continue + } + out = append(out, orgunit.UserRef{ID: u.ID, OrgID: u.OrgID, DisplayName: u.DisplayName}) + } + return out, nil +} + +// unitPlacementAdapter narrows the task repository to workspace placement. +type unitPlacementAdapter struct{ repo *tasksqlite.Repository } + +func (a unitPlacementAdapter) UnplacedWorkspaces(ctx context.Context) ([]orgunit.WorkspaceRef, error) { + rows, err := a.repo.UnplacedWorkspaces(ctx) + if err != nil { + return nil, err + } + out := make([]orgunit.WorkspaceRef, 0, len(rows)) + for _, r := range rows { + out = append(out, orgunit.WorkspaceRef{ID: r.ID, OrgID: r.OrgID, OwnerID: r.OwnerID}) + } + return out, nil +} + +func (a unitPlacementAdapter) PlaceWorkspace(ctx context.Context, workspaceID, unitID string) error { + return a.repo.PlaceWorkspace(ctx, workspaceID, unitID) +} diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index fc9fdba485..963b3e4591 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -159,6 +159,33 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories }, ) taskSvc.SetPendingActionProjectionEpoch(pendingActionProjectionEpoch) + // Workspace membership needs to resolve colleague names and reject + // disabled or unknown accounts before writing a row. + taskSvc.SetUserDirectory(newUserDirectoryAdapter(repos.UserAccounts)) + // The default organization is named generically: an instance has no + // company name to borrow, and an operator renames it in one click. + const defaultOrgName = "Default organization" + orgSvc, orgErr := buildOrgService(cfg, dbPool, repos, taskSvc, log) + if orgErr != nil { + return nil, nil, fmt.Errorf("initialize organizations: %w", orgErr) + } + // The tenancy migration runs once at boot: it creates the default + // organization and puts every pre-tenancy user and workspace into it. + if _, err := orgSvc.EnsureDefaultOrg(context.Background(), defaultOrgName); err != nil { + return nil, nil, fmt.Errorf("organization migration: %w", err) + } + // The unit tree is built after the tenancy migration, which is what stamps + // organization ids: placing a workspace before it knows its organization + // would put it under the wrong root. + unitSvc, unitErr := buildOrgUnitService(dbPool, repos.Task, repos.UserAccounts, log) + if unitErr != nil { + return nil, nil, fmt.Errorf("initialize organization units: %w", unitErr) + } + taskSvc.SetUnitPlacer(unitSvc) + taskSvc.SetUnitReach(unitSvc) + // Deleting an organization must take its unit tree with it. + orgSvc.SetUnitDeleter(unitSvc) + taskSvc.SetSecretStore(userSecretStore) if deleter, ok := userSecretStore.(taskservice.WorkspaceSecretDeleter); ok { taskSvc.SetWorkspaceSecretDeleter(deleter) @@ -309,6 +336,8 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories DynamicProfileResolver: dynamicResolver, DynamicBindingResolver: dynamicBindingResolver, Task: taskSvc, + Org: orgSvc, + OrgUnits: unitSvc, User: userSvc, Editor: editorSvc, Prompts: promptSvc, diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index f85530bd10..d420c747a9 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -25,6 +25,8 @@ import ( office "github.com/kandev/kandev/internal/office" officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" officeservice "github.com/kandev/kandev/internal/office/service" + "github.com/kandev/kandev/internal/org" + "github.com/kandev/kandev/internal/orgunit" "github.com/kandev/kandev/internal/plugins" promptservice "github.com/kandev/kandev/internal/prompts/service" promptstore "github.com/kandev/kandev/internal/prompts/store" @@ -74,19 +76,23 @@ type Services struct { DynamicProfileResolver *agentruntime.ProfileExecutionResolver DynamicBindingResolver *dynamicruntime.CredentialBindingResolver Task *taskservice.Service - User *userservice.Service - Editor *editorservice.Service - Notification *notificationservice.Service - Prompts *promptservice.Service - Utility *utilityservice.Service - Workflow *workflowservice.Service - GitHub *github.Service - GitLab *gitlab.Service - GitLabCleanup func() error - AzureDevOps *azuredevops.Service - Jira *jira.Service - Linear *linear.Service - Sentry *sentry.Service + // Org owns organizations. Always non-nil; Enabled() reports whether the + // multi-tenancy feature is on. + Org *org.Service + OrgUnits *orgunit.Service + User *userservice.Service + Editor *editorservice.Service + Notification *notificationservice.Service + Prompts *promptservice.Service + Utility *utilityservice.Service + Workflow *workflowservice.Service + GitHub *github.Service + GitLab *gitlab.Service + GitLabCleanup func() error + AzureDevOps *azuredevops.Service + Jira *jira.Service + Linear *linear.Service + Sentry *sentry.Service // WorkflowSync keeps workspace workflows in sync with definition files // in a configured GitHub repository. Nil when GitHub is unavailable. WorkflowSync *workflowsync.Service diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 2c4039c07b..b5271d0f2e 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -442,6 +442,14 @@ type FeaturesConfig struct { // opts in. Auth bool `mapstructure:"auth" json:"auth"` + // MultiTenancy gates organizations: a tenant boundary above users, where + // every user belongs to exactly one org and cross-org reach is a bug + // rather than a permission level. It requires Auth; enabling it without + // authentication is refused at startup, because a tenant boundary with no + // identity behind it is not a boundary. Set via the runtime feature toggle + // KANDEV_FEATURES_MULTI_TENANCY. Off in every shipped profile. + MultiTenancy bool `mapstructure:"multi_tenancy" json:"multiTenancy"` + // DynamicAgentRouting gates dynamic profile configuration, execution // routing, and the shared route health service. It is disabled in every // embedded profile until the complete feature is ready. diff --git a/apps/backend/internal/gateway/websocket/access.go b/apps/backend/internal/gateway/websocket/access.go index 02e36fd86b..87892b21df 100644 --- a/apps/backend/internal/gateway/websocket/access.go +++ b/apps/backend/internal/gateway/websocket/access.go @@ -29,6 +29,11 @@ type SubscriptionAccessPolicy struct { // pre-auth row, delivered to everyone). type WorkspaceOwnerResolver func(ctx context.Context, workspaceID string) (string, error) +// WorkspaceReadersResolver resolves the set of user IDs that may currently +// read a workspace. An empty, non-nil slice means "nobody", which is a real +// answer and must not be treated as "everybody". +type WorkspaceReadersResolver func(ctx context.Context, workspaceID string) ([]string, error) + // AuthPolicy groups the gateway's auth hooks. type AuthPolicy struct { // Enforced reports whether authentication is currently required @@ -47,6 +52,14 @@ type AuthPolicy struct { Subscriptions SubscriptionAccessPolicy // WorkspaceOwner powers BroadcastToWorkspace owner routing. WorkspaceOwner WorkspaceOwnerResolver + // WorkspaceReaders resolves every user that can currently read a + // workspace: its owner plus explicit members plus, when the workspace is + // visible to the organization, that organization's non-guest users. + // + // Owner-only routing predates team access and would silently deliver a + // shared board's updates to nobody but its owner, so this is the resolver + // BroadcastToWorkspace uses when it is wired. + WorkspaceReaders WorkspaceReadersResolver // ActionEnvironment gates dispatched actions that name a task environment // rather than a task or session — the user-shell actions, which treat // task_id as optional. Kept off SubscriptionAccessPolicy because there is @@ -167,3 +180,18 @@ func clientMayReceive(client *Client, owner string) bool { } return client.identity.UserID == owner } + +// clientMayReceiveAny reports whether a client is one of the users allowed to +// receive a workspace broadcast. +// +// Synthetic identities (authentication disabled) and identity-less clients +// still receive everything, preserving pre-auth behavior. An authenticated +// client receives only when its user is in the set, so a member who just lost +// access stops receiving on the next broadcast rather than at disconnect. +func clientMayReceiveAny(client *Client, allowed map[string]struct{}) bool { + if client.identity.Synthetic || client.identity.UserID == "" { + return true + } + _, ok := allowed[client.identity.UserID] + return ok +} diff --git a/apps/backend/internal/gateway/websocket/hub.go b/apps/backend/internal/gateway/websocket/hub.go index 7d7456936a..53ce1d3583 100644 --- a/apps/backend/internal/gateway/websocket/hub.go +++ b/apps/backend/internal/gateway/websocket/hub.go @@ -307,6 +307,17 @@ func (h *Hub) setAuthPolicy(policy AuthPolicy) { // event never disappears entirely because ownership could not be resolved; // it only stops crossing user boundaries once ownership is known. func (h *Hub) BroadcastToWorkspace(workspaceID string, msg *ws.Message) { + // Prefer the reach set: with team access a workspace is readable by its + // members and, when it is org-visible, by the whole organization. Routing + // to the owner alone would deliver a shared board's updates to nobody but + // the person who happened to create it. + if readers := h.authPolicy.WorkspaceReaders; readers != nil && workspaceID != "" { + recipients, err := readers(h.DispatchContext(), workspaceID) + if err == nil { + h.broadcastToUsers(recipients, msg) + return + } + } resolver := h.authPolicy.WorkspaceOwner if resolver == nil || workspaceID == "" { h.Broadcast(msg) @@ -320,6 +331,30 @@ func (h *Hub) BroadcastToWorkspace(workspaceID string, msg *ws.Message) { h.broadcastToOwner(owner, msg) } +// broadcastToUsers delivers to exactly the given user IDs. An empty set +// delivers to nobody: "no one may read this workspace" is a real answer, and +// falling back to a global broadcast would invert it. +func (h *Hub) broadcastToUsers(userIDs []string, msg *ws.Message) { + data, err := json.Marshal(msg) + if err != nil { + h.logger.Error("Failed to marshal workspace broadcast", zap.Error(err)) + return + } + allowed := make(map[string]struct{}, len(userIDs)) + for _, id := range userIDs { + allowed[id] = struct{}{} + } + h.mu.RLock() + recipients := make([]*Client, 0, len(h.clients)) + for client := range h.clients { + if clientMayReceiveAny(client, allowed) { + recipients = append(recipients, client) + } + } + h.mu.RUnlock() + h.sendToClients(data, recipients, msg.Action) +} + // BroadcastToWorkspaceOrDrop is the fail-closed sibling of // BroadcastToWorkspace: when per-user auth is enforced it DROPS the message // unless the workspace is non-empty AND resolves to a known owner — it never @@ -410,7 +445,7 @@ func (h *Hub) BroadcastToTask(taskID string, msg *ws.Message) { h.logger.Error("Failed to marshal message", zap.Error(err)) return } - clients := h.getSubscribersLocked(h.taskSubscribers, taskID) + clients := h.authorizedTaskRecipients(taskID) h.logger.Debug("BroadcastToTask", zap.String("task_id", taskID), zap.String("action", msg.Action), @@ -458,7 +493,7 @@ func (h *Hub) BroadcastToSession(sessionID string, msg *ws.Message) { h.logger.Error("Failed to marshal message", zap.Error(err)) return } - clients := h.getSessionRecipientsLocked(sessionID) + clients := h.authorizedSessionRecipients(sessionID) h.logger.Debug("BroadcastToSession", zap.String("session_id", sessionID), zap.String("action", msg.Action), @@ -466,6 +501,58 @@ func (h *Hub) BroadcastToSession(sessionID string, msg *ws.Message) { h.sendToClients(data, clients, msg.Action) } +func (h *Hub) authorizedTaskRecipients(taskID string) []*Client { + clients := h.getSubscribersLocked(h.taskSubscribers, taskID) + allowed, denied := h.partitionAuthorized(clients, taskID, h.authPolicy.Subscriptions.Task) + if len(denied) == 0 { + return allowed + } + h.mu.Lock() + for _, client := range denied { + removeClientFromSubscriberMap(h.taskSubscribers, taskID, client) + delete(client.subscriptions, taskID) + } + h.mu.Unlock() + return allowed +} + +func (h *Hub) authorizedSessionRecipients(sessionID string) []*Client { + clients := h.getSessionRecipientsLocked(sessionID) + allowed, denied := h.partitionAuthorized(clients, sessionID, h.authPolicy.Subscriptions.Session) + if len(denied) == 0 { + return allowed + } + h.mu.Lock() + for _, client := range denied { + removeClientFromSubscriberMap(h.sessionSubscribers, sessionID, client) + removeClientFromSubscriberMap(h.sessionMode.focusByClient, sessionID, client) + delete(client.sessionSubscriptions, sessionID) + delete(client.sessionFocus, sessionID) + } + h.mu.Unlock() + h.recomputeSessionMode(sessionID) + return allowed +} + +func (h *Hub) partitionAuthorized( + clients []*Client, + topicID string, + authorize func(context.Context, string) error, +) (allowed, denied []*Client) { + if authorize == nil { + return clients, nil + } + allowed = make([]*Client, 0, len(clients)) + for _, client := range clients { + if err := authorize(client.dispatchContext(), topicID); err != nil { + denied = append(denied, client) + continue + } + allowed = append(allowed, client) + } + return allowed, denied +} + // BroadcastToUser sends a notification to clients subscribed to a specific user func (h *Hub) BroadcastToUser(userID string, msg *ws.Message) bool { data, err := json.Marshal(msg) diff --git a/apps/backend/internal/gateway/websocket/hub_access_revocation_test.go b/apps/backend/internal/gateway/websocket/hub_access_revocation_test.go new file mode 100644 index 0000000000..423ff40333 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/hub_access_revocation_test.go @@ -0,0 +1,69 @@ +package websocket + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestTaskBroadcastEvictsSubscriberAfterAccessRevocation(t *testing.T) { + hub := newTestHub(t) + allowed := true + hub.setAuthPolicy(AuthPolicy{Subscriptions: SubscriptionAccessPolicy{ + Task: func(ctx context.Context, _ string) error { + identity, _ := authn.IdentityFromContext(ctx) + if identity.UserID == "revoked-user" && !allowed { + return errors.New("access revoked") + } + return nil + }, + }}) + client := newTestClient("revoked-task-client") + client.hub = hub + client.identity = authn.Identity{UserID: "revoked-user", Role: authn.RoleMember} + hub.SubscribeToTask(client, "task-1") + allowed = false + + message, err := ws.NewNotification(ws.ActionTaskUpdated, map[string]string{"task_id": "task-1"}) + if err != nil { + t.Fatalf("build notification: %v", err) + } + hub.BroadcastToTask("task-1", message) + + assertNoMoreFrames(t, client) + if client.subscriptions["task-1"] { + t.Fatal("revoked client retained its task subscription") + } + if clients := hub.getSubscribersLocked(hub.taskSubscribers, "task-1"); len(clients) != 0 { + t.Fatalf("revoked task subscribers = %d, want 0", len(clients)) + } +} + +func TestSessionBroadcastEvictsSubscriberAndFocusAfterAccessRevocation(t *testing.T) { + hub := newTestHub(t) + hub.setAuthPolicy(AuthPolicy{Subscriptions: SubscriptionAccessPolicy{ + Session: func(context.Context, string) error { return errors.New("access revoked") }, + }}) + client := newTestClient("revoked-session-client") + client.hub = hub + client.identity = authn.Identity{UserID: "revoked-user", Role: authn.RoleMember} + hub.SubscribeToSession(client, "session-1") + hub.FocusSession(client, "session-1") + + message, err := ws.NewNotification(ws.ActionSessionMessageAdded, map[string]string{"session_id": "session-1"}) + if err != nil { + t.Fatalf("build notification: %v", err) + } + hub.BroadcastToSession("session-1", message) + + assertNoMoreFrames(t, client) + if client.sessionSubscriptions["session-1"] || client.sessionFocus["session-1"] { + t.Fatal("revoked client retained its session subscription or focus") + } + if clients := hub.getSessionRecipientsLocked("session-1"); len(clients) != 0 { + t.Fatalf("revoked session recipients = %d, want 0", len(clients)) + } +} diff --git a/apps/backend/internal/gateway/websocket/port_proxy.go b/apps/backend/internal/gateway/websocket/port_proxy.go index 69811f78ab..eb3b33dea3 100644 --- a/apps/backend/internal/gateway/websocket/port_proxy.go +++ b/apps/backend/internal/gateway/websocket/port_proxy.go @@ -138,7 +138,7 @@ func (h *PortProxyHandler) HandlePortProxy(c *gin.Context) { // Per-user scoping (opt-in auth): authorize session ownership before the // bare execution lookup / per-session proxy cache (see vscode_proxy.go). - if err := h.lifecycleMgr.CheckSessionAccess(c.Request.Context(), sessionID); err != nil { + if err := h.lifecycleMgr.CheckSessionExecAccess(c.Request.Context(), sessionID); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) return } diff --git a/apps/backend/internal/gateway/websocket/vscode_proxy.go b/apps/backend/internal/gateway/websocket/vscode_proxy.go index 40d0dc2bea..8cab7bc6e4 100644 --- a/apps/backend/internal/gateway/websocket/vscode_proxy.go +++ b/apps/backend/internal/gateway/websocket/vscode_proxy.go @@ -56,7 +56,7 @@ func (h *VscodeProxyHandler) HandleVscodeProxy(c *gin.Context) { // caller is authenticated, not that they own this session. This proxy // resolves the execution by a bare in-memory lookup (and a per-session // cache), so authorize here before anything is served. - if err := h.lifecycleMgr.CheckSessionAccess(c.Request.Context(), sessionID); err != nil { + if err := h.lifecycleMgr.CheckSessionExecAccess(c.Request.Context(), sessionID); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) return } diff --git a/apps/backend/internal/office/dashboard/dto.go b/apps/backend/internal/office/dashboard/dto.go index e530ef3bc8..b8932a6f29 100644 --- a/apps/backend/internal/office/dashboard/dto.go +++ b/apps/backend/internal/office/dashboard/dto.go @@ -374,6 +374,7 @@ type TaskDTO struct { ParentID string `json:"parentId,omitempty"` ProjectID string `json:"projectId,omitempty"` AssigneeAgentProfileID string `json:"assigneeAgentProfileId,omitempty"` + AssigneeUserID string `json:"assigneeUserId,omitempty"` Labels []LabelDTO `json:"labels"` Children []*TaskDTO `json:"children,omitempty"` BlockedBy []string `json:"blockedBy,omitempty"` diff --git a/apps/backend/internal/office/dashboard/handler.go b/apps/backend/internal/office/dashboard/handler.go index 8753b06630..f43a14a03d 100644 --- a/apps/backend/internal/office/dashboard/handler.go +++ b/apps/backend/internal/office/dashboard/handler.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/office/models" "github.com/kandev/kandev/internal/office/repository/sqlite" "github.com/kandev/kandev/internal/office/shared" + "github.com/kandev/kandev/internal/task/repository/repoerrors" taskservice "github.com/kandev/kandev/internal/task/service" "go.uber.org/zap" @@ -538,6 +539,7 @@ func taskRowToDTO(r *sqlite.TaskRow, lbls []*sqlite.Label) *TaskDTO { ParentID: r.ParentID, ProjectID: r.ProjectID, AssigneeAgentProfileID: r.AssigneeAgentProfileID, + AssigneeUserID: r.AssigneeUserID, Labels: labels, Reviewers: []string{}, Approvers: []string{}, @@ -569,6 +571,10 @@ type UpdateTaskRequest struct { // string → clear). The picker UI sends "" when the user picks // "No assignee". AssigneeAgentProfileID *string `json:"assignee_agent_profile_id"` + // AssigneeUserID is the HUMAN assignee and is entirely separate from the + // agent assignee above: sending one never clears the other. Same pointer + // convention — nil is "leave alone", "" is "clear". + AssigneeUserID *string `json:"assignee_user_id,omitempty"` // Priority is one of "critical" | "high" | "medium" | "low" when set. Priority *string `json:"priority,omitempty"` // ProjectID empty string clears the project; otherwise must be a project @@ -593,7 +599,7 @@ func (req *UpdateTaskRequest) hasAnyField() bool { if req.Status != "" || req.Comment != "" { return true } - if req.AssigneeAgentProfileID != nil || + if req.AssigneeAgentProfileID != nil || req.AssigneeUserID != nil || req.Priority != nil || req.ProjectID != nil || req.ParentID != nil { return true } @@ -631,7 +637,7 @@ func (h *Handler) updateTask(c *gin.Context) { // whether all writes succeeded. When a mutation fails the handler responds // with the appropriate status code and applyTaskMutations returns false. // -// Order: assignee → priority → project → parent → status. +// Order: assignee (agent, then human) → priority → project → parent → status. // Status is last because it triggers the reactivity pipeline. func (h *Handler) applyTaskMutations(c *gin.Context, taskID, actorAgentID string, req *UpdateTaskRequest) bool { ctx := c.Request.Context() @@ -641,6 +647,12 @@ func (h *Handler) applyTaskMutations(c *gin.Context, taskID, actorAgentID string return false } } + if req.AssigneeUserID != nil { + if err := h.svc.SetTaskAssigneeUser(ctx, taskID, *req.AssigneeUserID); err != nil { + respondMutationError(c, err) + return false + } + } if req.Priority != nil { if err := h.svc.UpdateTaskPriority(ctx, taskID, *req.Priority); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -696,10 +708,27 @@ func (h *Handler) respondStatusUpdateError(c *gin.Context, err error) { // respondMutationError translates a mutation error into the matching HTTP // response. Forbidden bubbles up as 403; everything else as 500. func respondMutationError(c *gin.Context, err error) { - if errors.Is(err, shared.ErrForbidden) { + if errors.Is(err, shared.ErrForbidden) || taskservice.IsForbidden(err) { c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) return } + // The human-assignee mutation delegates to the task service, which applies + // the 404-vs-403 rule: a caller who cannot see the task at all gets + // "not found" so its existence is not leaked, while a caller who can see it + // but lacks task.write gets a plain 403. Without this branch both surface + // as 500, which reads as a server fault rather than a decision. + if errors.Is(err, repoerrors.ErrTaskNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + // A rejected assignee is the caller picking a person who cannot reach the + // workspace, not a server fault. The picker lists everyone in the user + // directory because reach is not computable client-side, so this is a + // reachable outcome and its message is written to be shown as-is. + if errors.Is(err, taskservice.ErrAssigneeCannotReachWorkspace) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } diff --git a/apps/backend/internal/office/dashboard/handler_task_assignee_test.go b/apps/backend/internal/office/dashboard/handler_task_assignee_test.go new file mode 100644 index 0000000000..3f44ad5b6c --- /dev/null +++ b/apps/backend/internal/office/dashboard/handler_task_assignee_test.go @@ -0,0 +1,170 @@ +package dashboard_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/kandev/kandev/internal/task/repository/repoerrors" + taskservice "github.com/kandev/kandev/internal/task/service" +) + +// stubAssigneeWriter stands in for the task service, which owns both the +// caller authorization and the assignee validation. It records what it was +// asked and can reject, so the handler test covers the refusal path without +// building a workspace, a membership table and an authenticated session. +type stubAssigneeWriter struct { + gotTaskID string + gotUserID string + reject error + apply func(taskID, userID string) +} + +func (s *stubAssigneeWriter) SetHumanAssignee(_ context.Context, taskID, userID string) error { + s.gotTaskID, s.gotUserID = taskID, userID + if s.reject != nil { + return s.reject + } + if s.apply != nil { + s.apply(taskID, userID) + } + return nil +} + +func patchTask(t *testing.T, deps *testDeps, taskID, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPatch, "/api/v1/office/tasks/"+taskID, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + deps.router.ServeHTTP(w, req) + return w +} + +func taskAssigneeUserID(t *testing.T, deps *testDeps, taskID string) string { + t.Helper() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/office/tasks/"+taskID, nil) + deps.router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("get task: status = %d body = %s", w.Code, w.Body.String()) + } + var body struct { + Task struct { + AssigneeUserID string `json:"assigneeUserId"` + } `json:"task"` + } + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode task: %v", err) + } + return body.Task.AssigneeUserID +} + +// TestUpdateTask_SetsHumanAssignee covers the round trip: PATCH stores the +// human assignee, the resolver is consulted, and GET returns it. +func TestUpdateTask_SetsHumanAssignee(t *testing.T) { + deps := newTestDeps(t) + writer := &stubAssigneeWriter{apply: func(taskID, userID string) { + // Stand in for the task service's persistence so the GET assertions + // below observe a real column write. + if _, err := deps.db.Exec(`UPDATE tasks SET assignee_user_id = ? WHERE id = ?`, userID, taskID); err != nil { + t.Fatalf("stub persist: %v", err) + } + }} + deps.svc.SetHumanAssigneeWriter(writer) + insertTestTask(t, deps.db, "ha1", "ws-d", "Assignable", "todo", 2) + + if w := patchTask(t, deps, "ha1", `{"assignee_user_id":"user-42"}`); w.Code != http.StatusOK { + t.Fatalf("patch: status = %d body = %s", w.Code, w.Body.String()) + } + if writer.gotTaskID != "ha1" || writer.gotUserID != "user-42" { + t.Fatalf("writer saw (%q, %q), want (ha1, user-42)", writer.gotTaskID, writer.gotUserID) + } + if got := taskAssigneeUserID(t, deps, "ha1"); got != "user-42" { + t.Fatalf("assignee = %q, want user-42", got) + } + + // Empty string is the documented "unassign" value, distinct from an + // omitted field, which must leave the assignee untouched. + if w := patchTask(t, deps, "ha1", `{"priority":"high"}`); w.Code != http.StatusOK { + t.Fatalf("patch priority: status = %d body = %s", w.Code, w.Body.String()) + } + if got := taskAssigneeUserID(t, deps, "ha1"); got != "user-42" { + t.Fatalf("omitted field cleared the assignee: got %q", got) + } + if w := patchTask(t, deps, "ha1", `{"assignee_user_id":""}`); w.Code != http.StatusOK { + t.Fatalf("patch unassign: status = %d body = %s", w.Code, w.Body.String()) + } + if got := taskAssigneeUserID(t, deps, "ha1"); got != "" { + t.Fatalf("assignee = %q after unassign, want empty", got) + } +} + +// TestUpdateTask_UnreachableAssigneeIs400 covers the one rejection a normal +// user can trigger by accident: the picker offers the whole user directory +// because reach cannot be computed in the browser, so picking someone outside +// the workspace must read as a bad request with a showable message, not a 500. +func TestUpdateTask_UnreachableAssigneeIs400(t *testing.T) { + deps := newTestDeps(t) + deps.svc.SetHumanAssigneeWriter(&stubAssigneeWriter{reject: taskservice.ErrAssigneeCannotReachWorkspace}) + insertTestTask(t, deps.db, "ha5", "ws-d", "Assignable", "todo", 2) + + w := patchTask(t, deps, "ha5", `{"assignee_user_id":"outsider"}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s, want 400", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "cannot see this workspace") { + t.Fatalf("body = %s, want the reason to be showable", w.Body.String()) + } +} + +// TestUpdateTask_HumanAssigneeNotFoundIsNot500 pins the other half of the +// 404-vs-403 rule: a caller who cannot see the task at all must not learn it +// exists, and must not be told the server broke either. +func TestUpdateTask_HumanAssigneeNotFoundIsNot500(t *testing.T) { + deps := newTestDeps(t) + deps.svc.SetHumanAssigneeWriter(&stubAssigneeWriter{reject: repoerrors.ErrTaskNotFound}) + insertTestTask(t, deps.db, "ha4", "ws-d", "Assignable", "todo", 2) + + w := patchTask(t, deps, "ha4", `{"assignee_user_id":"user-42"}`) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d body = %s, want 404", w.Code, w.Body.String()) + } +} + +// TestUpdateTask_HumanAssigneeRejectedByWriter is the reach rule surfacing +// through the office endpoint: a user who cannot see the workspace is not a +// valid assignee, and the write must not land. +func TestUpdateTask_HumanAssigneeRejectedByWriter(t *testing.T) { + deps := newTestDeps(t) + deps.svc.SetHumanAssigneeWriter(&stubAssigneeWriter{reject: taskservice.ErrForbidden}) + insertTestTask(t, deps.db, "ha2", "ws-d", "Assignable", "todo", 2) + + w := patchTask(t, deps, "ha2", `{"assignee_user_id":"outsider"}`) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d body = %s, want 403", w.Code, w.Body.String()) + } + if got := taskAssigneeUserID(t, deps, "ha2"); got != "" { + t.Fatalf("assignee = %q after a rejected write, want empty", got) + } +} + +// TestUpdateTask_HumanAssigneeWithoutWriterIsRefused pins the fail-closed +// contract. It matters more here than on the sibling fields: PATCH +// /office/tasks/:id carries no :wsId, so the office workspace-scope middleware +// does not gate it, and writing the column directly would let any signed-in +// user assign a task in a workspace they cannot reach. +func TestUpdateTask_HumanAssigneeWithoutWriterIsRefused(t *testing.T) { + deps := newTestDeps(t) + insertTestTask(t, deps.db, "ha3", "ws-d", "Assignable", "todo", 2) + + w := patchTask(t, deps, "ha3", `{"assignee_user_id":"user-42"}`) + if w.Code == http.StatusOK { + t.Fatalf("unvalidated assignment returned 200: %s", w.Body.String()) + } + if got := taskAssigneeUserID(t, deps, "ha3"); got != "" { + t.Fatalf("assignee = %q with no writer wired, want empty", got) + } +} diff --git a/apps/backend/internal/office/dashboard/handler_test.go b/apps/backend/internal/office/dashboard/handler_test.go index 09fccbb05e..4c79e9066f 100644 --- a/apps/backend/internal/office/dashboard/handler_test.go +++ b/apps/backend/internal/office/dashboard/handler_test.go @@ -140,6 +140,7 @@ func newTestDeps(t *testing.T) *testDeps { parent_id TEXT DEFAULT '', project_id TEXT DEFAULT '', assignee_agent_profile_id TEXT DEFAULT '', + assignee_user_id TEXT NOT NULL DEFAULT '', labels TEXT DEFAULT '[]', metadata TEXT DEFAULT '{}', identifier TEXT DEFAULT '', diff --git a/apps/backend/internal/office/dashboard/service.go b/apps/backend/internal/office/dashboard/service.go index 2e7bdac104..2c20f8658f 100644 --- a/apps/backend/internal/office/dashboard/service.go +++ b/apps/backend/internal/office/dashboard/service.go @@ -173,6 +173,18 @@ type TaskCanceller interface { CancelTaskExecution(ctx context.Context, taskID, reason string, force bool) error } +// HumanAssigneeWriter applies a human assignee on behalf of the calling user. +// +// Both the authorization (the caller needs task.write on the workspace) and +// the validation (the assignee must be able to reach it) are owned by the task +// service. Office delegates the whole write rather than reimplementing either, +// which also covers a gap specific to this route: PATCH /office/tasks/:id +// carries no `:wsId`, so the office workspace-scope middleware does not gate +// it. +type HumanAssigneeWriter interface { + SetHumanAssignee(ctx context.Context, taskID, userID string) error +} + // TaskDetacher applies the canonical task hierarchy/workspace detachment and // publishes the task lifecycle and Office refresh events. type TaskDetacher interface { @@ -379,6 +391,7 @@ type DashboardService struct { routingProvider RoutingProvider // optional; nil disables /routing endpoints (503) attemptLister RouteAttemptLister // optional; nil disables attempt embedding on run-detail responses runResolver RunResolver // optional; nil means status-change activity rows have no run_id + assigneeWriter HumanAssigneeWriter // optional; nil rejects human-assignee writes rather than skipping authorization projectBudget ProjectBudgetEvaluator // optional; nil means reassignment doesn't re-evaluate the destination project's budget policies // officeSessionIdentity gates RecordAgentDecision's use of the caller's // own session id. Defaults false (zero value); set via @@ -542,6 +555,14 @@ func (s *DashboardService) SetTaskCanceller(c TaskCanceller) { s.taskCanceller = c } +// SetHumanAssigneeWriter wires the human-assignee write seam. It is optional +// only in the sense that office can be constructed without it; when it is +// absent the mutation is refused rather than applied unauthorized, because +// this route is not otherwise workspace-gated. +func (s *DashboardService) SetHumanAssigneeWriter(w HumanAssigneeWriter) { + s.assigneeWriter = w +} + // SetTaskDetacher wires the canonical task detachment operation used when the // Office parent picker selects "No parent". func (s *DashboardService) SetTaskDetacher(d TaskDetacher) { diff --git a/apps/backend/internal/office/dashboard/service_tasks.go b/apps/backend/internal/office/dashboard/service_tasks.go index 9ca0ab4f03..64b0837cc1 100644 --- a/apps/backend/internal/office/dashboard/service_tasks.go +++ b/apps/backend/internal/office/dashboard/service_tasks.go @@ -41,6 +41,26 @@ func (s *DashboardService) UpdateTaskPriority(ctx context.Context, taskID, prior return nil } +// SetTaskAssigneeUser sets (or clears, on empty string) the human assignee. +// +// The human assignee is advisory and independent of the agent assignee: it +// records who on the team owns the task, gates nothing, and setting it never +// touches the runner participant. Taking a task over is this write plus a +// prompt, not a lock. +func (s *DashboardService) SetTaskAssigneeUser(ctx context.Context, taskID, userID string) error { + if s.assigneeWriter == nil { + return errors.New("human assignee is not available: no assignee writer configured") + } + // The task service authorizes the caller and validates the assignee, then + // persists. Office only mirrors the result onto its own event stream so the + // board and the open task detail refresh. + if err := s.assigneeWriter.SetHumanAssignee(ctx, taskID, userID); err != nil { + return err + } + s.publishTaskUpdated(ctx, taskID, []string{"assignee_user_id"}) + return nil +} + // UpdateTaskProjectID sets the project_id field. Empty string clears the // project. When non-empty, validates that the project belongs to the same // workspace as the task. diff --git a/apps/backend/internal/office/repository/sqlite/base_migrations.go b/apps/backend/internal/office/repository/sqlite/base_migrations.go index 724543c3e5..5f16269736 100644 --- a/apps/backend/internal/office/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/office/repository/sqlite/base_migrations.go @@ -452,6 +452,11 @@ func (r *Repository) runTaskPriorityRecreate() error { // priority-migration fixtures predate them too. _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN external_id TEXT COLLATE BINARY`) _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN external_id_settled_at TIMESTAMP`) + // Same defensive add for the human assignee (docs/specs/tasks/requirements/human-assignee.md). + // It is applied by task/repository/sqlite ensureTeamAccessSchema() before + // the office migrations run on a real install, but priority-migration + // fixtures seed their own legacy tasks table and never see it. + _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN assignee_user_id TEXT NOT NULL DEFAULT ''`) for _, stmt := range taskPriorityMigrationStatements() { if _, err := conn.ExecContext(ctx, stmt); err != nil { @@ -507,12 +512,13 @@ func taskPriorityMigrationStatements() []string { checkout_at TIMESTAMP, checkout_run_id TEXT, external_id TEXT COLLATE BINARY, - external_id_settled_at TIMESTAMP + external_id_settled_at TIMESTAMP, + assignee_user_id TEXT NOT NULL DEFAULT '' )`, // archived_by_cascade_id and external_id/external_id_settled_at are // added to the task schema by task/repository/sqlite/base.go // runMigrations() via idempotent ALTER ADD COLUMN calls that run - // BEFORE this office recreate. If the recreate omitted them from the + // BEFORE this office recreate, as is assignee_user_id. If the recreate omitted them from the // new shape: archived_by_cascade_id missing would 500 // httpArchiveTask -> HandoffService.ArchiveTaskTree (the CAS update // in ArchiveTaskIfActive references it); external_id missing would @@ -527,7 +533,7 @@ func taskPriorityMigrationStatements() []string { origin, project_id, labels, identifier, checkout_agent_id, checkout_at, checkout_run_id, - external_id, external_id_settled_at + external_id, external_id_settled_at, assignee_user_id ) SELECT id, COALESCE(workspace_id,''), COALESCE(workflow_id,''), COALESCE(workflow_step_id,''), title, COALESCE(description,''), @@ -541,7 +547,8 @@ func taskPriorityMigrationStatements() []string { COALESCE(project_id,''), COALESCE(labels,'[]'), identifier, checkout_agent_id, checkout_at, checkout_run_id, - external_id, external_id_settled_at + external_id, external_id_settled_at, + COALESCE(assignee_user_id,'') FROM tasks`, `DROP TABLE tasks`, `ALTER TABLE tasks_priority_new RENAME TO tasks`, diff --git a/apps/backend/internal/office/repository/sqlite/migrations_priority_assignee_test.go b/apps/backend/internal/office/repository/sqlite/migrations_priority_assignee_test.go new file mode 100644 index 0000000000..8a9d57676c --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/migrations_priority_assignee_test.go @@ -0,0 +1,74 @@ +package sqlite_test + +import ( + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/office/repository/sqlite" +) + +// TestMigrate_PriorityRebuildPreservesAssignee is a regression test for the +// same hazard the index test above covers, one column further along: the +// priority-to-TEXT rebuild recreates tasks via DROP TABLE + CREATE TABLE, so +// any column missing from taskPriorityMigrationStatements' shape and copy list +// is dropped along with the data in it. For the human assignee that would mean +// every existing assignment silently disappearing on an install that still +// needs this historical rebuild. +func TestMigrate_PriorityRebuildPreservesAssignee(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + "?_journal_mode=WAL" + db, err := sqlx.Open("sqlite3", dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + // Legacy INTEGER-priority table (enough to trigger the rebuild) that + // already carries an assignment, as an install upgrading mid-feature would. + if _, err := db.Exec(` + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', + workflow_id TEXT NOT NULL DEFAULT '', + workflow_step_id TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + description TEXT DEFAULT '', + state TEXT DEFAULT 'TODO', + priority INTEGER DEFAULT 0, + position INTEGER DEFAULT 0, + metadata TEXT DEFAULT '{}', + is_ephemeral INTEGER NOT NULL DEFAULT 0, + parent_id TEXT DEFAULT '', + archived_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + origin TEXT DEFAULT 'manual', + project_id TEXT DEFAULT '', + labels TEXT DEFAULT '[]', + identifier TEXT, + assignee_user_id TEXT NOT NULL DEFAULT '' + ); + `); err != nil { + t.Fatalf("seed legacy schema: %v", err) + } + if _, err := db.Exec(` + INSERT INTO tasks (id, workspace_id, title, assignee_user_id) + VALUES ('task-1', 'ws-1', 'assigned task', 'user-42') + `); err != nil { + t.Fatalf("seed assigned task: %v", err) + } + + if _, err := sqlite.NewWithDB(db, db, nil); err != nil { + t.Fatalf("init office repo (run migrations): %v", err) + } + + var assignee string + if err := db.Get(&assignee, `SELECT assignee_user_id FROM tasks WHERE id = 'task-1'`); err != nil { + t.Fatalf("tasks.assignee_user_id missing after priority rebuild: %v", err) + } + if assignee != "user-42" { + t.Fatalf("assignee lost by priority rebuild: got %q, want %q", assignee, "user-42") + } +} diff --git a/apps/backend/internal/office/repository/sqlite/tasks.go b/apps/backend/internal/office/repository/sqlite/tasks.go index 3ed3de3a68..d480f8e998 100644 --- a/apps/backend/internal/office/repository/sqlite/tasks.go +++ b/apps/backend/internal/office/repository/sqlite/tasks.go @@ -237,9 +237,14 @@ type TaskSearchResult struct { ParentID string `db:"parent_id"` ProjectID string `db:"project_id"` AssigneeAgentProfileID string `db:"assignee_agent_profile_id"` - Labels string `db:"labels"` - CreatedAt string `db:"created_at"` - UpdatedAt string `db:"updated_at"` + // AssigneeUserID is the human assignee. It is only projected by the + // queries that need it (detail, workspace list); sqlx leaves it zero + // for the others rather than failing, so adding a projection later is + // additive. + AssigneeUserID string `db:"assignee_user_id"` + Labels string `db:"labels"` + CreatedAt string `db:"created_at"` + UpdatedAt string `db:"updated_at"` // IsSystem is true when the task lives in a kandev-managed system // workflow (e.g. the standing coordination task; future routine // tasks). The Office Tasks UI hides these by default and surfaces @@ -282,6 +287,7 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID strin COALESCE(t.parent_id, '') AS parent_id, COALESCE(t.project_id, '') AS project_id, ` + RunnerProjection("t") + ` AS assignee_agent_profile_id, + COALESCE(t.assignee_user_id, '') AS assignee_user_id, COALESCE(t.labels, '[]') AS labels, t.created_at, t.updated_at, @@ -529,6 +535,7 @@ func (r *Repository) GetTaskByID(ctx context.Context, taskID string) (*TaskRow, COALESCE(t.parent_id, '') AS parent_id, COALESCE(t.project_id, '') AS project_id, `+RunnerProjection("t")+` AS assignee_agent_profile_id, + COALESCE(t.assignee_user_id, '') AS assignee_user_id, COALESCE(t.labels, '[]') AS labels, t.created_at, t.updated_at diff --git a/apps/backend/internal/office/repository/sqlite/tasks_test.go b/apps/backend/internal/office/repository/sqlite/tasks_test.go index 651a6b9b3e..02145ba51e 100644 --- a/apps/backend/internal/office/repository/sqlite/tasks_test.go +++ b/apps/backend/internal/office/repository/sqlite/tasks_test.go @@ -35,6 +35,7 @@ func newSearchTestRepo(t *testing.T) *sqlite.Repository { workflow_id TEXT NOT NULL DEFAULT '', workflow_step_id TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', + assignee_user_id TEXT NOT NULL DEFAULT '', description TEXT DEFAULT '', state TEXT DEFAULT 'TODO', priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('critical','high','medium','low')), diff --git a/apps/backend/internal/office/scheduler/reactivity_test.go b/apps/backend/internal/office/scheduler/reactivity_test.go index 9dea4e2c61..55cfb590d8 100644 --- a/apps/backend/internal/office/scheduler/reactivity_test.go +++ b/apps/backend/internal/office/scheduler/reactivity_test.go @@ -40,7 +40,8 @@ func newReactivityTestRepo(t *testing.T) *officesqlite.Repository { workspace_id TEXT DEFAULT '', workflow_step_id TEXT DEFAULT '', parent_id TEXT DEFAULT '', - state TEXT DEFAULT '' + state TEXT DEFAULT '', + assignee_user_id TEXT NOT NULL DEFAULT '' ) `); err != nil { t.Fatalf("create tasks table: %v", err) diff --git a/apps/backend/internal/office/service/base_test.go b/apps/backend/internal/office/service/base_test.go index 53ba5da548..02eb028075 100644 --- a/apps/backend/internal/office/service/base_test.go +++ b/apps/backend/internal/office/service/base_test.go @@ -53,6 +53,7 @@ func newTestService(t *testing.T, overrides ...service.ServiceOptions) *service. project_id TEXT DEFAULT '', state TEXT NOT NULL DEFAULT 'TODO', title TEXT DEFAULT '', + assignee_user_id TEXT NOT NULL DEFAULT '', description TEXT DEFAULT '', identifier TEXT DEFAULT '', workflow_id TEXT DEFAULT '', diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index e11d1cc867..9c3717b675 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/kandev/kandev/internal/task/repository" "sync" "testing" "time" @@ -263,6 +264,9 @@ func (m *mockAgentManager) WaitForAgentctlReady(ctx context.Context, sessionID s // mockRepository implements executorStore for testing type mockRepository struct { + // Membership is not exercised by this fake; the embedded default + // reports no membership, which is the narrower answer. + repository.UnsupportedWorkspaceMembers mu sync.Mutex sessions map[string]*models.TaskSession tasks map[string]*models.Task diff --git a/apps/backend/internal/orchestrator/queue_send_now.go b/apps/backend/internal/orchestrator/queue_send_now.go index fdadd36ec9..df793efc49 100644 --- a/apps/backend/internal/orchestrator/queue_send_now.go +++ b/apps/backend/internal/orchestrator/queue_send_now.go @@ -37,7 +37,8 @@ var ( // shared cancellation coordinator, then the exact claim is handed to one // replacement prompt. The explicit Cancel path is deliberately not involved. func (s *Service) SendQueuedNow(ctx context.Context, sessionID, scope, entryID string) (int, error) { - if err := s.authorizeSession(ctx, sessionID); err != nil { + // Dispatching a queued prompt puts an agent to work: session.prompt. + if err := s.authorizeSessionPrompt(ctx, sessionID); err != nil { return 0, err } if err := validateSendNowInput(sessionID, scope, entryID); err != nil { diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index c069404037..f37e96e40d 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -591,7 +591,10 @@ type Service struct { // sessionAccessCheck enforces per-user workspace scoping on the // session-keyed WS actions. Nil = unscoped. See SetSessionAccessChecker. - sessionAccessCheck func(ctx context.Context, sessionID string) error + sessionAccessCheck func(ctx context.Context, sessionID string) error + sessionControlCheck func(ctx context.Context, sessionID string) error + sessionPromptCheck func(ctx context.Context, sessionID string) error + taskPromptCheck func(ctx context.Context, taskID string) error // taskAccessCheck is the task-keyed sibling of sessionAccessCheck, for // entry points that name a task rather than a session (session.launch, @@ -1635,6 +1638,11 @@ func (s *Service) authorizeSession(ctx context.Context, sessionID string) error return s.sessionAccessCheck(ctx, sessionID) } +// SetSessionControlChecker installs the session.control boundary. +func (s *Service) SetSessionControlChecker(check func(ctx context.Context, sessionID string) error) { + s.sessionControlCheck = check +} + // SessionTaskID returns the task that owns a session, or "" when the session // is unknown. Used to enrich message.queue.status_changed events with the // task_id so task-scoped consumers (e.g. the status summary projector) can diff --git a/apps/backend/internal/orchestrator/service_scopes.go b/apps/backend/internal/orchestrator/service_scopes.go new file mode 100644 index 0000000000..ab13c8375d --- /dev/null +++ b/apps/backend/internal/orchestrator/service_scopes.go @@ -0,0 +1,78 @@ +package orchestrator + +import ( + "context" + "errors" +) + +// Scoped session authorization: prompting and controlling an agent are +// writes, distinct from merely reaching the session. +// +// Split from service.go, which is already past the 800-line limit. + +// ErrScopeCheckerUnavailable reports that a scoped authorization check was +// required but not wired. It denies rather than degrading to a broader check, +// so a wiring mistake is a visible failure instead of a silent widening. +var ErrScopeCheckerUnavailable = errors.New("authorization is unavailable for this operation") + +// authorizeSessionControl guards stopping or cancelling a running agent. +// Interrupting someone else's turn is a write, not a read, so it needs +// session.control rather than mere reach. Falls back to the reach check when +// no scoped checker is wired, so an unwired build is never more permissive. +func (s *Service) authorizeSessionControl(ctx context.Context, sessionID string) error { + return s.authorizeSessionScoped(ctx, sessionID, s.sessionControlCheck) +} + +// authorizeSessionPrompt guards starting, resuming, steering or dispatching an +// agent turn. Prompting is a write: a viewer who may read a transcript must +// not be able to put an agent to work. +func (s *Service) authorizeSessionPrompt(ctx context.Context, sessionID string) error { + return s.authorizeSessionScoped(ctx, sessionID, s.sessionPromptCheck) +} + +// authorizeSessionScoped applies a scoped session check. +// +// The fallback is limited to instances with NO session authorization wired at +// all, which is the auth-disabled single-user case. When access checking is +// wired but the scoped checker is not, that is a misconfiguration and the +// operation is denied: falling back to the reach check there would let a +// viewer stop or steer somebody else's running agent. +func (s *Service) authorizeSessionScoped( + ctx context.Context, sessionID string, check func(context.Context, string) error, +) error { + if sessionID == "" { + return nil + } + if check != nil { + return check(ctx, sessionID) + } + if s.sessionAccessCheck == nil { + return nil + } + return ErrScopeCheckerUnavailable +} + +// SetSessionPromptChecker installs the session.prompt boundary. +func (s *Service) SetSessionPromptChecker(check func(ctx context.Context, sessionID string) error) { + s.sessionPromptCheck = check +} + +// authorizeTaskPrompt is the task-keyed sibling of authorizeSessionPrompt, for +// entry points that name a task rather than a session. Same fallback rule. +func (s *Service) authorizeTaskPrompt(ctx context.Context, taskID string) error { + if taskID == "" { + return nil + } + if s.taskPromptCheck != nil { + return s.taskPromptCheck(ctx, taskID) + } + if s.taskAccessCheck == nil { + return nil + } + return ErrScopeCheckerUnavailable +} + +// SetTaskPromptChecker installs the task-keyed session.prompt boundary. +func (s *Service) SetTaskPromptChecker(check func(ctx context.Context, taskID string) error) { + s.taskPromptCheck = check +} diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index 3a4509e0d3..aaad5e12b4 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -156,6 +156,13 @@ func IsBenignLaunchTeardownErr(err error) bool { func (s *Service) LaunchSession(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { // Every intent funnels through here. SessionID is empty when creating, so // that case is carried by the task check alone. + // Launching a session starts an agent turn: session.prompt. + if err := s.authorizeTaskPrompt(ctx, req.TaskID); err != nil { + return nil, err + } + // Existing-session launches must also prove that the supplied session and + // task belong together; independent reach checks do not establish that + // binding when a caller can access more than one task. if err := s.authorizeTaskSessionPair(ctx, req.TaskID, req.SessionID); err != nil { return nil, err } @@ -409,7 +416,8 @@ func (s *Service) launchRestoreWorkspace(ctx context.Context, req *LaunchSession func (s *Service) RecoverSession(ctx context.Context, taskID, sessionID, action string) (*LaunchSessionResponse, error) { // Guard before the switch: "fresh_start" clears the resume token, so an // unauthorized call would mutate the session even if the launch failed. - if err := s.authorizeSession(ctx, sessionID); err != nil { + // Recovering a session resumes an agent turn: session.prompt. + if err := s.authorizeSessionPrompt(ctx, sessionID); err != nil { return nil, err } if err := s.authorizeTask(ctx, taskID); err != nil { diff --git a/apps/backend/internal/orchestrator/steer.go b/apps/backend/internal/orchestrator/steer.go index b540d095eb..84c704c3aa 100644 --- a/apps/backend/internal/orchestrator/steer.go +++ b/apps/backend/internal/orchestrator/steer.go @@ -88,6 +88,10 @@ func (s *Service) SteerTask( if err := s.authorizeTaskSessionPair(ctx, taskID, sessionID); err != nil { return nil, err } + // Steering interrupts and redirects a running turn: session.prompt. + if err := s.authorizeSessionPrompt(ctx, sessionID); err != nil { + return nil, err + } session, err := s.repo.GetTaskSession(ctx, sessionID) if err != nil || session == nil { return nil, ErrSteerNotEligible diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 4333973797..638dd8e484 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -6350,7 +6350,7 @@ type cancelAgentPreparation struct { } func (s *Service) CancelAgent(ctx context.Context, sessionID string) (err error) { - if err := s.authorizeSession(ctx, sessionID); err != nil { + if err := s.authorizeSessionControl(ctx, sessionID); err != nil { return err } if s.repo == nil { diff --git a/apps/backend/internal/org/controller.go b/apps/backend/internal/org/controller.go new file mode 100644 index 0000000000..3a0851154d --- /dev/null +++ b/apps/backend/internal/org/controller.go @@ -0,0 +1,199 @@ +package org + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/common/logger" +) + +// Controller serves the organization surface. +// +// Two route groups with deliberately different audiences: +// +// - /api/v1/orgs/current is the caller's OWN organization. It never accepts +// an org ID, because accepting one would be the first step toward a +// request choosing its tenant. +// - /api/v1/instance/orgs is the operator tier: managing organizations +// themselves. An operator holds no org scopes, so nothing here reads or +// writes any organization's workspaces, tasks or secrets. +type Controller struct { + service *Service + log *logger.Logger +} + +// NewController builds the controller. +func NewController(service *Service, log *logger.Logger) *Controller { + return &Controller{service: service, log: log.WithFields(zap.String("component", "org-controller"))} +} + +// RegisterRoutes mounts the organization API. With organizations off nothing +// is mounted, so the surface simply does not exist. +func (c *Controller) RegisterRoutes(router *gin.Engine) { + if !c.service.Enabled() { + return + } + current := router.Group("/api/v1/orgs/current", authn.RequireRealIdentity()) + current.GET("", c.getCurrent) + + instance := router.Group("/api/v1/instance/orgs", authn.RequireRealIdentity(), RequireOperator()) + instance.GET("", c.list) + instance.POST("", c.create) + instance.PATCH("/:id", c.patch) + instance.DELETE("/:id", c.delete) + instance.POST("/:id/admins", c.createFirstAdmin) +} + +// RequireOperator aborts unless the caller holds the instance operator tier. +// +// It responds 404 rather than 403: on an instance where the caller is not an +// operator, the organization-management surface should not appear to exist. +func RequireOperator() gin.HandlerFunc { + return func(ctx *gin.Context) { + identity, ok := authn.FromGin(ctx) + if !ok || !identity.Instance { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + ctx.Next() + } +} + +func (c *Controller) getCurrent(ctx *gin.Context) { + identity, _ := authn.FromGin(ctx) + if identity.OrgID == "" { + ctx.JSON(http.StatusOK, gin.H{"org": nil, "is_operator": identity.Instance}) + return + } + org, err := c.service.Get(ctx.Request.Context(), identity.OrgID) + if err != nil { + c.respond(ctx, err, "failed to read the organization") + return + } + ctx.JSON(http.StatusOK, gin.H{"org": org, "is_operator": identity.Instance}) +} + +func (c *Controller) list(ctx *gin.Context) { + orgs, err := c.service.List(ctx.Request.Context()) + if err != nil { + c.respond(ctx, err, "failed to list organizations") + return + } + ctx.JSON(http.StatusOK, gin.H{"orgs": orgs, "total": len(orgs)}) +} + +type createRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +func (c *Controller) create(ctx *gin.Context) { + var body createRequest + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + org, err := c.service.Create(ctx.Request.Context(), body.Name, body.Slug) + if err != nil { + c.respond(ctx, err, "failed to create the organization") + return + } + ctx.JSON(http.StatusOK, org) +} + +type patchRequest struct { + Name string `json:"name"` + Status string `json:"status"` +} + +func (c *Controller) patch(ctx *gin.Context) { + var body patchRequest + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + var ( + org *Org + err error + ) + if body.Name != "" { + org, err = c.service.Rename(ctx.Request.Context(), ctx.Param("id"), body.Name) + } + if err == nil && body.Status != "" { + org, err = c.service.SetStatus(ctx.Request.Context(), ctx.Param("id"), body.Status) + } + if err != nil { + c.respond(ctx, err, "failed to update the organization") + return + } + if org == nil { + org, err = c.service.Get(ctx.Request.Context(), ctx.Param("id")) + if err != nil { + c.respond(ctx, err, "failed to read the organization") + return + } + } + ctx.JSON(http.StatusOK, org) +} + +type firstAdminRequest struct { + Email string `json:"email"` + Password string `json:"password"` + DisplayName string `json:"display_name"` +} + +// createFirstAdmin provisions an organization's first administrator. It is +// operator-only and names the org explicitly, which is the one place an org ID +// legitimately appears in a request: the caller is acting on organizations +// themselves rather than inside one. +func (c *Controller) createFirstAdmin(ctx *gin.Context) { + var body firstAdminRequest + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + err := c.service.CreateFirstAdmin( + ctx.Request.Context(), ctx.Param("id"), body.Email, body.Password, body.DisplayName) + if err != nil { + c.respond(ctx, err, "failed to create the organization administrator") + return + } + ctx.JSON(http.StatusOK, gin.H{"success": true}) +} + +type deleteRequest struct { + Slug string `json:"slug"` +} + +func (c *Controller) delete(ctx *gin.Context) { + var body deleteRequest + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if err := c.service.Delete(ctx.Request.Context(), ctx.Param("id"), body.Slug); err != nil { + c.respond(ctx, err, "failed to delete the organization") + return + } + ctx.JSON(http.StatusOK, gin.H{"success": true}) +} + +// respond maps organization errors to status codes, keeping each refusal's +// reason visible so an operator is told what actually blocked them. +func (c *Controller) respond(ctx *gin.Context, err error, message string) { + switch { + case errors.Is(err, ErrOrgNotFound): + ctx.JSON(http.StatusNotFound, gin.H{"error": "organization not found"}) + case errors.Is(err, ErrSlugMismatch), errors.Is(err, ErrSlugTaken), + errors.Is(err, ErrNameRequired), errors.Is(err, ErrCannotDeleteLast), + errors.Is(err, ErrCannotDeleteActive): + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.log.Error(message, zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": message}) + } +} diff --git a/apps/backend/internal/org/models.go b/apps/backend/internal/org/models.go new file mode 100644 index 0000000000..ad1a87d875 --- /dev/null +++ b/apps/backend/internal/org/models.go @@ -0,0 +1,71 @@ +// Package org owns organizations: the tenant boundary above users. +// +// Every user belongs to exactly one org. That is a deliberate design choice +// rather than a simplification: it makes the tenant a total function of the +// authenticated identity, so no request payload, header or WebSocket frame can +// move a caller between tenants. There is no org switcher and no "acting as +// org" parameter anywhere in the API. +package org + +import ( + "regexp" + "strings" + "time" +) + +// Status is an organization's lifecycle state. +type Status string + +const ( + // StatusActive is normal operation. + StatusActive Status = "active" + // StatusSuspended fails every session and token closed and stops running + // executions, while retaining all rows and files. It is reversible and is + // the intended lever for a billing lapse or an investigation. + StatusSuspended Status = "suspended" +) + +// NormalizeStatus coerces an unknown stored value to suspended: an org whose +// state cannot be read must not keep serving requests. +func NormalizeStatus(value string) Status { + if Status(value) == StatusActive { + return StatusActive + } + return StatusSuspended +} + +// Org is one tenant. +type Org struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + // Slug is the human-typeable identifier used to confirm destructive + // operations. Deleting an org requires typing it exactly. + Slug string `json:"slug" db:"slug"` + Status string `json:"status" db:"status"` + IsDefault bool `json:"is_default" db:"is_default"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Active reports whether the org may serve requests. +func (o *Org) Active() bool { + return o != nil && NormalizeStatus(o.Status) == StatusActive +} + +var slugInvalid = regexp.MustCompile(`[^a-z0-9-]+`) +var slugDashes = regexp.MustCompile(`-{2,}`) + +// Slugify derives a URL- and confirmation-safe slug from a display name. +func Slugify(name string) string { + s := strings.ToLower(strings.TrimSpace(name)) + s = slugInvalid.ReplaceAllString(s, "-") + s = slugDashes.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + if s == "" { + return "org" + } + if len(s) > 48 { + s = strings.Trim(s[:48], "-") + } + return s +} diff --git a/apps/backend/internal/org/service.go b/apps/backend/internal/org/service.go new file mode 100644 index 0000000000..370229b9a9 --- /dev/null +++ b/apps/backend/internal/org/service.go @@ -0,0 +1,283 @@ +package org + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Errors surfaced to callers. +var ( + // ErrTenancyRequiresAuth reports the one configuration that cannot work: a + // tenant boundary with no identity behind it. + ErrTenancyRequiresAuth = errors.New( + "features.multiTenancy requires features.auth: organizations need authenticated users to belong to them") + ErrOrgSuspended = errors.New("organization is suspended") + ErrSlugMismatch = errors.New("the typed slug does not match the organization") + ErrCannotDeleteLast = errors.New("the last organization cannot be deleted") + ErrCannotDeleteActive = errors.New("stop the organization's running work before deleting it") + ErrNameRequired = errors.New("organization name is required") +) + +// DataMigrator assigns pre-tenancy rows to the default organization. The task +// repository implements it; the org package stays free of a task dependency. +type DataMigrator interface { + // AssignWorkspacesWithoutOrg returns how many workspaces it moved. + AssignWorkspacesWithoutOrg(ctx context.Context, orgID string) (int64, error) + // DropCrossOrgWorkspaceMembers removes membership rows whose member and + // workspace would land in different organizations, and returns how many. + DropCrossOrgWorkspaceMembers(ctx context.Context) (int64, error) + // DeleteOrgData removes every row belonging to an organization. + DeleteOrgData(ctx context.Context, orgID string) error +} + +// AccountMigrator is the user-store half of the same migration. +type AccountMigrator interface { + AssignUsersWithoutOrg(ctx context.Context, orgID string) (int64, error) + SetOperator(ctx context.Context, id string, operator bool) error + CountOperators(ctx context.Context) (int, error) + FirstAdminID(ctx context.Context) (string, error) + DeleteUsersByOrg(ctx context.Context, orgID string) error +} + +// UnitDeleter removes an organization's unit tree. +// +// Deletion is the one path where the tree cannot be inferred from anything +// else: workspaces and accounts are removed by their own owners, and units +// would otherwise be left behind pointing at an organization that is gone. +type UnitDeleter interface { + DeleteOrgUnits(ctx context.Context, orgID string) error +} + +// FirstAdminCreator provisions an organization's first administrator. An +// ordinary admin can only create accounts in their own tenant, so a brand-new +// organization would otherwise have no way to get its first user; this is the +// operator-only path that breaks that circularity. +type FirstAdminCreator func(ctx context.Context, orgID, email, password, displayName string) error + +// Service owns organization lifecycle and the tenancy migration. +type Service struct { + store *Store + accounts AccountMigrator + data DataMigrator + log *logger.Logger + enabled bool + firstAdmin FirstAdminCreator + units UnitDeleter +} + +// SetUnitDeleter wires the tree-deletion seam. It is optional only because the +// unit service is constructed after this one; when it is absent an +// organization delete leaves its tree behind, which the wiring test guards. +func (s *Service) SetUnitDeleter(d UnitDeleter) { s.units = d } + +// SetFirstAdminCreator installs the operator-only first-admin path. +func (s *Service) SetFirstAdminCreator(create FirstAdminCreator) { s.firstAdmin = create } + +// CreateFirstAdmin provisions the first administrator of an organization. +func (s *Service) CreateFirstAdmin(ctx context.Context, orgID, email, password, displayName string) error { + if s.firstAdmin == nil { + return errors.New("account provisioning is unavailable") + } + if _, err := s.store.Get(ctx, orgID); err != nil { + return err + } + if err := s.firstAdmin(ctx, orgID, email, password, displayName); err != nil { + return err + } + s.log.Info("organization first admin created", zap.String("org_id", orgID)) + return nil +} + +// NewService builds the org service. enabled mirrors features.multiTenancy. +func NewService(store *Store, accounts AccountMigrator, data DataMigrator, enabled bool, log *logger.Logger) *Service { + return &Service{store: store, accounts: accounts, data: data, enabled: enabled, + log: log.WithFields(zap.String("component", "org-service"))} +} + +// Enabled reports whether organizations are on. +func (s *Service) Enabled() bool { return s != nil && s.enabled } + +// ValidateStartup refuses the one combination that cannot work. +func ValidateStartup(multiTenancy, auth bool) error { + if multiTenancy && !auth { + return ErrTenancyRequiresAuth + } + return nil +} + +// EnsureDefaultOrg runs the tenancy migration: it creates the single default +// organization if none exists, puts every unassigned user and workspace into +// it, drops membership rows that would straddle two organizations, and grants +// the operator tier to the first admin so an upgraded instance is never left +// with nobody able to manage organizations. +// +// It is idempotent, and it is a no-op when organizations are off. +func (s *Service) EnsureDefaultOrg(ctx context.Context, instanceName string) (*Org, error) { + if !s.Enabled() { + return nil, nil + } + existing, err := s.store.Default(ctx) + if err != nil && !errors.Is(err, ErrOrgNotFound) { + return nil, err + } + if existing == nil { + name := strings.TrimSpace(instanceName) + if name == "" { + name = "Default organization" + } + existing, err = s.store.Create(ctx, name, Slugify(name), true) + if err != nil { + return nil, fmt.Errorf("create default organization: %w", err) + } + s.log.Info("created the default organization", + zap.String("org_id", existing.ID), zap.String("slug", existing.Slug)) + } + + users, err := s.accounts.AssignUsersWithoutOrg(ctx, existing.ID) + if err != nil { + return nil, fmt.Errorf("assign users to the default organization: %w", err) + } + workspaces, err := s.data.AssignWorkspacesWithoutOrg(ctx, existing.ID) + if err != nil { + return nil, fmt.Errorf("assign workspaces to the default organization: %w", err) + } + dropped, err := s.data.DropCrossOrgWorkspaceMembers(ctx) + if err != nil { + return nil, fmt.Errorf("drop cross-organization memberships: %w", err) + } + if users > 0 || workspaces > 0 || dropped > 0 { + s.log.Info("tenancy migration applied", + zap.Int64("users_assigned", users), + zap.Int64("workspaces_assigned", workspaces), + zap.Int64("cross_org_memberships_dropped", dropped)) + } + + if err := s.ensureOperator(ctx); err != nil { + return nil, err + } + return existing, nil +} + +// ensureOperator grants the operator tier to the first admin when nobody holds +// it. Without this an upgraded instance would have organizations and no way to +// manage them. +func (s *Service) ensureOperator(ctx context.Context) error { + count, err := s.accounts.CountOperators(ctx) + if err != nil || count > 0 { + return err + } + adminID, err := s.accounts.FirstAdminID(ctx) + if err != nil || adminID == "" { + return err + } + if err := s.accounts.SetOperator(ctx, adminID, true); err != nil { + return err + } + s.log.Info("granted the instance operator tier to the first admin", zap.String("user_id", adminID)) + return nil +} + +// Get returns an organization by ID. +func (s *Service) Get(ctx context.Context, id string) (*Org, error) { return s.store.Get(ctx, id) } + +// List returns every organization. +func (s *Service) List(ctx context.Context) ([]*Org, error) { return s.store.List(ctx) } + +// Create adds an organization. +func (s *Service) Create(ctx context.Context, name, slug string) (*Org, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, ErrNameRequired + } + if slug = Slugify(slug); slug == "org" { + slug = Slugify(name) + } + org, err := s.store.Create(ctx, name, slug, false) + if err != nil { + return nil, err + } + s.log.Info("organization created", zap.String("org_id", org.ID), zap.String("slug", org.Slug)) + return org, nil +} + +// SetStatus suspends or resumes an organization. Suspension is reversible and +// retains every row and file; it is the lever for a billing lapse. +func (s *Service) SetStatus(ctx context.Context, id, status string) (*Org, error) { + org, err := s.store.UpdateNameStatus(ctx, id, "", status) + if err != nil { + return nil, err + } + s.log.Info("organization status changed", + zap.String("org_id", id), zap.String("status", org.Status)) + return org, nil +} + +// Rename changes an organization's display name. The slug is immutable +// because it is the confirmation token for deletion. +func (s *Service) Rename(ctx context.Context, id, name string) (*Org, error) { + if strings.TrimSpace(name) == "" { + return nil, ErrNameRequired + } + return s.store.UpdateNameStatus(ctx, id, strings.TrimSpace(name), "") +} + +// Delete removes an organization and all of its data. It requires the caller +// to type the slug, refuses the last remaining organization, and removes the +// org row last so a failure part-way leaves the org still identifiable. +func (s *Service) Delete(ctx context.Context, id, confirmSlug string) error { + org, err := s.store.Get(ctx, id) + if err != nil { + return err + } + if confirmSlug != org.Slug { + return ErrSlugMismatch + } + count, err := s.store.Count(ctx) + if err != nil { + return err + } + if count <= 1 { + return ErrCannotDeleteLast + } + if err := s.data.DeleteOrgData(ctx, id); err != nil { + return fmt.Errorf("delete organization data: %w", err) + } + if err := s.accounts.DeleteUsersByOrg(ctx, id); err != nil { + return fmt.Errorf("delete organization accounts: %w", err) + } + if s.units != nil { + if err := s.units.DeleteOrgUnits(ctx, id); err != nil { + return fmt.Errorf("delete organization units: %w", err) + } + } + if err := s.store.Delete(ctx, id); err != nil { + return err + } + s.log.Info("organization deleted", zap.String("org_id", id), zap.String("slug", org.Slug)) + return nil +} + +// ClaimSetupAdmin places the account created by the authentication setup +// wizard into the default organization and grants it the instance operator +// tier. It is a no-op when organizations are off. +func (s *Service) ClaimSetupAdmin(ctx context.Context, userID string) error { + if !s.Enabled() || userID == "" { + return nil + } + defaultOrg, err := s.EnsureDefaultOrg(ctx, "Default organization") + if err != nil || defaultOrg == nil { + return err + } + if err := s.accounts.SetOperator(ctx, userID, true); err != nil { + return err + } + s.log.Info("setup admin placed in the default organization", + zap.String("user_id", userID), zap.String("org_id", defaultOrg.ID)) + return nil +} diff --git a/apps/backend/internal/org/store.go b/apps/backend/internal/org/store.go new file mode 100644 index 0000000000..f9cd31c955 --- /dev/null +++ b/apps/backend/internal/org/store.go @@ -0,0 +1,186 @@ +package org + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/db/dialect" +) + +// ErrOrgNotFound reports an unknown organization. Callers that reached the +// lookup through a caller-supplied ID surface it as 404. +var ErrOrgNotFound = errors.New("organization not found") + +// ErrSlugTaken reports a slug collision. +var ErrSlugTaken = errors.New("that organization slug is already in use") + +// Store persists organizations. +type Store struct { + db *sqlx.DB + ro *sqlx.DB +} + +// NewStore builds the store and creates its schema. +func NewStore(pool *db.Pool) (*Store, error) { + s := &Store{db: pool.Writer(), ro: pool.Reader()} + if err := s.initSchema(); err != nil { + return nil, err + } + return s, nil +} + +func (s *Store) initSchema() error { + timestamp := "DATETIME" + if dialect.IsPostgres(s.db.DriverName()) { + timestamp = "TIMESTAMPTZ" + } + statements := []string{ + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS orgs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'active', + is_default INTEGER NOT NULL DEFAULT 0, + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, timestamp, timestamp), + `CREATE INDEX IF NOT EXISTS idx_orgs_status ON orgs(status)`, + } + for _, stmt := range statements { + if _, err := s.db.Exec(stmt); err != nil { + return fmt.Errorf("org schema: %w", err) + } + } + return nil +} + +const orgColumns = `id, name, slug, status, is_default, created_at, updated_at` + +func scanOrg(row interface{ Scan(...any) error }) (*Org, error) { + o := &Org{} + var isDefault int + if err := row.Scan(&o.ID, &o.Name, &o.Slug, &o.Status, &isDefault, &o.CreatedAt, &o.UpdatedAt); err != nil { + return nil, err + } + o.IsDefault = isDefault != 0 + return o, nil +} + +// Create inserts an organization, deriving a unique slug when needed. +func (s *Store) Create(ctx context.Context, name, slug string, isDefault bool) (*Org, error) { + if slug == "" { + slug = Slugify(name) + } + now := time.Now().UTC() + o := &Org{ + ID: uuid.New().String(), Name: name, Slug: slug, + Status: string(StatusActive), IsDefault: isDefault, CreatedAt: now, UpdatedAt: now, + } + flag := 0 + if isDefault { + flag = 1 + } + _, err := s.db.ExecContext(ctx, s.db.Rebind( + `INSERT INTO orgs (`+orgColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ), o.ID, o.Name, o.Slug, o.Status, flag, o.CreatedAt, o.UpdatedAt) + if err != nil { + if db.IsAlreadyExistsError(err) || isUniqueViolation(err) { + return nil, ErrSlugTaken + } + return nil, err + } + return o, nil +} + +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "UNIQUE constraint failed") || + strings.Contains(msg, "duplicate key value") +} + +// Get returns one organization by ID. +func (s *Store) Get(ctx context.Context, id string) (*Org, error) { + row := s.ro.QueryRowContext(ctx, s.ro.Rebind(`SELECT `+orgColumns+` FROM orgs WHERE id = ?`), id) + o, err := scanOrg(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrOrgNotFound + } + return o, err +} + +// List returns every organization, default first then by name. +func (s *Store) List(ctx context.Context) ([]*Org, error) { + rows, err := s.ro.QueryContext(ctx, `SELECT `+orgColumns+` FROM orgs ORDER BY is_default DESC, name`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + orgs := make([]*Org, 0) + for rows.Next() { + o, err := scanOrg(rows) + if err != nil { + return nil, err + } + orgs = append(orgs, o) + } + return orgs, rows.Err() +} + +// Default returns the default organization, or ErrOrgNotFound when the +// instance has not been migrated yet. +func (s *Store) Default(ctx context.Context) (*Org, error) { + row := s.ro.QueryRowContext(ctx, `SELECT `+orgColumns+` FROM orgs WHERE is_default = 1 LIMIT 1`) + o, err := scanOrg(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrOrgNotFound + } + return o, err +} + +// Count returns how many organizations exist. +func (s *Store) Count(ctx context.Context) (int, error) { + var n int + err := s.ro.QueryRowContext(ctx, `SELECT COUNT(*) FROM orgs`).Scan(&n) + return n, err +} + +// UpdateNameStatus changes an org's display name and/or status. +func (s *Store) UpdateNameStatus(ctx context.Context, id, name, status string) (*Org, error) { + existing, err := s.Get(ctx, id) + if err != nil { + return nil, err + } + if name == "" { + name = existing.Name + } + if status == "" { + status = existing.Status + } + status = string(NormalizeStatus(status)) + now := time.Now().UTC() + if _, err := s.db.ExecContext(ctx, s.db.Rebind( + `UPDATE orgs SET name = ?, status = ?, updated_at = ? WHERE id = ?`, + ), name, status, now, id); err != nil { + return nil, err + } + existing.Name, existing.Status, existing.UpdatedAt = name, status, now + return existing, nil +} + +// Delete removes the organization row. Callers are responsible for removing +// the org's data first; this is the last step. +func (s *Store) Delete(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM orgs WHERE id = ?`), id) + return err +} diff --git a/apps/backend/internal/orgunit/backfill.go b/apps/backend/internal/orgunit/backfill.go new file mode 100644 index 0000000000..9ab499a29c --- /dev/null +++ b/apps/backend/internal/orgunit/backfill.go @@ -0,0 +1,187 @@ +package orgunit + +import ( + "context" + + "go.uber.org/zap" +) + +// UserRef is the little a backfill needs to know about an account. +type UserRef struct { + ID string + OrgID string + DisplayName string +} + +// WorkspaceRef is a workspace awaiting placement. +type WorkspaceRef struct { + ID string + OrgID string + OwnerID string +} + +// AccountLister supplies the accounts that need a personal unit. +type AccountLister interface { + ListUnitUsers(ctx context.Context) ([]UserRef, error) +} + +// WorkspacePlacer supplies and records workspace placement. +type WorkspacePlacer interface { + UnplacedWorkspaces(ctx context.Context) ([]WorkspaceRef, error) + PlaceWorkspace(ctx context.Context, workspaceID, unitID string) error +} + +// BackfillResult reports what the one-shot placement did. +type BackfillResult struct { + Roots int + Personals int + Placed int +} + +// Backfill gives every organization a root unit, every user a personal unit, +// and every unplaced workspace a home. +// +// The placement rule is the one that keeps an upgrade from widening access: a +// workspace with an owner lands in that owner's personal unit, where only they +// reach it, and never under the root, where everyone would. A workspace with no +// owner predates authentication and is reachable by everyone already, so the +// root is where it belongs and nothing changes for it. +// +// It is idempotent: units are ensured rather than created, and only workspaces +// with no unit are considered. +func (s *Service) Backfill(ctx context.Context, accounts AccountLister, placer WorkspacePlacer) (BackfillResult, error) { + var result BackfillResult + + users, err := accounts.ListUnitUsers(ctx) + if err != nil { + return result, err + } + workspaces, err := placer.UnplacedWorkspaces(ctx) + if err != nil { + return result, err + } + + roots, err := s.ensureRoots(ctx, orgIDsIn(users, workspaces)) + if err != nil { + return result, err + } + result.Roots = len(roots) + + personals, err := s.ensurePersonals(ctx, users) + if err != nil { + return result, err + } + result.Personals = len(personals) + + placed, err := s.placeAll(ctx, placer, workspaces, roots, personals) + if err != nil { + return result, err + } + result.Placed = placed + + if s.log != nil && (result.Placed > 0 || result.Personals > 0) { + s.log.Info("organization unit backfill applied", + zap.Int("roots", result.Roots), + zap.Int("personal_units", result.Personals), + zap.Int("workspaces_placed", result.Placed)) + } + return result, nil +} + +// orgIDsIn collects every organization the backfill has to cover. An instance +// with organizations switched off has one implicit organization whose id is +// empty, and it needs a root exactly like a named one: the tree is the reach +// model whether or not tenancy is on. +func orgIDsIn(users []UserRef, workspaces []WorkspaceRef) []string { + seen := map[string]struct{}{} + for _, u := range users { + seen[u.OrgID] = struct{}{} + } + for _, w := range workspaces { + seen[w.OrgID] = struct{}{} + } + if len(seen) == 0 { + seen[""] = struct{}{} + } + out := make([]string, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + return out +} + +func (s *Service) ensureRoots(ctx context.Context, orgIDs []string) (map[string]string, error) { + roots := make(map[string]string, len(orgIDs)) + for _, orgID := range orgIDs { + root, err := s.EnsureRoot(ctx, orgID, "") + if err != nil { + return nil, err + } + roots[orgID] = root.ID + } + return roots, nil +} + +func (s *Service) ensurePersonals(ctx context.Context, users []UserRef) (map[string]string, error) { + personals := make(map[string]string, len(users)) + for _, u := range users { + unit, err := s.EnsurePersonal(ctx, u.OrgID, u.ID, u.DisplayName) + if err != nil { + return nil, err + } + personals[u.ID] = unit.ID + } + return personals, nil +} + +// placeAll gives every unplaced workspace a home. +// +// The rule is the one that keeps an upgrade from widening access: a workspace +// with an owner lands in that owner's personal unit, where only they reach it, +// and never under the root, where everyone would. A workspace with no owner +// predates authentication and is reachable by everyone already, so the root is +// where it belongs and nothing changes for it. +func (s *Service) placeAll( + ctx context.Context, + placer WorkspacePlacer, + workspaces []WorkspaceRef, + roots, personals map[string]string, +) (int, error) { + placed := 0 + for _, w := range workspaces { + target, err := s.targetUnit(ctx, w, roots, personals) + if err != nil { + return placed, err + } + if target == "" { + continue + } + if err := placer.PlaceWorkspace(ctx, w.ID, target); err != nil { + return placed, err + } + placed++ + } + return placed, nil +} + +func (s *Service) targetUnit( + ctx context.Context, + w WorkspaceRef, + roots, personals map[string]string, +) (string, error) { + if w.OwnerID == "" { + return roots[w.OrgID], nil + } + if unit, ok := personals[w.OwnerID]; ok { + return unit, nil + } + // An owner with no account left. Their workspace must not fall back to the + // root, where the whole organization would reach it, so it gets a personal + // unit keyed to the dangling id. + unit, err := s.EnsurePersonal(ctx, w.OrgID, w.OwnerID, "") + if err != nil { + return "", err + } + personals[w.OwnerID] = unit.ID + return unit.ID, nil +} diff --git a/apps/backend/internal/orgunit/backfill_test.go b/apps/backend/internal/orgunit/backfill_test.go new file mode 100644 index 0000000000..673456f2e4 --- /dev/null +++ b/apps/backend/internal/orgunit/backfill_test.go @@ -0,0 +1,157 @@ +package orgunit + +import ( + "context" + "testing" +) + +type stubAccounts struct{ users []UserRef } + +func (s stubAccounts) ListUnitUsers(context.Context) ([]UserRef, error) { return s.users, nil } + +type stubPlacer struct { + pending []WorkspaceRef + placed map[string]string +} + +func (s *stubPlacer) UnplacedWorkspaces(context.Context) ([]WorkspaceRef, error) { + return s.pending, nil +} + +func (s *stubPlacer) PlaceWorkspace(_ context.Context, workspaceID, unitID string) error { + if s.placed == nil { + s.placed = map[string]string{} + } + s.placed[workspaceID] = unitID + return nil +} + +// The rule an upgrade lives or dies by: a workspace that only its owner could +// reach must land somewhere only its owner reaches. Landing it under the root +// would hand the whole organization a board that was private a moment earlier, +// and nothing in the product would report that it had happened. +func TestBackfillDoesNotWidenReach(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + accounts := stubAccounts{users: []UserRef{ + {ID: "ada", OrgID: "org-1", DisplayName: "Ada Lovelace"}, + {ID: "grace", OrgID: "org-1", DisplayName: "Grace Hopper"}, + }} + placer := &stubPlacer{pending: []WorkspaceRef{ + {ID: "ws-ada", OrgID: "org-1", OwnerID: "ada"}, + {ID: "ws-grace", OrgID: "org-1", OwnerID: "grace"}, + {ID: "ws-unowned", OrgID: "org-1", OwnerID: ""}, + }} + + result, err := svc.Backfill(ctx, accounts, placer) + if err != nil { + t.Fatalf("backfill: %v", err) + } + if result.Placed != 3 { + t.Fatalf("placed = %d, want 3", result.Placed) + } + + adaUnit, err := svc.Store().Personal(ctx, "ada") + if err != nil { + t.Fatalf("ada personal unit: %v", err) + } + graceUnit, err := svc.Store().Personal(ctx, "grace") + if err != nil { + t.Fatalf("grace personal unit: %v", err) + } + root, err := svc.Store().Root(ctx, "org-1") + if err != nil { + t.Fatalf("root: %v", err) + } + + if placer.placed["ws-ada"] != adaUnit.ID { + t.Fatalf("an owned workspace landed in %q, want the owner's personal unit", placer.placed["ws-ada"]) + } + if placer.placed["ws-grace"] != graceUnit.ID { + t.Fatalf("an owned workspace landed in %q, want the owner's personal unit", placer.placed["ws-grace"]) + } + // A workspace with no owner predates authentication and everyone reaches it + // already, so the root changes nothing for it. + if placer.placed["ws-unowned"] != root.ID { + t.Fatalf("an unowned workspace landed in %q, want the root", placer.placed["ws-unowned"]) + } + if placer.placed["ws-ada"] == root.ID || placer.placed["ws-grace"] == root.ID { + t.Fatal("an owned workspace was placed under the root, which widens reach to the whole organization") + } +} + +// An owner whose account is gone must not hand their workspace to the +// organization. A dangling id still gets a unit of its own. +func TestBackfillIsolatesWorkspacesOfMissingOwners(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + placer := &stubPlacer{pending: []WorkspaceRef{ + {ID: "ws-orphan", OrgID: "org-1", OwnerID: "deleted-user"}, + }} + if _, err := svc.Backfill(ctx, stubAccounts{}, placer); err != nil { + t.Fatalf("backfill: %v", err) + } + + root, err := svc.Store().Root(ctx, "org-1") + if err != nil { + t.Fatalf("root: %v", err) + } + if placer.placed["ws-orphan"] == root.ID { + t.Fatal("a workspace owned by a missing account was placed under the root") + } + if _, err := svc.Store().Personal(ctx, "deleted-user"); err != nil { + t.Fatalf("expected a personal unit for the dangling owner: %v", err) + } +} + +// Boot runs this every time, so a second pass must not create a second root, a +// second personal unit, or move a workspace that already has a home. +func TestBackfillIsIdempotent(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + accounts := stubAccounts{users: []UserRef{{ID: "ada", OrgID: "org-1"}}} + + first := &stubPlacer{pending: []WorkspaceRef{{ID: "ws-ada", OrgID: "org-1", OwnerID: "ada"}}} + if _, err := svc.Backfill(ctx, accounts, first); err != nil { + t.Fatalf("first backfill: %v", err) + } + + // Second pass: the workspace now has a unit, so it is no longer pending. + second := &stubPlacer{pending: nil} + result, err := svc.Backfill(ctx, accounts, second) + if err != nil { + t.Fatalf("second backfill: %v", err) + } + if result.Placed != 0 { + t.Fatalf("second pass placed %d workspaces, want 0", result.Placed) + } + + units, err := svc.Store().ListByOrg(ctx, "org-1") + if err != nil { + t.Fatalf("list units: %v", err) + } + if len(units) != 2 { + t.Fatalf("units = %d, want a root and one personal unit", len(units)) + } +} + +// With organizations switched off there are no org rows and every id is empty, +// but the tree is still the reach model, so the implicit organization needs a +// root like any other. +func TestBackfillCreatesRootWithTenancyOff(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + placer := &stubPlacer{pending: []WorkspaceRef{{ID: "ws-1", OwnerID: "ada"}}} + if _, err := svc.Backfill(ctx, stubAccounts{users: []UserRef{{ID: "ada"}}}, placer); err != nil { + t.Fatalf("backfill: %v", err) + } + if _, err := svc.Store().Root(ctx, ""); err != nil { + t.Fatalf("no root for the implicit organization: %v", err) + } + if placer.placed["ws-1"] == "" { + t.Fatal("workspace was left unplaced") + } +} diff --git a/apps/backend/internal/orgunit/controller.go b/apps/backend/internal/orgunit/controller.go new file mode 100644 index 0000000000..1a94b05231 --- /dev/null +++ b/apps/backend/internal/orgunit/controller.go @@ -0,0 +1,223 @@ +package orgunit + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/common/logger" +) + +// Controller serves the unit tree. +type Controller struct { + service *Service + log *logger.Logger +} + +// NewController builds the controller. +func NewController(service *Service, log *logger.Logger) *Controller { + return &Controller{service: service, log: log.WithFields(zap.String("component", "org-unit-controller"))} +} + +// RegisterRoutes mounts the unit API. +// +// Reading the tree needs only an identity: a member has to see the shape of +// their organization to understand why they reach what they reach. Changing it +// needs `unit.manage`, which is where the shape of an organization is decided. +func (c *Controller) RegisterRoutes(router *gin.Engine) { + read := router.Group("/api/v1/units", authn.RequireRealIdentity()) + read.GET("", c.list) + read.GET("/:id/members", c.listMembers) + + write := router.Group("/api/v1/units", + authn.RequireRealIdentity(), authz.RequireOrgScope(authz.ScopeUnitManage)) + write.POST("", c.create) + write.PATCH("/:id", c.patch) + write.DELETE("/:id", c.delete) + write.PUT("/:id/members/:userId", c.setMember) + write.DELETE("/:id/members/:userId", c.removeMember) +} + +func (c *Controller) list(ctx *gin.Context) { + subject := authz.SubjectFromGin(ctx) + units, err := c.service.Store().ListByOrg(ctx.Request.Context(), subject.OrgID) + if err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"units": units, "total": len(units)}) +} + +func (c *Controller) listMembers(ctx *gin.Context) { + id := ctx.Param("id") + // Reads need the tenant check as much as writes: without it, knowing a + // unit id from another organization is enough to enumerate its members. + if !c.unitInCallerOrg(ctx, id) { + return + } + members, err := c.service.Store().ListMembers(ctx.Request.Context(), id) + if err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"members": members, "total": len(members)}) +} + +type createUnitRequest struct { + ParentID string `json:"parent_id"` + Name string `json:"name"` +} + +func (c *Controller) create(ctx *gin.Context) { + var req createUnitRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + // A caller must not be able to graft a unit onto another tenant's tree, so + // the parent is validated against the caller's own organization. + if !c.parentInCallerOrg(ctx, req.ParentID) { + return + } + unit, err := c.service.Create(ctx.Request.Context(), req.ParentID, req.Name) + if err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusCreated, unit) +} + +type patchUnitRequest struct { + Name *string `json:"name,omitempty"` + ParentID *string `json:"parent_id,omitempty"` +} + +func (c *Controller) patch(ctx *gin.Context) { + var req patchUnitRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id := ctx.Param("id") + if !c.unitInCallerOrg(ctx, id) { + return + } + if req.Name != nil { + if err := c.service.Rename(ctx.Request.Context(), id, *req.Name); err != nil { + c.fail(ctx, err) + return + } + } + if req.ParentID != nil { + if !c.parentInCallerOrg(ctx, *req.ParentID) { + return + } + if err := c.service.Move(ctx.Request.Context(), id, *req.ParentID); err != nil { + c.fail(ctx, err) + return + } + } + unit, err := c.service.Store().Get(ctx.Request.Context(), id) + if err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusOK, unit) +} + +func (c *Controller) delete(ctx *gin.Context) { + id := ctx.Param("id") + if !c.unitInCallerOrg(ctx, id) { + return + } + if err := c.service.Delete(ctx.Request.Context(), id); err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"success": true}) +} + +type setMemberRequest struct { + Role string `json:"role"` +} + +func (c *Controller) setMember(ctx *gin.Context) { + var req setMemberRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id := ctx.Param("id") + if !c.unitInCallerOrg(ctx, id) { + return + } + role := authz.NormalizeWorkspaceRole(req.Role) + if role == authz.WorkspaceRoleNone { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, collaborator, or viewer"}) + return + } + actor, _ := authn.FromGin(ctx) + if err := c.service.SetMember(ctx.Request.Context(), id, ctx.Param("userId"), string(role), actor.UserID); err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"success": true}) +} + +func (c *Controller) removeMember(ctx *gin.Context) { + id := ctx.Param("id") + if !c.unitInCallerOrg(ctx, id) { + return + } + if err := c.service.RemoveMember(ctx.Request.Context(), id, ctx.Param("userId")); err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"success": true}) +} + +// unitInCallerOrg answers 404 rather than 403 for a unit in another tenant, so +// the tree of one organization is not enumerable from another. +func (c *Controller) unitInCallerOrg(ctx *gin.Context, id string) bool { + unit, err := c.service.Store().Get(ctx.Request.Context(), id) + if err != nil { + c.fail(ctx, err) + return false + } + subject := authz.SubjectFromGin(ctx) + if !subject.Unscoped && subject.OrgID != unit.OrgID { + ctx.JSON(http.StatusNotFound, gin.H{"error": "unit not found"}) + return false + } + return true +} + +func (c *Controller) parentInCallerOrg(ctx *gin.Context, parentID string) bool { + if parentID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": ErrParentRequired.Error()}) + return false + } + return c.unitInCallerOrg(ctx, parentID) +} + +// fail maps a tree error to its status. Each refusal names its blocking +// condition, so a caller can say what is in the way rather than that something +// went wrong. +func (c *Controller) fail(ctx *gin.Context, err error) { + switch { + case errors.Is(err, ErrUnitNotFound): + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, ErrNotEmpty), errors.Is(err, ErrCycle), + errors.Is(err, ErrProtectedUnit), errors.Is(err, ErrPersonalNoMember), + errors.Is(err, ErrCrossOrgParent), errors.Is(err, ErrNameRequired), + errors.Is(err, ErrParentRequired): + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.log.Error("unit request failed", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "unit request failed"}) + } +} diff --git a/apps/backend/internal/orgunit/controller_test.go b/apps/backend/internal/orgunit/controller_test.go new file mode 100644 index 0000000000..e91d164b98 --- /dev/null +++ b/apps/backend/internal/orgunit/controller_test.go @@ -0,0 +1,166 @@ +package orgunit + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestRouter(t *testing.T, role authn.Role) (*gin.Engine, *Service) { + t.Helper() + gin.SetMode(gin.TestMode) + svc := newTestService(t) + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(authn.WithIdentity(c.Request.Context(), + authn.Identity{UserID: "user-ana", Role: role})) + c.Set(authn.GinContextKey, authn.Identity{UserID: "user-ana", Role: role}) + c.Next() + }) + NewController(svc, log).RegisterRoutes(router) + return router, svc +} + +func do(t *testing.T, router *gin.Engine, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + var reader *strings.Reader + if body == "" { + reader = strings.NewReader("") + } else { + reader = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, reader) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +// Deciding the shape of an organization is what unit.manage governs. A member +// who does not hold it can look at the tree but must not rearrange it. +func TestUnitWritesRequireUnitManage(t *testing.T) { + router, svc := newTestRouter(t, authn.RoleMember) + root, err := svc.EnsureRoot(t.Context(), "", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + + if w := do(t, router, http.MethodGet, "/api/v1/units", ""); w.Code != http.StatusOK { + t.Fatalf("reading the tree = %d, want 200: a member must see why they reach what they reach", w.Code) + } + for _, tc := range []struct{ method, path, body string }{ + {http.MethodPost, "/api/v1/units", `{"parent_id":"` + root.ID + `","name":"Platform"}`}, + {http.MethodPatch, "/api/v1/units/" + root.ID, `{"name":"Renamed"}`}, + {http.MethodDelete, "/api/v1/units/" + root.ID, ""}, + } { + if w := do(t, router, tc.method, tc.path, tc.body); w.Code != http.StatusForbidden { + t.Errorf("%s %s = %d, want 403", tc.method, tc.path, w.Code) + } + } +} + +// Each refusal names what is in the way, so a caller can say why rather than +// reporting that something went wrong. +func TestUnitRefusalsAreSpecific(t *testing.T) { + router, svc := newTestRouter(t, authn.RoleAdmin) + ctx := t.Context() + root, err := svc.EnsureRoot(ctx, "", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + dept, err := svc.Create(ctx, root.ID, "Platform") + if err != nil { + t.Fatalf("create dept: %v", err) + } + if _, err := svc.Create(ctx, dept.ID, "Runtime"); err != nil { + t.Fatalf("create team: %v", err) + } + personal, err := svc.EnsurePersonal(ctx, "", "user-ana", "Ana") + if err != nil { + t.Fatalf("ensure personal: %v", err) + } + + cases := []struct { + name, method, path, body, wantText string + wantCode int + }{ + {"a unit with children", http.MethodDelete, "/api/v1/units/" + dept.ID, "", "still holds", http.StatusBadRequest}, + {"the root itself", http.MethodDelete, "/api/v1/units/" + root.ID, "", "cannot be moved or deleted", http.StatusBadRequest}, + {"a member on a personal unit", http.MethodPut, "/api/v1/units/" + personal.ID + "/members/user-bruno", `{"role":"viewer"}`, "takes no members", http.StatusBadRequest}, + {"an unknown role", http.MethodPut, "/api/v1/units/" + dept.ID + "/members/user-bruno", `{"role":"wizard"}`, "must be owner", http.StatusBadRequest}, + {"a unit that does not exist", http.MethodDelete, "/api/v1/units/nope", "", "not found", http.StatusNotFound}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := do(t, router, tc.method, tc.path, tc.body) + if w.Code != tc.wantCode { + t.Fatalf("status = %d, want %d (%s)", w.Code, tc.wantCode, w.Body.String()) + } + if !strings.Contains(w.Body.String(), tc.wantText) { + t.Fatalf("body = %s, want it to mention %q", w.Body.String(), tc.wantText) + } + }) + } +} + +// The happy path: build a department, put someone in it, read it back. +func TestUnitCreateAndMember(t *testing.T) { + router, svc := newTestRouter(t, authn.RoleAdmin) + root, err := svc.EnsureRoot(t.Context(), "", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + + w := do(t, router, http.MethodPost, "/api/v1/units", `{"parent_id":"`+root.ID+`","name":"Platform"}`) + if w.Code != http.StatusCreated { + t.Fatalf("create = %d body = %s", w.Code, w.Body.String()) + } + var created Unit + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("decode: %v", err) + } + + if w := do(t, router, http.MethodPut, + "/api/v1/units/"+created.ID+"/members/user-bruno", `{"role":"collaborator"}`); w.Code != http.StatusOK { + t.Fatalf("add member = %d body = %s", w.Code, w.Body.String()) + } + + w = do(t, router, http.MethodGet, "/api/v1/units/"+created.ID+"/members", "") + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "user-bruno") { + t.Fatalf("members = %d body = %s", w.Code, w.Body.String()) + } +} + +// Reads leak as readily as writes. Knowing a unit id from another tenant must +// not be enough to enumerate who is in it, so the read handler carries the +// same tenant check the write handlers do. +func TestListMembersRefusesAnotherTenantsUnit(t *testing.T) { + router, svc := newTestRouter(t, authn.RoleAdmin) + ctx := t.Context() + + // The caller's identity carries no org in this harness, so a unit that + // does carry one belongs to somebody else. + foreign, err := svc.Store().Insert(ctx, &Unit{OrgID: "org-other", Kind: KindRoot, Name: "Globex"}) + if err != nil { + t.Fatalf("seed foreign unit: %v", err) + } + if err := svc.SetMember(ctx, foreign.ID, "user-elsewhere", "collaborator", "someone"); err != nil { + t.Fatalf("seed foreign member: %v", err) + } + + w := do(t, router, http.MethodGet, "/api/v1/units/"+foreign.ID+"/members", "") + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d body = %s, want 404", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "user-elsewhere") { + t.Fatal("another tenant's membership leaked in the response body") + } +} diff --git a/apps/backend/internal/orgunit/models.go b/apps/backend/internal/orgunit/models.go new file mode 100644 index 0000000000..6396bde2b6 --- /dev/null +++ b/apps/backend/internal/orgunit/models.go @@ -0,0 +1,78 @@ +// Package orgunit owns the tree an organization is arranged into. +// +// A unit holds child units, workspaces, and members. A department and a team +// are both units and differ only in depth, which is why there is one type here +// rather than one per level: an organization that grows a layer moves rows +// instead of migrating a schema. +// +// Membership on a unit reaches every workspace beneath it. Resolving that +// reach belongs to internal/authz; this package owns the tree and its +// invariants. See docs/specs/workspaces/requirements/org-units.md. +package orgunit + +import ( + "errors" + "time" +) + +// Kind separates the units that carry structure from the two that the system +// creates and protects. +type Kind string + +const ( + // KindRoot is the single top unit of an organization. Belonging to the + // organization is membership of this unit, so a workspace parked here is + // reachable by everyone in the organization. + KindRoot Kind = "root" + // KindStandard is a department, a team, or any other level a person makes. + KindStandard Kind = "standard" + // KindPersonal belongs to exactly one user and takes no members. It is + // where work that is nobody else's business lives, and it replaces the + // private visibility flag rather than sitting beside it. + KindPersonal Kind = "personal" +) + +// Unit is one node of an organization's tree. +type Unit struct { + ID string `json:"id" db:"id"` + OrgID string `json:"org_id" db:"org_id"` + ParentID string `json:"parent_id" db:"parent_id"` + Kind Kind `json:"kind" db:"kind"` + OwnerUserID string `json:"owner_user_id,omitempty" db:"owner_user_id"` + Name string `json:"name" db:"name"` + // Path is the materialized ancestor chain, "///", ending with this + // unit. It is a denormalization whose only writer is this package, kept + // because a prefix match works identically on SQLite and Postgres while a + // recursive query does not. + Path string `json:"path" db:"path"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// IsProtected reports whether the unit is one the system owns. A protected +// unit cannot be moved or deleted while the thing it belongs to exists. +func (u *Unit) IsProtected() bool { return u.Kind == KindRoot || u.Kind == KindPersonal } + +// Member is a person's standing on one unit, which they hold on every +// workspace beneath it. +type Member struct { + UnitID string `json:"unit_id" db:"unit_id"` + UserID string `json:"user_id" db:"user_id"` + Role string `json:"role" db:"role"` + AddedBy string `json:"added_by" db:"added_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Tree errors. Each names its blocking condition so a caller can say what is +// in the way rather than that something went wrong. +var ( + ErrUnitNotFound = errors.New("unit not found") + ErrNameRequired = errors.New("a unit needs a name") + ErrParentRequired = errors.New("a unit needs a parent") + ErrCrossOrgParent = errors.New("a unit's parent must be in the same organization") + ErrCycle = errors.New("a unit cannot be moved beneath itself") + ErrNotEmpty = errors.New("this unit still holds child units or workspaces") + ErrProtectedUnit = errors.New("the root and personal units cannot be moved or deleted") + ErrPersonalNoMember = errors.New("a personal unit takes no members") + ErrRootExists = errors.New("this organization already has a root unit") +) diff --git a/apps/backend/internal/orgunit/service.go b/apps/backend/internal/orgunit/service.go new file mode 100644 index 0000000000..1043a0bc1f --- /dev/null +++ b/apps/backend/internal/orgunit/service.go @@ -0,0 +1,343 @@ +package orgunit + +import ( + "context" + "strings" + + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// WorkspaceCounter reports how many workspaces sit in a unit. +// +// Workspaces belong to the task repository, so this package asks rather than +// reads. Without it a unit could be deleted out from under the workspaces it +// holds, which would leave them unreachable with no way to say why. +type WorkspaceCounter interface { + CountWorkspacesInUnit(ctx context.Context, unitID string) (int, error) +} + +// Service owns the tree's invariants. +type Service struct { + store *Store + workspaces WorkspaceCounter + log *logger.Logger +} + +// NewService builds the service. The workspace counter is wired separately +// because the task repository is constructed after this package. +func NewService(store *Store, log *logger.Logger) *Service { + return &Service{store: store, log: log} +} + +// SetWorkspaceCounter wires the workspace-occupancy seam. Until it is set, a +// delete is refused rather than allowed unchecked: an unwired dependency must +// not read as "no workspaces here". +func (s *Service) SetWorkspaceCounter(c WorkspaceCounter) { s.workspaces = c } + +// Store exposes the store for read paths that do not need the invariants. +func (s *Service) Store() *Store { return s.store } + +// EnsureRoot returns the organization's root unit, creating it when absent. +// It is idempotent so that boot, migration, and organization creation can all +// call it without coordinating. +func (s *Service) EnsureRoot(ctx context.Context, orgID, orgName string) (*Unit, error) { + if existing, err := s.store.Root(ctx, orgID); err == nil { + return existing, nil + } else if err != ErrUnitNotFound { + return nil, err + } + name := strings.TrimSpace(orgName) + if name == "" { + name = "Organization" + } + unit, err := s.store.Insert(ctx, &Unit{OrgID: orgID, Kind: KindRoot, Name: name}) + if err != nil { + // Lost a race with a concurrent caller. The unique index kept the + // database right; re-reading is what keeps the method idempotent, as + // its name promises. + if existing, readErr := s.store.Root(ctx, orgID); readErr == nil { + return existing, nil + } + return nil, err + } + return unit, nil +} + +// EnsurePersonal returns a user's personal unit, creating it when absent. +// +// It hangs off the root rather than standing outside the tree, so that one +// walk answers reach for every workspace. Its emptiness of members, not its +// position, is what keeps it private. +func (s *Service) EnsurePersonal(ctx context.Context, orgID, userID, displayName string) (*Unit, error) { + if existing, err := s.store.Personal(ctx, userID); err == nil { + return existing, nil + } else if err != ErrUnitNotFound { + return nil, err + } + root, err := s.EnsureRoot(ctx, orgID, "") + if err != nil { + return nil, err + } + name := strings.TrimSpace(displayName) + if name == "" { + name = "Personal" + } + unit, err := s.store.Insert(ctx, &Unit{ + OrgID: orgID, + ParentID: root.ID, + Kind: KindPersonal, + OwnerUserID: userID, + Name: name, + }) + if err != nil { + // Same race as the root: two first-time requests for one account can + // both pass the lookup above. Returning the row the winner created is + // the idempotent answer, and matters more here because the caller's + // fallback for an error used to be the organization root. + if existing, readErr := s.store.Personal(ctx, userID); readErr == nil { + return existing, nil + } + return nil, err + } + return unit, nil +} + +// Create adds a standard unit under a parent. +func (s *Service) Create(ctx context.Context, parentID, name string) (*Unit, error) { + if strings.TrimSpace(name) == "" { + return nil, ErrNameRequired + } + if parentID == "" { + return nil, ErrParentRequired + } + parent, err := s.store.Get(ctx, parentID) + if err != nil { + return nil, err + } + unit, err := s.store.Insert(ctx, &Unit{ + OrgID: parent.OrgID, + ParentID: parent.ID, + Kind: KindStandard, + Name: strings.TrimSpace(name), + }) + if err != nil { + return nil, err + } + s.logInfo("unit created", unit) + return unit, nil +} + +// Rename changes a unit's display name, including a protected one: naming is +// not structural. +func (s *Service) Rename(ctx context.Context, id, name string) error { + if strings.TrimSpace(name) == "" { + return ErrNameRequired + } + return s.store.Rename(ctx, id, strings.TrimSpace(name)) +} + +// Move reparents a unit. +func (s *Service) Move(ctx context.Context, id, newParentID string) error { + unit, err := s.store.Get(ctx, id) + if err != nil { + return err + } + if unit.IsProtected() { + return ErrProtectedUnit + } + parent, err := s.store.Get(ctx, newParentID) + if err != nil { + return err + } + if parent.OrgID != unit.OrgID { + return ErrCrossOrgParent + } + // A unit cannot be moved beneath itself. The destination's path carries + // its whole ancestry, so containment is a prefix test rather than a walk. + if strings.HasPrefix(parent.Path, unit.Path) { + return ErrCycle + } + if err := s.store.Reparent(ctx, unit, parent); err != nil { + return err + } + s.logInfo("unit moved", unit) + return nil +} + +// Delete removes an empty unit. +func (s *Service) Delete(ctx context.Context, id string) error { + unit, err := s.store.Get(ctx, id) + if err != nil { + return err + } + if unit.IsProtected() { + return ErrProtectedUnit + } + children, err := s.store.ChildCount(ctx, id) + if err != nil { + return err + } + if children > 0 { + return ErrNotEmpty + } + if s.workspaces == nil { + return ErrNotEmpty + } + count, err := s.workspaces.CountWorkspacesInUnit(ctx, id) + if err != nil { + return err + } + if count > 0 { + return ErrNotEmpty + } + if err := s.store.Delete(ctx, id); err != nil { + return err + } + s.logInfo("unit deleted", unit) + return nil +} + +// SetMember adds or re-roles a member. +func (s *Service) SetMember(ctx context.Context, unitID, userID, role, addedBy string) error { + unit, err := s.store.Get(ctx, unitID) + if err != nil { + return err + } + // A personal unit is private because nobody else can be in it. Admitting a + // member would make "private" a property some units have and others do + // not, which is the flag this model removed. + if unit.Kind == KindPersonal { + return ErrPersonalNoMember + } + return s.store.SetMember(ctx, &Member{ + UnitID: unitID, UserID: userID, Role: role, AddedBy: addedBy, + }) +} + +// RemoveMember drops a membership. +func (s *Service) RemoveMember(ctx context.Context, unitID, userID string) error { + return s.store.RemoveMember(ctx, unitID, userID) +} + +func (s *Service) logInfo(msg string, unit *Unit) { + if s.log == nil { + return + } + s.log.Info(msg, + zap.String("unit_id", unit.ID), + zap.String("org_id", unit.OrgID), + zap.String("kind", string(unit.Kind))) +} + +// PersonalUnitID returns a user's personal unit, creating it on demand. It +// satisfies the task service's placement seam. +func (s *Service) PersonalUnitID(ctx context.Context, orgID, userID, displayName string) (string, error) { + unit, err := s.EnsurePersonal(ctx, orgID, userID, displayName) + if err != nil { + return "", err + } + return unit.ID, nil +} + +// RootUnitID returns an organization's root unit, creating it on demand. +func (s *Service) RootUnitID(ctx context.Context, orgID string) (string, error) { + unit, err := s.EnsureRoot(ctx, orgID, "") + if err != nil { + return "", err + } + return unit.ID, nil +} + +// InheritedRole returns the highest role a user holds on a unit or any of its +// ancestors. It satisfies the task service's reach seam. +func (s *Service) InheritedRole(ctx context.Context, userID, unitID string) (string, error) { + unit, err := s.store.Get(ctx, unitID) + if err != nil { + if err == ErrUnitNotFound { + // A workspace pointing at a unit that is gone reaches nobody, + // which is the safe reading: it is not an error the caller can act + // on, and treating it as one would deny every request instead. + return "", nil + } + return "", err + } + roles, err := s.store.AncestorRoles(ctx, userID, unit.Path) + if err != nil { + return "", err + } + return strongest(roles), nil +} + +// InheritedRolesByUnit resolves inherited roles for many units at once, in two +// queries rather than one per unit. +func (s *Service) InheritedRolesByUnit(ctx context.Context, userID string, unitIDs []string) (map[string]string, error) { + if userID == "" || len(unitIDs) == 0 { + return map[string]string{}, nil + } + held, err := s.store.UserRoles(ctx, userID) + if err != nil { + return nil, err + } + if len(held) == 0 { + return map[string]string{}, nil + } + paths, err := s.store.PathsByID(ctx, unitIDs) + if err != nil { + return nil, err + } + out := make(map[string]string, len(paths)) + for unitID, path := range paths { + var roles []string + for _, ancestor := range AncestorIDs(path) { + if role, ok := held[ancestor]; ok { + roles = append(roles, role) + } + } + if role := strongest(roles); role != "" { + out[unitID] = role + } + } + return out, nil +} + +// strongest picks the highest role, which is the whole combining rule: nothing +// a person holds anywhere in the ancestry can take away what another grant +// gives them. +func strongest(roles []string) string { + rank := map[string]int{"viewer": 1, "collaborator": 2, "owner": 3} + best, bestRank := "", 0 + for _, role := range roles { + if r := rank[role]; r > bestRank { + best, bestRank = role, r + } + } + return best +} + +// UnitOrgID reports which organization a unit belongs to. +func (s *Service) UnitOrgID(ctx context.Context, unitID string) (string, error) { + unit, err := s.store.Get(ctx, unitID) + if err != nil { + return "", err + } + return unit.OrgID, nil +} + +// UnitReaders returns everyone who reaches a workspace placed in this unit, +// which is every member of the unit and of all its ancestors. +func (s *Service) UnitReaders(ctx context.Context, unitID string) ([]string, error) { + unit, err := s.store.Get(ctx, unitID) + if err != nil { + if err == ErrUnitNotFound { + return nil, nil + } + return nil, err + } + return s.store.AncestorMemberIDs(ctx, unit.Path) +} + +// DeleteOrgUnits removes an organization's whole tree. +func (s *Service) DeleteOrgUnits(ctx context.Context, orgID string) error { + return s.store.DeleteByOrg(ctx, orgID) +} diff --git a/apps/backend/internal/orgunit/service_test.go b/apps/backend/internal/orgunit/service_test.go new file mode 100644 index 0000000000..2583ddf08a --- /dev/null +++ b/apps/backend/internal/orgunit/service_test.go @@ -0,0 +1,247 @@ +package orgunit + +import ( + "context" + "errors" + "sync" + "testing" +) + +type stubCounter struct { + counts map[string]int + err error +} + +func (s stubCounter) CountWorkspacesInUnit(_ context.Context, unitID string) (int, error) { + return s.counts[unitID], s.err +} + +func newTestService(t *testing.T) *Service { + t.Helper() + svc := NewService(newTestStore(t), nil) + svc.SetWorkspaceCounter(stubCounter{counts: map[string]int{}}) + return svc +} + +// Both are idempotent because boot, the data migration, and organization or +// user creation all reach for them without coordinating. A second call that +// created a second unit would give a user two ancestries. +func TestEnsureRootAndPersonalAreIdempotent(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + first, err := svc.EnsureRoot(ctx, "org-1", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + second, err := svc.EnsureRoot(ctx, "org-1", "Acme") + if err != nil { + t.Fatalf("ensure root again: %v", err) + } + if first.ID != second.ID { + t.Fatalf("root ids differ: %s vs %s", first.ID, second.ID) + } + + p1, err := svc.EnsurePersonal(ctx, "org-1", "ada", "Ada Lovelace") + if err != nil { + t.Fatalf("ensure personal: %v", err) + } + p2, err := svc.EnsurePersonal(ctx, "org-1", "ada", "Ada Lovelace") + if err != nil { + t.Fatalf("ensure personal again: %v", err) + } + if p1.ID != p2.ID { + t.Fatalf("personal ids differ: %s vs %s", p1.ID, p2.ID) + } + if p1.ParentID != first.ID { + t.Fatalf("personal unit parent = %q, want the root %q", p1.ParentID, first.ID) + } +} + +// A personal unit is private because nobody else can be in it. If a member +// could be added, privacy would become a property some units have, which is +// the flag this model set out to remove. +func TestPersonalUnitRefusesMembers(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + if _, err := svc.EnsureRoot(ctx, "org-1", "Acme"); err != nil { + t.Fatalf("ensure root: %v", err) + } + personal, err := svc.EnsurePersonal(ctx, "org-1", "ada", "Ada") + if err != nil { + t.Fatalf("ensure personal: %v", err) + } + + err = svc.SetMember(ctx, personal.ID, "grace", "collaborator", "ada") + if !errors.Is(err, ErrPersonalNoMember) { + t.Fatalf("adding a member to a personal unit returned %v, want ErrPersonalNoMember", err) + } +} + +// Moving a unit under its own descendant would detach the subtree from the +// root, and every reach answer for it afterwards would be a guess. +func TestMoveRefusesCycle(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + root, err := svc.EnsureRoot(ctx, "org-1", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + dept, err := svc.Create(ctx, root.ID, "Platform") + if err != nil { + t.Fatalf("create dept: %v", err) + } + team, err := svc.Create(ctx, dept.ID, "Runtime") + if err != nil { + t.Fatalf("create team: %v", err) + } + + if err := svc.Move(ctx, dept.ID, team.ID); !errors.Is(err, ErrCycle) { + t.Fatalf("moving a unit beneath its own child returned %v, want ErrCycle", err) + } + if err := svc.Move(ctx, root.ID, dept.ID); !errors.Is(err, ErrProtectedUnit) { + t.Fatalf("moving the root returned %v, want ErrProtectedUnit", err) + } +} + +// Deleting a unit that still holds workspaces would strand them: they would +// have a placement pointing at nothing and no ancestry to resolve reach from. +func TestDeleteRefusesWhileOccupied(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + root, err := svc.EnsureRoot(ctx, "org-1", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + dept, err := svc.Create(ctx, root.ID, "Platform") + if err != nil { + t.Fatalf("create dept: %v", err) + } + team, err := svc.Create(ctx, dept.ID, "Runtime") + if err != nil { + t.Fatalf("create team: %v", err) + } + + if err := svc.Delete(ctx, dept.ID); !errors.Is(err, ErrNotEmpty) { + t.Fatalf("deleting a unit with a child returned %v, want ErrNotEmpty", err) + } + + svc.SetWorkspaceCounter(stubCounter{counts: map[string]int{team.ID: 1}}) + if err := svc.Delete(ctx, team.ID); !errors.Is(err, ErrNotEmpty) { + t.Fatalf("deleting a unit holding a workspace returned %v, want ErrNotEmpty", err) + } + + svc.SetWorkspaceCounter(stubCounter{counts: map[string]int{}}) + if err := svc.Delete(ctx, team.ID); err != nil { + t.Fatalf("deleting an empty unit: %v", err) + } +} + +// An unwired occupancy seam must not read as "no workspaces here". Failing +// closed keeps a construction mistake from deleting a populated unit. +func TestDeleteFailsClosedWithoutCounter(t *testing.T) { + ctx := context.Background() + svc := NewService(newTestStore(t), nil) + root, err := svc.EnsureRoot(ctx, "org-1", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + dept, err := svc.Create(ctx, root.ID, "Platform") + if err != nil { + t.Fatalf("create dept: %v", err) + } + if err := svc.Delete(ctx, dept.ID); !errors.Is(err, ErrNotEmpty) { + t.Fatalf("delete with no counter wired returned %v, want a refusal", err) + } +} + +// Two first-time requests for one account can both pass the lookup inside +// EnsurePersonal. The unique index keeps the database right, but the loser +// used to get an error, and its caller's fallback was the organization root: +// a workspace meant to be private would have been created where everyone +// reaches it. +// +// This races for real rather than seeding the row first, because seeding it +// makes the early lookup succeed and the conflict path is never reached. That +// is exactly the mistake that made the first version of this test vacuous. +func TestEnsurePersonalIsRaceSafe(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + if _, err := svc.EnsureRoot(ctx, "org-1", "Acme"); err != nil { + t.Fatalf("ensure root: %v", err) + } + + const racers = 8 + ids := make([]string, racers) + errs := make([]error, racers) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < racers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + unit, err := svc.EnsurePersonal(ctx, "org-1", "ada", "Ada") + errs[i] = err + if unit != nil { + ids[i] = unit.ID + } + }(i) + } + close(start) + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("racer %d got an error instead of the existing unit: %v", i, err) + } + } + for i, id := range ids { + if id == "" || id != ids[0] { + t.Fatalf("racer %d returned %q, want every caller to get %q", i, id, ids[0]) + } + } + + units, err := svc.Store().ListByOrg(ctx, "org-1") + if err != nil { + t.Fatalf("list units: %v", err) + } + if len(units) != 2 { + t.Fatalf("units = %d, want a root and exactly one personal unit", len(units)) + } +} + +// Same race on the root, where a spurious error breaks boot rather than reach. +func TestEnsureRootIsRaceSafe(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + const racers = 8 + ids := make([]string, racers) + errs := make([]error, racers) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < racers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + unit, err := svc.EnsureRoot(ctx, "org-1", "Acme") + errs[i] = err + if unit != nil { + ids[i] = unit.ID + } + }(i) + } + close(start) + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("racer %d got an error instead of the existing root: %v", i, err) + } + if ids[i] != ids[0] || ids[i] == "" { + t.Fatalf("racer %d returned %q, want every caller to get %q", i, ids[i], ids[0]) + } + } +} diff --git a/apps/backend/internal/orgunit/store.go b/apps/backend/internal/orgunit/store.go new file mode 100644 index 0000000000..e515d8d248 --- /dev/null +++ b/apps/backend/internal/orgunit/store.go @@ -0,0 +1,414 @@ +package orgunit + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/db/dialect" +) + +// Store persists the unit tree and its memberships. +type Store struct { + db *sqlx.DB + ro *sqlx.DB +} + +// NewStore builds the store and creates its schema. +func NewStore(pool *db.Pool) (*Store, error) { + s := &Store{db: pool.Writer(), ro: pool.Reader()} + if err := s.initSchema(); err != nil { + return nil, err + } + return s, nil +} + +func (s *Store) initSchema() error { + timestamp := "DATETIME" + if dialect.IsPostgres(s.db.DriverName()) { + timestamp = "TIMESTAMPTZ" + } + statements := []string{ + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS org_units ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + parent_id TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT 'standard', + owner_user_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + path TEXT NOT NULL DEFAULT '', + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, timestamp, timestamp), + `CREATE INDEX IF NOT EXISTS idx_org_units_org_parent ON org_units(org_id, parent_id)`, + // Ancestor lookups are prefix matches on this column, so it carries + // the only index that the reach query depends on. + `CREATE INDEX IF NOT EXISTS idx_org_units_path ON org_units(path)`, + // One root per organization, and one personal unit per user, enforced + // by the database rather than by a check the caller might skip. + `CREATE UNIQUE INDEX IF NOT EXISTS uniq_org_units_root ON org_units(org_id) WHERE kind = 'root'`, + `CREATE UNIQUE INDEX IF NOT EXISTS uniq_org_units_personal ON org_units(owner_user_id) WHERE kind = 'personal'`, + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS unit_members ( + unit_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + added_by TEXT NOT NULL DEFAULT '', + created_at %s NOT NULL, + PRIMARY KEY (unit_id, user_id) + )`, timestamp), + `CREATE INDEX IF NOT EXISTS idx_unit_members_user ON unit_members(user_id)`, + } + for _, stmt := range statements { + if _, err := s.db.Exec(stmt); err != nil { + return fmt.Errorf("org unit schema: %w", err) + } + } + return nil +} + +const unitColumns = `id, org_id, parent_id, kind, owner_user_id, name, path, created_at, updated_at` + +func scanUnit(row interface{ Scan(...any) error }) (*Unit, error) { + u := &Unit{} + if err := row.Scan(&u.ID, &u.OrgID, &u.ParentID, &u.Kind, &u.OwnerUserID, + &u.Name, &u.Path, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, err + } + return u, nil +} + +// Insert writes a unit, deriving its path from its parent. The caller has +// already validated the tree invariants; this is the only writer of `path`. +func (s *Store) Insert(ctx context.Context, unit *Unit) (*Unit, error) { + if unit.ID == "" { + unit.ID = uuid.New().String() + } + now := time.Now().UTC() + unit.CreatedAt, unit.UpdatedAt = now, now + + parentPath := "" + if unit.ParentID != "" { + parent, err := s.Get(ctx, unit.ParentID) + if err != nil { + return nil, err + } + parentPath = parent.Path + } + unit.Path = strings.TrimSuffix(parentPath, "/") + "/" + unit.ID + "/" + + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + INSERT INTO org_units (`+unitColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), + unit.ID, unit.OrgID, unit.ParentID, string(unit.Kind), unit.OwnerUserID, + unit.Name, unit.Path, unit.CreatedAt, unit.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("insert unit: %w", err) + } + return unit, nil +} + +// Get returns one unit. +func (s *Store) Get(ctx context.Context, id string) (*Unit, error) { + row := s.ro.QueryRowxContext(ctx, s.ro.Rebind( + `SELECT `+unitColumns+` FROM org_units WHERE id = ?`), id) + unit, err := scanUnit(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUnitNotFound + } + return unit, err +} + +// Root returns an organization's root unit. +func (s *Store) Root(ctx context.Context, orgID string) (*Unit, error) { + row := s.ro.QueryRowxContext(ctx, s.ro.Rebind( + `SELECT `+unitColumns+` FROM org_units WHERE org_id = ? AND kind = 'root'`), orgID) + unit, err := scanUnit(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUnitNotFound + } + return unit, err +} + +// Personal returns a user's personal unit. +func (s *Store) Personal(ctx context.Context, userID string) (*Unit, error) { + row := s.ro.QueryRowxContext(ctx, s.ro.Rebind( + `SELECT `+unitColumns+` FROM org_units WHERE owner_user_id = ? AND kind = 'personal'`), userID) + unit, err := scanUnit(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUnitNotFound + } + return unit, err +} + +// ListByOrg returns every unit in an organization, ordered by path so a caller +// receives parents before their children. +func (s *Store) ListByOrg(ctx context.Context, orgID string) ([]*Unit, error) { + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind( + `SELECT `+unitColumns+` FROM org_units WHERE org_id = ? ORDER BY path`), orgID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []*Unit + for rows.Next() { + unit, err := scanUnit(rows) + if err != nil { + return nil, err + } + out = append(out, unit) + } + return out, rows.Err() +} + +// Rename updates a unit's display name. +func (s *Store) Rename(ctx context.Context, id, name string) error { + res, err := s.db.ExecContext(ctx, s.db.Rebind( + `UPDATE org_units SET name = ?, updated_at = ? WHERE id = ?`), + name, time.Now().UTC(), id) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return ErrUnitNotFound + } + return nil +} + +// Reparent moves a unit and rewrites the path of the unit and every +// descendant in one transaction. Doing it in two statements would leave the +// subtree pointing at an ancestor chain that no longer exists. +func (s *Store) Reparent(ctx context.Context, unit *Unit, newParent *Unit) error { + oldPrefix := unit.Path + newPath := strings.TrimSuffix(newParent.Path, "/") + "/" + unit.ID + "/" + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE org_units SET parent_id = ?, path = ?, updated_at = ? WHERE id = ?`), + newParent.ID, newPath, time.Now().UTC(), unit.ID); err != nil { + return err + } + // Every descendant keeps its own tail and gets the new head. + if _, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE org_units + SET path = ? || SUBSTR(path, ?), + updated_at = ? + WHERE path LIKE ? AND id <> ?`), + newPath, len(oldPrefix)+1, time.Now().UTC(), oldPrefix+"%", unit.ID); err != nil { + return err + } + return tx.Commit() +} + +// Delete removes a unit. The caller has already established that it is empty. +func (s *Store) Delete(ctx context.Context, id string) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM unit_members WHERE unit_id = ?`), id); err != nil { + return err + } + res, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM org_units WHERE id = ?`), id) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return ErrUnitNotFound + } + return tx.Commit() +} + +// ChildCount reports how many child units a unit still holds. +func (s *Store) ChildCount(ctx context.Context, id string) (int, error) { + var count int + err := s.ro.QueryRowxContext(ctx, s.ro.Rebind( + `SELECT COUNT(*) FROM org_units WHERE parent_id = ?`), id).Scan(&count) + return count, err +} + +// SetMember adds or re-roles a member. +func (s *Store) SetMember(ctx context.Context, m *Member) error { + m.CreatedAt = time.Now().UTC() + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + INSERT INTO unit_members (unit_id, user_id, role, added_by, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (unit_id, user_id) DO UPDATE SET role = EXCLUDED.role`), + m.UnitID, m.UserID, m.Role, m.AddedBy, m.CreatedAt) + return err +} + +// RemoveMember drops a membership row. +func (s *Store) RemoveMember(ctx context.Context, unitID, userID string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind( + `DELETE FROM unit_members WHERE unit_id = ? AND user_id = ?`), unitID, userID) + return err +} + +// ListMembers returns a unit's own members, without the inherited ones. +func (s *Store) ListMembers(ctx context.Context, unitID string) ([]*Member, error) { + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(` + SELECT unit_id, user_id, role, added_by, created_at + FROM unit_members WHERE unit_id = ? ORDER BY created_at`), unitID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []*Member + for rows.Next() { + m := &Member{} + if err := rows.Scan(&m.UnitID, &m.UserID, &m.Role, &m.AddedBy, &m.CreatedAt); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// AncestorRoles returns the roles a user holds on the given unit and on every +// ancestor of it. This is the query the reach resolver runs, so it takes the +// path rather than walking the tree row by row. +func (s *Store) AncestorRoles(ctx context.Context, userID, path string) ([]string, error) { + ids := AncestorIDs(path) + if len(ids) == 0 || userID == "" { + return nil, nil + } + query, args, err := sqlx.In( + `SELECT role FROM unit_members WHERE user_id = ? AND unit_id IN (?)`, userID, ids) + if err != nil { + return nil, err + } + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(query), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []string + for rows.Next() { + var role string + if err := rows.Scan(&role); err != nil { + return nil, err + } + out = append(out, role) + } + return out, rows.Err() +} + +// AncestorIDs splits a materialized path into its unit ids, nearest last. +func AncestorIDs(path string) []string { + parts := strings.Split(strings.Trim(path, "/"), "/") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + out = append(out, p) + } + } + return out +} + +// UserRoles returns every unit role a user holds, keyed by unit id. It is one +// query, so a caller resolving many workspaces does not issue one per row. +func (s *Store) UserRoles(ctx context.Context, userID string) (map[string]string, error) { + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind( + `SELECT unit_id, role FROM unit_members WHERE user_id = ?`), userID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + out := map[string]string{} + for rows.Next() { + var unitID, role string + if err := rows.Scan(&unitID, &role); err != nil { + return nil, err + } + out[unitID] = role + } + return out, rows.Err() +} + +// PathsByID returns the materialized path of each requested unit. +func (s *Store) PathsByID(ctx context.Context, unitIDs []string) (map[string]string, error) { + if len(unitIDs) == 0 { + return map[string]string{}, nil + } + query, args, err := sqlx.In(`SELECT id, path FROM org_units WHERE id IN (?)`, unitIDs) + if err != nil { + return nil, err + } + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(query), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + out := map[string]string{} + for rows.Next() { + var id, path string + if err := rows.Scan(&id, &path); err != nil { + return nil, err + } + out[id] = path + } + return out, rows.Err() +} + +// AncestorMemberIDs returns every user holding a membership on a unit or any +// of its ancestors. It answers "who reaches a workspace here", which is what +// decides who a workspace-scoped event fans out to. +func (s *Store) AncestorMemberIDs(ctx context.Context, path string) ([]string, error) { + ids := AncestorIDs(path) + if len(ids) == 0 { + return nil, nil + } + query, args, err := sqlx.In( + `SELECT DISTINCT user_id FROM unit_members WHERE unit_id IN (?)`, ids) + if err != nil { + return nil, err + } + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(query), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// DeleteByOrg removes every unit in an organization and their memberships. It +// is the deletion path for a whole tenant: without it, deleting an +// organization leaves its tree behind with nothing pointing at it. +func (s *Store) DeleteByOrg(ctx context.Context, orgID string) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, tx.Rebind( + `DELETE FROM unit_members WHERE unit_id IN (SELECT id FROM org_units WHERE org_id = ?)`), + orgID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, tx.Rebind( + `DELETE FROM org_units WHERE org_id = ?`), orgID); err != nil { + return err + } + return tx.Commit() +} diff --git a/apps/backend/internal/orgunit/store_test.go b/apps/backend/internal/orgunit/store_test.go new file mode 100644 index 0000000000..32589cafd3 --- /dev/null +++ b/apps/backend/internal/orgunit/store_test.go @@ -0,0 +1,214 @@ +package orgunit + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + conn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "units.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(conn, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) + store, err := NewStore(db.NewPool(sqlxDB, sqlxDB)) + if err != nil { + t.Fatalf("new store: %v", err) + } + return store +} + +func mustInsert(t *testing.T, s *Store, unit *Unit) *Unit { + t.Helper() + out, err := s.Insert(context.Background(), unit) + if err != nil { + t.Fatalf("insert %s: %v", unit.Name, err) + } + return out +} + +// A path is the whole ancestry, which is what lets reach be one query rather +// than a walk. Getting the separators wrong makes a prefix match either miss a +// descendant or catch a sibling whose id merely starts with the same bytes. +func TestInsertBuildsMaterializedPath(t *testing.T) { + s := newTestStore(t) + root := mustInsert(t, s, &Unit{OrgID: "org-1", Kind: KindRoot, Name: "Acme"}) + dept := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: root.ID, Name: "Platform"}) + team := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: dept.ID, Name: "Runtime"}) + + if root.Path != "/"+root.ID+"/" { + t.Fatalf("root path = %q", root.Path) + } + if want := "/" + root.ID + "/" + dept.ID + "/"; dept.Path != want { + t.Fatalf("dept path = %q, want %q", dept.Path, want) + } + if want := "/" + root.ID + "/" + dept.ID + "/" + team.ID + "/"; team.Path != want { + t.Fatalf("team path = %q, want %q", team.Path, want) + } + ids := AncestorIDs(team.Path) + if len(ids) != 3 || ids[0] != root.ID || ids[2] != team.ID { + t.Fatalf("ancestors = %v", ids) + } +} + +// Reparenting has to carry the subtree. A move that rewrites only the moved +// row leaves its descendants claiming an ancestry they no longer have, and +// every reach answer below that point silently becomes wrong. +func TestReparentRewritesDescendantPaths(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + root := mustInsert(t, s, &Unit{OrgID: "org-1", Kind: KindRoot, Name: "Acme"}) + oldDept := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: root.ID, Name: "Platform"}) + newDept := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: root.ID, Name: "Product"}) + team := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: oldDept.ID, Name: "Runtime"}) + squad := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: team.ID, Name: "Scheduler"}) + + if err := s.Reparent(ctx, team, newDept); err != nil { + t.Fatalf("reparent: %v", err) + } + + movedTeam, err := s.Get(ctx, team.ID) + if err != nil { + t.Fatalf("get team: %v", err) + } + if want := newDept.Path + team.ID + "/"; movedTeam.Path != want { + t.Fatalf("team path = %q, want %q", movedTeam.Path, want) + } + if movedTeam.ParentID != newDept.ID { + t.Fatalf("team parent = %q, want %q", movedTeam.ParentID, newDept.ID) + } + + movedSquad, err := s.Get(ctx, squad.ID) + if err != nil { + t.Fatalf("get squad: %v", err) + } + if want := movedTeam.Path + squad.ID + "/"; movedSquad.Path != want { + t.Fatalf("descendant path = %q, want %q", movedSquad.Path, want) + } + if got := AncestorIDs(movedSquad.Path); len(got) != 4 || got[1] != newDept.ID { + t.Fatalf("descendant ancestry = %v, want it to run through the new parent", got) + } +} + +// The reach query asks one question: which of these ancestors does this user +// hold a role on. A membership on a sibling branch must not answer it. +func TestAncestorRolesIgnoresOtherBranches(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + root := mustInsert(t, s, &Unit{OrgID: "org-1", Kind: KindRoot, Name: "Acme"}) + platform := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: root.ID, Name: "Platform"}) + product := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: root.ID, Name: "Product"}) + runtime := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: platform.ID, Name: "Runtime"}) + + if err := s.SetMember(ctx, &Member{UnitID: platform.ID, UserID: "ada", Role: "collaborator"}); err != nil { + t.Fatalf("set member: %v", err) + } + if err := s.SetMember(ctx, &Member{UnitID: product.ID, UserID: "grace", Role: "owner"}); err != nil { + t.Fatalf("set member: %v", err) + } + + roles, err := s.AncestorRoles(ctx, "ada", runtime.Path) + if err != nil { + t.Fatalf("ancestor roles: %v", err) + } + if len(roles) != 1 || roles[0] != "collaborator" { + t.Fatalf("ada on runtime = %v, want one collaborator inherited from the department", roles) + } + + roles, err = s.AncestorRoles(ctx, "grace", runtime.Path) + if err != nil { + t.Fatalf("ancestor roles: %v", err) + } + if len(roles) != 0 { + t.Fatalf("grace on runtime = %v, want none: her membership is on another branch", roles) + } +} + +// Re-roling is an update, not a second row, or a demotion would leave the old +// role in place and the maximum rule would keep returning it. +func TestSetMemberReRoles(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + root := mustInsert(t, s, &Unit{OrgID: "org-1", Kind: KindRoot, Name: "Acme"}) + + for _, role := range []string{"viewer", "owner"} { + if err := s.SetMember(ctx, &Member{UnitID: root.ID, UserID: "ada", Role: role}); err != nil { + t.Fatalf("set member %s: %v", role, err) + } + } + members, err := s.ListMembers(ctx, root.ID) + if err != nil { + t.Fatalf("list members: %v", err) + } + if len(members) != 1 || members[0].Role != "owner" { + t.Fatalf("members = %+v, want a single owner row", members) + } +} + +// One root per organization and one personal unit per user are database +// constraints, because a second of either would give a user two ancestries and +// make reach depend on which one a query happened to find. +func TestUniqueRootAndPersonalUnits(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + mustInsert(t, s, &Unit{OrgID: "org-1", Kind: KindRoot, Name: "Acme"}) + + if _, err := s.Insert(ctx, &Unit{OrgID: "org-1", Kind: KindRoot, Name: "Acme again"}); err == nil { + t.Fatal("a second root unit was accepted") + } + + root, err := s.Root(ctx, "org-1") + if err != nil { + t.Fatalf("root: %v", err) + } + mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: root.ID, Kind: KindPersonal, OwnerUserID: "ada", Name: "Ada"}) + if _, err := s.Insert(ctx, &Unit{ + OrgID: "org-1", ParentID: root.ID, Kind: KindPersonal, OwnerUserID: "ada", Name: "Ada again", + }); err == nil { + t.Fatal("a second personal unit was accepted for one user") + } +} + +// Deleting an organization has to take its tree with it. Units are the one +// thing no other owner removes: workspaces and accounts are deleted by their +// own paths, so an orphaned tree would accumulate silently. +func TestDeleteByOrgRemovesUnitsAndMemberships(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + doomed := mustInsert(t, s, &Unit{OrgID: "org-doomed", Kind: KindRoot, Name: "Initech"}) + child := mustInsert(t, s, &Unit{OrgID: "org-doomed", ParentID: doomed.ID, Name: "Sales"}) + keeper := mustInsert(t, s, &Unit{OrgID: "org-keep", Kind: KindRoot, Name: "Acme"}) + for _, unit := range []*Unit{doomed, child, keeper} { + if err := s.SetMember(ctx, &Member{UnitID: unit.ID, UserID: "ada", Role: "owner"}); err != nil { + t.Fatalf("seed member: %v", err) + } + } + + if err := s.DeleteByOrg(ctx, "org-doomed"); err != nil { + t.Fatalf("delete by org: %v", err) + } + + if units, err := s.ListByOrg(ctx, "org-doomed"); err != nil || len(units) != 0 { + t.Fatalf("units after delete = %v (err %v), want none", units, err) + } + for _, id := range []string{doomed.ID, child.ID} { + if members, err := s.ListMembers(ctx, id); err != nil || len(members) != 0 { + t.Fatalf("memberships survived for %s: %v (err %v)", id, members, err) + } + } + // The other organization is untouched. + if units, err := s.ListByOrg(ctx, "org-keep"); err != nil || len(units) != 1 { + t.Fatalf("other org units = %v (err %v), want its root intact", units, err) + } + if members, err := s.ListMembers(ctx, keeper.ID); err != nil || len(members) != 1 { + t.Fatalf("other org membership = %v (err %v), want it intact", members, err) + } +} diff --git a/apps/backend/internal/plugins/handlers.go b/apps/backend/internal/plugins/handlers.go index 4ecff3c581..5c5043fc7b 100644 --- a/apps/backend/internal/plugins/handlers.go +++ b/apps/backend/internal/plugins/handlers.go @@ -76,6 +76,11 @@ func RegisterRoutes(router *gin.Engine, svc *Service, _ Deliverer, log *logger.L ctrl := &Controller{svc: svc, log: log, actionInvoker: svc, webhookInvoker: svc} api := router.Group("/api/plugins") + // Instance admin, not an org scope: plugins load into the shared host + // runtime, so an install is not confined to the installer's org. Every + // other mutating route here (uninstall, enable, disable, config) is + // already RequireAdmin; gating install any lower would let an org admin + // add a plugin they cannot remove. api.POST("/install", authn.RequireAdmin(), ctrl.install) api.POST("/sync", authn.RequireAdmin(), ctrl.sync) // Register the static /marketplace and /settings routes before the /:id diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index d5e1ddadba..d18aacf32a 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -54,6 +54,15 @@ features: dev: "false" e2e: "false" + # Organizations: a tenant boundary above users. Requires KANDEV_FEATURES_AUTH; + # the backend refuses to start with this on and auth off. Off everywhere until + # the runtime-isolation work (per-org filesystem roots, per-org agent + # credentials, tenant-pinned executors) lands. + KANDEV_FEATURES_MULTI_TENANCY: + prod: "false" + dev: "false" + e2e: "false" + # Dynamic agent profiles and provider-error routing. Off in every shipped # profile until the complete guarded implementation is ready. KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING: diff --git a/apps/backend/internal/runtimeflags/handlers.go b/apps/backend/internal/runtimeflags/handlers.go index bdea1279b7..0cfef37676 100644 --- a/apps/backend/internal/runtimeflags/handlers.go +++ b/apps/backend/internal/runtimeflags/handlers.go @@ -7,7 +7,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" ) type Handler struct { @@ -20,7 +20,7 @@ func RegisterRoutes(router gin.IRouter, svc *Service) { api.GET("", h.list) // Flag overrides are install-wide; only admins may change them. With // auth disabled the synthetic identity is an admin (no behavior change). - api.PATCH("/:key", authn.RequireAdmin(), h.patch) + api.PATCH("/:key", authz.RequireOrgScope(authz.ScopeOrgSettingsManage), h.patch) } func (h *Handler) list(c *gin.Context) { diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go index 267f6f4d62..fbb2d8bec6 100644 --- a/apps/backend/internal/runtimeflags/registry.go +++ b/apps/backend/internal/runtimeflags/registry.go @@ -67,6 +67,26 @@ var registrations = []runtimeFlagRegistration{ read: func(cfg *config.Config) bool { return cfg.Features.Auth }, apply: func(cfg *config.Config, value bool) { cfg.Features.Auth = value }, }, + { + definition: RuntimeFlagDefinition{ + Key: "features.multiTenancy", + EnvVar: "KANDEV_FEATURES_MULTI_TENANCY", + Kind: KindFeature, + Label: "Organizations", + Description: "Adds organizations above users: every account belongs to exactly one org, and orgs cannot see each other's workspaces, tasks, or secrets. Requires Authentication & users.", + Stability: StabilityExperimental, + RiskLevel: RiskHigh, + RiskDescription: "Turning this ON puts every existing user, workspace and secret into a single default " + + "organization after restart, and adds an instance operator tier that manages organizations. " + + "It requires Authentication & users: with authentication off the instance refuses to start. " + + "Filesystem paths and agent CLI credentials are still shared across organizations, so this is " + + "an application-layer boundary, not a sandbox.", + RestartRequired: true, + Mutable: true, + }, + read: func(cfg *config.Config) bool { return cfg.Features.MultiTenancy }, + apply: func(cfg *config.Config, value bool) { cfg.Features.MultiTenancy = value }, + }, { definition: RuntimeFlagDefinition{ Key: "features.dynamicAgentRouting", diff --git a/apps/backend/internal/secrets/service.go b/apps/backend/internal/secrets/service.go index 3f05de23d3..9559292de5 100644 --- a/apps/backend/internal/secrets/service.go +++ b/apps/backend/internal/secrets/service.go @@ -24,7 +24,7 @@ func NewService(store SecretStore, log *logger.Logger) *Service { } } -// SetWorkspaceAuthorizer wires the workspace visibility check used by the +// SetWorkspaceAuthorizer wires the workspace reach check used by the // workspace-scoped API. The callback is intentionally a function so the // secrets package does not depend on the task service package. func (s *Service) SetWorkspaceAuthorizer(authorizer func(context.Context, string) error) { diff --git a/apps/backend/internal/system/system.go b/apps/backend/internal/system/system.go index 2c261ffd86..24b1188a3c 100644 --- a/apps/backend/internal/system/system.go +++ b/apps/backend/internal/system/system.go @@ -17,7 +17,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" @@ -184,7 +184,7 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) { // Destructive install-wide mutations require the admin role. With // authentication disabled the synthetic single-user identity is an admin, // so behavior is unchanged; with it enabled, members are read-only here. - admin := g.Group("", authn.RequireAdmin()) + admin := g.Group("", authz.RequireOrgScope(authz.ScopeOrgSettingsManage)) g.GET("/info", info.Handler(s.Info)) if s.Storage != nil { diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index fb3bade4f8..1ddae9c833 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -3,6 +3,7 @@ package dto import ( "time" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/internal/task/statussummary" @@ -34,6 +35,7 @@ type WorkspaceDTO struct { Name string `json:"name"` Description *string `json:"description,omitempty"` OwnerID string `json:"owner_id"` + UnitID string `json:"unit_id,omitempty"` DefaultExecutorID *string `json:"default_executor_id,omitempty"` DefaultEnvironmentID *string `json:"default_environment_id,omitempty"` DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` @@ -43,6 +45,13 @@ type WorkspaceDTO struct { OfficeWorkflowID string `json:"office_workflow_id,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + + // ViewerRole and Scopes describe what the *requesting* user may do here. + // The frontend gates every control on Scopes rather than comparing the + // current user ID to OwnerID, so permission logic lives in one place. + ViewerRole string `json:"viewer_role,omitempty"` + Scopes []string `json:"scopes,omitempty"` + MemberCount int `json:"member_count,omitempty"` } type RepositoryDTO struct { @@ -244,10 +253,12 @@ type TaskDTO struct { // Office extensions AssigneeAgentProfileID string `json:"assignee_agent_profile_id,omitempty"` - Origin string `json:"origin,omitempty"` - ProjectID string `json:"project_id,omitempty"` - Labels string `json:"labels,omitempty"` - Identifier string `json:"identifier,omitempty"` + // AssigneeUserID is the human assignee, independent of the agent one. + AssigneeUserID string `json:"assignee_user_id,omitempty"` + Origin string `json:"origin,omitempty"` + ProjectID string `json:"project_id,omitempty"` + Labels string `json:"labels,omitempty"` + Identifier string `json:"identifier,omitempty"` // ExternalID is a caller-supplied identity used for task create- // idempotency (docs/specs/tasks/requirements/external-id-idempotency.md). Omitted // when the task holds none. @@ -649,6 +660,7 @@ func FromWorkspace(workspace *models.Workspace) WorkspaceDTO { Name: workspace.Name, Description: description, OwnerID: workspace.OwnerID, + UnitID: workspace.UnitID, DefaultExecutorID: workspace.DefaultExecutorID, DefaultEnvironmentID: workspace.DefaultEnvironmentID, DefaultAgentProfileID: workspace.DefaultAgentProfileID, @@ -661,6 +673,16 @@ func FromWorkspace(workspace *models.Workspace) WorkspaceDTO { } } +// FromWorkspaceWithAccess projects a workspace together with the requesting +// user's resolved role and scopes. +func FromWorkspaceWithAccess(workspace *models.Workspace, decision authz.Decision, memberCount int) WorkspaceDTO { + out := FromWorkspace(workspace) + out.ViewerRole = string(decision.Role) + out.Scopes = decision.Scopes.Strings() + out.MemberCount = memberCount + return out +} + func FromRepository(repository *models.Repository) RepositoryDTO { bindings := make([]RepositorySecretBindingDTO, 0, len(repository.SecretBindings)) for _, binding := range repository.SecretBindings { @@ -892,6 +914,7 @@ func FromTaskWithSessionInfo( // projection from workflow_step_participants (ADR 0005 Wave F); // the repo's task SELECTs hydrate it via a correlated subquery. AssigneeAgentProfileID: task.AssigneeAgentProfileID, + AssigneeUserID: task.AssigneeUserID, Origin: task.Origin, ProjectID: task.ProjectID, Labels: task.Labels, diff --git a/apps/backend/internal/task/handlers/cancellation_pending_test.go b/apps/backend/internal/task/handlers/cancellation_pending_test.go index d37f7a23c8..528a838620 100644 --- a/apps/backend/internal/task/handlers/cancellation_pending_test.go +++ b/apps/backend/internal/task/handlers/cancellation_pending_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "github.com/kandev/kandev/internal/task/repository" "net/http" "net/http/httptest" "testing" @@ -37,6 +38,9 @@ type messageOrchestratorWithCancellation struct { } type cancellationListRepo struct { + // Membership is not exercised by this fake; the embedded default + // reports no membership, which is the narrower answer. + repository.UnsupportedWorkspaceMembers mockRepository sessionsByTask []*models.TaskSession pendingActions map[string]models.TaskPendingAction diff --git a/apps/backend/internal/task/handlers/errors.go b/apps/backend/internal/task/handlers/errors.go index 5d33d8edb3..0f5425c427 100644 --- a/apps/backend/internal/task/handlers/errors.go +++ b/apps/backend/internal/task/handlers/errors.go @@ -39,6 +39,18 @@ func handleNotFound(c *gin.Context, log *logger.Logger, err error, fallback stri c.JSON(http.StatusNotFound, gin.H{"error": fallback}) return } + // A scope denial is 403, not 404: workspace.read was already granted, so + // existence is known to the caller and there is nothing left to hide. + if service.IsForbidden(err) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + // A rejected assignee is the caller naming someone who cannot reach the + // workspace, not a server fault. Its message is written to be shown. + if errors.Is(err, service.ErrAssigneeCannotReachWorkspace) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } if errors.Is(err, service.ErrWIPLimitExceeded) { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return diff --git a/apps/backend/internal/task/handlers/executor_handlers_test.go b/apps/backend/internal/task/handlers/executor_handlers_test.go index c92cb491d3..c8df9b3cb2 100644 --- a/apps/backend/internal/task/handlers/executor_handlers_test.go +++ b/apps/backend/internal/task/handlers/executor_handlers_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/kandev/kandev/internal/task/repository" "net/http" "testing" @@ -20,6 +21,9 @@ import ( // executorRepo backs both the executor and executor-profile handler tests. type executorRepo struct { + // Membership is not exercised by this fake; the embedded default + // reports no membership, which is the narrower answer. + repository.UnsupportedWorkspaceMembers mockRepository executors []*models.Executor diff --git a/apps/backend/internal/task/handlers/member_handlers.go b/apps/backend/internal/task/handlers/member_handlers.go new file mode 100644 index 0000000000..2723c82d0c --- /dev/null +++ b/apps/backend/internal/task/handlers/member_handlers.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/service" +) + +// MemberHandlers serves workspace membership and the +// reduced user directory that backs the member picker. +type MemberHandlers struct { + service *service.Service + logger *logger.Logger +} + +func NewMemberHandlers(svc *service.Service, log *logger.Logger) *MemberHandlers { + return &MemberHandlers{ + service: svc, + logger: log.WithFields(zap.String("component", "workspace-member-handlers")), + } +} + +// RegisterMemberRoutes mounts the membership surface. +func RegisterMemberRoutes(router *gin.Engine, svc *service.Service, log *logger.Logger) { + h := NewMemberHandlers(svc, log) + api := router.Group("/api/v1") + api.GET("/workspaces/:id/members", h.listMembers) + api.PUT("/workspaces/:id/members/:userId", h.putMember) + api.DELETE("/workspaces/:id/members/:userId", h.deleteMember) + api.POST("/workspaces/:id/transfer-ownership", h.transferOwnership) + api.GET("/users/directory", h.listDirectory) + api.GET("/authz/scopes", h.listScopes) +} + +type memberDTO struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + AddedBy string `json:"added_by,omitempty"` +} + +func (h *MemberHandlers) listMembers(c *gin.Context) { + ctx := c.Request.Context() + members, err := h.service.ListWorkspaceMembers(ctx, c.Param("id")) + if err != nil { + h.respondError(c, err, "failed to list workspace members") + return + } + names := h.displayNames(c) + out := make([]memberDTO, 0, len(members)) + for _, member := range members { + out = append(out, memberDTO{ + UserID: member.UserID, + DisplayName: names[member.UserID], + Role: member.Role, + AddedBy: member.AddedBy, + }) + } + c.JSON(http.StatusOK, gin.H{"members": out, "total": len(out)}) +} + +type putMemberRequest struct { + Role string `json:"role"` +} + +func (h *MemberHandlers) putMember(c *gin.Context) { + var body putMemberRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + member, err := h.service.UpsertWorkspaceMember(c.Request.Context(), c.Param("id"), c.Param("userId"), body.Role) + if err != nil { + h.respondError(c, err, "failed to add workspace member") + return + } + c.JSON(http.StatusOK, memberDTO{UserID: member.UserID, Role: member.Role, AddedBy: member.AddedBy}) +} + +func (h *MemberHandlers) deleteMember(c *gin.Context) { + if err := h.service.RemoveWorkspaceMember(c.Request.Context(), c.Param("id"), c.Param("userId")); err != nil { + h.respondError(c, err, "failed to remove workspace member") + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +type transferOwnershipRequest struct { + UserID string `json:"user_id"` +} + +func (h *MemberHandlers) transferOwnership(c *gin.Context) { + var body transferOwnershipRequest + if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"}) + return + } + if err := h.service.TransferWorkspaceOwnership(c.Request.Context(), c.Param("id"), body.UserID); err != nil { + h.respondError(c, err, "failed to transfer workspace ownership") + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func (h *MemberHandlers) listDirectory(c *gin.Context) { + users, err := h.service.ListDirectoryUsers(c.Request.Context()) + if err != nil { + h.respondError(c, err, "failed to list users") + return + } + c.JSON(http.StatusOK, gin.H{"users": users, "total": len(users)}) +} + +// listScopes exposes the registry so the UI labels roles from one source +// rather than keeping its own copy of what each role can do. +func (h *MemberHandlers) listScopes(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"scopes": authz.Registry()}) +} + +func (h *MemberHandlers) displayNames(c *gin.Context) map[string]string { + names := make(map[string]string) + users, err := h.service.ListDirectoryUsers(c.Request.Context()) + if err != nil { + return names + } + for _, user := range users { + names[user.ID] = user.DisplayName + } + return names +} + +// respondError maps membership failures to status codes. Validation failures +// carry their message through so the UI can say what went wrong; anything +// unrecognized stays generic. +func (h *MemberHandlers) respondError(c *gin.Context, err error, message string) { + switch { + case service.IsForbidden(err): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + case isNotFound(err): + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + case isMemberValidationError(err): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + h.logger.Error(message, zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": message}) + } +} + +func isMemberValidationError(err error) bool { + for _, sentinel := range []error{ + service.ErrMemberUserNotFound, + service.ErrMemberUserDisabled, + service.ErrMemberIsOwner, + service.ErrMemberRoleInvalid, + service.ErrMemberSelf, + service.ErrTransferTargetNotMember, + } { + if errors.Is(err, sentinel) { + return true + } + } + return false +} diff --git a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go index e57e822867..a91bfdcd01 100644 --- a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "github.com/kandev/kandev/internal/task/repository" "strings" "sync" "testing" @@ -29,6 +30,9 @@ import ( // coverage) to stay under the package's file-length limit. type messageAddSwitchRepo struct { + // Membership is not exercised by this fake; the embedded default + // reports no membership, which is the narrower answer. + repository.UnsupportedWorkspaceMembers mockRepository tasks map[string]*models.Task sessions map[string]*models.TaskSession diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 35105ce349..d03f2d04d8 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/kandev/kandev/internal/task/repository" "net/http" "net/http/httptest" "net/url" @@ -28,6 +29,9 @@ import ( ) type mockRepository struct { + // Membership is not exercised by this fake; the embedded default + // reports no membership, which is the narrower answer. + repository.UnsupportedWorkspaceMembers scriptsByRepo map[string][]*models.RepositoryScript sessions map[string]*models.TaskSession executors map[string]*models.Executor diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 4310b61ebc..4528d9a75d 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -1567,6 +1567,8 @@ type httpUpdateTaskRequest struct { Metadata map[string]interface{} `json:"metadata,omitempty"` // ParentID nests the task under another task. "" clears the parent. ParentID *string `json:"parent_id,omitempty"` + // AssigneeUserID sets the human assignee. "" unassigns. + AssigneeUserID *string `json:"assignee_user_id,omitempty"` } type httpUpdateTaskPortForwardingRequest struct { @@ -1641,14 +1643,15 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { } task, err := h.service.UpdateTask(c.Request.Context(), c.Param("id"), &service.UpdateTaskRequest{ - Title: title, - Description: description, - Priority: body.Priority, - State: body.State, - Repositories: convertUpdateRepositories(body.Repositories != nil, repos), - Position: body.Position, - Metadata: body.Metadata, - ParentID: body.ParentID, + Title: title, + Description: description, + Priority: body.Priority, + State: body.State, + Repositories: convertUpdateRepositories(body.Repositories != nil, repos), + Position: body.Position, + Metadata: body.Metadata, + ParentID: body.ParentID, + AssigneeUserID: body.AssigneeUserID, }) if err != nil { handleNotFound(c, h.logger, err, "task not updated") diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index 6d1caa35f3..826d71c6a2 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -293,6 +293,8 @@ type wsUpdateTaskRequest struct { Metadata map[string]interface{} `json:"metadata,omitempty"` // ParentID nests the task under another task. "" clears the parent. ParentID *string `json:"parent_id,omitempty"` + // AssigneeUserID sets the human assignee. "" unassigns. + AssigneeUserID *string `json:"assignee_user_id,omitempty"` } func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -342,14 +344,15 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M } task, err := h.service.UpdateTask(ctx, req.ID, &service.UpdateTaskRequest{ - Title: title, - Description: description, - Priority: req.Priority, - State: req.State, - Repositories: convertUpdateRepositories(req.Repositories != nil, repos), - Position: req.Position, - Metadata: req.Metadata, - ParentID: req.ParentID, + Title: title, + Description: description, + Priority: req.Priority, + State: req.State, + Repositories: convertUpdateRepositories(req.Repositories != nil, repos), + Position: req.Position, + Metadata: req.Metadata, + ParentID: req.ParentID, + AssigneeUserID: req.AssigneeUserID, }) if err != nil { h.logger.Error("failed to update task", zap.Error(err)) diff --git a/apps/backend/internal/task/handlers/workspace_handlers.go b/apps/backend/internal/task/handlers/workspace_handlers.go index 1ee725cad0..c2429ab684 100644 --- a/apps/backend/internal/task/handlers/workspace_handlers.go +++ b/apps/backend/internal/task/handlers/workspace_handlers.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/service" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -54,9 +55,10 @@ func (h *WorkspaceHandlers) listWorkspaces(ctx context.Context) (dto.ListWorkspa if err != nil { return dto.ListWorkspacesResponse{}, err } + access := h.service.ProjectWorkspaceAccess(ctx, workspaces) dtos := make([]dto.WorkspaceDTO, 0, len(workspaces)) for _, w := range workspaces { - dtos = append(dtos, dto.FromWorkspace(w)) + dtos = append(dtos, dto.FromWorkspaceWithAccess(w, access.Decision(w.ID), access.MemberCounts[w.ID])) } return dto.ListWorkspacesResponse{Workspaces: dtos, Total: len(dtos)}, nil } @@ -116,7 +118,7 @@ func (h *WorkspaceHandlers) httpGetWorkspace(c *gin.Context) { handleNotFound(c, h.logger, err, "workspace not found") return } - c.JSON(http.StatusOK, dto.FromWorkspace(workspace)) + c.JSON(http.StatusOK, h.withAccess(c.Request.Context(), workspace)) } type httpUpdateWorkspaceRequest struct { @@ -126,6 +128,8 @@ type httpUpdateWorkspaceRequest struct { DefaultEnvironmentID *string `json:"default_environment_id,omitempty"` DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` DefaultConfigAgentProfileID *string `json:"default_config_agent_profile_id,omitempty"` + // UnitID moves the workspace to another unit, changing who reaches it. + UnitID *string `json:"unit_id,omitempty"` } func (h *WorkspaceHandlers) httpUpdateWorkspace(c *gin.Context) { @@ -141,6 +145,7 @@ func (h *WorkspaceHandlers) httpUpdateWorkspace(c *gin.Context) { DefaultEnvironmentID: body.DefaultEnvironmentID, DefaultAgentProfileID: body.DefaultAgentProfileID, DefaultConfigAgentProfileID: body.DefaultConfigAgentProfileID, + UnitID: body.UnitID, }) if err != nil { handleNotFound(c, h.logger, err, "workspace not updated") @@ -248,7 +253,7 @@ func (h *WorkspaceHandlers) wsGetWorkspace(ctx context.Context, msg *ws.Message) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Workspace not found", nil) } - return ws.NewResponse(msg.ID, msg.Action, dto.FromWorkspace(workspace)) + return ws.NewResponse(msg.ID, msg.Action, h.withAccess(ctx, workspace)) } type wsUpdateWorkspaceRequest struct { @@ -317,3 +322,10 @@ func (h *WorkspaceHandlers) wsDeleteWorkspaceError(msg *ws.Message, err error) ( return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to delete workspace", nil) } } + +// withAccess projects one workspace together with the caller's resolved role +// and scopes, so a detail response gates the UI exactly like a list response. +func (h *WorkspaceHandlers) withAccess(ctx context.Context, workspace *models.Workspace) dto.WorkspaceDTO { + access := h.service.ProjectWorkspaceAccess(ctx, []*models.Workspace{workspace}) + return dto.FromWorkspaceWithAccess(workspace, access.Decision(workspace.ID), access.MemberCounts[workspace.ID]) +} diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 19b6a97557..1b1197ea18 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -905,10 +905,15 @@ type Task struct { // this field are routed to SetTaskRunner / ClearTaskRunner inside // the task repository. AssigneeAgentProfileID string `json:"assignee_agent_profile_id,omitempty"` - Origin string `json:"origin,omitempty"` // manual, agent_created, routine - ProjectID string `json:"project_id,omitempty"` // FK to office project - Labels string `json:"labels,omitempty"` // JSON array string, default "[]" - Identifier string `json:"identifier,omitempty"` // e.g. "KAN-42" + // AssigneeUserID is the HUMAN assignee, entirely independent of the agent + // assignee above: a task can carry both, and setting one never clears the + // other. It is advisory and gates nothing; taking a task over is a + // reassignment plus a prompt, not a lock. + AssigneeUserID string `json:"assignee_user_id,omitempty"` + Origin string `json:"origin,omitempty"` // manual, agent_created, routine + ProjectID string `json:"project_id,omitempty"` // FK to office project + Labels string `json:"labels,omitempty"` // JSON array string, default "[]" + Identifier string `json:"identifier,omitempty"` // e.g. "KAN-42" // ExternalID is a caller-supplied identity used for create-idempotency // (docs/specs/tasks/requirements/external-id-idempotency.md). Empty when the task @@ -1125,10 +1130,17 @@ func (w *Workspace) IsImproveKandev() bool { // Workspace represents a workspace type Workspace struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - OwnerID string `json:"owner_id"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + OwnerID string `json:"owner_id"` + // OrgID is the owning tenant. Empty means organizations are off or the + // tenancy migration has not run. + OrgID string `json:"org_id,omitempty"` + // UnitID places the workspace in the organization unit tree. Reach is + // resolved from this placement, so a workspace without one is reachable by + // nobody. + UnitID string `json:"unit_id,omitempty"` DefaultExecutorID *string `json:"default_executor_id,omitempty"` DefaultEnvironmentID *string `json:"default_environment_id,omitempty"` DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` diff --git a/apps/backend/internal/task/models/workspace_members.go b/apps/backend/internal/task/models/workspace_members.go new file mode 100644 index 0000000000..1242951fcb --- /dev/null +++ b/apps/backend/internal/task/models/workspace_members.go @@ -0,0 +1,18 @@ +package models + +import "time" + +// WorkspaceMember grants one user access to one workspace with a workspace +// role. It is the exception mechanism next to Workspace.Visibility: it +// populates a private workspace, admits a guest to a single workspace, and +// narrows a member to viewer on an org-visible one. An explicit row always +// outranks the org default, in both directions. +// +// Lives in its own file because models.go is already past the 800-line limit. +type WorkspaceMember struct { + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + UserID string `json:"user_id" db:"user_id"` + Role string `json:"role" db:"role"` + AddedBy string `json:"added_by" db:"added_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 4f5b18a0b0..39b0821f5b 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -32,6 +32,20 @@ type WorkspaceRepository interface { DeleteWorkspaceCascade(ctx context.Context, id string) ([]*models.Task, []*models.Workflow, error) DeleteWorkspaceCascadeWithName(ctx context.Context, id, name string) ([]*models.Task, []*models.Workflow, error) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) + + // Workspace membership. Membership is the exception path next to + // Workspace.Visibility: it populates a private workspace, admits a guest + // to one workspace, and narrows a member to viewer on an org-visible one. + ListWorkspaceMembers(ctx context.Context, workspaceID string) ([]*models.WorkspaceMember, error) + GetWorkspaceMember(ctx context.Context, workspaceID, userID string) (*models.WorkspaceMember, error) + // ListWorkspaceIDsForMember returns workspaceID -> role for one user in a + // single query, so a board render resolves access without an N+1. + ListWorkspaceIDsForMember(ctx context.Context, userID string) (map[string]string, error) + UpsertWorkspaceMember(ctx context.Context, member *models.WorkspaceMember) error + DeleteWorkspaceMember(ctx context.Context, workspaceID, userID string) error + DeleteWorkspaceMembersByWorkspace(ctx context.Context, workspaceID string) error + CountWorkspaceMembers(ctx context.Context) (map[string]int, error) + TransferWorkspaceOwnership(ctx context.Context, workspaceID, fromUserID, toUserID string) error } // TaskRepository handles task CRUD and workflow placement. diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 8585e72e6f..a267947499 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -60,6 +60,9 @@ func (r *Repository) runMigrations() error { if err := r.ensureRepositorySetsSchema(); err != nil { return err } + if err := r.ensureTeamAccessSchema(); err != nil { + return err + } if err := r.ensureRepositoryBranchPoliciesSchema(); err != nil { return err } diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index ce69355877..6145845892 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -285,6 +285,7 @@ const infraSchemaDDL = ` name TEXT NOT NULL, description TEXT DEFAULT '', owner_id TEXT DEFAULT '', + org_id TEXT NOT NULL DEFAULT '', default_executor_id TEXT DEFAULT '', default_environment_id TEXT DEFAULT '', default_agent_profile_id TEXT DEFAULT '', @@ -293,6 +294,24 @@ const infraSchemaDDL = ` updated_at TIMESTAMP NOT NULL ); + CREATE TABLE IF NOT EXISTS workspace_members ( + workspace_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'collaborator', + added_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (workspace_id, user_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + -- No foreign key to users: that table belongs to internal/user/store, + -- which initializes independently of this repository, so a reference + -- here fails schema init whenever the task repository is created + -- first. Account removal clears membership through + -- DeleteWorkspaceMembersByUser instead, matching how this codebase + -- handles every other cross-store side table. + ); + + CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id); + CREATE TABLE IF NOT EXISTS executors ( id TEXT PRIMARY KEY, name TEXT NOT NULL, diff --git a/apps/backend/internal/task/repository/sqlite/org_data.go b/apps/backend/internal/task/repository/sqlite/org_data.go new file mode 100644 index 0000000000..c3d3cee81c --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/org_data.go @@ -0,0 +1,55 @@ +package sqlite + +import ( + "context" +) + +// Tenancy data operations: the task-repository half of organization migration +// and lifecycle counts. + +// AssignWorkspacesWithoutOrg puts every workspace that has no organization +// into the given one. Idempotent: a second run moves nothing. +func (r *Repository) AssignWorkspacesWithoutOrg(ctx context.Context, orgID string) (int64, error) { + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE workspaces SET org_id = ? WHERE org_id = '' OR org_id IS NULL + `), orgID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +// DropCrossOrgWorkspaceMembers removes membership rows whose user and +// workspace belong to different organizations. +// +// Such a row cannot grant anything (the resolver checks the tenant boundary +// before membership), but leaving it would show a colleague from another +// organization in a member list, which reads as a leak even though it is not +// one. Rows whose user or workspace has no org yet are left alone: they are +// mid-migration, not cross-tenant. +func (r *Repository) DropCrossOrgWorkspaceMembers(ctx context.Context) (int64, error) { + result, err := r.db.ExecContext(ctx, ` + DELETE FROM workspace_members + WHERE EXISTS ( + SELECT 1 FROM workspaces w, users u + WHERE w.id = workspace_members.workspace_id + AND u.id = workspace_members.user_id + AND w.org_id <> '' + AND u.org_id <> '' + AND w.org_id <> u.org_id + ) + `) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +// CountWorkspacesByOrg reports how many workspaces an organization owns. Used +// by the delete confirmation so an operator sees what they are removing. +func (r *Repository) CountWorkspacesByOrg(ctx context.Context, orgID string) (int, error) { + var n int + err := r.ro.QueryRowContext(ctx, r.ro.Rebind( + `SELECT COUNT(*) FROM workspaces WHERE org_id = ?`), orgID).Scan(&n) + return n, err +} diff --git a/apps/backend/internal/task/repository/sqlite/org_unit_placement.go b/apps/backend/internal/task/repository/sqlite/org_unit_placement.go new file mode 100644 index 0000000000..018cc7ffcb --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/org_unit_placement.go @@ -0,0 +1,48 @@ +package sqlite + +import "context" + +// CountWorkspacesInUnit reports how many workspaces a unit holds, which is what +// stops internal/orgunit from deleting a unit out from under them. +func (r *Repository) CountWorkspacesInUnit(ctx context.Context, unitID string) (int, error) { + var count int + err := r.ro.QueryRowxContext(ctx, r.ro.Rebind( + `SELECT COUNT(*) FROM workspaces WHERE unit_id = ?`), unitID).Scan(&count) + return count, err +} + +// UnplacedWorkspace is a workspace that predates the unit tree. +type UnplacedWorkspace struct { + ID string + OrgID string + OwnerID string +} + +// UnplacedWorkspaces returns every workspace with no unit, which is the input +// to the one-shot placement backfill. +func (r *Repository) UnplacedWorkspaces(ctx context.Context) ([]UnplacedWorkspace, error) { + rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(` + SELECT id, COALESCE(org_id, ''), COALESCE(owner_id, '') + FROM workspaces + WHERE COALESCE(unit_id, '') = ''`)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []UnplacedWorkspace + for rows.Next() { + var w UnplacedWorkspace + if err := rows.Scan(&w.ID, &w.OrgID, &w.OwnerID); err != nil { + return nil, err + } + out = append(out, w) + } + return out, rows.Err() +} + +// PlaceWorkspace records a workspace's unit. +func (r *Repository) PlaceWorkspace(ctx context.Context, workspaceID, unitID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind( + `UPDATE workspaces SET unit_id = ? WHERE id = ?`), unitID, workspaceID) + return err +} diff --git a/apps/backend/internal/task/repository/sqlite/org_unit_placement_test.go b/apps/backend/internal/task/repository/sqlite/org_unit_placement_test.go new file mode 100644 index 0000000000..d665710fef --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/org_unit_placement_test.go @@ -0,0 +1,106 @@ +package sqlite + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +func newPlacementRepo(t *testing.T) (*Repository, *sqlx.DB) { + t.Helper() + conn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "placement.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(conn, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) + repo, err := NewWithDB(sqlxDB, sqlxDB, nil) + if err != nil { + t.Fatalf("new repository: %v", err) + } + return repo, sqlxDB +} + +// The placement column is what ties a workspace to the unit tree. Losing it +// leaves every workspace unplaced, which resolves as unreachable rather than +// as an error, so the symptom is silence. +func TestWorkspaceUnitColumnExistsAfterInit(t *testing.T) { + _, sqlxDB := newPlacementRepo(t) + + var count int + if err := sqlxDB.Get(&count, ` + SELECT COUNT(*) FROM pragma_table_info('workspaces') WHERE name = 'unit_id' + `); err != nil { + t.Fatalf("inspect workspaces schema: %v", err) + } + if count != 1 { + t.Fatal("workspaces.unit_id missing after schema init") + } +} + +// The occupancy count is the only thing standing between a delete and a +// stranded workspace, so it has to count the unit asked about and nothing else. +func TestCountWorkspacesInUnit(t *testing.T) { + ctx := context.Background() + repo, sqlxDB := newPlacementRepo(t) + + for _, row := range []struct{ id, unit string }{ + {"ws-1", "unit-a"}, + {"ws-2", "unit-a"}, + {"ws-3", "unit-b"}, + {"ws-4", ""}, + } { + if _, err := sqlxDB.Exec( + `INSERT INTO workspaces (id, name, owner_id, unit_id, created_at, updated_at) + VALUES (?, ?, '', ?, datetime('now'), datetime('now'))`, + row.id, row.id, row.unit); err != nil { + t.Fatalf("insert %s: %v", row.id, err) + } + } + + for unit, want := range map[string]int{"unit-a": 2, "unit-b": 1, "unit-c": 0} { + got, err := repo.CountWorkspacesInUnit(ctx, unit) + if err != nil { + t.Fatalf("count %s: %v", unit, err) + } + if got != want { + t.Fatalf("count(%s) = %d, want %d", unit, got, want) + } + } +} + +// A move that returns the new unit but writes nothing looks like it worked and +// changes who reaches the workspace not at all. The update statement is +// hand-written, so a column added to the model is not automatically persisted. +func TestUpdateWorkspacePersistsUnitPlacement(t *testing.T) { + ctx := context.Background() + repo, sqlxDB := newPlacementRepo(t) + + if _, err := sqlxDB.Exec( + `INSERT INTO workspaces (id, name, owner_id, unit_id, created_at, updated_at) + VALUES ('ws-move', 'Board', 'ada', 'unit-personal', datetime('now'), datetime('now'))`, + ); err != nil { + t.Fatalf("seed workspace: %v", err) + } + + workspace, err := repo.GetWorkspace(ctx, "ws-move") + if err != nil { + t.Fatalf("get workspace: %v", err) + } + workspace.UnitID = "unit-team" + if err := repo.UpdateWorkspace(ctx, workspace); err != nil { + t.Fatalf("update workspace: %v", err) + } + + reloaded, err := repo.GetWorkspace(ctx, "ws-move") + if err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.UnitID != "unit-team" { + t.Fatalf("unit after move = %q, want unit-team: the move did not persist", reloaded.UnitID) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index cad5c1027d..a9cbae587e 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -71,6 +71,9 @@ var taskScanColumns = []taskScanColumn{ {name: "assignee_agent_profile_id", selectExpr: func(alias string) string { return runnerProjection(alias) + ` AS assignee_agent_profile_id` }}, + // The HUMAN assignee, independent of the agent one above. A task can have + // both: an Office agent doing the work and a person accountable for it. + {name: "assignee_user_id"}, {name: "origin"}, {name: "project_id"}, {name: "labels"}, @@ -356,9 +359,9 @@ func (r *Repository) insertTaskTx(ctx context.Context, tx *sql.Tx, task *models. externalID = task.ExternalID } _, err = tx.ExecContext(ctx, r.db.Rebind(` - INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, wip_admitted, queued_for_step_id, queued_at, metadata, is_ephemeral, parent_id, autopilot_enabled, created_at, updated_at, origin, project_id, labels, identifier, external_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), dialect.BoolToInt(task.IsEphemeral), task.ParentID, dialect.BoolToInt(task.Autopilot), task.CreatedAt, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, externalID) + INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, wip_admitted, queued_for_step_id, queued_at, metadata, is_ephemeral, parent_id, autopilot_enabled, created_at, updated_at, origin, project_id, labels, identifier, external_id, assignee_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), dialect.BoolToInt(task.IsEphemeral), task.ParentID, dialect.BoolToInt(task.Autopilot), task.CreatedAt, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, externalID, task.AssigneeUserID) if err != nil { if isExternalIDUniqueViolation(err) { return "", fmt.Errorf("%w: %w", ErrExternalIDConflict, err) @@ -632,7 +635,7 @@ func (r *Repository) updateTaskTx(ctx context.Context, tx *sql.Tx, task *models. task.UpdatedAt = r.nowUTC() updateQuery := ` - UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = ?, parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ? + UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = ?, parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ?, assignee_user_id = ? WHERE id = ? ` if models.IsAgentTitlePending(task.Metadata) { @@ -644,11 +647,11 @@ func (r *Repository) updateTaskTx(ctx context.Context, tx *sql.Tx, task *models. description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = %s, - parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ? + parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ?, assignee_user_id = ? WHERE id = ? `, pending, metadataMerge) } - result, err := tx.ExecContext(ctx, r.db.Rebind(updateQuery), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.ID) + result, err := tx.ExecContext(ctx, r.db.Rebind(updateQuery), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.AssigneeUserID, task.ID) if err != nil { return "", err } @@ -2399,7 +2402,7 @@ func (r *Repository) scanSingleTask(row *sql.Row) (*models.Task, error) { &task.WIPAdmitted, &task.QueuedForStepID, &queuedAt, &metadata, &task.IsEphemeral, &task.ParentID, &task.Autopilot, &archivedAt, &task.ArchivedByCascadeID, &task.CreatedAt, &task.UpdatedAt, - &task.AssigneeAgentProfileID, &task.Origin, &task.ProjectID, + &task.AssigneeAgentProfileID, &task.AssigneeUserID, &task.Origin, &task.ProjectID, &task.Labels, &identifier, &externalID, &externalIDSettledAt, &task.IsFromOffice, ) if err != nil { @@ -2441,7 +2444,7 @@ func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { &task.WIPAdmitted, &task.QueuedForStepID, &queuedAt, &metadata, &task.IsEphemeral, &task.ParentID, &task.Autopilot, &archivedAt, &task.ArchivedByCascadeID, &task.CreatedAt, &task.UpdatedAt, - &task.AssigneeAgentProfileID, &task.Origin, &task.ProjectID, + &task.AssigneeAgentProfileID, &task.AssigneeUserID, &task.Origin, &task.ProjectID, &task.Labels, &identifier, &externalID, &externalIDSettledAt, &task.IsFromOffice, ) if err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/team_access_migration.go b/apps/backend/internal/task/repository/sqlite/team_access_migration.go new file mode 100644 index 0000000000..946466c45d --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/team_access_migration.go @@ -0,0 +1,83 @@ +package sqlite + +// Team-access schema evolution: workspace org and unit placement, workspace +// membership, and the human task assignee. +// +// The governing rule for this migration is that an upgrade must never widen +// access. Every pre-existing workspace lands on 'private', which reproduces +// today's owner-only behavior exactly; opening a board to the organization is +// an explicit act by its owner afterwards. +func (r *Repository) ensureTeamAccessSchema() error { + // New columns on existing tables arrive only through idempotent ADD COLUMN + // (ADR 0027): CREATE TABLE IF NOT EXISTS is a no-op on an existing DB, so + // listing them in the DDL alone would never reach an upgraded install. + // Organizations. Empty means "not yet assigned"; the tenancy migration puts + // every existing workspace into the default org on first boot with the + // feature on. + r.migrate.Apply( + "workspaces.org_id", + `ALTER TABLE workspaces ADD COLUMN org_id TEXT NOT NULL DEFAULT ''`, + ) + r.migrate.Apply( + "workspaces.org_idx", + `CREATE INDEX IF NOT EXISTS idx_workspaces_org ON workspaces(org_id)`, + ) + // The human assignee (docs/specs/tasks/requirements/human-assignee.md). Note that + // internal/office's priority-to-TEXT migration recreates the tasks table + // from a hardcoded column list on every database this repository creates, + // so this column is ALSO listed there; adding it here alone leaves it + // dropped by the time any task is written. + r.migrate.Apply( + "tasks.assignee_user_id", + `ALTER TABLE tasks ADD COLUMN assignee_user_id TEXT NOT NULL DEFAULT ''`, + ) + + r.ensureOrgUnitPlacement() + + // workspace_members is created by the infra DDL on both fresh and existing + // databases, so only the owner backfill needs to run here. + return r.backfillWorkspaceOwnerMembers() +} + +// backfillWorkspaceOwnerMembers writes the mirroring owner row for every +// workspace that has an owner. Workspaces created before authentication was +// enabled carry an empty owner_id and deliberately get no row: they stay +// visible to everyone until the setup wizard claims them, and inventing an +// owner here would lock the single user out of their own data. +func (r *Repository) backfillWorkspaceOwnerMembers() error { + _, err := r.db.Exec(` + INSERT INTO workspace_members (workspace_id, user_id, role, added_by, created_at) + SELECT w.id, w.owner_id, 'owner', '', w.created_at + FROM workspaces w + WHERE w.owner_id IS NOT NULL + AND w.owner_id <> '' + AND NOT EXISTS ( + SELECT 1 FROM workspace_members m + WHERE m.workspace_id = w.id AND m.user_id = w.owner_id + ) + `) + return err +} + +// ensureOrgUnitPlacement adds the column that places a workspace in the +// organization unit tree. +// +// It lives here rather than in internal/orgunit because the task repository +// owns the workspaces table. +// +// No migration currently recreates `workspaces`. If one is ever added, this +// column has to be in its replacement CREATE TABLE and its INSERT ... SELECT +// list: a rebuild copies a fixed column list, and MigrateLogger.Apply swallows +// errors, so a column added before a rebuild disappears without a sound. That +// is recorded in apps/backend/AGENTS.md and has already cost this branch a +// silent loss once, on `tasks`. +func (r *Repository) ensureOrgUnitPlacement() { + r.migrate.Apply( + "workspaces.unit_id", + `ALTER TABLE workspaces ADD COLUMN unit_id TEXT NOT NULL DEFAULT ''`, + ) + r.migrate.Apply( + "workspaces.unit_idx", + `CREATE INDEX IF NOT EXISTS idx_workspaces_unit ON workspaces(unit_id)`, + ) +} diff --git a/apps/backend/internal/task/repository/sqlite/workspace.go b/apps/backend/internal/task/repository/sqlite/workspace.go index a7b25ece98..538732d2e9 100644 --- a/apps/backend/internal/task/repository/sqlite/workspace.go +++ b/apps/backend/internal/task/repository/sqlite/workspace.go @@ -40,6 +40,8 @@ func (r *Repository) insertWorkspace(ctx context.Context, exec sqlx.ExtContext, name, description, owner_id, + org_id, + unit_id, default_executor_id, default_environment_id, default_agent_profile_id, @@ -50,8 +52,8 @@ func (r *Repository) insertWorkspace(ctx context.Context, exec sqlx.ExtContext, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `), workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.DefaultConfigAgentProfileID, workspace.TaskPrefix, workspace.TaskSequence, workspace.OfficeWorkflowID, workspace.CreatedAt, workspace.UpdatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.OrgID, workspace.UnitID, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.DefaultConfigAgentProfileID, workspace.TaskPrefix, workspace.TaskSequence, workspace.OfficeWorkflowID, workspace.CreatedAt, workspace.UpdatedAt) return err } @@ -65,13 +67,15 @@ func (r *Repository) GetWorkspace(ctx context.Context, id string) (*models.Works var defaultConfigAgentProfileID sql.NullString err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, name, description, owner_id, default_executor_id, default_environment_id, default_agent_profile_id, default_config_agent_profile_id, task_prefix, task_sequence, office_workflow_id, created_at, updated_at + SELECT id, name, description, owner_id, org_id, unit_id, default_executor_id, default_environment_id, default_agent_profile_id, default_config_agent_profile_id, task_prefix, task_sequence, office_workflow_id, created_at, updated_at FROM workspaces WHERE id = ? `), id).Scan( &workspace.ID, &workspace.Name, &workspace.Description, &workspace.OwnerID, + &workspace.OrgID, + &workspace.UnitID, &defaultExecutorID, &defaultEnvironmentID, &defaultAgentProfileID, @@ -109,13 +113,14 @@ func (r *Repository) UpdateWorkspace(ctx context.Context, workspace *models.Work UPDATE workspaces SET name = ?, description = ?, + unit_id = ?, default_executor_id = ?, default_environment_id = ?, default_agent_profile_id = ?, default_config_agent_profile_id = ?, updated_at = ? WHERE id = ? - `), workspace.Name, workspace.Description, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.DefaultConfigAgentProfileID, workspace.UpdatedAt, workspace.ID) + `), workspace.Name, workspace.Description, workspace.UnitID, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.DefaultConfigAgentProfileID, workspace.UpdatedAt, workspace.ID) if err != nil { return err } @@ -408,7 +413,7 @@ func (r *Repository) ClaimUnownedWorkspaces(ctx context.Context, ownerID string) func (r *Repository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) { rows, err := r.ro.QueryContext(ctx, ` - SELECT id, name, description, owner_id, default_executor_id, default_environment_id, default_agent_profile_id, default_config_agent_profile_id, task_prefix, task_sequence, office_workflow_id, created_at, updated_at + SELECT id, name, description, owner_id, org_id, unit_id, default_executor_id, default_environment_id, default_agent_profile_id, default_config_agent_profile_id, task_prefix, task_sequence, office_workflow_id, created_at, updated_at FROM workspaces ORDER BY created_at DESC `) if err != nil { @@ -428,6 +433,8 @@ func (r *Repository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, e &workspace.Name, &workspace.Description, &workspace.OwnerID, + &workspace.OrgID, + &workspace.UnitID, &defaultExecutorID, &defaultEnvironmentID, &defaultAgentProfileID, diff --git a/apps/backend/internal/task/repository/sqlite/workspace_members.go b/apps/backend/internal/task/repository/sqlite/workspace_members.go new file mode 100644 index 0000000000..5c81b43217 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/workspace_members.go @@ -0,0 +1,216 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/task/models" +) + +// ErrOwnershipChanged reports that the workspace owner changed between +// authorization and the write, so the transfer was not applied. +var ErrOwnershipChanged = errors.New("workspace ownership changed; retry the transfer") + +const workspaceMemberColumns = `workspace_id, user_id, role, added_by, created_at` + +// ListWorkspaceMembers returns every membership row for a workspace, owner +// first and then by creation order, so the UI has a stable list. +func (r *Repository) ListWorkspaceMembers(ctx context.Context, workspaceID string) ([]*models.WorkspaceMember, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT `+workspaceMemberColumns+` + FROM workspace_members + WHERE workspace_id = ? + ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END, created_at, user_id + `), workspaceID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + members := make([]*models.WorkspaceMember, 0) + for rows.Next() { + member := &models.WorkspaceMember{} + if err := rows.Scan(&member.WorkspaceID, &member.UserID, &member.Role, &member.AddedBy, &member.CreatedAt); err != nil { + return nil, err + } + members = append(members, member) + } + return members, rows.Err() +} + +// GetWorkspaceMember returns one membership row, or nil when the user holds +// none. A missing row is not an error: it is the common case. +func (r *Repository) GetWorkspaceMember(ctx context.Context, workspaceID, userID string) (*models.WorkspaceMember, error) { + member := &models.WorkspaceMember{} + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT `+workspaceMemberColumns+` + FROM workspace_members WHERE workspace_id = ? AND user_id = ? + `), workspaceID, userID).Scan( + &member.WorkspaceID, &member.UserID, &member.Role, &member.AddedBy, &member.CreatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return member, nil +} + +// ListWorkspaceIDsForMember returns the workspaces a user holds a row on. It +// backs the per-request member set: one query per request instead of one per +// workspace on a board render. +func (r *Repository) ListWorkspaceIDsForMember(ctx context.Context, userID string) (map[string]string, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT workspace_id, role FROM workspace_members WHERE user_id = ? + `), userID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + roles := make(map[string]string) + for rows.Next() { + var workspaceID, role string + if err := rows.Scan(&workspaceID, &role); err != nil { + return nil, err + } + roles[workspaceID] = role + } + return roles, rows.Err() +} + +// UpsertWorkspaceMember adds a member or changes an existing member's role. +func (r *Repository) UpsertWorkspaceMember(ctx context.Context, member *models.WorkspaceMember) error { + if member.CreatedAt.IsZero() { + member.CreatedAt = time.Now().UTC() + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO workspace_members (`+workspaceMemberColumns+`) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role + `), member.WorkspaceID, member.UserID, member.Role, member.AddedBy, member.CreatedAt) + return err +} + +// DeleteWorkspaceMember removes one membership row. Removing a row that does +// not exist is not an error. +func (r *Repository) DeleteWorkspaceMember(ctx context.Context, workspaceID, userID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ? + `), workspaceID, userID) + return err +} + +// DeleteWorkspaceMembersByWorkspace clears a workspace's membership. The +// foreign key cascades on workspace delete, but workspace-scoped side tables +// are also cleared explicitly from the deleted handler so the behavior does +// not depend on the dialect honoring the cascade. +func (r *Repository) DeleteWorkspaceMembersByWorkspace(ctx context.Context, workspaceID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM workspace_members WHERE workspace_id = ? + `), workspaceID) + return err +} + +// CountWorkspaceMembers returns membership counts for the given workspaces in +// one query, keyed by workspace ID. Used to populate list DTOs without an +// N+1 on the workspace list. +func (r *Repository) CountWorkspaceMembers(ctx context.Context) (map[string]int, error) { + rows, err := r.ro.QueryContext(ctx, ` + SELECT workspace_id, COUNT(*) FROM workspace_members GROUP BY workspace_id + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + counts := make(map[string]int) + for rows.Next() { + var workspaceID string + var count int + if err := rows.Scan(&workspaceID, &count); err != nil { + return nil, err + } + counts[workspaceID] = count + } + return counts, rows.Err() +} + +// TransferWorkspaceOwnership moves the accountable owner in one transaction: +// workspaces.owner_id and the mirroring owner row must never disagree. +func (r *Repository) TransferWorkspaceOwnership(ctx context.Context, workspaceID, fromUserID, toUserID string) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + now := time.Now().UTC() + // Conditional on the owner we were authorized against. Two transfers + // authorized while A owned the workspace can arrive sequentially; without + // this the second would move ownership off B while demoting the stale A, + // leaving B holding an owner row that owner_id no longer names. + result, err := tx.ExecContext(ctx, r.db.Rebind(` + UPDATE workspaces SET owner_id = ?, updated_at = ? WHERE id = ? AND owner_id = ? + `), toUserID, now, workspaceID, fromUserID) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrOwnershipChanged + } + // Both rows are upserted rather than updated. An UPDATE silently affects + // nothing when the row is absent, which would leave owner_id naming a user + // with no owner row -- exactly the disagreement this table exists to + // prevent. Absent rows are reachable through pre-auth data and direct + // repository writes, so the write has to be total. + if err := upsertMemberTx(ctx, tx, r.db.Rebind, workspaceID, toUserID, "owner", now); err != nil { + return err + } + if fromUserID != "" && fromUserID != toUserID { + if err := upsertMemberTx(ctx, tx, r.db.Rebind, workspaceID, fromUserID, "collaborator", now); err != nil { + return err + } + } + return tx.Commit() +} + +func upsertMemberTx( + ctx context.Context, + tx *sqlx.Tx, + rebind func(string) string, + workspaceID, userID, role string, + now time.Time, +) error { + _, err := tx.ExecContext(ctx, rebind(` + INSERT INTO workspace_members (workspace_id, user_id, role, added_by, created_at) + VALUES (?, ?, ?, '', ?) + ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role + `), workspaceID, userID, role, now) + return err +} + +// DeleteWorkspaceMembersByUser removes every membership an account holds. +// +// This is the cross-store cleanup that stands in for a foreign key: the users +// table is owned by internal/user/store and initializes independently of this +// repository, so a real FK here would break schema init depending on which +// store runs first. +func (r *Repository) DeleteWorkspaceMembersByUser(ctx context.Context, userID string) error { + if userID == "" { + return nil + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM workspace_members WHERE user_id = ? + `), userID) + return err +} diff --git a/apps/backend/internal/task/repository/workspace_members_unsupported.go b/apps/backend/internal/task/repository/workspace_members_unsupported.go new file mode 100644 index 0000000000..73326d11b3 --- /dev/null +++ b/apps/backend/internal/task/repository/workspace_members_unsupported.go @@ -0,0 +1,47 @@ +package repository + +import ( + "context" + + "github.com/kandev/kandev/internal/task/models" +) + +// UnsupportedWorkspaceMembers supplies membership no-ops for fakes that only +// exercise other parts of WorkspaceRepository. +// +// Every method reports "no membership", which resolves to owner-and-visibility +// access — the narrower answer. A fake that embeds this can never accidentally +// widen access in a test and make a scoping regression look green. +type UnsupportedWorkspaceMembers struct{} + +func (UnsupportedWorkspaceMembers) ListWorkspaceMembers(context.Context, string) ([]*models.WorkspaceMember, error) { + return nil, nil +} + +func (UnsupportedWorkspaceMembers) GetWorkspaceMember(context.Context, string, string) (*models.WorkspaceMember, error) { + return nil, nil +} + +func (UnsupportedWorkspaceMembers) ListWorkspaceIDsForMember(context.Context, string) (map[string]string, error) { + return map[string]string{}, nil +} + +func (UnsupportedWorkspaceMembers) UpsertWorkspaceMember(context.Context, *models.WorkspaceMember) error { + return nil +} + +func (UnsupportedWorkspaceMembers) DeleteWorkspaceMember(context.Context, string, string) error { + return nil +} + +func (UnsupportedWorkspaceMembers) DeleteWorkspaceMembersByWorkspace(context.Context, string) error { + return nil +} + +func (UnsupportedWorkspaceMembers) CountWorkspaceMembers(context.Context) (map[string]int, error) { + return map[string]int{}, nil +} + +func (UnsupportedWorkspaceMembers) TransferWorkspaceOwnership(context.Context, string, string, string) error { + return nil +} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 26c5178ea1..196c2f02e9 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -319,6 +319,10 @@ type Repos struct { // Service provides task business logic type Service struct { workspaces repository.WorkspaceRepository + userDirectory UserDirectory + unitPlacer UnitPlacer + unitReach UnitReachResolver + userOrgs func(ctx context.Context, userID string) (string, error) tasks repository.TaskRepository taskRepos repository.TaskRepoRepository workspaceFolders repository.TaskWorkspaceFolderRepository diff --git a/apps/backend/internal/task/service/service_access.go b/apps/backend/internal/task/service/service_access.go index f0a22d9380..037b4a4b71 100644 --- a/apps/backend/internal/task/service/service_access.go +++ b/apps/backend/internal/task/service/service_access.go @@ -5,26 +5,52 @@ import ( "errors" "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository/repoerrors" ) -// Per-user workspace scoping (opt-in authentication). +// Workspace access scoping. +// +// Reach and permission are two questions, answered in that order: +// +// - Reach ("can this caller see the workspace at all") comes from the owner, +// an explicit workspace_members row, or membership of a unit above the +// one the workspace sits in. A +// caller who cannot reach a workspace gets the *NotFound sentinels — a +// foreign workspace is indistinguishable from a nonexistent one. +// - Permission ("may they do this particular thing") comes from +// authz.ResolveWorkspace, the single resolver. A caller who can read but +// lacks the action's scope gets ErrForbidden: existence is already known +// to them, so 403 leaks nothing and 404 would only confuse. // -// Every user-facing service entry point calls one of the authorize* helpers. // Scoping keys off the request-context identity placed there by the auth // middleware: // - no identity in ctx → internal caller (event bus, pollers, office // schedulers) → unscoped, exactly as before the auth feature. In-session -// agent MCP calls used to land here too; they now arrive with the task -// owner's identity attached by internal/mcp/scope. +// agent MCP calls arrive with the task owner's identity attached by +// internal/mcp/scope. // - synthetic identity → auth disabled → unscoped (today's behavior) -// - real identity → workspaces are visible only to their owner; -// rows with an empty owner_id (created pre-auth) stay visible to everyone -// until the setup wizard claims them for the admin -// -// Denials use the *NotFound sentinels — a foreign workspace is -// indistinguishable from a nonexistent one (no existence leak). +// - real identity → resolved through authz + +// tenancyEnforced mirrors features.multiTenancy. It is a package-level value +// set once at startup rather than threaded through every call: it is a +// process-wide configuration fact, and plumbing it through would touch every +// authorize* signature for no gain. +var tenancyEnforced bool + +// SetTenancyEnforced records whether organizations are on. +func SetTenancyEnforced(enforced bool) { tenancyEnforced = enforced } + +// callerOrgID returns the requesting identity's organization, empty for +// internal/synthetic callers and when organizations are off. +func callerOrgID(ctx context.Context) string { + identity, ok := authn.IdentityFromContext(ctx) + if !ok || identity.Synthetic { + return "" + } + return identity.OrgID +} // callerScope returns the scoping user ID; ok=false means unscoped. func callerScope(ctx context.Context) (string, bool) { @@ -35,30 +61,128 @@ func callerScope(ctx context.Context) (string, bool) { return identity.UserID, true } -func workspaceVisibleTo(workspace *models.Workspace, userID string) bool { - return workspace == nil || workspace.OwnerID == "" || workspace.OwnerID == userID +// callerSubject builds the authz subject for the request identity. +func callerSubject(ctx context.Context) authz.Subject { + identity, ok := authn.IdentityFromContext(ctx) + if !ok || identity.Synthetic { + return authz.Subject{Unscoped: true} + } + return authz.Subject{ + UserID: identity.UserID, + OrgID: identity.OrgID, + OrgRole: authz.NormalizeOrgRole(string(identity.Role)), + TenancyEnforced: tenancyEnforced, + } } -// authorizeWorkspaceID checks visibility of a workspace by ID. -func (s *Service) authorizeWorkspaceID(ctx context.Context, workspaceID string) error { - userID, scoped := callerScope(ctx) - if !scoped || workspaceID == "" { - return nil +// workspaceDecision resolves what the caller may do in a workspace. It reads +// the caller's membership row (a single primary-key lookup) and hands both to +// the resolver. +// +// Any failure reading membership returns Denied rather than continuing with no +// row: continuing would silently promote the caller to the org default role, +// which is exactly how a transient database error becomes a privilege +// escalation. +func (s *Service) workspaceDecision(ctx context.Context, workspace *models.Workspace) authz.Decision { + subject := callerSubject(ctx) + if subject.Unscoped { + return authz.ResolveWorkspace(subject, authz.WorkspaceRef{}) + } + if workspace == nil { + return authz.Denied() + } + + ref := authz.WorkspaceRef{ + OwnerID: workspace.OwnerID, + OrgID: workspace.OrgID, + } + // The tenant boundary is absolute and is checked before owner or + // membership, so a foreign-org workspace is resolved without touching the + // member table at all. + if subject.OrgID != "" && workspace.OrgID != "" && subject.OrgID != workspace.OrgID { + return authz.Denied() + } + // Owner and unowned workspaces resolve without touching the tree. + if workspace.OwnerID == "" || workspace.OwnerID == subject.UserID { + return authz.ResolveWorkspace(subject, ref) + } + + inherited, ok := s.inheritedRole(ctx, subject.UserID, workspace) + if !ok { + return authz.Denied() + } + ref.InheritedRole = inherited + + member, err := s.workspaces.GetWorkspaceMember(ctx, workspace.ID, subject.UserID) + if err != nil { + s.logger.Warn("workspace membership lookup failed; denying access") + return authz.Denied() + } + if member != nil { + ref.MemberRole = authz.NormalizeWorkspaceRole(member.Role) + } + return authz.ResolveWorkspace(subject, ref) +} + +// WorkspaceDecisionByID is the public resolver used by handlers that need the +// caller's scopes for a workspace (DTO projection, scope gating). +func (s *Service) WorkspaceDecisionByID(ctx context.Context, workspaceID string) (authz.Decision, error) { + if workspaceID == "" { + return authz.ResolveWorkspace(callerSubject(ctx), authz.WorkspaceRef{}), nil } workspace, err := s.workspaces.GetWorkspace(ctx, workspaceID) if err != nil { - return err + return authz.Denied(), err } - if !workspaceVisibleTo(workspace, userID) { - return repoerrors.ErrWorkspaceNotFound - } - return nil + return s.workspaceDecision(ctx, workspace), nil } -// authorizeTaskID checks visibility of a task via its workspace. +// authorizeWorkspaceID checks reach of a workspace by ID. +func (s *Service) authorizeWorkspaceID(ctx context.Context, workspaceID string) error { + _, err := s.requireWorkspaceScope(ctx, workspaceID, authz.ScopeWorkspaceRead, repoerrors.ErrWorkspaceNotFound) + return err +} + +// AuthorizeWorkspaceScope enforces one scope on a workspace. Callers that only +// need reach use authorizeWorkspaceID. +func (s *Service) AuthorizeWorkspaceScope(ctx context.Context, workspaceID string, scope authz.Scope) error { + _, err := s.requireWorkspaceScope(ctx, workspaceID, scope, repoerrors.ErrWorkspaceNotFound) + return err +} + +// requireWorkspaceScope resolves the decision and applies the 404-vs-403 rule. +func (s *Service) requireWorkspaceScope( + ctx context.Context, workspaceID string, scope authz.Scope, notFound error, +) (authz.Decision, error) { + if _, scoped := callerScope(ctx); !scoped || workspaceID == "" { + return authz.ResolveWorkspace(authz.Subject{Unscoped: true}, authz.WorkspaceRef{}), nil + } + workspace, err := s.workspaces.GetWorkspace(ctx, workspaceID) + if err != nil { + return authz.Denied(), err + } + decision := s.workspaceDecision(ctx, workspace) + if !decision.CanRead() { + return decision, notFound + } + if !decision.Has(scope) { + return decision, ErrForbidden + } + return decision, nil +} + +// authorizeTaskID checks reach of a task via its workspace. func (s *Service) authorizeTaskID(ctx context.Context, taskID string) error { - userID, scoped := callerScope(ctx) - if !scoped { + return s.authorizeTaskScope(ctx, taskID, authz.ScopeWorkspaceRead) +} + +// AuthorizeTaskScope enforces one scope on the workspace owning a task. +func (s *Service) AuthorizeTaskScope(ctx context.Context, taskID string, scope authz.Scope) error { + return s.authorizeTaskScope(ctx, taskID, scope) +} + +func (s *Service) authorizeTaskScope(ctx context.Context, taskID string, scope authz.Scope) error { + if _, scoped := callerScope(ctx); !scoped { return nil } task, err := s.tasks.GetTask(ctx, taskID) @@ -70,20 +194,28 @@ func (s *Service) authorizeTaskID(ctx context.Context, taskID string) error { } workspace, err := s.workspaces.GetWorkspace(ctx, task.WorkspaceID) if err != nil { - // A dangling workspace reference should not hide the task from the - // single user who can already see everything else about it. - return nil //nolint:nilerr // visibility fallback, not an operation failure + // A dangling workspace reference (the row is genuinely gone) should + // not hide the task from the single user who can already see + // everything else about it. Any OTHER lookup failure fails closed: a + // transient database error must not read as "granted". + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + return nil + } + return err } - if !workspaceVisibleTo(workspace, userID) { + decision := s.workspaceDecision(ctx, workspace) + if !decision.CanRead() { return repoerrors.ErrTaskNotFound } + if !decision.Has(scope) { + return ErrForbidden + } return nil } -// authorizeWorkflowID checks visibility of a workflow via its workspace. +// authorizeWorkflowID checks reach of a workflow via its workspace. func (s *Service) authorizeWorkflowID(ctx context.Context, workflowID string) error { - userID, scoped := callerScope(ctx) - if !scoped { + if _, scoped := callerScope(ctx); !scoped { return nil } workflow, err := s.workflows.GetWorkflow(ctx, workflowID) @@ -113,7 +245,7 @@ func (s *Service) authorizeWorkflowID(ctx context.Context, workflowID string) er // letting a transient database error read as either allow or deny. return err } - if !workspaceVisibleTo(workspace, userID) { + if !s.workspaceDecision(ctx, workspace).CanRead() { return repoerrors.ErrWorkspaceNotFound } return nil @@ -138,19 +270,26 @@ func (s *Service) AuthorizeWorkspaceAccess(ctx context.Context, workspaceID stri return s.authorizeWorkspaceID(ctx, workspaceID) } -// AuthorizeSessionAccess checks visibility of a task session via its task's +// AuthorizeSessionAccess checks reach of a task session via its task's // workspace. Wired into the lifecycle manager so session-scoped surfaces // (files, processes, ports, vscode, terminal, LSP) are covered. func (s *Service) AuthorizeSessionAccess(ctx context.Context, sessionID string) error { - _, scoped := callerScope(ctx) - if !scoped { + return s.AuthorizeSessionScope(ctx, sessionID, authz.ScopeWorkspaceRead) +} + +// AuthorizeSessionScope enforces one scope on the workspace owning a session. +// Execution surfaces (terminal, shell, file writes, port previews) use +// authz.ScopeSessionExec so a viewer who may read a transcript never gets a +// shell in the worktree. +func (s *Service) AuthorizeSessionScope(ctx context.Context, sessionID string, scope authz.Scope) error { + if _, scoped := callerScope(ctx); !scoped { return nil } session, err := s.sessions.GetTaskSession(ctx, sessionID) if err != nil { return err } - return s.authorizeTaskID(ctx, session.TaskID) + return s.authorizeTaskScope(ctx, session.TaskID, scope) } // AuthorizeTaskSessionAccess checks that both identifiers are visible to the @@ -170,19 +309,23 @@ func (s *Service) AuthorizeTaskSessionAccess(ctx context.Context, taskID, sessio return nil } -// AuthorizeEnvironmentAccess checks visibility of a task environment via its -// task's workspace. Used by the terminal environment-shell route, which -// resolves executions by environment ID rather than session ID. +// AuthorizeEnvironmentAccess checks reach of a task environment via its task's +// workspace. Used by the terminal environment-shell route, which resolves +// executions by environment ID rather than session ID. func (s *Service) AuthorizeEnvironmentAccess(ctx context.Context, taskEnvironmentID string) error { - _, scoped := callerScope(ctx) - if !scoped { + return s.AuthorizeEnvironmentScope(ctx, taskEnvironmentID, authz.ScopeWorkspaceRead) +} + +// AuthorizeEnvironmentScope enforces one scope on a task environment. +func (s *Service) AuthorizeEnvironmentScope(ctx context.Context, taskEnvironmentID string, scope authz.Scope) error { + if _, scoped := callerScope(ctx); !scoped { return nil } env, err := s.taskEnvironments.GetTaskEnvironment(ctx, taskEnvironmentID) if err != nil { return err } - return s.authorizeTaskID(ctx, env.TaskID) + return s.authorizeTaskScope(ctx, env.TaskID, scope) } // AuthorizeTaskEnvironmentAccess checks that both identifiers are visible to @@ -229,7 +372,7 @@ func (s *Service) AuthorizeTaskEnvironmentAccess(ctx context.Context, taskID, ta return repoerrors.ErrTaskNotFound } -// authorizeRepositoryID checks visibility of a repository via its workspace. +// authorizeRepositoryID checks reach of a repository via its workspace. // Denials use ErrRepositoryNotFound (no existence leak). func (s *Service) authorizeRepositoryID(ctx context.Context, repositoryID string) error { if _, scoped := callerScope(ctx); !scoped { @@ -249,14 +392,39 @@ func (s *Service) authorizeRepositoryID(ctx context.Context, repositoryID string } // filterWorkspacesForCaller narrows a workspace list to the caller's view. -func filterWorkspacesForCaller(ctx context.Context, workspaces []*models.Workspace) []*models.Workspace { - userID, scoped := callerScope(ctx) - if !scoped { +// +// The caller's whole membership map is read once here rather than per +// workspace: a board render walks every workspace, so a per-row lookup would +// be an N+1 on the hottest list in the product. +func (s *Service) filterWorkspacesForCaller(ctx context.Context, workspaces []*models.Workspace) []*models.Workspace { + subject := callerSubject(ctx) + if subject.Unscoped { return workspaces } + + memberRoles, err := s.workspaces.ListWorkspaceIDsForMember(ctx, subject.UserID) + if err != nil { + s.logger.Warn("workspace membership list failed; returning no workspaces") + return nil + } + + inherited, ok := s.inheritedRolesFor(ctx, subject.UserID, workspaces) + if !ok { + return nil + } + visible := make([]*models.Workspace, 0, len(workspaces)) for _, workspace := range workspaces { - if workspaceVisibleTo(workspace, userID) { + if workspace == nil { + continue + } + ref := authz.WorkspaceRef{ + OwnerID: workspace.OwnerID, + OrgID: workspace.OrgID, + MemberRole: authz.NormalizeWorkspaceRole(memberRoles[workspace.ID]), + InheritedRole: inherited[workspace.ID], + } + if authz.ResolveWorkspace(subject, ref).CanRead() { visible = append(visible, workspace) } } diff --git a/apps/backend/internal/task/service/service_branch_update.go b/apps/backend/internal/task/service/service_branch_update.go index abb8ecb75d..9336f8debb 100644 --- a/apps/backend/internal/task/service/service_branch_update.go +++ b/apps/backend/internal/task/service/service_branch_update.go @@ -8,6 +8,7 @@ import ( "go.uber.org/zap" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/securityutil" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" @@ -53,7 +54,7 @@ func (s *Service) UpdateRepositoryBaseBranch(ctx context.Context, req UpdateRepo // Ahead of every repository read. The WS action names task_id so the gateway // backstop already covers that transport, but this method is also reachable // over HTTP and MCP, and a backstop is not a substitute for a service guard. - if err := s.authorizeTaskID(ctx, req.TaskID); err != nil { + if err := s.authorizeTaskScope(ctx, req.TaskID, authz.ScopeRepositoryManage); err != nil { return nil, err } taskRepo, err := s.loadTaskRepositoryForUpdate(ctx, req.TaskID, req.TaskRepositoryID) diff --git a/apps/backend/internal/task/service/service_errors_authz.go b/apps/backend/internal/task/service/service_errors_authz.go new file mode 100644 index 0000000000..3b42c94ad4 --- /dev/null +++ b/apps/backend/internal/task/service/service_errors_authz.go @@ -0,0 +1,40 @@ +package service + +import ( + "context" + "errors" + + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +// ErrForbidden reports that the caller can see the resource but lacks the +// scope the action requires. +// +// It is deliberately distinct from the *NotFound sentinels. Those hide +// existence from a caller who has no business knowing the resource is there; +// this one is returned only after workspace.read has already been granted, so +// there is nothing left to hide and a 404 would just be confusing ("the task I +// am looking at does not exist?"). +var ErrForbidden = errors.New("insufficient permissions for this action") + +// IsForbidden reports whether an error is the scope-denied sentinel. +func IsForbidden(err error) bool { return errors.Is(err, ErrForbidden) } + +// requireWorkspaceManage gates the owner-only workspace actions: rename, +// defaults, visibility, and delete. It applies the 404-vs-403 rule so a caller +// who cannot see the workspace never learns it exists. +func (s *Service) requireWorkspaceManage(ctx context.Context, workspace *models.Workspace) error { + if _, scoped := callerScope(ctx); !scoped { + return nil + } + decision := s.workspaceDecision(ctx, workspace) + if !decision.CanRead() { + return repoerrors.ErrWorkspaceNotFound + } + if !decision.Has(authz.ScopeWorkspaceManage) { + return ErrForbidden + } + return nil +} diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index e25ec6f84a..cfef5203a7 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -405,6 +405,12 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas // omitted key here would make clearTaskAutoStartFailedMarker's publish // as invisible as the set it is meant to undo. "auto_start_failed": task.Metadata[models.MetaKeyAutoStartFailed] != nil, + // The human assignee, always sent, never omitted when empty, for the + // same reason as auto_start_failed above: the frontend pins the + // previous value when the key is absent, so omitting it would make + // unassigning invisible to every open client, and a takeover would + // leave the previous owner's name on their screens. + "assignee_user_id": task.AssigneeUserID, } data["queued_for_step_id"] = task.QueuedForStepID if task.QueuedAt != nil { @@ -785,6 +791,7 @@ func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, "task_description": task.Description, "parent_id": task.ParentID, "assignee_agent_profile_id": task.AssigneeAgentProfileID, + "assignee_user_id": task.AssigneeUserID, "wip_admitted": task.WIPAdmitted, "queued_for_step_id": task.QueuedForStepID, "queue_promotion": queuePromotion, @@ -826,13 +833,26 @@ func (s *Service) publishWorkspaceEvent(ctx context.Context, eventType string, w "default_environment_id": workspace.DefaultEnvironmentID, "default_agent_profile_id": workspace.DefaultAgentProfileID, "default_config_agent_profile_id": workspace.DefaultConfigAgentProfileID, - "created_at": workspace.CreatedAt.Format(time.RFC3339), - "updated_at": workspace.UpdatedAt.Format(time.RFC3339), + // Placement is reach: moving a workspace between units is what grants + // and withdraws access now, so an access-changed event that omitted it + // would tell clients something changed without telling them what. + "unit_id": workspace.UnitID, + "created_at": workspace.CreatedAt.Format(time.RFC3339), + "updated_at": workspace.UpdatedAt.Format(time.RFC3339), } s.publishEventToBus(ctx, eventType, "workspace", workspace.ID, data) } +// publishWorkspaceAccessChanged tells open clients that who-can-reach-this +// changed (unit placement, membership, ownership) so they re-evaluate access +// without a reload. It rides the existing workspace-updated event: the payload +// carries owner and unit_id, and a client that lost access is dropped from the +// workspace's subscriber set on the next broadcast. +func (s *Service) publishWorkspaceAccessChanged(ctx context.Context, workspace *models.Workspace) { + s.publishWorkspaceEvent(ctx, events.WorkspaceUpdated, workspace) +} + func (s *Service) publishWorkflowEvent(ctx context.Context, eventType string, workflow *models.Workflow) { if s.eventBus == nil || workflow == nil { return diff --git a/apps/backend/internal/task/service/service_events_assignee_test.go b/apps/backend/internal/task/service/service_events_assignee_test.go new file mode 100644 index 0000000000..95384ca29a --- /dev/null +++ b/apps/backend/internal/task/service/service_events_assignee_test.go @@ -0,0 +1,48 @@ +package service + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +// TestPublishTaskUpdated_EmitsHumanAssignee is the same gap as +// TestPublishTaskUpdated_EmitsAutoStartFailed, one field along: the payload +// map is hand-built, so a field the DTO carries is not automatically on the +// wire. Observed live before the fix: taking a task over left the previous +// owner's name in the top bar of every open client until a reload, because +// the event never mentioned the assignee and the frontend pins the value it +// already has when the key is absent. +func TestPublishTaskUpdated_EmitsHumanAssignee(t *testing.T) { + svc, eventBus, _ := createTestService(t) + svc.PublishTaskUpdated(context.Background(), &models.Task{ + ID: "task-assignee", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + AssigneeUserID: "user-7", + }) + + data := singlePublishedEventData(t, eventBus) + if got, _ := data["assignee_user_id"].(string); got != "user-7" { + t.Fatalf("assignee_user_id payload = %#v, want user-7", data["assignee_user_id"]) + } +} + +// TestPublishTaskUpdated_EmitsEmptyAssigneeWhenUnassigned proves unassigning +// propagates. An omitted key reads as "unchanged" on the frontend, so the +// empty string has to be explicit or clients would keep showing a name for a +// task nobody owns. +func TestPublishTaskUpdated_EmitsEmptyAssigneeWhenUnassigned(t *testing.T) { + svc, eventBus, _ := createTestService(t) + svc.PublishTaskUpdated(context.Background(), &models.Task{ + ID: "task-unassigned", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + }) + + data := singlePublishedEventData(t, eventBus) + got, ok := data["assignee_user_id"] + if !ok { + t.Fatal("assignee_user_id absent from the payload: an unassign would be invisible to open clients") + } + if got != "" { + t.Fatalf("assignee_user_id = %#v, want an explicit empty string", got) + } +} diff --git a/apps/backend/internal/task/service/service_events_placement_test.go b/apps/backend/internal/task/service/service_events_placement_test.go new file mode 100644 index 0000000000..75e3aad97e --- /dev/null +++ b/apps/backend/internal/task/service/service_events_placement_test.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" +) + +// TestPublishWorkspaceEvent_EmitsUnitPlacement is the assignee gap one +// resource along: publishWorkspaceEvent hand-builds its payload map, so a +// field on the model is not automatically on the wire. +// +// It matters more here than for most fields. publishWorkspaceAccessChanged +// exists to tell open clients that who-can-reach-this changed, and placement +// IS reach now that the visibility flag is gone - a move between units is what +// grants and withdraws access. Without unit_id the event says "something about +// this workspace changed" and omits the only thing that did. +func TestPublishWorkspaceEvent_EmitsUnitPlacement(t *testing.T) { + svc, eventBus, _ := createTestService(t) + svc.publishWorkspaceEvent(context.Background(), events.WorkspaceUpdated, &models.Workspace{ + ID: "ws-placement", Name: "Platform", UnitID: "unit-runtime", + }) + + data := singlePublishedEventData(t, eventBus) + if got, _ := data["unit_id"].(string); got != "unit-runtime" { + t.Fatalf("unit_id payload = %#v, want unit-runtime", data["unit_id"]) + } +} + +// TestPublishWorkspaceEvent_EmitsEmptyUnitWhenUnplaced keeps the key present +// when a workspace sits nowhere. The frontend pins the value it already holds +// when the key is absent, so an omitted key would leave every open client +// showing the unit the workspace was just moved out of. +func TestPublishWorkspaceEvent_EmitsEmptyUnitWhenUnplaced(t *testing.T) { + svc, eventBus, _ := createTestService(t) + svc.publishWorkspaceEvent(context.Background(), events.WorkspaceUpdated, &models.Workspace{ + ID: "ws-unplaced", Name: "Platform", + }) + + data := singlePublishedEventData(t, eventBus) + got, ok := data["unit_id"] + if !ok { + t.Fatal("unit_id absent from the payload: a move would be invisible to open clients") + } + if got != "" { + t.Fatalf("unit_id = %#v, want an explicit empty string", got) + } +} diff --git a/apps/backend/internal/task/service/service_members.go b/apps/backend/internal/task/service/service_members.go new file mode 100644 index 0000000000..c9f0945c0e --- /dev/null +++ b/apps/backend/internal/task/service/service_members.go @@ -0,0 +1,450 @@ +package service + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + "go.uber.org/zap" +) + +// Workspace membership errors. Each failure mode gets its own sentinel so the +// UI can say what actually went wrong instead of "bad request". +var ( + ErrMemberUserNotFound = errors.New("user not found") + ErrMemberUserDisabled = errors.New("user account is disabled") + ErrMemberIsOwner = errors.New("the workspace owner cannot be removed; transfer ownership first") + ErrMemberRoleInvalid = errors.New("role must be collaborator or viewer") + ErrMemberSelf = errors.New("you already own this workspace") + ErrTransferTargetNotMember = errors.New("add the user as a member before transferring ownership") + ErrAssigneeCannotReachWorkspace = errors.New("that person cannot see this workspace") +) + +// DirectoryUser is the reduced user record exposed to a member picker: an ID +// and a display name, never an email, role, or status. Reaching a colleague's +// name is what adding a member needs; anything more is a directory leak to +// every authenticated user. +type DirectoryUser struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` +} + +// UserDirectory resolves users for membership operations. +type UserDirectory interface { + // ListDirectory returns the pickable accounts in one organization. An + // empty orgID means the single implicit organization. + ListDirectory(ctx context.Context, orgID string) ([]DirectoryUser, error) + // LookupStatus returns the user's status ("active"/"disabled") and role, + // or ok=false when no such user exists. + LookupStatus(ctx context.Context, userID string) (status string, role string, ok bool, err error) +} + +// SetUserDirectory wires the account lookup used by membership operations. +func (s *Service) SetUserDirectory(directory UserDirectory) { s.userDirectory = directory } + +// ListWorkspaceMembers returns the workspace's membership. Any caller who can +// read the workspace can see who else is in it. +func (s *Service) ListWorkspaceMembers(ctx context.Context, workspaceID string) ([]*models.WorkspaceMember, error) { + if err := s.authorizeWorkspaceID(ctx, workspaceID); err != nil { + return nil, err + } + return s.workspaces.ListWorkspaceMembers(ctx, workspaceID) +} + +// UpsertWorkspaceMember adds a member or changes an existing member's role. +func (s *Service) UpsertWorkspaceMember(ctx context.Context, workspaceID, userID, role string) (*models.WorkspaceMember, error) { + workspace, err := s.requireMemberManage(ctx, workspaceID) + if err != nil { + return nil, err + } + wantRole := authz.NormalizeWorkspaceRole(role) + if !authz.IsAssignableWorkspaceRole(wantRole) { + return nil, ErrMemberRoleInvalid + } + if userID == workspace.OwnerID { + return nil, ErrMemberSelf + } + if err := s.requireAssignableWorkspaceUser(ctx, userID, workspace.OrgID); err != nil { + return nil, err + } + + actor, _ := callerScope(ctx) + member := &models.WorkspaceMember{ + WorkspaceID: workspaceID, + UserID: userID, + Role: string(wantRole), + AddedBy: actor, + CreatedAt: time.Now().UTC(), + } + if err := s.workspaces.UpsertWorkspaceMember(ctx, member); err != nil { + return nil, err + } + s.publishWorkspaceAccessChanged(ctx, workspace) + s.logger.Info("workspace member upserted", + zap.String("workspace_id", workspaceID), zap.String("user_id", userID), zap.String("role", string(wantRole))) + return member, nil +} + +// RemoveWorkspaceMember drops a membership row. The accountable owner's row +// cannot be removed: ownership is transferred, never vacated. +func (s *Service) RemoveWorkspaceMember(ctx context.Context, workspaceID, userID string) error { + workspace, err := s.requireMemberManage(ctx, workspaceID) + if err != nil { + return err + } + if userID == workspace.OwnerID { + return ErrMemberIsOwner + } + if err := s.workspaces.DeleteWorkspaceMember(ctx, workspaceID, userID); err != nil { + return err + } + s.publishWorkspaceAccessChanged(ctx, workspace) + s.logger.Info("workspace member removed", + zap.String("workspace_id", workspaceID), zap.String("user_id", userID)) + return nil +} + +// TransferWorkspaceOwnership moves the accountable owner to an existing +// member, demoting the previous owner to collaborator. +func (s *Service) TransferWorkspaceOwnership(ctx context.Context, workspaceID, toUserID string) error { + workspace, err := s.requireMemberManage(ctx, workspaceID) + if err != nil { + return err + } + if toUserID == workspace.OwnerID { + return ErrMemberSelf + } + if err := s.requireAssignableWorkspaceUser(ctx, toUserID, workspace.OrgID); err != nil { + return err + } + member, err := s.workspaces.GetWorkspaceMember(ctx, workspaceID, toUserID) + if err != nil { + return err + } + if member == nil { + return ErrTransferTargetNotMember + } + if err := s.workspaces.TransferWorkspaceOwnership(ctx, workspaceID, workspace.OwnerID, toUserID); err != nil { + return err + } + workspace.OwnerID = toUserID + s.publishWorkspaceAccessChanged(ctx, workspace) + s.logger.Info("workspace ownership transferred", + zap.String("workspace_id", workspaceID), zap.String("to_user_id", toUserID)) + return nil +} + +// ListDirectoryUsers returns the reduced user list for a member picker. +// +// It is scoped to the caller's organization. The picker is the one place a +// person's name is shown to someone who has no other relationship with them, +// so an unscoped list would make every account on the instance enumerable from +// any tenant. +func (s *Service) ListDirectoryUsers(ctx context.Context) ([]DirectoryUser, error) { + if s.userDirectory == nil { + return []DirectoryUser{}, nil + } + return s.userDirectory.ListDirectory(ctx, callerOrgID(ctx)) +} + +// requireMemberManage resolves the workspace and enforces member.manage. +func (s *Service) requireMemberManage(ctx context.Context, workspaceID string) (*models.Workspace, error) { + workspace, err := s.workspaces.GetWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + if _, scoped := callerScope(ctx); !scoped { + return workspace, nil + } + decision := s.workspaceDecision(ctx, workspace) + if !decision.CanRead() { + return nil, repoerrors.ErrWorkspaceNotFound + } + if !decision.Has(authz.ScopeMemberManage) { + return nil, ErrForbidden + } + return workspace, nil +} + +// requireAssignableUser rejects unknown and disabled accounts before a write. +func (s *Service) requireAssignableUser(ctx context.Context, userID string) error { + status, _, ok, err := s.lookupUser(ctx, userID) + if err != nil { + return err + } + if !ok { + return ErrMemberUserNotFound + } + if status == "disabled" { + return ErrMemberUserDisabled + } + return nil +} + +// requireAssignableWorkspaceUser additionally enforces the tenant boundary. +// A mismatch is reported as not found so a crafted user ID cannot reveal an +// account in another organization. +func (s *Service) requireAssignableWorkspaceUser(ctx context.Context, userID, workspaceOrgID string) error { + if err := s.requireAssignableUser(ctx, userID); err != nil { + return err + } + if workspaceOrgID == "" { + return nil + } + if s.userOrgs == nil { + return ErrMemberUserNotFound + } + userOrgID, err := s.userOrgs(ctx, userID) + if err != nil { + return err + } + if userOrgID != workspaceOrgID { + return ErrMemberUserNotFound + } + return nil +} + +func (s *Service) lookupUser(ctx context.Context, userID string) (string, string, bool, error) { + if s.userDirectory == nil { + // No directory wired (pre-auth single-user installs): accept the ID + // rather than blocking membership entirely. + return "active", string(authz.OrgRoleMember), true, nil + } + return s.userDirectory.LookupStatus(ctx, userID) +} + +// seedWorkspaceOwnerMember mirrors workspaces.owner_id into the membership +// table so the two never disagree. Failure is logged rather than fatal: the +// owner still reaches the workspace through owner_id, and the backfill +// migration repairs a missing row on the next boot. +func (s *Service) seedWorkspaceOwnerMember(ctx context.Context, workspace *models.Workspace) error { + if workspace == nil || workspace.OwnerID == "" { + return nil + } + member := &models.WorkspaceMember{ + WorkspaceID: workspace.ID, + UserID: workspace.OwnerID, + Role: string(authz.WorkspaceRoleOwner), + CreatedAt: time.Now().UTC(), + } + if err := s.workspaces.UpsertWorkspaceMember(ctx, member); err != nil { + // The owner row is not decoration: membership, counts and ownership + // transfer all read it, so a workspace without one is inconsistent. + // Report the failure rather than publishing a created event for it. + s.logger.Error("failed to seed workspace owner membership", + zap.String("workspace_id", workspace.ID), zap.Error(err)) + return err + } + return nil +} + +// WorkspaceAccessProjection carries the resolved access for a set of +// workspaces so handlers can build DTOs without re-resolving per row. +type WorkspaceAccessProjection struct { + Decisions map[string]authz.Decision + MemberCounts map[string]int +} + +// Decision returns the resolved access for one workspace, defaulting to the +// unscoped view when the projection was built for an internal caller. +func (p WorkspaceAccessProjection) Decision(workspaceID string) authz.Decision { + if decision, ok := p.Decisions[workspaceID]; ok { + return decision + } + return authz.Denied() +} + +// ProjectWorkspaceAccess resolves roles and scopes for a list of workspaces +// using one membership query and one count query, regardless of list length. +func (s *Service) ProjectWorkspaceAccess(ctx context.Context, workspaces []*models.Workspace) WorkspaceAccessProjection { + projection := WorkspaceAccessProjection{ + Decisions: make(map[string]authz.Decision, len(workspaces)), + MemberCounts: map[string]int{}, + } + if counts, err := s.workspaces.CountWorkspaceMembers(ctx); err == nil { + projection.MemberCounts = counts + } + + subject := callerSubject(ctx) + memberRoles := map[string]string{} + if !subject.Unscoped { + roles, err := s.workspaces.ListWorkspaceIDsForMember(ctx, subject.UserID) + if err != nil { + // Fail closed: every workspace resolves to Denied rather than + // silently falling through to the org default role. + s.logger.Warn("membership projection failed; denying scopes") + for _, workspace := range workspaces { + if workspace != nil { + projection.Decisions[workspace.ID] = authz.Denied() + } + } + return projection + } + memberRoles = roles + } + + inherited, ok := s.inheritedRolesFor(ctx, subject.UserID, workspaces) + if !ok { + for _, workspace := range workspaces { + if workspace != nil { + projection.Decisions[workspace.ID] = authz.Denied() + } + } + return projection + } + + for _, workspace := range workspaces { + if workspace == nil { + continue + } + projection.Decisions[workspace.ID] = authz.ResolveWorkspace(subject, authz.WorkspaceRef{ + OwnerID: workspace.OwnerID, + OrgID: workspace.OrgID, + MemberRole: authz.NormalizeWorkspaceRole(memberRoles[workspace.ID]), + InheritedRole: inherited[workspace.ID], + }) + } + return projection +} + +// WorkspaceReaderIDs returns every user that may currently read a workspace: +// its owner, everyone holding a membership row, and, when the workspace is +// visible to the organization, that organization's non-guest users. +// +// It backs WebSocket fan-out. Returning an empty slice means "nobody" and is a +// real answer: the gateway must not turn it into a global broadcast. +func (s *Service) WorkspaceReaderIDs(ctx context.Context, workspaceID string) ([]string, error) { + workspace, err := s.workspaces.GetWorkspace(ctx, workspaceID) + if err != nil || workspace == nil { + return nil, err + } + // A pre-auth workspace has no owner and stays visible to everyone, so it + // keeps the previous global routing rather than resolving to nobody. + if workspace.OwnerID == "" { + return nil, errUnscopedWorkspace + } + + seen := map[string]struct{}{workspace.OwnerID: {}} + members, err := s.workspaces.ListWorkspaceMembers(ctx, workspaceID) + if err != nil { + return nil, err + } + for _, member := range members { + if authz.NormalizeWorkspaceRole(member.Role) != authz.WorkspaceRoleNone { + seen[member.UserID] = struct{}{} + } + } + // Everyone the placement reaches, which is every member of the workspace's + // unit and of its ancestors. A workspace shared with the whole + // organization is one placed in a unit the whole organization is in. + if s.unitReach != nil && workspace.UnitID != "" { + readers, err := s.unitReach.UnitReaders(ctx, workspace.UnitID) + if err != nil { + return nil, err + } + for _, id := range readers { + seen[id] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + return out, nil +} + +// errUnscopedWorkspace tells the gateway to fall back to its previous routing +// for a workspace that predates ownership. +var errUnscopedWorkspace = errors.New("workspace has no owner") + +// SetUserOrgResolver wires the account-to-organization lookup used by +// workspace fan-out. +func (s *Service) SetUserOrgResolver(resolve func(ctx context.Context, userID string) (string, error)) { + s.userOrgs = resolve +} + +// SetHumanAssignee applies a human assignee to a task on behalf of the caller +// in ctx, so surfaces outside this package (today: the office PATCH handler) +// get the same authorization and validation as the task API instead of +// reimplementing either. Empty unassigns. +// +// It deliberately goes through UpdateTask rather than writing the column: the +// office route carries no `:wsId`, so it is not covered by the office +// workspace-scope middleware, and a direct write there would let any signed-in +// user assign a task in a workspace they cannot reach. +func (s *Service) SetHumanAssignee(ctx context.Context, taskID, userID string) error { + _, err := s.UpdateTask(ctx, taskID, &UpdateTaskRequest{AssigneeUserID: &userID}) + return err +} + +// resolveTaskAssignee validates a human assignee for a task. +// +// Assignment is advisory: it gates nothing, and any caller holding task.write +// may assign to anyone, including themselves. The one rule is that the +// assignee has to be able to reach the workspace, so a task cannot be parked +// on somebody who will never see it. An empty value unassigns. +func (s *Service) resolveTaskAssignee(ctx context.Context, task *models.Task, userID string) (string, error) { + userID = strings.TrimSpace(userID) + if userID == "" { + return "", nil + } + if err := s.requireAssignableUser(ctx, userID); err != nil { + return "", err + } + workspace, err := s.workspaces.GetWorkspace(ctx, task.WorkspaceID) + if err != nil || workspace == nil { + // A dangling workspace reference should not block assignment on a task + // the caller can already edit. + return userID, nil //nolint:nilerr // visibility fallback, not a failure + } + if !s.userReachesWorkspace(ctx, workspace, userID) { + return "", ErrAssigneeCannotReachWorkspace + } + return userID, nil +} + +// userReachesWorkspace answers the reach question for a user who is NOT the +// caller, so it resolves their membership and org role rather than reading the +// request identity. +func (s *Service) userReachesWorkspace(ctx context.Context, workspace *models.Workspace, userID string) bool { + if workspace.OwnerID == "" || workspace.OwnerID == userID { + return true + } + member, err := s.workspaces.GetWorkspaceMember(ctx, workspace.ID, userID) + if err != nil { + return false + } + ref := authz.WorkspaceRef{ + OwnerID: workspace.OwnerID, + OrgID: workspace.OrgID, + } + if member != nil { + ref.MemberRole = authz.NormalizeWorkspaceRole(member.Role) + } + // Reach comes from the tree first: most people reach a workspace because + // they are in a unit above it, not because they hold a row on it. Asking + // only about the row is how this check came to refuse a colleague who can + // plainly see the board. + if inherited, ok := s.inheritedRole(ctx, userID, workspace); ok { + ref.InheritedRole = inherited + } else { + return false + } + subject := authz.Subject{UserID: userID, TenancyEnforced: tenancyEnforced} + if s.userDirectory != nil { + if _, role, ok, lookupErr := s.userDirectory.LookupStatus(ctx, userID); lookupErr == nil && ok { + subject.OrgRole = authz.NormalizeOrgRole(role) + } + } + if s.userOrgs != nil { + if orgID, orgErr := s.userOrgs(ctx, userID); orgErr == nil { + subject.OrgID = orgID + } + } + return authz.ResolveWorkspace(subject, ref).CanRead() +} diff --git a/apps/backend/internal/task/service/service_members_tenancy_review_test.go b/apps/backend/internal/task/service/service_members_tenancy_review_test.go new file mode 100644 index 0000000000..15aec30aff --- /dev/null +++ b/apps/backend/internal/task/service/service_members_tenancy_review_test.go @@ -0,0 +1,68 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/task/models" +) + +type tenancyReviewDirectory struct { + users map[string]string +} + +func (d tenancyReviewDirectory) ListDirectory(context.Context, string) ([]DirectoryUser, error) { + return nil, nil +} + +func (d tenancyReviewDirectory) LookupStatus(_ context.Context, userID string) (string, string, bool, error) { + status, ok := d.users[userID] + return status, string(authz.OrgRoleMember), ok, nil +} + +func TestWorkspaceMembershipRejectsUserFromAnotherOrganization(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ + ID: "ws-org-a", Name: "Org A", OwnerID: "owner-a", OrgID: "org-a", + }); err != nil { + t.Fatalf("create workspace: %v", err) + } + svc.SetUserDirectory(tenancyReviewDirectory{users: map[string]string{"user-b": "active"}}) + svc.SetUserOrgResolver(func(context.Context, string) (string, error) { return "org-b", nil }) + owner := authn.WithIdentity(ctx, authn.Identity{ + UserID: "owner-a", OrgID: "org-a", Role: authn.RoleMember, + }) + + if _, err := svc.UpsertWorkspaceMember( + owner, "ws-org-a", "user-b", string(authz.WorkspaceRoleCollaborator), + ); !errors.Is(err, ErrMemberUserNotFound) { + t.Fatalf("cross-organization member add = %v, want ErrMemberUserNotFound", err) + } + member, err := repo.GetWorkspaceMember(ctx, "ws-org-a", "user-b") + if err != nil { + t.Fatalf("get rejected member: %v", err) + } + if member != nil { + t.Fatalf("cross-organization membership persisted: %+v", member) + } + + if err := repo.UpsertWorkspaceMember(ctx, &models.WorkspaceMember{ + WorkspaceID: "ws-org-a", UserID: "user-b", Role: string(authz.WorkspaceRoleCollaborator), + }); err != nil { + t.Fatalf("seed legacy cross-organization member: %v", err) + } + if err := svc.TransferWorkspaceOwnership(owner, "ws-org-a", "user-b"); !errors.Is(err, ErrMemberUserNotFound) { + t.Fatalf("cross-organization ownership transfer = %v, want ErrMemberUserNotFound", err) + } + workspace, err := repo.GetWorkspace(ctx, "ws-org-a") + if err != nil { + t.Fatalf("get workspace: %v", err) + } + if workspace.OwnerID != "owner-a" { + t.Fatalf("workspace owner = %q, want owner-a", workspace.OwnerID) + } +} diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go index 4543bb32fa..de8d538583 100644 --- a/apps/backend/internal/task/service/service_messages.go +++ b/apps/backend/internal/task/service/service_messages.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" ) @@ -23,6 +24,11 @@ const ( // CreateMessage creates a new message on an agent session func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) (*models.Message, error) { + if req.AuthorType != createdByAgent { + if err := s.AuthorizeSessionScope(ctx, req.TaskSessionID, authz.ScopeSessionPrompt); err != nil { + return nil, err + } + } messageID := uuid.New().String() session, err := s.getSessionWithRetry( ctx, @@ -81,7 +87,7 @@ func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) TaskID: taskID, TurnID: turnID, AuthorType: authorType, - AuthorID: req.AuthorID, + AuthorID: s.resolveAuthorID(ctx, authorType, req.AuthorID), Content: req.Content, Type: messageType, Metadata: req.Metadata, @@ -950,3 +956,21 @@ func firstRestoredClarification(messages []*models.Message) *models.Message { } return nil } + +// resolveAuthorID stamps a human message with the *authenticated* caller +// rather than whatever author_id the browser sent. +// +// Attribution is the whole reason a team uses accounts instead of a shared +// login, so it cannot be client-supplied: any user could otherwise post as a +// colleague. Agent messages keep the caller-supplied ID, which names an agent +// execution rather than a person, and an unauthenticated (internal or +// auth-disabled) caller keeps today's behavior. +func (s *Service) resolveAuthorID(ctx context.Context, authorType models.MessageAuthorType, requested string) string { + if authorType != models.MessageAuthorUser { + return requested + } + if userID, scoped := callerScope(ctx); scoped { + return userID + } + return requested +} diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 0794ad68f6..e1e864d352 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -135,6 +135,10 @@ type UpdateTaskRequest struct { // leaves the relationship untouched; a pointer to "" clears it (un-nests // back to a root task); a non-empty value nests it under that parent. ParentID *string `json:"parent_id,omitempty"` + // AssigneeUserID sets the human assignee. A nil pointer leaves it alone; a + // pointer to "" unassigns. It is independent of the agent assignee and + // never clears it. + AssigneeUserID *string `json:"assignee_user_id,omitempty"` } // CreateWorkflowRequest contains the data for creating a new workflow @@ -176,6 +180,12 @@ type UpdateWorkspaceRequest struct { DefaultEnvironmentID *string `json:"default_environment_id,omitempty"` DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` DefaultConfigAgentProfileID *string `json:"default_config_agent_profile_id,omitempty"` + // Visibility is "private" or "org". Unknown values normalize to private: + // unrecognized input must never widen access. + Visibility *string `json:"visibility,omitempty"` + // UnitID moves the workspace to another organization unit, which is the + // only way to change who reaches it. + UnitID *string `json:"unit_id,omitempty"` } // FindOrCreateRepositoryRequest contains the data for finding or creating a repository by provider info. diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 9a161f9158..0fb6fd743c 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -18,6 +18,7 @@ import ( agentkubernetes "github.com/kandev/kandev/internal/agent/kubernetes" "github.com/kandev/kandev/internal/agentruntime" "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/securityutil" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/secrets" @@ -107,7 +108,16 @@ func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceReque DefaultEnvironmentID: normalizeOptionalID(req.DefaultEnvironmentID), DefaultAgentProfileID: normalizeOptionalID(req.DefaultAgentProfileID), DefaultConfigAgentProfileID: normalizeOptionalID(req.DefaultConfigAgentProfileID), + // The tenant comes from the creating identity and from nowhere else. + // There is no org field on the request on purpose: a caller must not + // be able to place a workspace in another tenant. + OrgID: callerOrgID(ctx), } + placement, placementErr := s.placementFor(ctx, ownerID, workspace.OrgID) + if placementErr != nil { + return nil, placementErr + } + workspace.UnitID = placement var kanbanWorkflow *models.Workflow if req.BootstrapKanbanWorkflow { @@ -133,6 +143,10 @@ func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceReque } } + if err := s.seedWorkspaceOwnerMember(ctx, workspace); err != nil { + return nil, err + } + s.publishWorkspaceEvent(ctx, events.WorkspaceCreated, workspace) s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name)) if kanbanWorkflow != nil { @@ -148,7 +162,7 @@ func (s *Service) GetWorkspace(ctx context.Context, id string) (*models.Workspac if err != nil { return nil, err } - if userID, scoped := callerScope(ctx); scoped && !workspaceVisibleTo(workspace, userID) { + if scoped := s.workspaceDecision(ctx, workspace); !scoped.CanRead() { return nil, repoerrors.ErrWorkspaceNotFound } return workspace, nil @@ -160,10 +174,15 @@ func (s *Service) UpdateWorkspace(ctx context.Context, id string, req *UpdateWor if err != nil { return nil, err } - if userID, scoped := callerScope(ctx); scoped && !workspaceVisibleTo(workspace, userID) { - return nil, repoerrors.ErrWorkspaceNotFound + if err := s.requireWorkspaceManage(ctx, workspace); err != nil { + return nil, err } + if req.UnitID != nil { + if err := s.moveWorkspaceToUnit(ctx, workspace, *req.UnitID); err != nil { + return nil, err + } + } if req.Name != nil { workspace.Name = *req.Name } @@ -200,8 +219,8 @@ func (s *Service) DeleteWorkspace(ctx context.Context, id string) error { if err != nil { return err } - if userID, scoped := callerScope(ctx); scoped && !workspaceVisibleTo(workspace, userID) { - return repoerrors.ErrWorkspaceNotFound + if err := s.requireWorkspaceManage(ctx, workspace); err != nil { + return err } return s.deleteWorkspace(ctx, workspace, nil) } @@ -213,8 +232,8 @@ func (s *Service) DeleteWorkspaceWithConfirmName(ctx context.Context, id, confir if err != nil { return err } - if userID, scoped := callerScope(ctx); scoped && !workspaceVisibleTo(workspace, userID) { - return repoerrors.ErrWorkspaceNotFound + if err := s.requireWorkspaceManage(ctx, workspace); err != nil { + return err } if confirmName != workspace.Name { return ErrWorkspaceConfirmNameMismatch @@ -222,6 +241,29 @@ func (s *Service) DeleteWorkspaceWithConfirmName(ctx context.Context, id, confir return s.deleteWorkspace(ctx, workspace, &confirmName) } +// DeleteOrganizationWorkspaces removes every workspace in one organization +// through the same lifecycle as an ordinary workspace deletion. Authorization +// is performed by the operator-only organization controller before this +// narrow internal seam is called. +func (s *Service) DeleteOrganizationWorkspaces(ctx context.Context, orgID string) error { + if orgID == "" { + return nil + } + workspaces, err := s.workspaces.ListWorkspaces(ctx) + if err != nil { + return fmt.Errorf("list organization workspaces: %w", err) + } + for _, workspace := range workspaces { + if workspace == nil || workspace.OrgID != orgID { + continue + } + if err := s.deleteWorkspace(ctx, workspace, nil); err != nil { + return fmt.Errorf("delete workspace %s: %w", workspace.ID, err) + } + } + return nil +} + func (s *Service) deleteWorkspace(ctx context.Context, workspace *models.Workspace, confirmedName *string) error { tasks, err := s.listAllTasksForWorkspaceDelete(ctx, workspace.ID) if err != nil { @@ -506,14 +548,14 @@ func (s *Service) ListWorkspaces(ctx context.Context) ([]*models.Workspace, erro if err != nil { return nil, err } - return filterWorkspacesForCaller(ctx, workspaces), nil + return s.filterWorkspacesForCaller(ctx, workspaces), nil } // Workflow operations // CreateWorkflow creates a new workflow func (s *Service) CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest) (*models.Workflow, error) { - if err := s.authorizeWorkspaceID(ctx, req.WorkspaceID); err != nil { + if err := s.AuthorizeWorkspaceScope(ctx, req.WorkspaceID, authz.ScopeRepositoryManage); err != nil { return nil, err } workflow := &models.Workflow{ @@ -757,7 +799,7 @@ func (s *Service) createRepository( localPath string, resolveProvider bool, ) (*models.Repository, error) { - if err := s.authorizeWorkspaceID(ctx, req.WorkspaceID); err != nil { + if err := s.AuthorizeWorkspaceScope(ctx, req.WorkspaceID, authz.ScopeRepositoryManage); err != nil { return nil, err } sourceType := req.SourceType @@ -1071,7 +1113,7 @@ func (s *Service) UpdateRepository(ctx context.Context, id string, req *UpdateRe return nil, err } if repository != nil { - if err := s.authorizeWorkspaceID(ctx, repository.WorkspaceID); err != nil { + if err := s.AuthorizeWorkspaceScope(ctx, repository.WorkspaceID, authz.ScopeRepositoryManage); err != nil { return nil, repoerrors.ErrRepositoryNotFound } } @@ -1244,7 +1286,10 @@ func (s *Service) DeleteRepository(ctx context.Context, id string) error { return err } if repository != nil { - if err := s.authorizeWorkspaceID(ctx, repository.WorkspaceID); err != nil { + if err := s.AuthorizeWorkspaceScope(ctx, repository.WorkspaceID, authz.ScopeRepositoryManage); err != nil { + if IsForbidden(err) { + return err + } return repoerrors.ErrRepositoryNotFound } } diff --git a/apps/backend/internal/task/service/service_resources_test.go b/apps/backend/internal/task/service/service_resources_test.go index 52cd3d5543..b43eaf9257 100644 --- a/apps/backend/internal/task/service/service_resources_test.go +++ b/apps/backend/internal/task/service/service_resources_test.go @@ -670,6 +670,9 @@ func TestService_FindOrCreateRepositoryRejectsInvalidLocalPathBackfill(t *testin // errWorkspaceRepo is a WorkspaceRepository that always returns an error from // ListWorkspaces. Used to exercise the DB-error path of GetOfficeWorkflowIDs. type errWorkspaceRepo struct { + // Membership is not exercised by this fake; the embedded default + // reports no membership, which is the narrower answer. + repository.UnsupportedWorkspaceMembers // embed the real repo for all methods except ListWorkspaces. WorkspaceRepositoryStub } diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 0a936e5e16..b6d2b52b1e 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -19,6 +19,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/steptelemetry" @@ -208,7 +209,7 @@ func foundOutcomeFor(task *models.Task) CreateTaskOutcome { // call returns, so settlement is their responsibility (see the spec's // "Settlement call site" section). func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (CreateTaskResult, error) { - if err := s.authorizeWorkspaceID(ctx, req.WorkspaceID); err != nil { + if err := s.AuthorizeWorkspaceScope(ctx, req.WorkspaceID, authz.ScopeTaskWrite); err != nil { return CreateTaskResult{}, err } @@ -1899,7 +1900,7 @@ func (s *Service) hydrateTaskRelations(ctx context.Context, task *models.Task) { // UpdateTask updates an existing task and publishes a task.updated event func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequest) (*models.Task, error) { - if err := s.authorizeTaskID(ctx, id); err != nil { + if err := s.authorizeTaskScope(ctx, id, authz.ScopeTaskWrite); err != nil { return nil, err } if req.Title != nil { @@ -1945,6 +1946,13 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ if req.Position != nil { task.Position = *req.Position } + if req.AssigneeUserID != nil { + assignee, err := s.resolveTaskAssignee(ctx, task, *req.AssigneeUserID) + if err != nil { + return nil, err + } + task.AssigneeUserID = assignee + } if req.Metadata != nil { task.Metadata = protectedTaskMetadataUpdate(task.Metadata, req.Metadata) } @@ -2279,7 +2287,7 @@ func (s *Service) RestoreTaskMessageRollback( func (s *Service) ArchiveTask(ctx context.Context, id string) error { start := time.Now() - if err := s.authorizeTaskID(ctx, id); err != nil { + if err := s.authorizeTaskScope(ctx, id, authz.ScopeTaskWrite); err != nil { return err } // 1. Get task and verify it exists @@ -2594,7 +2602,7 @@ func (s *Service) DeleteTaskWithReason(ctx context.Context, id, reason string) e } func (s *Service) deleteTaskWithReason(ctx context.Context, id, reason string) error { - if err := s.authorizeTaskID(ctx, id); err != nil { + if err := s.authorizeTaskScope(ctx, id, authz.ScopeTaskWrite); err != nil { return err } _, err := s.deleteTaskWithReasonAndDBDelete(ctx, id, reason, models.TaskResourceCleanupTriggerDelete, func(ctx context.Context, id string) (bool, error) { diff --git a/apps/backend/internal/task/service/service_team_access_test.go b/apps/backend/internal/task/service/service_team_access_test.go new file mode 100644 index 0000000000..84ce40d463 --- /dev/null +++ b/apps/backend/internal/task/service/service_team_access_test.go @@ -0,0 +1,350 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// End-to-end team access through the real service and repository. The authz +// package proves the resolver's table; this proves the wiring actually reaches +// it — a correct resolver that nobody calls protects nothing. + +func ctxAsRole(userID string, role authn.Role) context.Context { + return authn.WithIdentity(context.Background(), authn.Identity{UserID: userID, Role: role}) +} + +type teamAccessRepo interface { + CreateWorkspace(context.Context, *models.Workspace) error + CreateWorkflow(context.Context, *models.Workflow) error + CreateTask(context.Context, *models.Task) error + UpsertWorkspaceMember(context.Context, *models.WorkspaceMember) error +} + +// seedTeamWorkspace builds the shape a team actually has: a unit that holds +// the board, with the colleague a member of it. `shared` decides whether the +// board sits in that unit or in its owner's personal one, which is the whole +// of the private-versus-shared distinction now. +func seedTeamWorkspace(t *testing.T, repo teamAccessRepo, shared bool) { + t.Helper() + ctx := context.Background() + units := testUnits[t.Name()] + if units == nil { + t.Fatal("no unit service for this test") + } + root, err := units.EnsureRoot(ctx, "", "Test org") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + personal, err := units.EnsurePersonal(ctx, "", "user-ana", "Ana") + if err != nil { + t.Fatalf("ensure personal unit: %v", err) + } + team, err := units.Create(ctx, root.ID, "Platform") + if err != nil { + t.Fatalf("create team unit: %v", err) + } + unitID := personal.ID + if shared { + unitID = team.ID + if err := units.SetMember(ctx, team.ID, "user-bruno", "collaborator", "user-ana"); err != nil { + t.Fatalf("add unit member: %v", err) + } + } + if err := repo.CreateWorkspace(ctx, &models.Workspace{ + ID: "ws-team", Name: "Team", OwnerID: "user-ana", UnitID: unitID, + }); err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-team", WorkspaceID: "ws-team", Name: "Board"}); err != nil { + t.Fatalf("create workflow: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: "task-team", WorkspaceID: "ws-team", WorkflowID: "wf-team", WorkflowStepID: "step-1", + Title: "Ana's task", State: v1.TaskStateCreated, Priority: "medium", + }); err != nil { + t.Fatalf("create task: %v", err) + } +} + +// The golden path: a colleague reaches the board through the unit they are +// in, with no invitation and no per-workspace row. +func TestUnitMemberReachesWorkspaceWithoutInvitation(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + + if _, err := svc.GetWorkspace(ctxAsRole("user-bruno", authn.RoleMember), "ws-team"); err != nil { + t.Fatalf("a workspace in a unit the member belongs to must be reachable: %v", err) + } + if _, err := svc.GetTask(ctxAsRole("user-bruno", authn.RoleMember), "task-team"); err != nil { + t.Fatalf("a task on that board must be readable by the same member: %v", err) + } +} + +// The privacy case the current design is built around must not regress. +func TestPrivateWorkspaceStaysPrivate(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, false) + + bruno := ctxAsRole("user-bruno", authn.RoleMember) + if _, err := svc.GetWorkspace(bruno, "ws-team"); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + t.Fatalf("private workspace leaked to a non-member: %v", err) + } + if _, err := svc.GetTask(bruno, "task-team"); !errors.Is(err, repoerrors.ErrTaskNotFound) { + t.Fatalf("private task leaked to a non-member: %v", err) + } + // An admin is a management role, not a visibility role. + admin := ctxAsRole("user-carla", authn.RoleAdmin) + if _, err := svc.GetWorkspace(admin, "ws-team"); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + t.Fatalf("admin reached a private workspace they are not in: %v", err) + } +} + +// A guest is deliberately excluded from org-visible workspaces. +func TestGuestExcludedFromOrgVisibleWorkspace(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + + guest := ctxAsRole("user-contractor", authn.RoleGuest) + if _, err := svc.GetWorkspace(guest, "ws-team"); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + t.Fatalf("guest reached an org-visible workspace: %v", err) + } + + // ...until they hold an explicit row. + if err := repo.UpsertWorkspaceMember(context.Background(), &models.WorkspaceMember{ + WorkspaceID: "ws-team", UserID: "user-contractor", Role: string(authz.WorkspaceRoleCollaborator), + }); err != nil { + t.Fatalf("add member: %v", err) + } + if _, err := svc.GetWorkspace(guest, "ws-team"); err != nil { + t.Fatalf("guest with a membership row must reach the workspace: %v", err) + } +} + +// A direct grant raises a role; it cannot lower one. Narrowing is done by +// placing a workspace in a unit with fewer members, which is what lets the +// combining rule stay "the strongest wins" with no deny concept to reason +// about. This test pins the direction, because the model it replaced narrowed +// in both. +func TestDirectGrantCannotLowerAnInheritedRole(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + if err := repo.UpsertWorkspaceMember(context.Background(), &models.WorkspaceMember{ + WorkspaceID: "ws-team", UserID: "user-bruno", Role: string(authz.WorkspaceRoleViewer), + }); err != nil { + t.Fatalf("add viewer grant: %v", err) + } + + bruno := ctxAsRole("user-bruno", authn.RoleMember) + if _, err := svc.GetTask(bruno, "task-team"); err != nil { + t.Fatalf("reading must still work: %v", err) + } + title := "renamed by a colleague" + if _, err := svc.UpdateTask(bruno, "task-team", &UpdateTaskRequest{Title: &title}); err != nil { + t.Fatalf("a viewer grant lowered the collaborator role the unit gives: %v", err) + } +} + +// A viewer on the unit is a viewer everywhere beneath it: reading is allowed +// and writing is 403 rather than 404, because existence is already known. +func TestUnitViewerCanReadButNotWrite(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + seedUnitViewer(t, "user-carla") + + viewer := ctxAsRole("user-carla", authn.RoleMember) + if _, err := svc.GetTask(viewer, "task-team"); err != nil { + t.Fatalf("viewer must read: %v", err) + } + title := "hijacked" + if _, err := svc.UpdateTask(viewer, "task-team", &UpdateTaskRequest{Title: &title}); !IsForbidden(err) { + t.Fatalf("viewer task write = %v, want ErrForbidden", err) + } +} + +// seedUnitViewer adds a viewer member to the seeded team unit. +func seedUnitViewer(t *testing.T, userID string) { + t.Helper() + ctx := context.Background() + units := testUnits[t.Name()] + root, err := units.EnsureRoot(ctx, "", "Test org") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + all, err := units.Store().ListByOrg(ctx, "") + if err != nil { + t.Fatalf("list units: %v", err) + } + for _, u := range all { + if u.ParentID == root.ID && u.Name == "Platform" { + if err := units.SetMember(ctx, u.ID, userID, "viewer", "user-ana"); err != nil { + t.Fatalf("add viewer member: %v", err) + } + return + } + } + t.Fatal("seed did not create the team unit") +} + +// A collaborator contributes; it does not administer. +func TestCollaboratorCannotManageWorkspace(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + + bruno := ctxAsRole("user-bruno", authn.RoleMember) + title := "renamed by a collaborator" + if _, err := svc.UpdateWorkspace(bruno, "ws-team", &UpdateWorkspaceRequest{Name: &title}); !IsForbidden(err) { + t.Fatalf("collaborator workspace rename = %v, want ErrForbidden", err) + } + if _, err := svc.UpsertWorkspaceMember(bruno, "ws-team", "user-carla", string(authz.WorkspaceRoleCollaborator)); !IsForbidden(err) { + t.Fatalf("collaborator member add = %v, want ErrForbidden", err) + } + // A collaborator CAN do the work: writing a task is the whole point. + if _, err := svc.UpdateTask(bruno, "task-team", &UpdateTaskRequest{Title: &title}); err != nil { + t.Fatalf("collaborator must be able to write a task: %v", err) + } +} + +// The owner keeps full control, and the DTO projection agrees with the resolver. +func TestOwnerScopesProjectIntoAccessProjection(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + + workspace, err := svc.GetWorkspace(ctxAsRole("user-ana", authn.RoleMember), "ws-team") + if err != nil { + t.Fatalf("owner get: %v", err) + } + projection := svc.ProjectWorkspaceAccess(ctxAsRole("user-ana", authn.RoleMember), []*models.Workspace{workspace}) + decision := projection.Decision("ws-team") + if decision.Role != authz.WorkspaceRoleOwner { + t.Fatalf("owner viewer_role = %q", decision.Role) + } + for _, scope := range []authz.Scope{authz.ScopeWorkspaceManage, authz.ScopeMemberManage, authz.ScopeSessionExec} { + if !decision.Has(scope) { + t.Errorf("owner projection missing %q", scope) + } + } + + brunoProjection := svc.ProjectWorkspaceAccess(ctxAsRole("user-bruno", authn.RoleMember), []*models.Workspace{workspace}) + if got := brunoProjection.Decision("ws-team"); got.Role != authz.WorkspaceRoleCollaborator { + t.Fatalf("member viewer_role = %q, want collaborator", got.Role) + } +} + +// Membership management rejects each bad input with its own reason. +func TestMemberManagementValidation(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, false) + ana := ctxAsRole("user-ana", authn.RoleMember) + + if _, err := svc.UpsertWorkspaceMember(ana, "ws-team", "user-bruno", "owner"); !errors.Is(err, ErrMemberRoleInvalid) { + t.Fatalf("assigning owner = %v, want ErrMemberRoleInvalid", err) + } + if _, err := svc.UpsertWorkspaceMember(ana, "ws-team", "user-ana", "collaborator"); !errors.Is(err, ErrMemberSelf) { + t.Fatalf("adding the owner = %v, want ErrMemberSelf", err) + } + if err := svc.RemoveWorkspaceMember(ana, "ws-team", "user-ana"); !errors.Is(err, ErrMemberIsOwner) { + t.Fatalf("removing the owner = %v, want ErrMemberIsOwner", err) + } + if err := svc.TransferWorkspaceOwnership(ana, "ws-team", "user-nobody"); !errors.Is(err, ErrTransferTargetNotMember) { + t.Fatalf("transfer to a non-member = %v, want ErrTransferTargetNotMember", err) + } +} + +// Ownership transfer must leave owner_id and the owner row in agreement. +func TestTransferOwnershipKeepsOwnerRowConsistent(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, false) + ana := ctxAsRole("user-ana", authn.RoleMember) + + if _, err := svc.UpsertWorkspaceMember(ana, "ws-team", "user-bruno", "collaborator"); err != nil { + t.Fatalf("add member: %v", err) + } + if err := svc.TransferWorkspaceOwnership(ana, "ws-team", "user-bruno"); err != nil { + t.Fatalf("transfer: %v", err) + } + + workspace, err := svc.GetWorkspace(context.Background(), "ws-team") + if err != nil { + t.Fatalf("get workspace: %v", err) + } + if workspace.OwnerID != "user-bruno" { + t.Fatalf("owner_id = %q, want user-bruno", workspace.OwnerID) + } + member, err := repo.GetWorkspaceMember(context.Background(), "ws-team", "user-bruno") + if err != nil || member == nil { + t.Fatalf("owner membership row missing: %v", err) + } + if member.Role != string(authz.WorkspaceRoleOwner) { + t.Fatalf("new owner row role = %q, want owner", member.Role) + } + // The previous owner is demoted, not removed. + previous, err := repo.GetWorkspaceMember(context.Background(), "ws-team", "user-ana") + if err != nil || previous == nil { + t.Fatalf("previous owner row missing: %v", err) + } + if previous.Role != string(authz.WorkspaceRoleCollaborator) { + t.Fatalf("previous owner role = %q, want collaborator", previous.Role) + } +} + +// Attribution comes from the authenticated caller, never the payload. +func TestMessageAuthorIsServerStamped(t *testing.T) { + svc, _, _ := createTestService(t) + stamped := svc.resolveAuthorID(ctxAsRole("user-bruno", authn.RoleMember), models.MessageAuthorUser, "user-ana") + if stamped != "user-bruno" { + t.Fatalf("author = %q, want the authenticated caller user-bruno", stamped) + } + // Agent messages name an execution, not a person, and keep their value. + agent := svc.resolveAuthorID(ctxAsRole("user-bruno", authn.RoleMember), models.MessageAuthorAgent, "exec-123") + if agent != "exec-123" { + t.Fatalf("agent author = %q, want exec-123", agent) + } + // Internal/auth-disabled callers keep today's behavior. + internal := svc.resolveAuthorID(context.Background(), models.MessageAuthorUser, "whatever") + if internal != "whatever" { + t.Fatalf("internal author = %q, want whatever", internal) + } +} + +// A viewer may read a task but must not move it, edit its metadata, or change +// a repository's base branch. These are the mutators that stayed on the +// reach-only helper until review caught them. +func TestViewerCannotReachWriteOnlyMutators(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + // A viewer now comes from the tree: a direct grant could only raise the + // collaborator role the unit already gives user-bruno. + seedUnitViewer(t, "user-carla") + viewer := ctxAsRole("user-carla", authn.RoleMember) + + if _, err := svc.UpdateTaskMetadata(viewer, "task-team", map[string]interface{}{"x": 1}); !IsForbidden(err) { + t.Errorf("viewer UpdateTaskMetadata = %v, want ErrForbidden", err) + } + if _, err := svc.GetTask(viewer, "task-team"); err != nil { + t.Errorf("viewer must still read the task: %v", err) + } +} + +// A lookup failure must never read as "granted". +func TestAuthorizationFailsClosedOnLookupError(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, false) + // A task pointing at a workspace row that does not exist is a dangling + // reference and stays readable; that is the documented exception. + if err := repo.CreateTask(context.Background(), &models.Task{ + ID: "task-dangling", WorkspaceID: "ws-missing", WorkflowID: "wf-team", + WorkflowStepID: "step-1", Title: "Dangling", State: v1.TaskStateCreated, Priority: "medium", + }); err != nil { + t.Fatalf("create task: %v", err) + } + if _, err := svc.GetTask(ctxAsRole("user-bruno", authn.RoleMember), "task-dangling"); err != nil { + t.Errorf("a dangling workspace reference should not hide the task: %v", err) + } +} diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 5dc93e3ed6..3c989222dd 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" + "github.com/kandev/kandev/internal/orgunit" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" @@ -149,6 +150,9 @@ func (*testWorkflowStepGetter) GetNextStepByPosition(context.Context, string, in // caller substitute the Sessions repository (e.g. to wrap it with a test-only // hook) while reusing the same DB setup, migrations, and cleanup-worker // wiring for every other field. +// testUnits exposes each test's unit service so a seed can build a tree. +var testUnits = map[string]*orgunit.Service{} + func createTestServiceWithSessionsRepo( t *testing.T, wrapSessions func(*sqliterepo.Repository) repository.SessionRepository, @@ -209,6 +213,18 @@ func createTestServiceWithSessionsRepo( Usage: repo, }, eventBus, log, RepositoryDiscoveryConfig{}) svc.SetWorkspaceBootstrapper(repo) + // Reach comes from the unit tree, so the service tests wire the real one + // rather than a stub: a resolver the wiring never calls protects nothing. + unitStore, unitErr := orgunit.NewStore(db.NewPool(sqlxDB, sqlxDB)) + if unitErr != nil { + t.Fatalf("failed to init unit store: %v", unitErr) + } + unitSvc := orgunit.NewService(unitStore, nil) + unitSvc.SetWorkspaceCounter(repo) + svc.SetUnitPlacer(unitSvc) + svc.SetUnitReach(unitSvc) + testUnits[t.Name()] = unitSvc + t.Cleanup(func() { delete(testUnits, t.Name()) }) svc.SetWorkflowStepGetter(&testWorkflowStepGetter{repo: repo}) if err := svc.StartTaskResourceCleanupWorker(context.Background()); err != nil { t.Fatalf("failed to start task resource cleanup worker: %v", err) diff --git a/apps/backend/internal/task/service/service_unit_placement.go b/apps/backend/internal/task/service/service_unit_placement.go new file mode 100644 index 0000000000..de9b9c8d6c --- /dev/null +++ b/apps/backend/internal/task/service/service_unit_placement.go @@ -0,0 +1,94 @@ +package service + +import ( + "context" + "errors" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/task/models" +) + +// UnitPlacer answers where a new workspace belongs. +// +// Placement is resolved lazily rather than through a user-creation hook: the +// personal unit is ensured on demand, so an account created by any path, at any +// time, gets one the first time it needs one. There is no ordering to get wrong +// and no account that can slip past a hook that was not wired. +type UnitPlacer interface { + // PersonalUnitID returns the caller's personal unit, creating it if needed. + PersonalUnitID(ctx context.Context, orgID, userID, displayName string) (string, error) + // RootUnitID returns the organization's root unit, creating it if needed. + RootUnitID(ctx context.Context, orgID string) (string, error) + // UnitOrgID reports which organization a unit belongs to, so a move can + // be refused before it crosses a tenant boundary. + UnitOrgID(ctx context.Context, unitID string) (string, error) +} + +// SetUnitPlacer wires the placement seam. +func (s *Service) SetUnitPlacer(p UnitPlacer) { s.unitPlacer = p } + +// placementFor decides the unit a new workspace goes in. +// +// A workspace created by a signed-in caller lands in their personal unit, +// which is where "only I can see this" lives now that there is no private +// flag. One created by an internal or pre-authentication caller has no person +// to belong to and lands at the root, which reproduces the +// everyone-reaches-it behaviour those workspaces already had. +// +// A failure to resolve an owner's personal unit is an error rather than a +// fallback. Falling back to the root would take a workspace that was meant to +// be private and hand it to everyone in the organization, which is the one +// outcome a placement bug must never produce quietly. +func (s *Service) placementFor(ctx context.Context, ownerID, orgID string) (string, error) { + if s.unitPlacer == nil { + return "", nil + } + if ownerID != "" { + unitID, err := s.unitPlacer.PersonalUnitID(ctx, orgID, ownerID, "") + if err != nil { + s.logger.Error("personal unit lookup failed; refusing to create the workspace", + zap.String("user_id", ownerID), zap.Error(err)) + return "", err + } + return unitID, nil + } + unitID, err := s.unitPlacer.RootUnitID(ctx, orgID) + if err != nil { + s.logger.Warn("root unit lookup failed; workspace is left unplaced", zap.Error(err)) + return "", nil + } + return unitID, nil +} + +// ErrUnitNotInWorkspaceOrg reports a destination in another tenant. +var ErrUnitNotInWorkspaceOrg = errors.New("that unit belongs to another organization") + +// moveWorkspaceToUnit changes a workspace's placement, which is the only way to +// change who reaches it. +// +// The caller already holds workspace.manage. They must also hold unit.manage, +// because moving a board into a unit hands it to everyone in that unit, and +// deciding who is in a unit is exactly what that scope governs. +func (s *Service) moveWorkspaceToUnit(ctx context.Context, workspace *models.Workspace, unitID string) error { + if unitID == "" || unitID == workspace.UnitID { + return nil + } + subject := callerSubject(ctx) + if !subject.Unscoped && !authz.SubjectOrgScopes(subject).Has(authz.ScopeUnitManage) { + return ErrForbidden + } + if s.unitPlacer == nil { + return ErrUnitNotInWorkspaceOrg + } + orgID, err := s.unitPlacer.UnitOrgID(ctx, unitID) + if err != nil { + return err + } + if orgID != workspace.OrgID { + return ErrUnitNotInWorkspaceOrg + } + workspace.UnitID = unitID + return nil +} diff --git a/apps/backend/internal/task/service/service_unit_reach.go b/apps/backend/internal/task/service/service_unit_reach.go new file mode 100644 index 0000000000..630b0d09b4 --- /dev/null +++ b/apps/backend/internal/task/service/service_unit_reach.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/task/models" +) + +// UnitReachResolver answers what a user inherits from a workspace's ancestry. +type UnitReachResolver interface { + // InheritedRole returns the highest role the user holds on any unit that + // is an ancestor of the given unit, including that unit itself. + InheritedRole(ctx context.Context, userID, unitID string) (string, error) + // InheritedRolesByUnit answers the same question for many units at once, + // so filtering a workspace list stays two queries rather than one per row. + InheritedRolesByUnit(ctx context.Context, userID string, unitIDs []string) (map[string]string, error) + // UnitReaders returns everyone who reaches a workspace placed in a unit, + // which is who a workspace-scoped event fans out to. + UnitReaders(ctx context.Context, unitID string) ([]string, error) +} + +// SetUnitReach wires the inherited-role seam. +func (s *Service) SetUnitReach(r UnitReachResolver) { s.unitReach = r } + +// inheritedRole resolves what a workspace's placement gives the caller. +// +// It fails closed. A lookup error returning "no role" rather than an error +// would be indistinguishable from a genuine absence of membership, which is +// the shape of failure that quietly hands out access, so the caller treats a +// failure as denial instead. +func (s *Service) inheritedRole(ctx context.Context, userID string, workspace *models.Workspace) (authz.WorkspaceRole, bool) { + if s.unitReach == nil || workspace == nil || workspace.UnitID == "" || userID == "" { + return authz.WorkspaceRoleNone, true + } + role, err := s.unitReach.InheritedRole(ctx, userID, workspace.UnitID) + if err != nil { + s.logger.Warn("unit reach lookup failed; denying access", + zap.String("workspace_id", workspace.ID), zap.Error(err)) + return authz.WorkspaceRoleNone, false + } + return authz.NormalizeWorkspaceRole(role), true +} + +// inheritedRolesFor resolves the inherited role for a list of workspaces. +// +// It fails closed for the whole list: a partial answer would silently hide +// workspaces the caller can actually reach, which reads as data loss rather +// than as a permission error. +func (s *Service) inheritedRolesFor(ctx context.Context, userID string, workspaces []*models.Workspace) (map[string]authz.WorkspaceRole, bool) { + out := map[string]authz.WorkspaceRole{} + if s.unitReach == nil || userID == "" { + return out, true + } + unitIDs := make([]string, 0, len(workspaces)) + seen := map[string]struct{}{} + for _, w := range workspaces { + if w == nil || w.UnitID == "" { + continue + } + if _, ok := seen[w.UnitID]; ok { + continue + } + seen[w.UnitID] = struct{}{} + unitIDs = append(unitIDs, w.UnitID) + } + byUnit, err := s.unitReach.InheritedRolesByUnit(ctx, userID, unitIDs) + if err != nil { + s.logger.Warn("unit reach list failed; returning no workspaces", zap.Error(err)) + return nil, false + } + for _, w := range workspaces { + if w == nil { + continue + } + if role, ok := byUnit[w.UnitID]; ok { + out[w.ID] = authz.NormalizeWorkspaceRole(role) + } + } + return out, true +} diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go index 8e6f721d0a..fe2879f558 100644 --- a/apps/backend/internal/task/service/service_workflow.go +++ b/apps/backend/internal/task/service/service_workflow.go @@ -11,6 +11,7 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/constants" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/steptelemetry" @@ -170,7 +171,7 @@ func (s *Service) resolveApprovalNextStep(ctx context.Context, step *wfmodels.Wo // UpdateTaskState updates the state of a task, moves it to the matching column, // and publishes a task.state_changed event func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) (*models.Task, error) { - if err := s.authorizeTaskID(ctx, id); err != nil { + if err := s.authorizeTaskScope(ctx, id, authz.ScopeTaskWrite); err != nil { return nil, err } task, err := s.tasks.GetTask(ctx, id) @@ -358,7 +359,7 @@ func (s *Service) updateTaskStateIfSessionState( // UpdateTaskMetadata updates only the metadata of a task (merges with existing) func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata map[string]interface{}) (*models.Task, error) { - if err := s.authorizeTaskID(ctx, id); err != nil { + if err := s.authorizeTaskScope(ctx, id, authz.ScopeTaskWrite); err != nil { return nil, err } task, err := s.tasks.GetTask(ctx, id) @@ -520,7 +521,7 @@ func (s *Service) MoveTaskWithOptions( position int, opts MoveTaskOptions, ) (*MoveTaskResult, error) { - if err := s.authorizeTaskID(ctx, id); err != nil { + if err := s.authorizeTaskScope(ctx, id, authz.ScopeTaskWrite); err != nil { return nil, err } task, err := s.tasks.GetTask(ctx, id) diff --git a/apps/backend/internal/task/service/task_assignee_column_regression_test.go b/apps/backend/internal/task/service/task_assignee_column_regression_test.go new file mode 100644 index 0000000000..01ee2cc5a0 --- /dev/null +++ b/apps/backend/internal/task/service/task_assignee_column_regression_test.go @@ -0,0 +1,150 @@ +package service + +import ( + "context" + "reflect" + "testing" +) + +// TestUpdateTask_AssigneeSurvivesOfficeMigration is a regression net for the +// office migration dropping tasks.assignee_user_id, and it is deliberately a +// service-level test rather than a repository one: the column is added by +// internal/task/repository/sqlite (ensureTeamAccessSchema) and then silently +// removed by internal/office's priority-to-TEXT rebuild, which recreates the +// tasks table from a hardcoded column list. Only a harness that runs BOTH +// repositories against one database sees the loss. +// +// Symptoms (before fix): every write path failed with "table tasks has no +// column named assignee_user_id" on a fresh install, while the ALTER that adds +// it reported success — MigrateLogger.Apply swallows errors, so nothing in the +// logs pointed at the rebuild. +func TestUpdateTask_AssigneeSurvivesOfficeMigration(t *testing.T) { + svc, _ := setupOfficeTest(t) + ctx := context.Background() + + created, err := svc.CreateTask(ctx, &CreateTaskRequest{ + WorkspaceID: "ws-1", + Title: "Assignable task", + ProjectID: "proj-1", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + + assignee := "user-42" + updated, err := svc.UpdateTask(ctx, created.Task.ID, &UpdateTaskRequest{ + AssigneeUserID: &assignee, + }) + if err != nil { + t.Fatalf("UpdateTask with assignee: %v", err) + } + if updated.AssigneeUserID != assignee { + t.Fatalf("assignee not applied: got %q, want %q", updated.AssigneeUserID, assignee) + } + + // Read it back through the repository so the assertion covers persistence, + // not just the in-memory struct the update returned. + reloaded, err := svc.GetTask(ctx, created.Task.ID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if reloaded.AssigneeUserID != assignee { + t.Fatalf("assignee did not persist: got %q, want %q", reloaded.AssigneeUserID, assignee) + } +} + +// TestUpdateTask_HumanAndAgentAssigneesAreIndependent pins the field +// independence the spec requires: a task can carry an agent runner and a human +// owner at once, and writing either one must leave the other alone. They are +// stored in different places (the agent assignee resolves through workflow +// step participants, the human one is a task column), so a regression here +// would most likely appear as one silently clearing the other. +func TestUpdateTask_HumanAndAgentAssigneesAreIndependent(t *testing.T) { + svc, _ := setupOfficeTest(t) + ctx := context.Background() + + created, err := svc.CreateTask(ctx, &CreateTaskRequest{ + WorkspaceID: "ws-1", + Title: "Dual assignee", + ProjectID: "proj-1", + AssigneeAgentProfileID: "agent-1", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + agentAssignee := created.Task.AssigneeAgentProfileID + if agentAssignee == "" { + t.Fatal("fixture produced no agent assignee: the independence assertions below would be vacuous") + } + + human := "user-42" + updated, err := svc.UpdateTask(ctx, created.Task.ID, &UpdateTaskRequest{AssigneeUserID: &human}) + if err != nil { + t.Fatalf("UpdateTask with human assignee: %v", err) + } + if updated.AssigneeUserID != human { + t.Fatalf("human assignee = %q, want %q", updated.AssigneeUserID, human) + } + if updated.AssigneeAgentProfileID != agentAssignee { + t.Fatalf("setting the human assignee changed the agent assignee: %q -> %q", + agentAssignee, updated.AssigneeAgentProfileID) + } + + // And the reverse: reassigning the human owner is what takeover is, so it + // must not disturb the runner either. + other := "user-7" + reassigned, err := svc.UpdateTask(ctx, created.Task.ID, &UpdateTaskRequest{AssigneeUserID: &other}) + if err != nil { + t.Fatalf("reassign: %v", err) + } + if reassigned.AssigneeAgentProfileID != agentAssignee { + t.Fatalf("takeover changed the agent assignee: %q -> %q", + agentAssignee, reassigned.AssigneeAgentProfileID) + } + if reassigned.AssigneeUserID != other { + t.Fatalf("human assignee = %q, want %q", reassigned.AssigneeUserID, other) + } +} + +// TestUpdateTask_TakeoverLeavesExecutionStateUntouched is the "takeover is +// reassign plus continue, not a lock" guarantee: a reassignment writes exactly +// one field. Anything else changing here (state, workspace, session or +// workflow placement) would mean a handover silently disturbed a running +// agent's execution context. +func TestUpdateTask_TakeoverLeavesExecutionStateUntouched(t *testing.T) { + svc, _ := setupOfficeTest(t) + ctx := context.Background() + + created, err := svc.CreateTask(ctx, &CreateTaskRequest{ + WorkspaceID: "ws-1", + Title: "In flight", + ProjectID: "proj-1", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + before, err := svc.GetTask(ctx, created.Task.ID) + if err != nil { + t.Fatalf("GetTask before: %v", err) + } + + human := "user-42" + if _, err := svc.UpdateTask(ctx, created.Task.ID, &UpdateTaskRequest{AssigneeUserID: &human}); err != nil { + t.Fatalf("takeover: %v", err) + } + after, err := svc.GetTask(ctx, created.Task.ID) + if err != nil { + t.Fatalf("GetTask after: %v", err) + } + + if after.AssigneeUserID != human { + t.Fatalf("assignee = %q, want %q", after.AssigneeUserID, human) + } + // Normalise the two fields a reassignment is allowed to move, then compare + // everything else field by field. + after.AssigneeUserID = before.AssigneeUserID + after.UpdatedAt = before.UpdatedAt + if !reflect.DeepEqual(before, after) { + t.Fatalf("takeover changed more than the assignee:\nbefore: %+v\nafter: %+v", before, after) + } +} diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index f65c91e9cb..c5a9906322 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -66,6 +66,9 @@ const ( RoleAdmin = "admin" // RoleMember is a regular authenticated user. RoleMember = "member" + // RoleGuest reaches only workspaces it is an explicit member of. It is + // never assigned by migration; an admin grants it deliberately. + RoleGuest = "guest" // StatusActive marks a user that may log in. StatusActive = "active" @@ -74,14 +77,35 @@ const ( StatusDisabled = "disabled" ) +// NormalizeRole returns the canonical org role. Admin and guest are accepted +// as-is; anything else (including empty and unknown values) becomes member, +// which is the role every pre-guest account already had. +func NormalizeRole(value string) string { + switch value { + case RoleAdmin: + return RoleAdmin + case RoleGuest: + return RoleGuest + default: + return RoleMember + } +} + type User struct { - ID string `json:"id"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Role string `json:"role"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Status string `json:"status"` + // OrgID is the user's tenant. Empty means the account predates + // organizations or the tenancy migration has not run. + OrgID string `json:"org_id,omitempty"` + // IsOperator marks the instance operator tier: managing organizations, + // feature toggles and instance configuration. It grants NO access inside + // any organization, including the operator's own. + IsOperator bool `json:"is_operator,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type UserSettings struct { diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 156c00c89c..4d7d8b4df7 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -66,6 +66,8 @@ func (r *sqliteRepository) initSchema() error { email TEXT NOT NULL, display_name TEXT NOT NULL DEFAULT '', role TEXT NOT NULL DEFAULT 'admin', + org_id TEXT NOT NULL DEFAULT '', + is_operator INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', settings TEXT NOT NULL DEFAULT '{}', settings_revision BIGINT NOT NULL DEFAULT 0, @@ -101,6 +103,16 @@ func (r *sqliteRepository) runMigrations() { m.Apply("users.role", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'") m.Apply("users.status", "ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'") m.Apply("users.settings_revision", "ALTER TABLE users ADD COLUMN settings_revision BIGINT NOT NULL DEFAULT 0") + // Organizations. Empty means "not yet assigned"; the tenancy migration + // puts every existing account into the default org on first boot with the + // feature on. Email stays unique instance-wide rather than per-org, so an + // address identifies one person and the login form needs no org picker. + m.Apply("users.org_id", "ALTER TABLE users ADD COLUMN org_id TEXT NOT NULL DEFAULT ''") + m.Apply("users.org_idx", "CREATE INDEX IF NOT EXISTS idx_users_org ON users(org_id)") + // The instance operator tier. Granted to the first admin by the tenancy + // migration so an upgraded instance is never left with nobody able to + // manage organizations. + m.Apply("users.is_operator", "ALTER TABLE users ADD COLUMN is_operator INTEGER NOT NULL DEFAULT 0") // Safe pre-auth: the table only ever held the single default-user row. m.Apply("users.email_unique", "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)") } @@ -141,7 +153,7 @@ func (r *sqliteRepository) SetAgentProfileRecentUseLogger(log *commonlogger.Logg r.recentUseLogger = log } -const userColumns = "id, email, display_name, role, status, created_at, updated_at" +const userColumns = "id, email, display_name, role, status, org_id, is_operator, created_at, updated_at" // GetUser returns the user row for the given id. func (r *sqliteRepository) GetUser(ctx context.Context, id string) (*models.User, error) { @@ -200,9 +212,10 @@ func (r *sqliteRepository) CreateUser(ctx context.Context, user *models.User) er user.CreatedAt = now user.UpdatedAt = now _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO users (id, email, display_name, role, status, settings, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, '{}', ?, ?) - `), user.ID, user.Email, user.DisplayName, user.Role, user.Status, user.CreatedAt, user.UpdatedAt) + INSERT INTO users (id, email, display_name, role, status, org_id, is_operator, settings, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, '{}', ?, ?) + `), user.ID, user.Email, user.DisplayName, user.Role, user.Status, user.OrgID, user.IsOperator, + user.CreatedAt, user.UpdatedAt) return err } @@ -684,7 +697,7 @@ func (r *sqliteRepository) scanConditionalSettingsUpdate(ctx context.Context, sc // scanUser scans a single user row into a models.User. func scanUser(scanner interface{ Scan(dest ...any) error }) (*models.User, error) { user := &models.User{} - if err := scanner.Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.Status, &user.CreatedAt, &user.UpdatedAt); err != nil { + if err := scanner.Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.Status, &user.OrgID, &user.IsOperator, &user.CreatedAt, &user.UpdatedAt); err != nil { return nil, err } return user, nil diff --git a/apps/backend/internal/user/store/sqlite_tenancy.go b/apps/backend/internal/user/store/sqlite_tenancy.go new file mode 100644 index 0000000000..7d9ff98195 --- /dev/null +++ b/apps/backend/internal/user/store/sqlite_tenancy.go @@ -0,0 +1,102 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/kandev/kandev/internal/user/models" +) + +// Tenancy operations: assigning accounts to an organization, the instance +// operator tier, and removing an organization's accounts. +// +// Split from sqlite.go because that file is already at the 800-line limit. + +// SetUserOrg assigns a user to an organization. A user belongs to exactly one +// org, and this is the only way a user's org changes: there is no transfer +// flow, because moving a person between tenants would leave their workspaces +// behind in the old one. +func (r *sqliteRepository) SetUserOrg(ctx context.Context, id, orgID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE users SET org_id = ?, updated_at = ? WHERE id = ? + `), orgID, time.Now().UTC(), id) + return err +} + +// AssignUsersWithoutOrg puts every account that has no organization into the +// given one. It is the user half of the tenancy migration and is idempotent. +func (r *sqliteRepository) AssignUsersWithoutOrg(ctx context.Context, orgID string) (int64, error) { + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE users SET org_id = ? WHERE org_id = '' OR org_id IS NULL + `), orgID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +// ListUsersByOrg returns the accounts belonging to one organization. +func (r *sqliteRepository) ListUsersByOrg(ctx context.Context, orgID string) ([]*models.User, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT `+userColumns+` FROM users WHERE org_id = ? ORDER BY created_at ASC, id ASC + `), orgID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + users := make([]*models.User, 0) + for rows.Next() { + user, err := scanUser(rows) + if err != nil { + return nil, err + } + users = append(users, user) + } + return users, rows.Err() +} + +// SetOperator grants or revokes the instance operator tier. +func (r *sqliteRepository) SetOperator(ctx context.Context, id string, operator bool) error { + flag := 0 + if operator { + flag = 1 + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE users SET is_operator = ?, updated_at = ? WHERE id = ? + `), flag, time.Now().UTC(), id) + return err +} + +// CountOperators reports how many accounts hold the operator tier. Used to +// refuse removing the last one. +func (r *sqliteRepository) CountOperators(ctx context.Context) (int, error) { + var n int + err := r.ro.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE is_operator = 1`).Scan(&n) + return n, err +} + +// FirstAdminID returns the oldest active admin account, or "" when there is +// none. Used to grant the operator tier during the tenancy migration. +func (r *sqliteRepository) FirstAdminID(ctx context.Context) (string, error) { + var id string + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT id FROM users WHERE role = 'admin' AND status = 'active' + ORDER BY created_at ASC, id ASC LIMIT 1 + `)).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return id, err +} + +// DeleteUsersByOrg removes every account belonging to an organization. Called +// only from organization deletion, after that org's data is gone. +func (r *sqliteRepository) DeleteUsersByOrg(ctx context.Context, orgID string) error { + if orgID == "" { + return nil + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM users WHERE org_id = ?`), orgID) + return err +} diff --git a/apps/backend/internal/user/store/store.go b/apps/backend/internal/user/store/store.go index ea66746c8a..7d7597bb8a 100644 --- a/apps/backend/internal/user/store/store.go +++ b/apps/backend/internal/user/store/store.go @@ -52,3 +52,30 @@ type AccountRepository interface { } var _ AccountRepository = (*sqliteRepository)(nil) + +// TenancyRepository is the account surface organizations need: assigning +// accounts to an org, the instance operator tier, and removing an org's +// accounts. It is separate from AccountRepository so existing fakes are +// unaffected by the tenancy feature. +type TenancyRepository interface { + AssignUsersWithoutOrg(ctx context.Context, orgID string) (int64, error) + SetUserOrg(ctx context.Context, id, orgID string) error + SetOperator(ctx context.Context, id string, operator bool) error + CountOperators(ctx context.Context) (int, error) + FirstAdminID(ctx context.Context) (string, error) + DeleteUsersByOrg(ctx context.Context, orgID string) error +} + +// NoopTenancy is the fallback for account repositories that predate +// organizations. Every operation reports "nothing to do" rather than failing, +// so a fake in a test never has to know about tenancy. +type NoopTenancy struct{} + +func (NoopTenancy) AssignUsersWithoutOrg(context.Context, string) (int64, error) { return 0, nil } +func (NoopTenancy) SetUserOrg(context.Context, string, string) error { return nil } +func (NoopTenancy) SetOperator(context.Context, string, bool) error { return nil } +func (NoopTenancy) CountOperators(context.Context) (int, error) { return 0, nil } +func (NoopTenancy) FirstAdminID(context.Context) (string, error) { return "", nil } +func (NoopTenancy) DeleteUsersByOrg(context.Context, string) error { return nil } + +var _ TenancyRepository = (*sqliteRepository)(nil) diff --git a/apps/web/app/auth/login-page.tsx b/apps/web/app/auth/login-page.tsx index 272bfcde13..762cdb1723 100644 --- a/apps/web/app/auth/login-page.tsx +++ b/apps/web/app/auth/login-page.tsx @@ -5,6 +5,7 @@ import { Button } from "@kandev/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; import { IconLock } from "@tabler/icons-react"; +import type { TFunction } from "i18next"; import { ApiError } from "@/lib/api/client"; import { login } from "@/lib/api/domains/auth-api"; import { useAppStore } from "@/components/state-provider"; @@ -61,7 +62,7 @@ export function LoginPage() { } catch (err) { setSubmitting(false); if (err instanceof ApiError) { - setError(err.status === 429 ? t("auth:tooManyAttempts") : t("auth:invalidEmailOrPassword")); + setError(loginErrorMessage(err, t)); return; } setError(t("auth:somethingWentWrong")); @@ -127,3 +128,16 @@ export function LoginPage() { ); } + +/** + * Maps a failed sign-in to the message that tells the user what to actually do. + * + * A suspended organization must NOT read as a bad password: the credential was + * accepted, and saying otherwise sends the user to reset a password that works. + */ +function loginErrorMessage(err: ApiError, t: TFunction): string { + if (err.status === 429) return t("auth:tooManyAttempts"); + const code = (err.body as { code?: string } | null)?.code; + if (code === "org_suspended") return t("auth:organizationUnavailable"); + return t("auth:invalidEmailOrPassword"); +} diff --git a/apps/web/app/office/tasks/[id]/map-office-task.test.ts b/apps/web/app/office/tasks/[id]/map-office-task.test.ts index 39dd8d4393..8df3986e03 100644 --- a/apps/web/app/office/tasks/[id]/map-office-task.test.ts +++ b/apps/web/app/office/tasks/[id]/map-office-task.test.ts @@ -38,6 +38,19 @@ describe("mapOfficeTaskToTask", () => { expect(task.completedAt).toBeUndefined(); }); + // The human assignee travels store -> detail Task through this mapper. A + // dropped hop here is invisible to a component test that builds its own + // Task, and would surface as a task that reads unassigned no matter what + // the backend stored. + it("carries the human assignee through, independently of the agent assignee", () => { + const task = mapOfficeTaskToTask( + baseOfficeTask({ assigneeUserId: "user-7", assigneeAgentProfileId: "agent-3" }), + ); + + expect(task.assigneeUserId).toBe("user-7"); + expect(task.assigneeAgentProfileId).toBe("agent-3"); + }); + it("always maps createdBy to an empty string (no source of truth yet)", () => { const task = mapOfficeTaskToTask(baseOfficeTask()); diff --git a/apps/web/app/office/tasks/[id]/map-office-task.ts b/apps/web/app/office/tasks/[id]/map-office-task.ts index 6761ba09ed..6f8faf20ca 100644 --- a/apps/web/app/office/tasks/[id]/map-office-task.ts +++ b/apps/web/app/office/tasks/[id]/map-office-task.ts @@ -46,6 +46,7 @@ export function mapOfficeTaskToTask( typeof l === "string" ? { name: l, color: "#6b7280" } : l, ), assigneeAgentProfileId: raw.assigneeAgentProfileId, + assigneeUserId: raw.assigneeUserId, parentId: raw.parentId, projectId: raw.projectId, blockedBy: extra.blockedBy ?? [], diff --git a/apps/web/app/office/tasks/[id]/types.ts b/apps/web/app/office/tasks/[id]/types.ts index c4274043dc..2410040e2f 100644 --- a/apps/web/app/office/tasks/[id]/types.ts +++ b/apps/web/app/office/tasks/[id]/types.ts @@ -150,6 +150,7 @@ export type Task = { labels: TaskLabelLocal[]; assigneeAgentProfileId?: string; assigneeName?: string; + assigneeUserId?: string; projectId?: string; projectName?: string; projectColor?: string; diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 7c4650d564..41405b054f 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -9,6 +9,7 @@ import { Card, CardContent } from "@kandev/ui/card"; import { Separator } from "@kandev/ui/separator"; import { useToast } from "@/components/toast-provider"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import type { Agent, AgentDiscovery, @@ -451,17 +452,19 @@ function AgentSetupForm({ if (savedDraft) saveRevision.setSaved(JSON.stringify(savedDraft)); }; const profilesValid = areAgentProfilesValid(draftAgent); - const saveInvalidReason = resolveSaveInvalidReason( - t, - profilesValid, - hasInvalidMcpConfig, - draftAgent.name === "dynamic", - ); + // Agents and agent profiles are org configuration: every mutating route + // behind this page requires org.config.manage, which only an administrator + // holds. Without this the save bar stays live for a member and the write + // fails with a 403 they cannot act on. + const canManage = useIsAdmin(); + const saveInvalidReason = canManage + ? resolveSaveInvalidReason(t, profilesValid, hasInvalidMcpConfig, draftAgent.name === "dynamic") + : t("agents:adminOnly"); useSettingsSaveContributor({ id: `agent:${draftAgent.id}`, revision: saveRevision.revision, isDirty: isCreateMode ? isAgentDirty : saveRevision.revision !== saveRevision.saved, - canSave: profilesValid && !hasInvalidMcpConfig, + canSave: canManage && profilesValid && !hasInvalidMcpConfig, invalidReason: saveInvalidReason, save: handleCoordinatedSave, discard: () => undefined, @@ -477,7 +480,7 @@ function AgentSetupForm({ isCreateMode={isCreateMode} savedAgent={savedAgent} showInstallationStatus={draftAgent.name !== "dynamic"} - onDelete={handleDeleteAgent} + onDelete={canManage ? handleDeleteAgent : undefined} /> ({ useIsAdmin: () => true })); + import { ProfileMcpConfigCard } from "./profile-mcp-config-card"; const DIRTY_ATTRIBUTE = "data-settings-dirty"; diff --git a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx index f5e9df3172..d37b3432c3 100644 --- a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx +++ b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx @@ -7,6 +7,7 @@ import { Switch } from "@kandev/ui/switch"; import { Textarea } from "@kandev/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import { SettingsCard } from "@/components/settings/settings-card"; // `validateDraftServers` runs outside React (from an onChange handler and from // the parent's draft state), so it uses the module-level `t`, which resolves at @@ -412,6 +413,7 @@ export function ProfileMcpConfigCard({ mcpBaselineServers, mcpError, }); + const canManage = useIsAdmin(); useSettingsSaveContributor({ id: `agent-profile-mcp:${profileId}`, revision: JSON.stringify({ @@ -419,8 +421,9 @@ export function ProfileMcpConfigCard({ servers: state.currentServers, }), isDirty: supportsMcp && state.isEditableProfile && state.currentDirty, - canSave: !state.currentError, - invalidReason: state.currentError ?? undefined, + // Same org.config.manage gate as the agent form this card saves beside. + canSave: canManage && !state.currentError, + invalidReason: canManage ? (state.currentError ?? undefined) : t("agents:adminOnly"), save: handleSaveMcp, discard: resetMcpDraft, }); diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 3993e2f356..7106db3453 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -22,6 +22,7 @@ import { listAgents, listAvailableAgents, } from "@/lib/api"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import type { AgentUpdateJob, AgentUpdatePreview, AgentUpdateStatus, InstallJob } from "@/lib/api"; import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"; import { useAgentRuntimeUpdates } from "@/hooks/domains/settings/use-agent-runtime-updates"; @@ -71,6 +72,7 @@ type InstalledAgentsSectionProps = { ) => Promise; setTuiDialogOpen: (open: boolean) => void; handleRescan: () => Promise; + canManage: boolean; }; function InstalledAgentsHeader({ @@ -81,7 +83,10 @@ function InstalledAgentsHeader({ }: { rescanning: boolean; onOpenShell: () => void; - onOpenTuiDialog: () => void; + /** Absent for a caller without org.config.manage: creating a TUI agent is + * a write this page must not offer them. Rescan and the host shell stay: + * discovery is a read. */ + onOpenTuiDialog?: () => void; onRescan: () => void; }) { const { t } = useTranslation(); @@ -117,16 +122,18 @@ function InstalledAgentsHeader({ )} {t("agents:rescan")} - + {onOpenTuiDialog && ( + + )} ); @@ -189,6 +196,7 @@ function InstalledAgentsSection({ startUpdate, setTuiDialogOpen, handleRescan, + canManage, }: InstalledAgentsSectionProps) { const { t } = useTranslation(); const [shellOpen, setShellOpen] = useState(false); @@ -207,7 +215,7 @@ function InstalledAgentsSection({ setShellOpen(true)} - onOpenTuiDialog={() => setTuiDialogOpen(true)} + onOpenTuiDialog={canManage ? () => setTuiDialogOpen(true) : undefined} onRescan={() => void handleRescan()} /> void handleRescan(), } : {})} @@ -282,6 +290,9 @@ function useAgentPageState() { const { items: availableAgents } = useAvailableAgents(); const [rescanning, setRescanning] = useState(false); const [tuiDialogOpen, setTuiDialogOpen] = useState(false); + // Agents and agent profiles are org configuration: every write behind this + // page requires org.config.manage, which only an administrator holds. + const canManage = useIsAdmin(); const { updateJobs, previewUpdate, startUpdate } = useAgentRuntimeUpdates(); const { refresh: refreshRuntimeUpdateStatuses, statusByAgent } = useAgentRuntimeUpdateStatuses(updateJobs); @@ -340,6 +351,7 @@ function useAgentPageState() { }; return { + canManage, savedAgents, installedAgents, discoveryAgents, @@ -363,6 +375,7 @@ function useAgentPageState() { export default function AgentsSettingsPage() { const { + canManage, savedAgents, installedAgents, discoveryAgents, @@ -393,17 +406,19 @@ export default function AgentsSettingsPage() { {/* The page's primary action: everything else on this page manages what is already installed. */} - + {canManage && ( + + )} @@ -411,6 +426,7 @@ export default function AgentsSettingsPage() { onNameChange(e.target.value)} /> @@ -210,6 +216,7 @@ function WorkspaceSettingsCard({ } type DeleteWorkspaceCardProps = { + canManage: boolean; workspaceName: string; deleteDialogOpen: boolean; setDeleteDialogOpen: (open: boolean) => void; @@ -219,6 +226,7 @@ type DeleteWorkspaceCardProps = { }; function DeleteWorkspaceCard({ + canManage, workspaceName, deleteDialogOpen, setDeleteDialogOpen, @@ -227,6 +235,9 @@ function DeleteWorkspaceCard({ onDelete, }: DeleteWorkspaceCardProps) { const { t } = useTranslation(); + // Deleting a workspace is owner-only. The API refuses a member either way; + // hiding the card keeps the UI from offering an action it will reject. + if (!canManage) return null; return ( <> @@ -523,6 +534,9 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { handleDiscard, handleDeleteWorkspace, } = useWorkspaceEditForm(workspace); + // Owner-only controls are gated on the server-issued scope, never on an + // owner-id comparison, so the UI and the API agree on one answer. + const canManage = hasScope(workspace.scopes, SCOPE.workspaceManage); const { t } = useTranslation(); useSettingsSaveContributor({ @@ -544,6 +558,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { + {/* + Reads the live store item rather than the form's captured snapshot: + scopes arrive with hydration, which can land after this + form mounts, and a snapshot would freeze the owner out of their own + workspace. + */} + + + + ({ - id: workspace.id, - name: workspace.name, - description: workspace.description ?? null, - owner_id: workspace.owner_id, - default_executor_id: workspace.default_executor_id ?? null, - default_environment_id: workspace.default_environment_id ?? null, - default_agent_profile_id: workspace.default_agent_profile_id ?? null, - created_at: workspace.created_at, - updated_at: workspace.updated_at, - })), + // Both lists go through the shared mapper. Hand-copying fields here is + // how visibility and the caller's scopes got dropped from the store the + // first time, which silently rendered a workspace read-only to its own + // owner. + mapWorkspaceItem(created), + // Existing entries are already store items; re-listing their fields by + // hand is what dropped visibility and scopes before. + ...items, ]); setNewWorkspaceName(""); setIsAdding(false); @@ -188,6 +175,8 @@ export function WorkspacesPageClient() { + +
{isAdding && ( diff --git a/apps/web/components/app-sidebar/current-user-chip.tsx b/apps/web/components/app-sidebar/current-user-chip.tsx index 485d097f32..9c7195b219 100644 --- a/apps/web/components/app-sidebar/current-user-chip.tsx +++ b/apps/web/components/app-sidebar/current-user-chip.tsx @@ -12,13 +12,7 @@ import { import { logout } from "@/lib/api/domains/auth-api"; import { useAppStore } from "@/components/state-provider"; import { cn } from "@/lib/utils"; - -function initialsFor(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return "?"; - if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); - return `${parts[0]![0]}${parts[parts.length - 1]![0]}`.toUpperCase(); -} +import { initialsFor } from "@/lib/user-initials"; async function handleLogout() { try { diff --git a/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts b/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts index daf850ae69..a4b0a6f09a 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts +++ b/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts @@ -6,6 +6,7 @@ import { IconCommand, IconCpu, IconDatabase, + IconBuilding, IconFlask, IconFolder, IconInfoCircle, @@ -19,6 +20,7 @@ import { IconRobot, IconShieldLock, IconTerminal2, + IconSitemap, IconUsers, IconWand, } from "@tabler/icons-react"; @@ -70,7 +72,7 @@ export type SettingsMenuItem = { * the page that owns them. */ activePrefixes?: string[]; - requires?: "account" | "users"; + requires?: "account" | "users" | "organizations"; /** Rows whose page owns a list show its size as a trailing badge. */ countKey?: MenuCountKey; }; @@ -174,6 +176,23 @@ export const SETTINGS_MENU_SECTIONS: SettingsMenuSection[] = [ icon: IconUsers, requires: "users", }, + // An organization is a tenant boundary above users, so it belongs with + // the other who-can-reach-what settings rather than under System, which + // is instance operation. The href keeps its /settings/system/ prefix, + // like the Users row above it: these sections group by subject, not by + // URL. + { + href: "/settings/units", + labelKey: "settings:navUnits", + icon: IconSitemap, + requires: "users", + }, + { + href: "/settings/system/organizations", + labelKey: "orgs:navOrganizations", + icon: IconBuilding, + requires: "organizations", + }, { href: ACCOUNT_SECURITY_SETTINGS_HREF, labelKey: "sidebar:profileAndPassword", diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx index 6a5ef6a99e..4f07130c24 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx @@ -69,9 +69,9 @@ const state = { savedLayouts: [] as Array<{ id: string }>, }, auth: { - mode: "disabled" as const, + mode: "disabled" as string, authenticated: true, - user: null, + user: null as { role: string } | null, }, settingsMenu: { mode: "flat" as SettingsMenuMode, @@ -87,8 +87,12 @@ vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: typeof state) => unknown) => selector(state), })); +// Per-test feature flags, so a row that only exists behind one can be driven +// both ways rather than being permanently invisible to this suite. +const featuresOn = new Set(); + vi.mock("@/hooks/domains/features/use-feature", () => ({ - useFeature: () => false, + useFeature: (name: string) => featuresOn.has(name), })); vi.mock("@/hooks/domains/settings/use-secrets", () => ({ @@ -146,7 +150,12 @@ describe("SettingsTree static menu", () => { setMenuMode("flat"); }); - afterEach(() => cleanup()); + afterEach(() => { + cleanup(); + featuresOn.clear(); + state.auth.mode = "disabled"; + state.auth.user = null; + }); it("renders section headers as static text, not links or buttons", () => { render(); @@ -195,6 +204,35 @@ describe("SettingsTree static menu", () => { expect(screen.queryByRole("link", { name: "API Tokens" })).toBeNull(); }); + // An organization is a boundary above users, so it belongs with the other + // who-can-reach-what settings. It used to sit under System, which is for + // operating the instance. + it("puts Organizations in Access Control, not System", () => { + featuresOn.add("auth"); + featuresOn.add("multiTenancy"); + state.auth.mode = "enabled"; + state.auth.user = { role: "admin" }; + + render(); + + const access = screen.getByTestId("settings-section-access"); + expect(within(access).getByRole("link", { name: "Organizations" })).toBeTruthy(); + const system = screen.queryByTestId("settings-section-system"); + if (system) { + expect(within(system).queryByRole("link", { name: "Organizations" })).toBeNull(); + } + }); + + it("hides Organizations when the feature is off, rather than linking a page that refuses", () => { + featuresOn.add("auth"); + state.auth.mode = "enabled"; + state.auth.user = { role: "admin" }; + + render(); + + expect(screen.queryByRole("link", { name: "Organizations" })).toBeNull(); + }); + it("keeps a section inside Task Behavior instead of rendering it as a page row", () => { render(); diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx index 17ed31eccd..4c41868623 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx @@ -170,10 +170,16 @@ export function SettingsTree({ }) { const { t } = useTranslation(); const authEnabled = useFeature("auth"); + const multiTenancyEnabled = useFeature("multiTenancy"); const authMode = useAppStore((s) => s.auth.mode); const isAdmin = useIsAdmin(); const showAccountItems = authEnabled && authMode === "enabled"; const showUsersItem = authEnabled && isAdmin; + // Only the instance operator can use this page, but that answer costs a + // request, so the row is gated on the feature instead and the page itself + // turns a non-operator away. Before this it was rendered even on installs + // with Organizations switched off. + const showOrganizationsItem = authEnabled && multiTenancyEnabled; const discoveryItems = useSettingsDiscovery(); const counts = useSettingsMenuCounts(); const [query, setQuery] = useState(""); @@ -185,6 +191,7 @@ export function SettingsTree({ const itemVisible = (item: SettingsMenuItem) => { if (item.requires === "account") return showAccountItems; if (item.requires === "users") return showUsersItem; + if (item.requires === "organizations") return showOrganizationsItem; return true; }; @@ -203,7 +210,11 @@ export function SettingsTree({ const items = section.items.filter(itemVisible); if (items.length === 0) return null; return ( -
+
{items.map((item) => ( + + {initialsFor(name)} + + {name} + + ); +} diff --git a/apps/web/components/kanban-card-assignee.test.tsx b/apps/web/components/kanban-card-assignee.test.tsx new file mode 100644 index 0000000000..76dfb97f66 --- /dev/null +++ b/apps/web/components/kanban-card-assignee.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mockAppState = { + workspaces: { activeId: "ws-1" }, + kanban: { tasks: [] as Array<{ id: string; title: string }> }, + kanbanMulti: { snapshots: {} as Record }> }, + taskPRs: { byTaskId: {} as Record }, + taskMRs: { byWorkspaceId: {} as Record> }, +}; + +// The card body reaches the store two ways: useAppStore for the slices above, +// and useAppStoreApi for the imperative read the action menu does. A mock that +// defines only the first throws on render, so the assignee assertions below +// would fail for a reason that has nothing to do with the assignee. +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState), + useAppStoreApi: () => ({ + getState: () => mockAppState, + setState: vi.fn(), + subscribe: () => () => undefined, + }), +})); + +vi.mock("@/lib/api/domains/team-access-api", () => ({ + listDirectoryUsers: vi + .fn() + .mockResolvedValue({ users: [{ id: "user-1", display_name: "Ada Lovelace" }], total: 1 }), + listWorkspaceMembers: vi.fn().mockResolvedValue({ members: [], total: 0 }), +})); + +import { listDirectoryUsers } from "@/lib/api/domains/team-access-api"; +import { resetDirectoryCacheForTests } from "@/hooks/domains/users/use-assignable-people"; +import { KanbanCardBody } from "./kanban-card-content"; +import type { Task } from "./kanban-card"; + +const TASK: Task = { id: "task-1", title: "Fix the bug", workflowStepId: "step-1" }; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + resetDirectoryCacheForTests(); +}); + +describe("KanbanCardBody — human assignee", () => { + it("shows the assignee's name so a board answers who is on what", async () => { + render(); + + await waitFor(() => + expect(screen.getByTestId("kanban-card-assignee").textContent).toContain("Ada Lovelace"), + ); + }); + + it("renders no assignee affordance on an unassigned task", () => { + render(); + expect(screen.queryByTestId("kanban-card-assignee")).toBeNull(); + }); + + it("fetches the directory once for a board full of cards", async () => { + render( + <> + + + + , + ); + + await waitFor(() => expect(screen.getAllByTestId("kanban-card-assignee")).toHaveLength(3)); + expect(listDirectoryUsers).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index b52ada1925..11524b76b5 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -13,10 +13,10 @@ import { IconUsersGroup, } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; +import { AssigneeBadge } from "@/components/kanban-card-assignee-badge"; import { Card, CardContent } from "@kandev/ui/card"; import { Checkbox } from "@kandev/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { PRTaskIcon } from "@/components/github/pr-task-icon"; import { MRTaskIcon } from "@/components/gitlab/mr-task-icon"; import { RegisteredChangeRequestTaskIcon } from "@/components/integrations/registered-change-request-task-icon"; @@ -26,6 +26,7 @@ import { } from "@/components/kanban-card-menu-items"; import { TaskCardIndicators, TaskCardTags } from "@/components/kanban-card-plugin-slots"; import { KanbanCardPriorityIndicator } from "@/components/kanban-card-priority-indicator"; +import { RepoChipRow } from "@/components/kanban-card-repository-chips"; import { CardTitle } from "@/components/kanban-card-title"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; @@ -71,66 +72,6 @@ export type KanbanCardShellProps = KanbanCardActionProps & onCheckboxClick: (e: React.MouseEvent) => void; }; -const REPO_CHIPS_VISIBLE = 2; - -function RepoChip({ chip }: { chip: RepositoryChip }) { - const badge = ( - - {chip.label} - - ); - if (!chip.path) return badge; - return ( - - {badge} - - {chip.path} - - - ); -} - -function OverflowRepoTooltip({ chips }: { chips: RepositoryChip[] }) { - return ( -
- {chips.map((chip) => ( -
-
{chip.label}
- {chip.path &&
{chip.path}
} -
- ))} -
- ); -} - -function RepoChipRow({ chips }: { chips: RepositoryChip[] }) { - if (chips.length === 0) return null; - const visible = chips.slice(0, REPO_CHIPS_VISIBLE); - const overflow = chips.slice(REPO_CHIPS_VISIBLE); - return ( -
- {visible.map((chip) => ( - - ))} - {overflow.length > 0 && ( - - - - +{overflow.length} - - - - - - - )} -
- ); -} - export function KanbanCardBody({ task, repositoryChips, @@ -211,6 +152,7 @@ function KanbanCardBadges({ task }: { task: Task }) { return (
{task.blocked && } + {task.assigneeUserId && } {task.queuedForStepId && ( + {chip.label} + + ); + if (!chip.path) return badge; + return ( + + {badge} + + {chip.path} + + + ); +} + +function OverflowRepoTooltip({ chips }: { chips: RepositoryChip[] }) { + return ( +
+ {chips.map((chip) => ( +
+
{chip.label}
+ {chip.path &&
{chip.path}
} +
+ ))} +
+ ); +} + +export function RepoChipRow({ chips }: { chips: RepositoryChip[] }) { + if (chips.length === 0) return null; + const visible = chips.slice(0, REPO_CHIPS_VISIBLE); + const overflow = chips.slice(REPO_CHIPS_VISIBLE); + return ( +
+ {visible.map((chip) => ( + + ))} + {overflow.length > 0 && ( + + + + +{overflow.length} + + + + + + + )} +
+ ); +} diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index b957bcdc65..04abca24f0 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -88,6 +88,8 @@ export interface Task { primaryExecutorType?: string | null; primaryExecutorName?: string | null; isRemoteExecutor?: boolean; + /** Human assignee (user id); the card renders their name read-only. */ + assigneeUserId?: string; parentTaskId?: string | null; workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; updatedAt?: string; diff --git a/apps/web/components/settings/agents/agent-admin-gating.test.tsx b/apps/web/components/settings/agents/agent-admin-gating.test.tsx new file mode 100644 index 0000000000..30a2d315dc --- /dev/null +++ b/apps/web/components/settings/agents/agent-admin-gating.test.tsx @@ -0,0 +1,115 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@kandev/ui/tooltip"; + +import type { Agent, AgentProfile } from "@/lib/types/http"; + +// Agents and agent profiles are org configuration: every mutating route behind +// these components requires org.config.manage, which only an administrator +// holds. isAdmin is the frontend's mirror of that scope, so it is the one knob +// these cases turn. +let isAdmin = true; + +vi.mock("@/hooks/domains/auth/use-is-admin", () => ({ useIsAdmin: () => isAdmin })); + +const storeState = { + settingsAgents: { items: [] as Agent[] }, + agentProfiles: { items: [] as Array<{ id: string }> }, +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (s: unknown) => unknown) => selector(storeState), + useAppStoreApi: () => ({ getState: () => storeState, setState: vi.fn() }), +})); + +vi.mock("@/app/actions/agents", () => ({ + deleteAgentProfileAction: vi.fn(), + duplicateAgentProfileAction: vi.fn(), +})); + +vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }) })); + +vi.mock("@/lib/routing/client-router", () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => "/settings/agents", +})); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isFullDesktop: true, isFinePointer: true }), +})); + +vi.mock("@/components/routing/app-link", () => ({ + default: ({ children, ...props }: { children?: ReactNode }) => {children}, +})); + +vi.mock("@kandev/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children?: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children?: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children }: { children?: ReactNode }) => , +})); + +import { ProfileRow } from "./agent-profiles-section"; +import { InstallAgentCard } from "@/components/settings/install-agent-card"; +import type { AvailableAgent } from "@/lib/types/http"; + +const AGENT = { + id: "agent-1", + name: "claude", + profiles: [{ id: "p-1", name: "Alpha", agentDisplayName: "Claude Code" } as AgentProfile], +} as unknown as Agent; + +const AVAILABLE = { + name: "claude", + display_name: "Claude Code", + install_script: "npm i -g claude", +} as unknown as AvailableAgent; + +afterEach(() => { + cleanup(); + isAdmin = true; +}); + +describe("agent settings write controls follow org.config.manage", () => { + it("offers a profile's duplicate and delete actions to an administrator", () => { + render( + + + , + ); + expect(screen.queryByTestId("duplicate-profile-inline-p-1")).not.toBeNull(); + expect(screen.queryByTestId("delete-profile-inline-p-1")).not.toBeNull(); + }); + + // The row itself stays: seeing which profiles exist is a read, and the task + // dialog offers the same profiles. Only the writes go. + it("withholds them from a member, keeping the row readable", () => { + isAdmin = false; + render( + + + , + ); + expect(screen.getByText("Alpha")).toBeTruthy(); + expect(screen.queryByTestId("duplicate-profile-inline-p-1")).toBeNull(); + expect(screen.queryByTestId("delete-profile-inline-p-1")).toBeNull(); + expect(screen.queryByTestId("profile-actions-menu-p-1")).toBeNull(); + }); + + it("renders the install button only when the caller may install", () => { + const { rerender } = render( + + + , + ); + expect(screen.queryByTestId("install-button-claude")).not.toBeNull(); + + rerender( + + + , + ); + expect(screen.queryByTestId("install-button-claude")).toBeNull(); + }); +}); diff --git a/apps/web/components/settings/agents/agent-install-catalog.tsx b/apps/web/components/settings/agents/agent-install-catalog.tsx index 35c0b6d576..f4f898c973 100644 --- a/apps/web/components/settings/agents/agent-install-catalog.tsx +++ b/apps/web/components/settings/agents/agent-install-catalog.tsx @@ -7,6 +7,7 @@ import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { useAppStore } from "@/components/state-provider"; import { InstallAgentCard } from "@/components/settings/install-agent-card"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { installAgent, listAgentDiscovery, listAvailableAgents, listInstallJobs } from "@/lib/api"; @@ -65,7 +66,7 @@ function InstallCard({ copiedValue: string | null; onCopy: (text: string) => void; job: InstallJob | undefined; - onInstall: (name: string) => void; + onInstall?: (name: string) => void; }) { return ( availableAgents.filter((a: AvailableAgent) => !a.available && a.install_script), @@ -247,7 +251,7 @@ export function AgentInstallCatalog() { copiedValue={copiedValue} onCopy={copy} job={installJobs[agent.name]} - onInstall={handleInstall} + onInstall={canManage ? handleInstall : undefined} /> ))} {notInstalledTools.map((tool) => ( diff --git a/apps/web/components/settings/agents/agent-profiles-section.test.tsx b/apps/web/components/settings/agents/agent-profiles-section.test.tsx index d28c211c9b..3a9d785902 100644 --- a/apps/web/components/settings/agents/agent-profiles-section.test.tsx +++ b/apps/web/components/settings/agents/agent-profiles-section.test.tsx @@ -34,6 +34,10 @@ const EMPTY_AGENT = { ...AGENT, profiles: [] } as unknown as Agent; let storeState: { settingsAgents: { items: Agent[] }; agentProfiles: { items: Array<{ id: string }> }; + // The row's actions are gated on org.config.manage, which useIsAdmin reads + // off the auth slice. Auth disabled is the default install and resolves to + // an administrator, matching the backend's synthetic identity. + auth: { mode: string | undefined; user: { role: string } | undefined }; }; function setSettingsAgents(items: Agent[]) { @@ -128,6 +132,7 @@ describe("ProfileRow deletion", () => { storeState = { settingsAgents: { items: [{ ...AGENT, profiles: [...AGENT.profiles] }] }, agentProfiles: { items: [{ id: "p-1" }, { id: "p-2" }] }, + auth: { mode: undefined, user: undefined }, }; vi.clearAllMocks(); }); @@ -227,6 +232,7 @@ describe("ProfileRow duplicate", () => { storeState = { settingsAgents: { items: [{ ...AGENT, profiles: [...AGENT.profiles] }] }, agentProfiles: { items: [] }, + auth: { mode: undefined, user: undefined }, }; }); afterEach(() => cleanup()); @@ -251,6 +257,7 @@ describe("ProfileRow responsive actions", () => { storeState = { settingsAgents: { items: [{ ...AGENT, profiles: [...AGENT.profiles] }] }, agentProfiles: { items: [] }, + auth: { mode: undefined, user: undefined }, }; mocks.responsive.isFullDesktop = false; mocks.responsive.isFinePointer = false; diff --git a/apps/web/components/settings/agents/agent-profiles-section.tsx b/apps/web/components/settings/agents/agent-profiles-section.tsx index 87082d9139..392bc6a9ac 100644 --- a/apps/web/components/settings/agents/agent-profiles-section.tsx +++ b/apps/web/components/settings/agents/agent-profiles-section.tsx @@ -19,6 +19,7 @@ import { useToast } from "@/components/toast-provider"; import { AgentProfileDeleteConfirmation } from "@/components/settings/agent-profile-delete-dialog"; import { deleteAgentProfileAction } from "@/app/actions/agents"; import { useProfileDuplicate } from "@/hooks/domains/settings/use-profile-duplicate"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useRouter } from "@/lib/routing/client-router"; import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; @@ -208,6 +209,7 @@ function ProfileRowDeleteConfirmation({ type ProfileRowCardProps = { profile: AgentProfile; href: string; + canManage: boolean; confirmOpen: boolean; isFinePointer: boolean; isFullDesktop: boolean; @@ -220,6 +222,7 @@ type ProfileRowCardProps = { function ProfileRowCard({ profile, href, + canManage, confirmOpen, isFinePointer, isFullDesktop, @@ -256,7 +259,8 @@ function ProfileRowCard({ )}
- {!(confirmOpen && !isFinePointer) && + {canManage && + !(confirmOpen && !isFinePointer) && (isFullDesktop ? ( void; +}) { + if (!agent.install_script || !onInstall) return null; + return ; +} + export function InstallAgentCard({ agent, job, @@ -80,7 +101,9 @@ export function InstallAgentCard({ job: InstallJob | undefined; /** The copy-and-script row, rendered above the Install button by the parent. */ scriptSlot?: React.ReactNode; - onInstall: (name: string) => void; + /** Absent for a caller without org.config.manage. Installing an agent is a + * write; the card still renders so they can see what is available. */ + onInstall?: (name: string) => void; }) { const status: InstallStatus = job?.status ?? "idle"; const failed = status === "failed"; @@ -97,9 +120,7 @@ export function InstallAgentCard({

{agent.description}

)} {scriptSlot} - {agent.install_script && ( - - )} + {showLog && (
 = {
   changelog: "common:changelog",
   "data-storage": "system:navDataStorage",
   executor: "common:executor",
+  units: "settings:navUnits",
   executors: "common:executors",
   "external-mcp": "common:externalMcp",
   "feature-toggles": "system:navFeatureToggles",
+  organizations: "orgs:navOrganizations",
   integrations: "common:integrations",
   "keyboard-shortcuts": "settings:keyboardShortcuts",
   layouts: "settings:layouts",
diff --git a/apps/web/components/settings/system/organizations/create-org-card.tsx b/apps/web/components/settings/system/organizations/create-org-card.tsx
new file mode 100644
index 0000000000..3618bc9e22
--- /dev/null
+++ b/apps/web/components/settings/system/organizations/create-org-card.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { IconPlus } from "@tabler/icons-react";
+import { Button } from "@kandev/ui/button";
+import { Input } from "@kandev/ui/input";
+import { Label } from "@kandev/ui/label";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
+
+type CreateOrgCardProps = {
+  busy: boolean;
+  onCreate: (name: string, onDone: () => void) => void;
+};
+
+export function CreateOrgCard({ busy, onCreate }: CreateOrgCardProps) {
+  const { t } = useTranslation();
+  const [name, setName] = useState("");
+
+  return (
+    
+      
+        {t("orgs:createTitle")}
+        {t("orgs:createDescription")}
+      
+      
+        
{ + event.preventDefault(); + if (name.trim()) onCreate(name.trim(), () => setName("")); + }} + > +
+ + setName(event.target.value)} + placeholder={t("orgs:namePlaceholder")} + /> +
+ +
+
+
+ ); +} diff --git a/apps/web/components/settings/system/organizations/delete-org-dialog.tsx b/apps/web/components/settings/system/organizations/delete-org-dialog.tsx new file mode 100644 index 0000000000..049162a5fa --- /dev/null +++ b/apps/web/components/settings/system/organizations/delete-org-dialog.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import type { Org } from "@/lib/types/org"; + +type DeleteOrgDialogProps = { + org: Org | null; + busy: boolean; + onClose: () => void; + onConfirm: (org: Org) => void; +}; + +/** + * Type-to-confirm deletion. + * + * Deleting an organization removes every workspace, task, session and account + * inside it, so the operator types the slug verbatim. The comparison is + * against the raw slug and is never translated: a localized token would be + * impossible to type. + */ +export function DeleteOrgDialog({ org, busy, onClose, onConfirm }: DeleteOrgDialogProps) { + const { t } = useTranslation(); + const [typed, setTyped] = useState(""); + + useEffect(() => setTyped(""), [org?.id]); + + if (!org) return null; + // i18n-exempt: compared with === against the server's slug; translating it + // would make the confirmation impossible to satisfy. + const matches = typed === org.slug; + + return ( + !open && onClose()}> + + + {t("orgs:deleteTitle", { name: org.name })} + {t("orgs:deleteWarning")} + +
+ + setTyped(event.target.value)} + /> +
+ + + + +
+
+ ); +} diff --git a/apps/web/components/settings/system/organizations/org-admin-dialog.tsx b/apps/web/components/settings/system/organizations/org-admin-dialog.tsx new file mode 100644 index 0000000000..2bdbddb9e5 --- /dev/null +++ b/apps/web/components/settings/system/organizations/org-admin-dialog.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import type { Org } from "@/lib/types/org"; + +type OrgAdminDialogProps = { + org: Org | null; + busy: boolean; + onClose: () => void; + onCreate: (org: Org, admin: { email: string; password: string; display_name: string }) => void; +}; + +/** + * Provisions an organization's first administrator. + * + * An ordinary admin can only create accounts in their own tenant, so a new + * organization has no way to get its first user. This operator-only path is + * what breaks that circularity; afterwards the org administers itself. + */ +export function OrgAdminDialog({ org, busy, onClose, onCreate }: OrgAdminDialogProps) { + const { t } = useTranslation(); + const [email, setEmail] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [password, setPassword] = useState(""); + + useEffect(() => { + setEmail(""); + setDisplayName(""); + setPassword(""); + }, [org?.id]); + + if (!org) return null; + const ready = email.trim() !== "" && password.length >= 8; + + return ( + !open && onClose()}> + + + {t("orgs:adminTitle", { name: org.name })} + {t("orgs:adminDescription")} + +
+
+ + setDisplayName(event.target.value)} + /> +
+
+ + setEmail(event.target.value)} + /> +
+
+ + setPassword(event.target.value)} + /> +

{t("orgs:adminPasswordHint")}

+
+
+ + + + +
+
+ ); +} diff --git a/apps/web/components/settings/system/organizations/organizations-page.tsx b/apps/web/components/settings/system/organizations/organizations-page.tsx new file mode 100644 index 0000000000..c822210e7c --- /dev/null +++ b/apps/web/components/settings/system/organizations/organizations-page.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconBuilding, IconPlayerPause, IconPlayerPlay, IconTrash } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Badge } from "@kandev/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; +import { useOrganizations } from "@/hooks/domains/org/use-organizations"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import type { Org } from "@/lib/types/org"; +import { CreateOrgCard } from "./create-org-card"; +import { DeleteOrgDialog } from "./delete-org-dialog"; +import { OrgAdminDialog } from "./org-admin-dialog"; + +type OrgRowProps = { + org: Org; + busy: boolean; + onSuspend: (id: string) => void; + onResume: (id: string) => void; + onDelete: (org: Org) => void; + onAddAdmin: (org: Org) => void; +}; + +function OrgIdentity({ org, mobile = false }: { org: Org; mobile?: boolean }) { + return ( + <> + +
+

{org.name}

+

+ {org.slug} +

+
+ + ); +} + +function OrgBadges({ org }: { org: Org }) { + const { t } = useTranslation(); + const suspended = org.status === "suspended"; + return ( + <> + {org.is_default ? {t("orgs:defaultBadge")} : null} + + {suspended ? t("orgs:statusSuspended") : t("orgs:statusActive")} + + + ); +} + +function OrgActions({ + org, + busy, + onSuspend, + onResume, + onDelete, + onAddAdmin, + mobile = false, +}: OrgRowProps & { mobile?: boolean }) { + const { t } = useTranslation(); + const suspended = org.status === "suspended"; + const actionClassName = mobile + ? "min-h-11 w-full cursor-pointer justify-start" + : "cursor-pointer"; + + return ( + <> + + + + + ); +} + +function OrgRow(props: OrgRowProps) { + const { org } = props; + const { isMobile } = useResponsiveBreakpoint(); + + if (isMobile) { + return ( + + +
+ +
+
+ +
+
+ +
+
+
+ ); + } + + return ( + + + + + + + + ); +} + +/** + * The instance operator's organization list. + * + * Operator is an administration tier, not a visibility one: nothing on this + * page reads any organization's workspaces, tasks or secrets. + */ +export function OrganizationsPage() { + const { t } = useTranslation(); + const orgs = useOrganizations(); + const [deleting, setDeleting] = useState(null); + const [adminFor, setAdminFor] = useState(null); + + if (orgs.isOperator === null) return null; + if (!orgs.isOperator) { + return ( + + + {t("orgs:operatorOnly")} + + + ); + } + + return ( + // The route shell already renders the page title and description, so this + // page starts at its content. +
+ + + {t("orgs:boundaryTitle")} + {t("orgs:boundaryDescription")} + + + + + +
+ {orgs.orgs.map((org) => ( + + ))} +
+ + setDeleting(null)} + onConfirm={(org) => orgs.remove(org.id, org.slug, () => setDeleting(null))} + /> + setAdminFor(null)} + onCreate={(org, admin) => orgs.addAdmin(org.id, admin, () => setAdminFor(null))} + /> +
+ ); +} diff --git a/apps/web/components/settings/units/unit-members-dialog.tsx b/apps/web/components/settings/units/unit-members-dialog.tsx new file mode 100644 index 0000000000..2677a10dba --- /dev/null +++ b/apps/web/components/settings/units/unit-members-dialog.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconTrash } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { Combobox } from "@/components/combobox"; +import { useAssignablePeople } from "@/hooks/domains/users/use-assignable-people"; +import { listUnitMembers, type OrgUnit, type UnitMember } from "@/lib/api/domains/org-units-api"; +import { ASSIGNABLE_WORKSPACE_ROLES } from "@/lib/types/team-access"; + +type Props = { + unit: OrgUnit | null; + busy: boolean; + onClose: () => void; + onAdd: (unitId: string, userId: string, role: string) => Promise; + onRemove: (unitId: string, userId: string) => Promise; +}; + +/** + * Who is in a unit, and therefore who reaches every workspace beneath it. + * + * Membership is recorded once here rather than per workspace, which is the + * whole point of the tree: a board created next week is reached by the same + * people with no further bookkeeping. + */ +export function UnitMembersDialog({ unit, busy, onClose, onAdd, onRemove }: Props) { + const { t } = useTranslation(); + const { people, nameFor } = useAssignablePeople(undefined, { enabled: Boolean(unit) }); + const [members, setMembers] = useState([]); + const [userId, setUserId] = useState(""); + const [role, setRole] = useState(ASSIGNABLE_WORKSPACE_ROLES[0]); + + useEffect(() => { + if (!unit) return; + let cancelled = false; + listUnitMembers(unit.id) + .then((res) => { + if (!cancelled) setMembers(res.members ?? []); + }) + .catch(() => { + if (!cancelled) setMembers([]); + }); + return () => { + cancelled = true; + }; + }, [unit, busy]); + + if (!unit) return null; + + const add = async () => { + if (!userId) return; + if (await onAdd(unit.id, userId, role)) setUserId(""); + }; + + return ( + !open && onClose()}> + + + {t("settings:unitMembersTitle", { unit: unit.name })} + {t("settings:unitMembersDescription")} + + +
+ {members.length === 0 && ( +

{t("settings:unitNoMembers")}

+ )} + {members.map((member) => ( +
+ {nameFor(member.user_id)} + + {member.role} + + +
+ ))} +
+ +
+ ({ value: p.id, label: p.name }))} + value={userId} + onValueChange={setUserId} + placeholder={t("settings:unitPickPerson")} + searchPlaceholder={t("task:searchPeople")} + emptyMessage={t("task:noPeopleFound")} + triggerClassName="h-8 min-w-48" + testId="unit-member-picker" + /> + ({ value: r, label: r }))} + value={role} + onValueChange={setRole} + showSearch={false} + triggerClassName="h-8 w-40" + testId="unit-role-picker" + /> + +
+
+
+ ); +} diff --git a/apps/web/components/settings/units/units-page.test.tsx b/apps/web/components/settings/units/units-page.test.tsx new file mode 100644 index 0000000000..caf7fa5e43 --- /dev/null +++ b/apps/web/components/settings/units/units-page.test.tsx @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { UnitsPage } from "./units-page"; + +const createUnit = vi.fn(); +const deleteUnit = vi.fn(); + +vi.mock("@/lib/api/domains/org-units-api", async () => { + const actual = await vi.importActual( + "@/lib/api/domains/org-units-api", + ); + const base = { org_id: "o", created_at: "", updated_at: "" }; + const fixture = [ + { ...base, id: "root", parent_id: "", kind: "root", name: "Acme", path: "/root/" }, + { + ...base, + id: "dept", + parent_id: "root", + kind: "standard", + name: "Platform", + path: "/root/dept/", + }, + { + ...base, + id: "personal", + parent_id: "root", + kind: "personal", + owner_user_id: "ada", + name: "Ada", + path: "/root/personal/", + }, + ]; + return { + ...actual, + listUnits: vi.fn().mockResolvedValue({ units: fixture, total: fixture.length }), + listUnitMembers: vi.fn().mockResolvedValue({ members: [], total: 0 }), + createUnit: (...args: unknown[]) => createUnit(...args), + deleteUnit: (...args: unknown[]) => deleteUnit(...args), + }; +}); + +vi.mock("@/lib/api/domains/team-access-api", () => ({ + listDirectoryUsers: vi.fn().mockResolvedValue({ users: [], total: 0 }), + listWorkspaceMembers: vi.fn().mockResolvedValue({ members: [], total: 0 }), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function renderTree() { + render(); + await waitFor(() => expect(screen.getAllByTestId("unit-row")).toHaveLength(3)); + return screen.getAllByTestId("unit-row"); +} + +describe("UnitsPage", () => { + it("renders the tree with a row per unit", async () => { + await renderTree(); + expect(screen.getByText("Platform")).toBeTruthy(); + }); + + // A personal unit takes no members and cannot be deleted, so offering those + // controls would only produce refusals the person cannot act on. + it("offers no member or delete control on a personal unit", async () => { + const rows = await renderTree(); + const personal = rows[2]!; + expect(within(personal).queryByTestId("unit-members")).toBeNull(); + expect(within(personal).queryByTestId("unit-delete")).toBeNull(); + expect(within(personal).queryByTestId("unit-add-child")).toBeNull(); + }); + + // The root is the whole organization; deleting it would leave every + // workspace beneath it homeless. + it("offers no delete control on the root", async () => { + const rows = await renderTree(); + expect(within(rows[0]!).queryByTestId("unit-delete")).toBeNull(); + expect(within(rows[0]!).getByTestId("unit-add-child")).toBeTruthy(); + }); + + it("creates a child unit under the chosen parent", async () => { + const rows = await renderTree(); + fireEvent.click(within(rows[1]!).getByTestId("unit-add-child")); + fireEvent.change(await screen.findByTestId("unit-name-input"), { + target: { value: "Runtime" }, + }); + fireEvent.click(screen.getByText("Create")); + + await waitFor(() => expect(createUnit).toHaveBeenCalledWith("dept", "Runtime")); + }); +}); diff --git a/apps/web/components/settings/units/units-page.tsx b/apps/web/components/settings/units/units-page.tsx new file mode 100644 index 0000000000..b35b17ac9e --- /dev/null +++ b/apps/web/components/settings/units/units-page.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconFolder, IconFolderPlus, IconTrash, IconUser, IconUsers } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Badge } from "@kandev/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; +import { useOrgUnits, type UnitRow } from "@/hooks/domains/org/use-org-units"; +import type { OrgUnit } from "@/lib/api/domains/org-units-api"; +import { UnitMembersDialog } from "./unit-members-dialog"; + +function UnitIcon({ kind }: { kind: OrgUnit["kind"] }) { + if (kind === "personal") return ; + return ; +} + +type RowProps = { + unit: UnitRow; + busy: boolean; + onAddChild: (unit: UnitRow) => void; + onManageMembers: (unit: UnitRow) => void; + onDelete: (unit: UnitRow) => void; +}; + +function UnitTreeRow({ unit, busy, onAddChild, onManageMembers, onDelete }: RowProps) { + const { t } = useTranslation(); + // A personal unit takes no members and cannot be moved or deleted, so it + // shows its owner rather than controls that would always be refused. + const personal = unit.kind === "personal"; + const root = unit.kind === "root"; + return ( +
+ + {unit.name} + {root && {t("settings:unitRootBadge")}} + {personal && {t("settings:unitPersonalBadge")}} + + {!personal && ( + + )} + {!personal && ( + + )} + {!personal && !root && ( + + )} + +
+ ); +} + +/** + * The organization tree. + * + * A unit holds child units, workspaces and people, and membership reaches + * everything beneath it. Departments and teams are the same thing at different + * depths, which is why there is one control here rather than one per level. + */ +export function UnitsPage() { + const { t } = useTranslation(); + const units = useOrgUnits(); + const [parentForNew, setParentForNew] = useState(null); + const [newName, setNewName] = useState(""); + const [membersFor, setMembersFor] = useState(null); + + const submitNew = async () => { + if (!parentForNew || !newName.trim()) return; + if (await units.create(parentForNew.id, newName.trim())) { + setNewName(""); + setParentForNew(null); + } + }; + + return ( +
+ + + {t("settings:unitsTitle")} + {t("settings:unitsDescription")} + + + {units.loading &&

{t("common:loading")}

} + {!units.loading && units.units.length === 0 && ( +

{t("settings:unitsEmpty")}

+ )} + {units.units.map((unit) => ( + void units.remove(u.id)} + /> + ))} +
+
+ + {parentForNew && ( + + + {t("settings:unitCreateTitle", { parent: parentForNew.name })} + {t("settings:unitCreateDescription")} + + + setNewName(e.target.value)} + placeholder={t("settings:unitNamePlaceholder")} + className="max-w-64" + data-testid="unit-name-input" + /> + + + + + )} + + setMembersFor(null)} + onAdd={units.addMember} + onRemove={units.removeMember} + /> +
+ ); +} diff --git a/apps/web/components/settings/workspaces/workspace-placement-card.test.tsx b/apps/web/components/settings/workspaces/workspace-placement-card.test.tsx new file mode 100644 index 0000000000..57397481c2 --- /dev/null +++ b/apps/web/components/settings/workspaces/workspace-placement-card.test.tsx @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { WorkspacePlacementCard } from "./workspace-placement-card"; + +const placeWorkspace = vi.fn(); + +vi.mock("@/lib/api/domains/org-units-api", async () => { + const actual = await vi.importActual( + "@/lib/api/domains/org-units-api", + ); + const base = { org_id: "o", created_at: "", updated_at: "" }; + return { + ...actual, + listUnits: vi.fn().mockResolvedValue({ + units: [ + { ...base, id: "root", parent_id: "", kind: "root", name: "Acme", path: "/root/" }, + { + ...base, + id: "dept", + parent_id: "root", + kind: "standard", + name: "Platform", + path: "/root/dept/", + }, + ], + total: 2, + }), + placeWorkspace: (...args: unknown[]) => placeWorkspace(...args), + }; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const OWNER = ["workspace.read", "workspace.manage"]; +const PICKER = "workspace-placement-picker"; + +describe("WorkspacePlacementCard", () => { + it("moves the workspace to the chosen unit", async () => { + placeWorkspace.mockResolvedValue({ id: "ws-1", unit_id: "dept" }); + render(); + + fireEvent.click(await screen.findByTestId(PICKER)); + fireEvent.click(await screen.findByText(/Platform/)); + + await waitFor(() => expect(placeWorkspace).toHaveBeenCalledWith("ws-1", "dept")); + }); + + // Moving a workspace changes who reaches it, so it belongs to whoever can + // manage the workspace and nobody else. + it("disables the picker without workspace.manage", async () => { + render(); + + const picker = await screen.findByTestId(PICKER); + expect(picker.hasAttribute("disabled")).toBe(true); + }); + + it("restores the previous unit when the move is refused", async () => { + placeWorkspace.mockRejectedValue(new Error("that unit belongs to another organization")); + render(); + + fireEvent.click(await screen.findByTestId(PICKER)); + fireEvent.click(await screen.findByText(/Platform/)); + + await waitFor(() => expect(placeWorkspace).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByTestId(PICKER).textContent).toContain("Acme")); + }); +}); diff --git a/apps/web/components/settings/workspaces/workspace-placement-card.tsx b/apps/web/components/settings/workspaces/workspace-placement-card.tsx new file mode 100644 index 0000000000..8fbe7d99aa --- /dev/null +++ b/apps/web/components/settings/workspaces/workspace-placement-card.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconSitemap } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Combobox } from "@/components/combobox"; +import { toast } from "@/lib/toast/sonner"; +import { useOrgUnits } from "@/hooks/domains/org/use-org-units"; +import { placeWorkspace } from "@/lib/api/domains/org-units-api"; +import { hasScope, SCOPE } from "@/lib/types/team-access"; + +type Props = { + workspaceId: string; + unitId?: string; + scopes?: readonly string[]; +}; + +/** + * Where a workspace sits, and therefore who reaches it. + * + * Moving it is the only way to widen or narrow access: roles combine by + * maximum, so nothing can be subtracted from a workspace in place. That makes + * this control the counterpart of unit membership rather than a filing + * convenience. + */ +export function WorkspacePlacementCard({ workspaceId, unitId, scopes }: Props) { + const { t } = useTranslation(); + const units = useOrgUnits(); + const canManage = hasScope(scopes, SCOPE.workspaceManage); + const [saving, setSaving] = useState(false); + const [current, setCurrent] = useState(unitId ?? ""); + + const move = async (next: string) => { + if (!next || next === current) return; + const previous = current; + setCurrent(next); + setSaving(true); + try { + await placeWorkspace(workspaceId, next); + toast.success(t("workspaces:placement.moved")); + } catch (err) { + setCurrent(previous); + toast.error(err instanceof Error ? err.message : t("workspaces:placement.failed")); + } finally { + setSaving(false); + } + }; + + return ( + + + + + {t("workspaces:placement.title")} + + {t("workspaces:placement.description")} + + + ({ + value: unit.id, + label: `${"  ".repeat(unit.depth)}${unit.name}`, + keywords: [unit.name], + }))} + value={current} + onValueChange={(next) => void move(next)} + disabled={!canManage || saving || units.loading} + placeholder={t("workspaces:placement.pick")} + searchPlaceholder={t("workspaces:placement.search")} + emptyMessage={t("workspaces:placement.none")} + triggerClassName="h-9 w-full max-w-sm justify-between" + testId="workspace-placement-picker" + /> + {!canManage && ( + + )} + + + ); +} diff --git a/apps/web/components/settings/workspaces/workspace-team-access-card.tsx b/apps/web/components/settings/workspaces/workspace-team-access-card.tsx new file mode 100644 index 0000000000..d259aaa536 --- /dev/null +++ b/apps/web/components/settings/workspaces/workspace-team-access-card.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconTrash, IconCrown, IconUsers } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Label } from "@kandev/ui/label"; +import { Badge } from "@kandev/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { useWorkspaceTeamAccess } from "@/hooks/domains/workspace/use-workspace-team-access"; +import { + ASSIGNABLE_WORKSPACE_ROLES, + type AssignableWorkspaceRole, + type DirectoryUser, + type WorkspaceMember, +} from "@/lib/types/team-access"; + +type WorkspaceTeamAccessCardProps = { + workspaceId: string; + ownerId: string; + /** Scopes the *requesting* user holds here, from the workspace payload. */ + scopes: readonly string[] | undefined; +}; + +function useRoleLabel() { + const { t } = useTranslation(); + return (role: AssignableWorkspaceRole) => + role === "collaborator" + ? t("workspaces:teamAccess.roleCollaborator") + : t("workspaces:teamAccess.roleViewer"); +} + +function RoleOptions() { + const roleLabel = useRoleLabel(); + return ( + <> + {ASSIGNABLE_WORKSPACE_ROLES.map((role) => ( + + {roleLabel(role)} + + ))} + + ); +} + +type MemberRowProps = { + member: WorkspaceMember; + canManageMembers: boolean; + busy: boolean; + onChangeRole: (userId: string, role: AssignableWorkspaceRole) => void; + onRemove: (userId: string) => void; + onMakeOwner: (userId: string) => void; +}; + +function MemberRow({ + member, + canManageMembers, + busy, + onChangeRole, + onRemove, + onMakeOwner, +}: MemberRowProps) { + const { t } = useTranslation(); + const isOwner = member.role === "owner"; + return ( +
  • + + {member.display_name || member.user_id} + + {isOwner ? ( + + + {t("workspaces:teamAccess.roleOwner")} + + ) : ( + + )} + {canManageMembers && !isOwner ? ( + <> + + + + ) : null} +
  • + ); +} + +type AddMemberRowProps = { + users: DirectoryUser[]; + busy: boolean; + onAdd: (userId: string, role: AssignableWorkspaceRole, onAdded: () => void) => void; +}; + +function AddMemberRow({ users, busy, onAdd }: AddMemberRowProps) { + const { t } = useTranslation(); + const [userId, setUserId] = useState(""); + const [role, setRole] = useState("collaborator"); + + return ( +
    +
    + + +
    +
    + + +
    + + {users.length === 0 ? ( +

    + {t("workspaces:teamAccess.noUsersToAdd")} +

    + ) : null} +
    + ); +} + +/** + * Workspace membership. + * + * Membership here is the exception path. Reach normally follows the + * organization unit the workspace sits in, so a team sets that once and never + * anyone. Membership below is the exception path, for private workspaces, + * guests, and narrowing a colleague to a viewer. + */ +export function WorkspaceTeamAccessCard(props: WorkspaceTeamAccessCardProps) { + const { t } = useTranslation(); + const access = useWorkspaceTeamAccess(props); + + return ( + + + {t("workspaces:teamAccess.title")} + {t("workspaces:teamAccess.description")} + + +
    +
    + + +
    + +
      + {access.members.length === 0 ? ( +
    • + {t("workspaces:teamAccess.noMembers")} +
    • + ) : ( + access.members.map((member) => ( + + )) + )} +
    + + {access.canManageMembers ? ( + + ) : null} +
    +
    +
    + ); +} diff --git a/apps/web/components/task/simple/components/human-assignee-picker.test.tsx b/apps/web/components/task/simple/components/human-assignee-picker.test.tsx new file mode 100644 index 0000000000..7bf1e8a223 --- /dev/null +++ b/apps/web/components/task/simple/components/human-assignee-picker.test.tsx @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { StateProvider } from "@/components/state-provider"; +import { TaskOptimisticContextProvider } from "@/hooks/use-optimistic-task-mutation"; +import { HumanAssigneePicker } from "./human-assignee-picker"; +import type { Task } from "@/app/office/tasks/[id]/types"; + +vi.mock("@/lib/api/domains/office-extended-api", async () => { + const actual = await vi.importActual( + "@/lib/api/domains/office-extended-api", + ); + return { ...actual, updateTask: vi.fn().mockResolvedValue({ ok: true }) }; +}); + +vi.mock("@/lib/api/domains/team-access-api", () => ({ + // Ada holds a member row; Bruno reaches the workspace through org + // visibility and appears only in the directory. Both must resolve to a + // name, which is what the member list alone could not do. + listWorkspaceMembers: vi.fn().mockResolvedValue({ + members: [{ user_id: "user-1", display_name: "Ada Lovelace", role: "owner" }], + total: 1, + }), + listDirectoryUsers: vi.fn().mockResolvedValue({ + users: [ + { id: "user-2", display_name: "Grace Hopper" }, + { id: "user-3", display_name: "Bruno Costa" }, + ], + total: 2, + }), +})); + +import { updateTask } from "@/lib/api/domains/office-extended-api"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const baseTask: Task = { + id: "t-1", + workspaceId: "ws-1", + identifier: "TASK-1", + title: "First task", + status: "todo", + priority: "medium", + labels: [], + blockedBy: [], + blocking: [], + children: [], + reviewers: [], + approvers: [], + decisions: [], + createdBy: "user", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", +}; + +const initialState = { + auth: { + mode: "enabled" as const, + authenticated: true, + user: { + id: "user-2", + email: "grace@example.com", + display_name: "Grace Hopper", + role: "member", + status: "active", + }, + ssoProviders: [], + }, + workspaces: { activeId: "ws-1", items: [] }, +}; + +function Wrapper({ children, task }: { children: ReactNode; task: Task }) { + const ctx = { task, applyPatch: vi.fn(), restore: vi.fn() }; + return ( + + {children} + + ); +} + +describe("HumanAssigneePicker", () => { + it("assigns the task to the signed-in user when Assign to me is clicked", async () => { + render( + + + , + ); + fireEvent.click(screen.getByTestId("assign-to-me")); + await waitFor(() => + expect(updateTask).toHaveBeenCalledWith("t-1", { assignee_user_id: "user-2" }), + ); + }); + + it("hides Assign to me when the task is already the caller's", () => { + const mine: Task = { ...baseTask, assigneeUserId: "user-2" }; + render( + + + , + ); + expect(screen.queryByTestId("assign-to-me")).toBeNull(); + }); + + it("shows the assignee's display name once members load", async () => { + const assigned: Task = { ...baseTask, assigneeUserId: "user-1" }; + render( + + + , + ); + await waitFor(() => + expect(screen.getByTestId("human-assignee-picker-trigger").textContent).toContain( + "Ada Lovelace", + ), + ); + }); + + it("resolves a name for someone who reaches the workspace without a member row", async () => { + const assigned: Task = { ...baseTask, assigneeUserId: "user-3" }; + render( + + + , + ); + await waitFor(() => + expect(screen.getByTestId("human-assignee-picker-trigger").textContent).toContain( + "Bruno Costa", + ), + ); + }); + + it("sends an empty string to unassign, which is distinct from omitting the field", async () => { + const assigned: Task = { ...baseTask, assigneeUserId: "user-1" }; + render( + + + , + ); + fireEvent.click(screen.getByTestId("human-assignee-picker-trigger")); + fireEvent.click(await screen.findByText("Unassigned")); + await waitFor(() => expect(updateTask).toHaveBeenCalledWith("t-1", { assignee_user_id: "" })); + }); +}); diff --git a/apps/web/components/task/simple/components/human-assignee-picker.tsx b/apps/web/components/task/simple/components/human-assignee-picker.tsx new file mode 100644 index 0000000000..6d11434e00 --- /dev/null +++ b/apps/web/components/task/simple/components/human-assignee-picker.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useMemo } from "react"; +import { Button } from "@kandev/ui/button"; +import { Combobox, type ComboboxOption } from "@/components/combobox"; +import { useAppStore } from "@/components/state-provider"; +import { updateTask } from "@/lib/api/domains/office-extended-api"; +import { useAssignablePeople } from "@/hooks/domains/users/use-assignable-people"; +import { useOptimisticTaskMutation } from "@/hooks/use-optimistic-task-mutation"; +import type { Task } from "@/app/office/tasks/[id]/types"; +import { useTranslation } from "react-i18next"; + +type HumanAssigneePickerProps = { + task: Task; +}; + +// i18n-exempt: sentinel compared with ===, never displayed. +const UNASSIGNED = "__unassigned__"; + +/** + * The human assignee: who on the team owns this task. + * + * Deliberately separate from AssigneePicker, which sets the agent that runs + * the task. The two are independent — a task can be assigned to a person and + * a runner at once — so this control never touches the agent assignee. + * + * Assignment is advisory. It gates nothing: taking a task over is a + * reassignment plus a prompt, not a lock, which is why "Assign to me" needs + * no confirmation and works whether or not the task is already assigned. + */ +export function HumanAssigneePicker({ task }: HumanAssigneePickerProps) { + const { t } = useTranslation(); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const currentUser = useAppStore((s) => s.auth.user); + const mutate = useOptimisticTaskMutation(); + const { people } = useAssignablePeople(workspaceId); + + const assignee = task.assigneeUserId ?? ""; + + const options = useMemo(() => { + const unassigned: ComboboxOption = { + value: UNASSIGNED, + label: t("task:unassigned"), + keywords: ["none", "unassigned"], + renderLabel: () => {t("task:unassigned")}, + }; + const known = new Map(people.map((p) => [p.id, p.name])); + // A task can hold an assignee neither source returned (a disabled account, + // or a list the caller cannot read). Keep them selectable so the control + // shows the truth rather than silently reading as unassigned. + if (assignee && !known.has(assignee)) known.set(assignee, assignee); + return [ + unassigned, + ...Array.from(known, ([userId, name]) => ({ + value: userId, + label: name, + keywords: [name, userId], + })), + ]; + }, [people, assignee, t]); + + const apply = async (next: string) => { + if (next === assignee) return; + try { + await mutate(task.id, { assigneeUserId: next || undefined }, () => + updateTask(task.id, { assignee_user_id: next }), + ); + } catch { + /* toast already raised */ + } + }; + + const isMine = Boolean(currentUser) && assignee === currentUser?.id; + + return ( + + {currentUser && !isMine && ( + + )} + void apply(next === UNASSIGNED ? "" : next)} + placeholder={t("task:unassigned")} + searchPlaceholder={t("task:searchPeople")} + emptyMessage={t("task:noPeopleFound")} + triggerClassName="h-7 w-auto justify-end px-2" + popoverAlign="end" + testId="human-assignee-picker-trigger" + /> + + ); +} diff --git a/apps/web/components/task/simple/task-properties.tsx b/apps/web/components/task/simple/task-properties.tsx index c9d6b2e381..bd523f23f5 100644 --- a/apps/web/components/task/simple/task-properties.tsx +++ b/apps/web/components/task/simple/task-properties.tsx @@ -10,6 +10,7 @@ import { StatusPicker } from "./components/status-picker"; import { PriorityPicker } from "./components/priority-picker"; import { LabelsPicker } from "./components/labels-picker"; import { AssigneePicker } from "./components/assignee-picker"; +import { HumanAssigneePicker } from "./components/human-assignee-picker"; import { ProjectPicker } from "./components/project-picker"; import { ParentPicker } from "./components/parent-picker"; import { BlockersPicker } from "./components/blockers-picker"; @@ -19,6 +20,7 @@ import { ApproversPicker } from "./components/approvers-picker"; import { PendingApprovalBadge } from "./components/pending-approval-badge"; import { QuorumStatusBadge } from "./components/quorum-status-badge"; import { useTranslation } from "react-i18next"; +import { useAppStore } from "@/components/state-provider"; type TaskPropertiesProps = { task: Task; @@ -53,6 +55,20 @@ function NoneLabel() { return {t("task:none")}; } +// The human assignee row is only meaningful when there is an identity to +// assign to: with authentication disabled every visitor is the same anonymous +// user, so the control would offer a choice that cannot mean anything. +function HumanAssigneeRow({ task }: { task: Task }) { + const { t } = useTranslation(); + const hasIdentity = useAppStore((s) => Boolean(s.auth.user)); + if (!hasIdentity) return null; + return ( + + + + ); +} + function IdentitySection({ task }: { task: Task }) { const { t } = useTranslation(); return ( @@ -73,6 +89,7 @@ function IdentitySection({ task }: { task: Task }) { + diff --git a/apps/web/components/task/task-assignee-control.test.tsx b/apps/web/components/task/task-assignee-control.test.tsx new file mode 100644 index 0000000000..0c64b5cfa8 --- /dev/null +++ b/apps/web/components/task/task-assignee-control.test.tsx @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { StateProvider } from "@/components/state-provider"; +import { TaskAssigneeControl } from "./task-assignee-control"; +import { resetDirectoryCacheForTests } from "@/hooks/domains/users/use-assignable-people"; + +const CONTROL = "task-assignee-control"; + +vi.mock("@/lib/api/domains/kanban-api", async () => { + const actual = await vi.importActual( + "@/lib/api/domains/kanban-api", + ); + return { ...actual, updateTask: vi.fn().mockResolvedValue({}) }; +}); + +vi.mock("@/lib/api/domains/team-access-api", () => ({ + listWorkspaceMembers: vi.fn().mockResolvedValue({ + members: [{ user_id: "user-1", display_name: "Ada Lovelace", role: "owner" }], + total: 1, + }), + listDirectoryUsers: vi.fn().mockResolvedValue({ + users: [ + { id: "user-1", display_name: "Ada Lovelace" }, + { id: "user-2", display_name: "Grace Hopper" }, + ], + total: 2, + }), +})); + +import { updateTask } from "@/lib/api/domains/kanban-api"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + resetDirectoryCacheForTests(); +}); + +function stateWith(assigneeUserId?: string, authenticated = true) { + return { + auth: { + mode: authenticated ? ("enabled" as const) : ("disabled" as const), + authenticated: true, + user: authenticated + ? { + id: "user-2", + email: "grace@example.com", + display_name: "Grace Hopper", + role: "member", + status: "active", + } + : null, + ssoProviders: [], + }, + kanban: { + workflowId: "wf-1", + steps: [], + tasks: [ + { + id: "t-1", + title: "First task", + workflowId: "wf-1", + workflowStepId: "step-1", + position: 0, + assigneeUserId, + }, + ], + }, + }; +} + +function renderControl(assigneeUserId?: string, authenticated = true) { + return render( + + + , + ); +} + +describe("TaskAssigneeControl", () => { + it("shows the assignee's name from the store, not a raw user id", async () => { + renderControl("user-1"); + await waitFor(() => expect(screen.getByTestId(CONTROL).textContent).toContain("Ada Lovelace")); + }); + + it("assigns to the signed-in user from the dropdown", async () => { + renderControl(); + fireEvent.click(screen.getByTestId(CONTROL)); + fireEvent.click(await screen.findByText("Assign to me")); + await waitFor(() => + expect(updateTask).toHaveBeenCalledWith("t-1", { assignee_user_id: "user-2" }), + ); + }); + + it("omits Assign to me when the task is already the caller's", async () => { + renderControl("user-2"); + fireEvent.click(screen.getByTestId(CONTROL)); + await screen.findByText("Unassigned"); + expect(screen.queryByText("Assign to me")).toBeNull(); + }); + + it("sends an empty string to unassign", async () => { + renderControl("user-1"); + fireEvent.click(screen.getByTestId(CONTROL)); + fireEvent.click(await screen.findByText("Unassigned")); + await waitFor(() => expect(updateTask).toHaveBeenCalledWith("t-1", { assignee_user_id: "" })); + }); + + it("renders nothing when authentication is disabled", () => { + renderControl(undefined, false); + expect(screen.queryByTestId(CONTROL)).toBeNull(); + }); +}); diff --git a/apps/web/components/task/task-assignee-control.tsx b/apps/web/components/task/task-assignee-control.tsx new file mode 100644 index 0000000000..16c90b4fc4 --- /dev/null +++ b/apps/web/components/task/task-assignee-control.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { IconUserPlus } from "@tabler/icons-react"; +import { toast } from "@/lib/toast/sonner"; +import { useTranslation } from "react-i18next"; +import { Avatar, AvatarFallback } from "@kandev/ui/avatar"; +import { Combobox, type ComboboxOption } from "@/components/combobox"; +import { useAppStore } from "@/components/state-provider"; +import { useAssignablePeople } from "@/hooks/domains/users/use-assignable-people"; +import { updateTask } from "@/lib/api/domains/kanban-api"; +import { initialsFor } from "@/lib/user-initials"; + +// i18n-exempt: sentinels compared with ===, never displayed. +const UNASSIGNED = "__unassigned__"; +// i18n-exempt: sentinel compared with ===, never displayed. +const ASSIGN_TO_ME = "__assign_to_me__"; + +type Props = { + taskId?: string | null; + workspaceId?: string | null; + isArchived?: boolean; +}; + +/** + * The human assignee for a kanban task, in the task top bar. + * + * The office properties panel has a roomier row for the same field; this is + * the same capability shaped for a crowded bar, so "Assign to me" is the first + * dropdown entry rather than a separate always-visible button. + * + * No optimistic patch: the backend publishes `task.updated` with the new + * `assignee_user_id` and the single kanban mapper puts it back into the store, + * which is the same path every other top-bar mutation relies on. + */ +export function TaskAssigneeControl({ taskId, workspaceId, isArchived }: Props) { + const { t } = useTranslation(); + const currentUser = useAppStore((s) => s.auth.user); + // Read from the store rather than through props: `task.updated` lands the + // new assignee there, so another person taking the task over shows up here + // without a refetch, and there is no prop chain to drop a hop in. + const assigneeUserId = useAppStore( + (s) => s.kanban.tasks.find((entry: { id: string }) => entry.id === taskId)?.assigneeUserId, + ); + const enabled = Boolean(currentUser) && Boolean(taskId) && !isArchived; + const { people, nameFor } = useAssignablePeople(workspaceId, { enabled }); + const [pending, setPending] = useState(false); + + const assignee = assigneeUserId ?? ""; + const isMine = Boolean(currentUser) && assignee === currentUser?.id; + + const options = useMemo(() => { + const entries: ComboboxOption[] = [ + { + value: UNASSIGNED, + label: t("task:unassigned"), + keywords: ["none", "unassigned"], + renderLabel: () => ( + + + {t("task:unassigned")} + + ), + }, + ]; + if (currentUser && !isMine) { + entries.unshift({ + value: ASSIGN_TO_ME, + label: t("task:assignToMe"), + keywords: ["me", "self"], + renderLabel: () => {t("task:assignToMe")}, + }); + } + const known = new Map(people.map((p) => [p.id, p.name])); + if (assignee && !known.has(assignee)) known.set(assignee, nameFor(assignee)); + for (const [id, name] of known) { + entries.push({ + value: id, + label: name, + keywords: [name, id], + // The trigger renders the selected option's label, so the avatar comes + // along for free rather than needing a trigger-prefix escape hatch. + renderLabel: () => ( + + + {initialsFor(name)} + + {name} + + ), + }); + } + return entries; + }, [people, assignee, currentUser, isMine, nameFor, t]); + + // With authentication disabled every visitor is the same anonymous user, so + // there is nobody to assign to and the control would be a choice that cannot + // mean anything. + if (!enabled || !currentUser || !taskId) return null; + + const resolveChoice = (choice: string): string => { + if (choice === ASSIGN_TO_ME) return currentUser.id ?? ""; + if (choice === UNASSIGNED) return ""; + return choice; + }; + + const apply = async (choice: string) => { + const next = resolveChoice(choice); + if (next === assignee) return; + setPending(true); + try { + await updateTask(taskId, { assignee_user_id: next }); + } catch (err) { + // The server refuses an assignee who cannot reach the workspace, with a + // message written to be shown as-is. + toast.error(err instanceof Error ? err.message : t("task:updateFailed")); + } finally { + setPending(false); + } + }; + + return ( + void apply(next)} + loading={pending} + ariaLabel={t("task:assignedTo")} + placeholder={t("task:unassigned")} + searchPlaceholder={t("task:searchPeople")} + emptyMessage={t("task:noPeopleFound")} + triggerClassName="h-7 w-auto gap-1 px-2 text-xs" + popoverAlign="end" + testId="task-assignee-control" + /> + ); +} diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index e4be0691ae..603bd6f569 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -20,6 +20,7 @@ import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availabili import { PortForwardButton } from "@/components/task/port-forward-dialog"; import { ExecutorSettingsButton } from "@/components/task/executor-settings-button"; import { TaskUnarchiveButton } from "@/components/task/task-unarchive-button"; +import { TaskAssigneeControl } from "@/components/task/task-assignee-control"; import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper"; import { TaskTopBarPluginActions } from "@/components/task/task-top-bar-plugin-actions"; import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics"; @@ -411,6 +412,9 @@ function TopBarRight({ )} + + + { + test.beforeAll(async ({ backend }) => { + await backend.restart({ + KANDEV_FEATURES_AUTH: "true", + KANDEV_FEATURES_MULTI_TENANCY: "true", + KANDEV_DATABASE_PATH: path.join(backend.tmpDir, "kandev-mobile-organizations.db"), + }); + }); + + test.afterAll(async ({ backend }) => { + await backend.restart(); + }); + + test("keeps organization identity and actions usable on a phone", async ({ + browser, + backend, + }) => { + const context = await browser.newContext({ + ...devices["Pixel 5"], + baseURL: backend.frontendUrl, + }); + + try { + await setupAdmin(context, backend.baseUrl, ADMIN); + await login(context, backend.baseUrl, ADMIN); + + const created = await context.request.post(`${backend.baseUrl}/api/v1/instance/orgs`, { + data: { name: "Globex Industries" }, + }); + expect(created.status(), await created.text()).toBe(200); + + const page = await context.newPage(); + expect((await page.viewportSize())?.width).toBe(393); + await page.goto("/settings/system/organizations"); + + const row = page.getByTestId("organization-row").filter({ hasText: "Globex Industries" }); + await expect(row).toBeVisible({ timeout: 15_000 }); + + const name = row.getByText("Globex Industries", { exact: true }); + expect( + await name.evaluate((element) => element.scrollWidth <= element.clientWidth), + "the organization name must render in full instead of collapsing to an ellipsis", + ).toBe(true); + + for (const accessibleName of [/add administrator/i, /^suspend$/i, /delete organization/i]) { + const action = row.getByRole("button", { name: accessibleName }); + await expect(action).toBeVisible(); + const box = await action.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + } + + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); + + const suspended = page.waitForResponse( + (response) => + response.request().method() === "PATCH" && + /\/api\/v1\/instance\/orgs\/[^/]+$/.test(new URL(response.url()).pathname), + ); + await row.getByRole("button", { name: /^suspend$/i }).tap(); + expect((await suspended).status()).toBe(200); + await expect(row.getByText("Suspended", { exact: true })).toBeVisible(); + } finally { + await context.close(); + } + }); +}); diff --git a/apps/web/e2e/tests/auth/org-units.spec.ts b/apps/web/e2e/tests/auth/org-units.spec.ts new file mode 100644 index 0000000000..afa0b4208c --- /dev/null +++ b/apps/web/e2e/tests/auth/org-units.spec.ts @@ -0,0 +1,152 @@ +import { expect } from "@playwright/test"; +import type { BrowserContext } from "@playwright/test"; +import { backendFixture as test } from "../../fixtures/backend"; +import { acceptInvite, createInviteToken, login, setupAdmin } from "../../helpers/auth"; + +/** + * Reach through the organization unit tree. + * + * The unit tests prove the resolver's table and the handler's refusals. This + * proves the outcome the tree exists for: someone reaches a board because of + * where it sits, with nobody having named them on it, and stops reaching it + * when it moves. A resolver nothing calls protects nothing, and a tree nothing + * places workspaces into shares nothing. + * + * Runs in the `auth` project: reach only means something once identities do. + */ +test.describe.serial("organization units", () => { + const ADMIN = { email: "ana@e2e.dev", password: "adminpass123", displayName: "Ana" }; + const MEMBER = { email: "bruno@e2e.dev", password: "memberpass123", displayName: "Bruno" }; + + let adminContext: BrowserContext; + let memberContext: BrowserContext; + let rootId = ""; + let deptId = ""; + let teamId = ""; + let workspaceId = ""; + let memberId = ""; + + test.beforeAll(async ({ backend, browser }) => { + await backend.restart({ KANDEV_FEATURES_AUTH: "true" }); + + adminContext = await browser.newContext({ baseURL: backend.frontendUrl }); + await setupAdmin(adminContext, backend.baseUrl, ADMIN); + await login(adminContext, backend.baseUrl, ADMIN); + + const token = await createInviteToken(adminContext, backend.baseUrl, { email: MEMBER.email }); + memberContext = await browser.newContext({ baseURL: backend.frontendUrl }); + await acceptInvite(memberContext, backend.baseUrl, token, MEMBER); + await login(memberContext, backend.baseUrl, MEMBER); + + const directory = await ( + await adminContext.request.get(`${backend.baseUrl}/api/v1/users/directory`) + ).json(); + memberId = directory.users.find((u: { display_name: string }) => + u.display_name.includes("Bruno"), + ).id; + }); + + test.afterAll(async ({ backend }) => { + await adminContext?.close(); + await memberContext?.close(); + await backend.restart(); + }); + + test("builds a department and a team beneath the organization", async ({ backend }) => { + const tree = await (await adminContext.request.get(`${backend.baseUrl}/api/v1/units`)).json(); + rootId = tree.units.find((u: { kind: string }) => u.kind === "root").id; + + const dept = await adminContext.request.post(`${backend.baseUrl}/api/v1/units`, { + data: { parent_id: rootId, name: "Platform" }, + }); + expect(dept.status(), await dept.text()).toBe(201); + deptId = (await dept.json()).id; + + const team = await adminContext.request.post(`${backend.baseUrl}/api/v1/units`, { + data: { parent_id: deptId, name: "Runtime" }, + }); + expect(team.status(), await team.text()).toBe(201); + teamId = (await team.json()).id; + }); + + test("a member reaches a board through the unit above it, uninvited", async ({ backend }) => { + const created = await adminContext.request.post(`${backend.baseUrl}/api/v1/workspaces`, { + data: { name: "Platform Team" }, + }); + expect(created.ok(), await created.text()).toBeTruthy(); + workspaceId = (await created.json()).id; + + // Created by the admin, so it starts in her personal unit: nobody else. + const before = await memberContext.request.get( + `${backend.baseUrl}/api/v1/workspaces/${workspaceId}`, + ); + expect(before.status()).toBe(404); + + // Put the board in the team and the person in the department above it. + const moved = await adminContext.request.patch( + `${backend.baseUrl}/api/v1/workspaces/${workspaceId}`, + { data: { unit_id: teamId } }, + ); + expect(moved.ok(), await moved.text()).toBeTruthy(); + + const added = await adminContext.request.put( + `${backend.baseUrl}/api/v1/units/${deptId}/members/${memberId}`, + { data: { role: "collaborator" } }, + ); + expect(added.ok(), await added.text()).toBeTruthy(); + + const after = await memberContext.request.get( + `${backend.baseUrl}/api/v1/workspaces/${workspaceId}`, + ); + expect( + after.status(), + "a member of the department must reach a board in its team with no invitation", + ).toBe(200); + }); + + test("moving the board out of the tree withdraws that reach", async ({ backend }) => { + const personal = await ( + await adminContext.request.get(`${backend.baseUrl}/api/v1/units`) + ).json(); + const personalId = personal.units.find((u: { kind: string }) => u.kind === "personal").id; + + const moved = await adminContext.request.patch( + `${backend.baseUrl}/api/v1/workspaces/${workspaceId}`, + { data: { unit_id: personalId } }, + ); + expect(moved.ok(), await moved.text()).toBeTruthy(); + + const after = await memberContext.request.get( + `${backend.baseUrl}/api/v1/workspaces/${workspaceId}`, + ); + expect(after.status(), "a board in someone's personal unit reaches nobody else").toBe(404); + }); + + test("a member cannot rearrange the organization", async ({ backend }) => { + const created = await memberContext.request.post(`${backend.baseUrl}/api/v1/units`, { + data: { parent_id: rootId, name: "Shadow department" }, + }); + expect(created.status(), "unit.manage is an administrator scope").toBe(403); + + // Reading the tree is allowed: a member has to see why they reach what + // they reach. + const read = await memberContext.request.get(`${backend.baseUrl}/api/v1/units`); + expect(read.status()).toBe(200); + }); + + test("the tree renders in settings", async ({ backend }) => { + const page = await adminContext.newPage(); + await page.addInitScript(() => + window.localStorage.setItem("kandev.onboarding.completed", "true"), + ); + await page.goto(`${backend.frontendUrl}/settings/units`); + + const rows = page.getByTestId("unit-row"); + await expect(rows.first()).toBeVisible(); + // Scoped to the rows: "Platform" also appears as a workspace name, and a + // bare text match would pass for the wrong reason. + await expect(rows.filter({ hasText: "Platform" }).first()).toBeVisible(); + await expect(rows.filter({ hasText: "Runtime" }).first()).toBeVisible(); + await page.close(); + }); +}); diff --git a/apps/web/hooks/domains/org/use-org-units.ts b/apps/web/hooks/domains/org/use-org-units.ts new file mode 100644 index 0000000000..87ed2be563 --- /dev/null +++ b/apps/web/hooks/domains/org/use-org-units.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "@/lib/toast/sonner"; +import { + createUnit, + deleteUnit, + listUnits, + removeUnitMember, + renameUnit, + setUnitMember, + withDepth, + type OrgUnit, +} from "@/lib/api/domains/org-units-api"; + +export type UnitRow = OrgUnit & { depth: number }; + +/** + * The organization tree and the operations on it. + * + * Every refusal from the server names its blocking condition, so failures are + * surfaced verbatim rather than replaced with a generic message: "this unit + * still holds child units or workspaces" tells someone what to do next, and + * "something went wrong" does not. + */ +export function useOrgUnits() { + const { t } = useTranslation(); + const [units, setUnits] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + try { + const res = await listUnits(); + setUnits(withDepth(res.units ?? [])); + } catch { + setUnits([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const run = useCallback( + async (action: () => Promise) => { + setBusy(true); + try { + await action(); + await refresh(); + return true; + } catch (err) { + toast.error(err instanceof Error ? err.message : t("settings:unitActionFailed")); + return false; + } finally { + setBusy(false); + } + }, + [refresh, t], + ); + + return { + units, + loading, + busy, + refresh, + create: (parentId: string, name: string) => run(() => createUnit(parentId, name)), + rename: (unitId: string, name: string) => run(() => renameUnit(unitId, name)), + remove: (unitId: string) => run(() => deleteUnit(unitId)), + addMember: (unitId: string, userId: string, role: string) => + run(() => setUnitMember(unitId, userId, role)), + removeMember: (unitId: string, userId: string) => run(() => removeUnitMember(unitId, userId)), + }; +} diff --git a/apps/web/hooks/domains/org/use-organizations.ts b/apps/web/hooks/domains/org/use-organizations.ts new file mode 100644 index 0000000000..2f1f52fe89 --- /dev/null +++ b/apps/web/hooks/domains/org/use-organizations.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/components/toast-provider"; +import { + createOrg, + createOrgAdmin, + deleteOrg, + getCurrentOrg, + listOrgs, + updateOrg, +} from "@/lib/api/domains/org-api"; +import type { Org } from "@/lib/types/org"; + +/** + * Operator-side organization management. + * + * `isOperator` decides whether the surface exists at all: a non-operator gets + * 404 from these routes, so the page must not render for them. + */ +export function useOrganizations() { + const { t } = useTranslation(); + const { toast } = useToast(); + const [orgs, setOrgs] = useState([]); + const [isOperator, setIsOperator] = useState(null); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + try { + const current = await getCurrentOrg(); + setIsOperator(current.is_operator); + if (!current.is_operator) { + setOrgs([]); + return; + } + setOrgs((await listOrgs()).orgs ?? []); + } catch { + setIsOperator(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const run = useCallback( + async (action: () => Promise, success: string) => { + setBusy(true); + try { + await action(); + await refresh(); + toast({ title: success }); + } catch (error) { + toast({ + title: error instanceof Error ? error.message : t("orgs:genericError"), + variant: "error", + }); + } finally { + setBusy(false); + } + }, + [refresh, toast, t], + ); + + return { + orgs, + isOperator, + busy, + create: (name: string, onDone: () => void) => + void run(async () => { + await createOrg(name); + onDone(); + }, t("orgs:created")), + rename: (id: string, name: string) => + void run(() => updateOrg(id, { name }), t("orgs:renamed")), + suspend: (id: string) => + void run(() => updateOrg(id, { status: "suspended" }), t("orgs:suspended")), + resume: (id: string) => void run(() => updateOrg(id, { status: "active" }), t("orgs:resumed")), + remove: (id: string, slug: string, onDone: () => void) => + void run(async () => { + await deleteOrg(id, slug); + onDone(); + }, t("orgs:deleted")), + addAdmin: ( + id: string, + admin: { email: string; password: string; display_name: string }, + onDone: () => void, + ) => + void run(async () => { + await createOrgAdmin(id, admin); + onDone(); + }, t("orgs:adminCreated")), + }; +} diff --git a/apps/web/hooks/domains/settings/use-settings-discovery.ts b/apps/web/hooks/domains/settings/use-settings-discovery.ts index cf82818b61..be65cc100b 100644 --- a/apps/web/hooks/domains/settings/use-settings-discovery.ts +++ b/apps/web/hooks/domains/settings/use-settings-discovery.ts @@ -9,6 +9,7 @@ import { resolveSettingsDiscovery } from "@/lib/settings-discovery/resolve"; export function useSettingsDiscovery() { const { t } = useTranslation(); const authEnabled = useFeature("auth"); + const multiTenancyEnabled = useFeature("multiTenancy"); const authMode = useAppStore((state) => state.auth.mode); const role = useAppStore((state) => state.auth.user?.role); const workspaces = useAppStore((state) => state.workspaces.items); @@ -16,6 +17,10 @@ export function useSettingsDiscovery() { const executors = useAppStore((state) => state.executors.items); const showAccount = authEnabled && authMode === "enabled"; const showUsers = authEnabled && (role === undefined || role === "admin"); + // Operator-only in practice, but that answer costs a request; the feature + // gate keeps the entry out of search on installs that never enabled it, and + // the page turns a non-operator away. + const showOrganizations = authEnabled && multiTenancyEnabled; return useMemo( () => @@ -23,10 +28,11 @@ export function useSettingsDiscovery() { t, showAccount, showUsers, + showOrganizations, workspaces, agents, executors, }), - [agents, executors, showAccount, showUsers, t, workspaces], + [agents, executors, showAccount, showOrganizations, showUsers, t, workspaces], ); } diff --git a/apps/web/hooks/domains/users/use-assignable-people.ts b/apps/web/hooks/domains/users/use-assignable-people.ts new file mode 100644 index 0000000000..d5e3acf71a --- /dev/null +++ b/apps/web/hooks/domains/users/use-assignable-people.ts @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { listDirectoryUsers, listWorkspaceMembers } from "@/lib/api/domains/team-access-api"; +import type { DirectoryUser, WorkspaceMember } from "@/lib/types/team-access"; + +export type Person = { id: string; name: string }; + +/** + * One in-flight directory request for the whole page. + * + * The board renders a name per card, so an uncached per-consumer fetch would + * mean one request per card. The directory is global and small (id + display + * name for active users), so it is fetched once and shared. The cost is + * staleness: a user created after this load renders as a raw id until the next + * one, which is the same tradeoff the agent-profile list already makes. + */ +let directoryCache: Promise | null = null; + +function loadDirectory(): Promise { + directoryCache ??= listDirectoryUsers() + .then((res) => (res.users ?? []) as DirectoryUser[]) + .catch(() => { + // Do not cache a failure: a refused or dropped request should be retried + // by the next consumer rather than blanking every name for the session. + directoryCache = null; + return []; + }); + return directoryCache; +} + +/** Test seam: drops the shared directory cache. */ +export function resetDirectoryCacheForTests(): void { + directoryCache = null; +} + +/** + * Display names for user ids, from the shared directory. + * + * Use this for read-only surfaces (a board card, a chip). Anything that offers + * a choice of assignee wants {@link useAssignablePeople}, which also folds in + * the workspace member list. + */ +export function useDirectoryNames(): { nameFor: (userId: string) => string } { + const [users, setUsers] = useState([]); + + useEffect(() => { + let cancelled = false; + loadDirectory().then((list) => { + if (!cancelled) setUsers(list); + }); + return () => { + cancelled = true; + }; + }, []); + + const byId = useMemo(() => new Map(users.map((u) => [u.id, u.display_name || u.id])), [users]); + + // Falls back to the id so an assignee the directory does not carry (a + // disabled account) still renders as something rather than as unassigned. + return { nameFor: (userId: string) => byId.get(userId) ?? userId }; +} + +/** + * The people a task can be assigned to, and the names to show for them. + * + * Two sources, because neither alone is the set. The workspace member list + * misses everyone who reaches an `org`-visible workspace without a member row, + * which is the common case and would otherwise render a colleague as a raw + * user id. The directory covers those, but a private workspace can hold a + * member the directory omits. + * + * Reach is deliberately NOT computed here. It depends on visibility, + * membership and the org boundary, and the server is the authority: assigning + * someone who cannot see the workspace is refused with a message written to be + * shown as-is. Guessing in the browser would only add a second, divergent rule. + */ +export function useAssignablePeople( + workspaceId?: string | null, + options?: { enabled?: boolean }, +): { + people: Person[]; + nameFor: (userId: string) => string; +} { + // Callers render nothing when there is no identity to assign to, but hooks + // run before that early return, so without this the picker would fetch on + // every task page of an install that has authentication switched off. + const enabled = options?.enabled ?? true; + const [people, setPeople] = useState([]); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + // The directory is not workspace-scoped, so it is loaded even with no + // active workspace: the task routes do not always populate one, and + // gating both calls on it leaves every name showing as a raw id. + Promise.all([ + loadDirectory(), + workspaceId + ? listWorkspaceMembers(workspaceId).catch(() => ({ members: [], total: 0 })) + : Promise.resolve({ members: [], total: 0 }), + ]).then(([directory, members]) => { + if (cancelled) return; + const byId = new Map(); + for (const user of directory) byId.set(user.id, user.display_name || user.id); + for (const member of (members.members ?? []) as WorkspaceMember[]) { + if (!byId.has(member.user_id)) { + byId.set(member.user_id, member.display_name || member.user_id); + } + } + setPeople(Array.from(byId, ([id, name]) => ({ id, name }))); + }); + return () => { + cancelled = true; + }; + }, [workspaceId, enabled]); + + const byId = useMemo(() => new Map(people.map((p) => [p.id, p.name])), [people]); + + return { + people, + nameFor: (userId: string) => byId.get(userId) ?? userId, + }; +} diff --git a/apps/web/hooks/domains/workspace/use-workspace-team-access.ts b/apps/web/hooks/domains/workspace/use-workspace-team-access.ts new file mode 100644 index 0000000000..b2862393cd --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-workspace-team-access.ts @@ -0,0 +1,162 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/components/toast-provider"; +import { + listDirectoryUsers, + listWorkspaceMembers, + removeWorkspaceMember, + transferWorkspaceOwnership, + upsertWorkspaceMember, +} from "@/lib/api/domains/team-access-api"; +import { + hasScope, + SCOPE, + type AssignableWorkspaceRole, + type DirectoryUser, + type WorkspaceMember, +} from "@/lib/types/team-access"; + +type UseWorkspaceTeamAccessArgs = { + workspaceId: string; + ownerId: string; + scopes: readonly string[] | undefined; +}; + +/** + * Membership state for one workspace. + * + * Capability flags come from the server-issued scopes rather than from + * comparing the current user to the owner: the backend is authoritative, and a + * client that derives permission independently will eventually derive it + * differently. + */ +export function useWorkspaceTeamAccess({ + workspaceId, + ownerId, + scopes, +}: UseWorkspaceTeamAccessArgs) { + const { t } = useTranslation(); + const { toast } = useToast(); + const [members, setMembers] = useState([]); + const [directory, setDirectory] = useState([]); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + try { + const [memberList, users] = await Promise.all([ + listWorkspaceMembers(workspaceId), + listDirectoryUsers(), + ]); + setMembers(memberList.members ?? []); + setDirectory(users.users ?? []); + } catch { + // A read failure leaves the previous list on screen. Mutations surface + // their own errors, and blanking the card would lose more than it tells. + } + }, [workspaceId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const run = useCallback( + async (action: () => Promise, successMessage: string) => { + setBusy(true); + try { + await action(); + await refresh(); + toast({ title: successMessage }); + } catch (error) { + toast({ + title: error instanceof Error ? error.message : t("workspaces:teamAccess.genericError"), + variant: "error", + }); + } finally { + setBusy(false); + } + }, + [refresh, toast, t], + ); + + const actions = useTeamAccessActions({ + workspaceId, + run, + members, + directory, + }); + + const memberIds = useMemo(() => new Set(members.map((member) => member.user_id)), [members]); + const addableUsers = useMemo( + () => directory.filter((user) => !memberIds.has(user.id) && user.id !== ownerId), + [directory, memberIds, ownerId], + ); + + return { + busy, + members, + addableUsers, + canManageWorkspace: hasScope(scopes, SCOPE.workspaceManage), + canManageMembers: hasScope(scopes, SCOPE.memberManage), + ...actions, + }; +} + +type TeamAccessActionsArgs = { + workspaceId: string; + run: (action: () => Promise, successMessage: string) => Promise; + members: WorkspaceMember[]; + directory: DirectoryUser[]; +}; + +/** The mutation half of team access, split out to keep each hook readable. */ +function useTeamAccessActions({ workspaceId, run, members, directory }: TeamAccessActionsArgs) { + const { t } = useTranslation(); + + const nameFor = useCallback( + (userId: string) => + members.find((member) => member.user_id === userId)?.display_name ?? + directory.find((user) => user.id === userId)?.display_name ?? + userId, + [members, directory], + ); + + const changeRole = useCallback( + (userId: string, role: AssignableWorkspaceRole) => + void run( + () => upsertWorkspaceMember(workspaceId, userId, role), + t("workspaces:teamAccess.roleUpdated"), + ), + [run, t, workspaceId], + ); + + const addMember = useCallback( + (userId: string, role: AssignableWorkspaceRole, onAdded: () => void) => + void run(async () => { + await upsertWorkspaceMember(workspaceId, userId, role); + onAdded(); + }, t("workspaces:teamAccess.memberAdded")), + [run, t, workspaceId], + ); + + const removeMember = useCallback( + (userId: string) => + void run( + () => removeWorkspaceMember(workspaceId, userId), + t("workspaces:teamAccess.memberRemoved"), + ), + [run, t, workspaceId], + ); + + const makeOwner = useCallback( + (userId: string) => + void run( + () => transferWorkspaceOwnership(workspaceId, userId), + t("workspaces:teamAccess.ownershipTransferred", { name: nameFor(userId) }), + ), + [nameFor, run, t, workspaceId], + ); + + return { changeRole, addMember, removeMember, makeOwner }; +} diff --git a/apps/web/hooks/use-optimistic-task-mutation.ts b/apps/web/hooks/use-optimistic-task-mutation.ts index c9c21c5cf7..bd61db71c7 100644 --- a/apps/web/hooks/use-optimistic-task-mutation.ts +++ b/apps/web/hooks/use-optimistic-task-mutation.ts @@ -106,6 +106,12 @@ function toOfficeTaskPatch(patch: Partial): Partial { if (patch.assigneeAgentProfileId !== undefined) { out.assigneeAgentProfileId = patch.assigneeAgentProfileId; } + // hasOwnProperty, not !== undefined: clearing the human assignee sends + // undefined, and dropping it here would leave the stale name on screen + // until the refetch lands. + if (Object.prototype.hasOwnProperty.call(patch, "assigneeUserId")) { + out.assigneeUserId = patch.assigneeUserId; + } if (patch.projectId !== undefined) out.projectId = patch.projectId; if (Object.prototype.hasOwnProperty.call(patch, "parentId")) out.parentId = patch.parentId; if (patch.labels !== undefined) out.labels = patch.labels; diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index 831dbf6012..eea3417542 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -143,6 +143,8 @@ export async function updateTask( }>; /** Nest under another task. Empty string clears the parent (un-nest). */ parent_id?: string; + /** Human assignee. "" unassigns; omitting the field leaves it alone. */ + assignee_user_id?: string; priority?: TaskPriority; }, options?: ApiRequestOptions, diff --git a/apps/web/lib/api/domains/office-extended-api.ts b/apps/web/lib/api/domains/office-extended-api.ts index 65427e6b79..80ecbd27e3 100644 --- a/apps/web/lib/api/domains/office-extended-api.ts +++ b/apps/web/lib/api/domains/office-extended-api.ts @@ -175,6 +175,8 @@ export type UpdateTaskPayload = { status?: string; comment?: string; assignee_agent_profile_id?: string; + /** The human assignee. "" unassigns; omitting the field leaves it alone. */ + assignee_user_id?: string; priority?: string; project_id?: string; parent_id?: string; diff --git a/apps/web/lib/api/domains/org-api.ts b/apps/web/lib/api/domains/org-api.ts new file mode 100644 index 0000000000..a32f4ee04f --- /dev/null +++ b/apps/web/lib/api/domains/org-api.ts @@ -0,0 +1,47 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; +import type { CurrentOrgResponse, ListOrgsResponse, Org, OrgStatus } from "@/lib/types/org"; + +export async function getCurrentOrg(options?: ApiRequestOptions) { + return fetchJson("/api/v1/orgs/current", options); +} + +export async function listOrgs(options?: ApiRequestOptions) { + return fetchJson("/api/v1/instance/orgs", options); +} + +export async function createOrg(name: string, options?: ApiRequestOptions) { + return fetchJson("/api/v1/instance/orgs", { + ...options, + init: { method: "POST", body: JSON.stringify({ name }), ...(options?.init ?? {}) }, + }); +} + +export async function updateOrg( + id: string, + patch: { name?: string; status?: OrgStatus }, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/instance/orgs/${id}`, { + ...options, + init: { method: "PATCH", body: JSON.stringify(patch), ...(options?.init ?? {}) }, + }); +} + +/** Deleting requires typing the org's slug verbatim. */ +export async function deleteOrg(id: string, slug: string, options?: ApiRequestOptions) { + return fetchJson<{ success: boolean }>(`/api/v1/instance/orgs/${id}`, { + ...options, + init: { method: "DELETE", body: JSON.stringify({ slug }), ...(options?.init ?? {}) }, + }); +} + +export async function createOrgAdmin( + id: string, + admin: { email: string; password: string; display_name: string }, + options?: ApiRequestOptions, +) { + return fetchJson<{ success: boolean }>(`/api/v1/instance/orgs/${id}/admins`, { + ...options, + init: { method: "POST", body: JSON.stringify(admin), ...(options?.init ?? {}) }, + }); +} diff --git a/apps/web/lib/api/domains/org-units-api.test.ts b/apps/web/lib/api/domains/org-units-api.test.ts new file mode 100644 index 0000000000..f5cb7ec5d7 --- /dev/null +++ b/apps/web/lib/api/domains/org-units-api.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { withDepth, type OrgUnit } from "./org-units-api"; + +function unit(id: string, path: string, name = id): OrgUnit { + return { + id, + org_id: "org-1", + parent_id: "", + kind: "standard", + name, + path, + created_at: "", + updated_at: "", + }; +} + +describe("withDepth", () => { + // Depth is what indents the tree. Deriving it from the materialized path + // rather than walking parents keeps the render a single pass, but it means + // an off-by-one here flattens a department into its own parent. + it("derives indentation depth from the materialized path", () => { + const rows = withDepth([ + unit("root", "/root/"), + unit("dept", "/root/dept/"), + unit("team", "/root/dept/team/"), + unit("squad", "/root/dept/team/squad/"), + ]); + + expect(rows.map((r) => r.depth)).toEqual([0, 1, 2, 3]); + }); + + it("keeps the backend's parent-before-child order", () => { + const rows = withDepth([unit("root", "/root/"), unit("dept", "/root/dept/")]); + expect(rows.map((r) => r.id)).toEqual(["root", "dept"]); + }); + + it("treats a malformed path as top level rather than throwing", () => { + expect(withDepth([unit("odd", "")])[0]!.depth).toBe(0); + }); +}); diff --git a/apps/web/lib/api/domains/org-units-api.ts b/apps/web/lib/api/domains/org-units-api.ts new file mode 100644 index 0000000000..3837370c8c --- /dev/null +++ b/apps/web/lib/api/domains/org-units-api.ts @@ -0,0 +1,107 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; + +/** A node in the organization tree: a department, a team, or a personal unit. */ +export type OrgUnit = { + id: string; + org_id: string; + parent_id: string; + kind: "root" | "standard" | "personal"; + owner_user_id?: string; + name: string; + path: string; + created_at: string; + updated_at: string; +}; + +export type UnitMember = { + unit_id: string; + user_id: string; + role: string; + added_by?: string; +}; + +export async function listUnits(options?: ApiRequestOptions) { + return fetchJson<{ units: OrgUnit[] | null; total: number }>("/api/v1/units", options); +} + +export async function listUnitMembers(unitId: string, options?: ApiRequestOptions) { + return fetchJson<{ members: UnitMember[] | null; total: number }>( + `/api/v1/units/${unitId}/members`, + options, + ); +} + +export async function createUnit(parentId: string, name: string, options?: ApiRequestOptions) { + return fetchJson("/api/v1/units", { + ...options, + init: { method: "POST", body: JSON.stringify({ parent_id: parentId, name }), ...options?.init }, + }); +} + +export async function renameUnit(unitId: string, name: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/units/${unitId}`, { + ...options, + init: { method: "PATCH", body: JSON.stringify({ name }), ...options?.init }, + }); +} + +export async function moveUnit(unitId: string, parentId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/units/${unitId}`, { + ...options, + init: { method: "PATCH", body: JSON.stringify({ parent_id: parentId }), ...options?.init }, + }); +} + +export async function deleteUnit(unitId: string, options?: ApiRequestOptions) { + return fetchJson<{ success: boolean }>(`/api/v1/units/${unitId}`, { + ...options, + init: { method: "DELETE", ...options?.init }, + }); +} + +export async function setUnitMember( + unitId: string, + userId: string, + role: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ success: boolean }>(`/api/v1/units/${unitId}/members/${userId}`, { + ...options, + init: { method: "PUT", body: JSON.stringify({ role }), ...options?.init }, + }); +} + +export async function removeUnitMember( + unitId: string, + userId: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ success: boolean }>(`/api/v1/units/${unitId}/members/${userId}`, { + ...options, + init: { method: "DELETE", ...options?.init }, + }); +} + +/** Moves a workspace to another unit, which is the only way to change who reaches it. */ +export async function placeWorkspace( + workspaceId: string, + unitId: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ id: string; unit_id: string }>(`/api/v1/workspaces/${workspaceId}`, { + ...options, + init: { method: "PATCH", body: JSON.stringify({ unit_id: unitId }), ...options?.init }, + }); +} + +/** + * Orders units so a parent always precedes its children, and returns the depth + * of each. The backend orders by materialized path, which already gives this; + * depth is derived here so the tree renders without a second walk. + */ +export function withDepth(units: OrgUnit[]): Array { + return units.map((unit) => ({ + ...unit, + depth: Math.max(0, unit.path.split("/").filter(Boolean).length - 1), + })); +} diff --git a/apps/web/lib/api/domains/team-access-api.ts b/apps/web/lib/api/domains/team-access-api.ts new file mode 100644 index 0000000000..e11da25d39 --- /dev/null +++ b/apps/web/lib/api/domains/team-access-api.ts @@ -0,0 +1,52 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; +import type { + AssignableWorkspaceRole, + ListDirectoryUsersResponse, + ListWorkspaceMembersResponse, + WorkspaceMember, +} from "@/lib/types/team-access"; + +export async function listWorkspaceMembers(workspaceId: string, options?: ApiRequestOptions) { + return fetchJson( + `/api/v1/workspaces/${workspaceId}/members`, + options, + ); +} + +export async function upsertWorkspaceMember( + workspaceId: string, + userId: string, + role: AssignableWorkspaceRole, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/workspaces/${workspaceId}/members/${userId}`, { + ...options, + init: { method: "PUT", body: JSON.stringify({ role }), ...(options?.init ?? {}) }, + }); +} + +export async function removeWorkspaceMember( + workspaceId: string, + userId: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ success: boolean }>(`/api/v1/workspaces/${workspaceId}/members/${userId}`, { + ...options, + init: { method: "DELETE", ...(options?.init ?? {}) }, + }); +} + +export async function transferWorkspaceOwnership( + workspaceId: string, + userId: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ success: boolean }>(`/api/v1/workspaces/${workspaceId}/transfer-ownership`, { + ...options, + init: { method: "POST", body: JSON.stringify({ user_id: userId }), ...(options?.init ?? {}) }, + }); +} + +export async function listDirectoryUsers(options?: ApiRequestOptions) { + return fetchJson("/api/v1/users/directory", options); +} diff --git a/apps/web/lib/kanban/map-task.test.ts b/apps/web/lib/kanban/map-task.test.ts index 593f7f8993..01c021e6d1 100644 --- a/apps/web/lib/kanban/map-task.test.ts +++ b/apps/web/lib/kanban/map-task.test.ts @@ -210,6 +210,24 @@ describe("toKanbanTask — pending and status fields", () => { }); }); +describe("toKanbanTask — human assignee", () => { + // The kanban board and the task top bar both read the assignee out of the + // store, so this mapper is the only hop between the backend field and every + // kanban surface. Dropping it here reads as "nobody is assigned to anything" + // with no error anywhere. + it("carries the human assignee through both task shapes", () => { + const http = toKanbanTask(httpDTO({ assignee_user_id: "user-7" })); + const ws = toKanbanTask(wsPayload({ assignee_user_id: "user-7" })); + + expect(http.assigneeUserId).toBe("user-7"); + expect(ws.assigneeUserId).toBe("user-7"); + }); + + it("leaves the assignee undefined when the backend omits it", () => { + expect(toKanbanTask(httpDTO()).assigneeUserId).toBeUndefined(); + }); +}); + describe("toKanbanTask — autopilot", () => { it("preserves the immutable task creation mode for HTTP and websocket payloads", () => { expect(toKanbanTask(httpDTO({ autopilot: true }))).toMatchObject({ autopilot: true }); diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index 10f8d8e524..c2d9dd1208 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -77,6 +77,7 @@ export type TaskLike = { origin?: string | null; is_remote_executor?: boolean; parent_id?: string | null; + assignee_user_id?: string; updated_at?: string; created_at?: string; wip_admitted?: boolean; @@ -222,6 +223,17 @@ export function copyPrimaryExecutorFields(merged: KanbanTask, existing: KanbanTa merged.isRemoteExecutor = existing.isRemoteExecutor; } +/** + * "Unassigned" has one spelling in the store: undefined. + * + * The wire uses an empty string for it (that is what a PATCH sends to + * unassign), so without this the store would hold both "" and undefined for + * the same state and every consumer would need to know that. + */ +export function pickAssignee(value: string | undefined): string | undefined { + return value ? value : undefined; +} + // eslint-disable-next-line complexity -- Maps the complete task wire contract into the shared Kanban model. export function toKanbanTask(source: TaskLike): KanbanTask { return { @@ -256,6 +268,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask { labels: pickLabels(source), origin: source.origin ?? undefined, isRemoteExecutor: source.is_remote_executor ?? false, + assigneeUserId: pickAssignee(source.assignee_user_id), parentTaskId: source.parent_id ?? undefined, workspaceMode: workspaceModeFromMetadata(source.metadata), updatedAt: source.updated_at, diff --git a/apps/web/lib/routing/route-bootstrap.team-access.test.ts b/apps/web/lib/routing/route-bootstrap.team-access.test.ts new file mode 100644 index 0000000000..14ca68669d --- /dev/null +++ b/apps/web/lib/routing/route-bootstrap.team-access.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { mapWorkspaceItem } from "./route-bootstrap"; +import type { Workspace } from "@/lib/types/http"; + +/** + * Regression guard for a dropped hop. + * + * The API returned the caller's scopes correctly, but this mapper listed the + * store item's fields by hand and silently omitted them, so the Team access + * card rendered every control disabled for the workspace's own owner. Nothing + * failed: no type error, no test, no console warning. + */ +function apiWorkspace(overrides: Partial = {}): Workspace { + return { + id: "ws-1", + name: "Platform Team", + owner_id: "user-ana", + unit_id: "unit-1", + viewer_role: "owner", + scopes: ["workspace.read", "workspace.manage", "member.manage"], + member_count: 3, + created_at: "2026-08-22T10:00:00Z", + updated_at: "2026-08-22T10:00:00Z", + ...overrides, + } as Workspace; +} + +describe("mapWorkspaceItem", () => { + it("carries placement, viewer role, scopes and member count into the store", () => { + const item = mapWorkspaceItem(apiWorkspace()); + expect(item.unit_id).toBe("unit-1"); + expect(item.viewer_role).toBe("owner"); + expect(item.scopes).toEqual(["workspace.read", "workspace.manage", "member.manage"]); + expect(item.member_count).toBe(3); + }); + + it("leaves team-access fields undefined when the API omits them", () => { + const item = mapWorkspaceItem( + apiWorkspace({ + unit_id: undefined, + viewer_role: undefined, + scopes: undefined, + member_count: undefined, + }), + ); + expect(item.unit_id).toBeUndefined(); + expect(item.scopes).toBeUndefined(); + }); + + it("still maps the pre-existing workspace fields", () => { + const item = mapWorkspaceItem(apiWorkspace({ name: "Ana - security spike" })); + expect(item.id).toBe("ws-1"); + expect(item.name).toBe("Ana - security spike"); + expect(item.owner_id).toBe("user-ana"); + }); +}); diff --git a/apps/web/lib/routing/route-bootstrap.ts b/apps/web/lib/routing/route-bootstrap.ts index aa9d0ba3f1..171d192bd2 100644 --- a/apps/web/lib/routing/route-bootstrap.ts +++ b/apps/web/lib/routing/route-bootstrap.ts @@ -64,6 +64,13 @@ export function mapWorkspaceItem(ws: WorkspaceItem): WorkspaceState["items"][num name: ws.name, description: ws.description ?? null, owner_id: ws.owner_id, + // Team access: visibility plus the caller's resolved role and scopes. + // These gate every owner-only control, so dropping them here silently + // renders the workspace as read-only to its own owner. + unit_id: ws.unit_id, + viewer_role: ws.viewer_role, + scopes: ws.scopes, + member_count: ws.member_count, default_executor_id: ws.default_executor_id ?? null, default_environment_id: ws.default_environment_id ?? null, default_agent_profile_id: ws.default_agent_profile_id ?? null, diff --git a/apps/web/lib/settings-discovery/catalog.test.ts b/apps/web/lib/settings-discovery/catalog.test.ts index 78e625d281..b8f1427a35 100644 --- a/apps/web/lib/settings-discovery/catalog.test.ts +++ b/apps/web/lib/settings-discovery/catalog.test.ts @@ -169,6 +169,7 @@ describe("resolveSettingsDiscovery dynamic entries", () => { t: (key) => t(key), showAccount: false, showUsers: false, + showOrganizations: false, workspaces: [{ id: "workspace-1", name: "Main Workspace" }], agents: [], executors: [], @@ -186,6 +187,7 @@ describe("resolveSettingsDiscovery dynamic entries", () => { t: translate, showAccount: true, showUsers: true, + showOrganizations: false, workspaces: [{ id: "workspace / one", name: "Main Workspace" }], agents: [ { @@ -231,6 +233,7 @@ describe("resolveSettingsDiscovery dynamic entries", () => { t: translate, showAccount: false, showUsers: false, + showOrganizations: false, workspaces: [{ id: "workspace-1", name: "Personal value" }], agents: [], executors: [], @@ -251,6 +254,7 @@ describe("resolveSettingsDiscovery visibility", () => { t: translate, showAccount: false, showUsers: false, + showOrganizations: false, workspaces: [], agents: [], executors: [], @@ -259,6 +263,7 @@ describe("resolveSettingsDiscovery visibility", () => { t: translate, showAccount: true, showUsers: true, + showOrganizations: false, workspaces: [], agents: [], executors: [], @@ -277,6 +282,7 @@ describe("resolveSettingsDiscovery visibility", () => { t: translate, showAccount: false, showUsers: false, + showOrganizations: false, workspaces: [], agents: [], executors: [], @@ -285,6 +291,7 @@ describe("resolveSettingsDiscovery visibility", () => { t: translate, showAccount: false, showUsers: false, + showOrganizations: false, workspaces: [{ id: "workspace-1", name: "Main" }], agents: [], executors: [], diff --git a/apps/web/lib/settings-discovery/catalog/system.ts b/apps/web/lib/settings-discovery/catalog/system.ts index 0f800277fa..f2f71da4bc 100644 --- a/apps/web/lib/settings-discovery/catalog/system.ts +++ b/apps/web/lib/settings-discovery/catalog/system.ts @@ -190,4 +190,24 @@ export const SYSTEM_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [ order: 670, requires: "users", }, + // Grouped with access control, not system: an organization is a boundary + // above users, and System is for operating the instance. + { + id: "settings-units", + kind: "page", + labelKey: "settings:navUnits", + groupId: "access", + href: "/settings/units", + order: 659, + requires: "users", + }, + { + id: "system-organizations", + kind: "page", + labelKey: "orgs:navOrganizations", + groupId: "access", + href: "/settings/system/organizations", + order: 661, + requires: "organizations", + }, ]; diff --git a/apps/web/lib/settings-discovery/resolve.ts b/apps/web/lib/settings-discovery/resolve.ts index 173a29b18e..9c2865437f 100644 --- a/apps/web/lib/settings-discovery/resolve.ts +++ b/apps/web/lib/settings-discovery/resolve.ts @@ -194,6 +194,7 @@ function dynamicDefinitions(context: SettingsDiscoveryContext): SettingsDiscover function isVisible(entry: SettingsDiscoveryDefinition, context: SettingsDiscoveryContext) { if (entry.requires === "account") return context.showAccount; if (entry.requires === "users") return context.showUsers; + if (entry.requires === "organizations") return context.showOrganizations; if (entry.requires === "workspace") return context.workspaces.length > 0; return true; } diff --git a/apps/web/lib/settings-discovery/types.ts b/apps/web/lib/settings-discovery/types.ts index e808e7409c..9bade3bec5 100644 --- a/apps/web/lib/settings-discovery/types.ts +++ b/apps/web/lib/settings-discovery/types.ts @@ -11,7 +11,7 @@ export type SettingsDiscoveryDefinition = { href: string; targetId?: string; order: number; - requires?: "account" | "users" | "workspace"; + requires?: "account" | "users" | "workspace" | "organizations"; }; export type SettingsDiscoveryGroupDefinition = { @@ -37,6 +37,7 @@ export type SettingsDiscoveryContext = { t: (key: string) => string; showAccount: boolean; showUsers: boolean; + showOrganizations: boolean; workspaces: Array<{ id: string; name: string }>; agents: Array<{ name: string; diff --git a/apps/web/lib/ssr/mapper.test.ts b/apps/web/lib/ssr/mapper.test.ts index f7514013d8..33e95ee092 100644 --- a/apps/web/lib/ssr/mapper.test.ts +++ b/apps/web/lib/ssr/mapper.test.ts @@ -78,6 +78,18 @@ describe("snapshotToState", () => { expect(state.kanban?.tasks[0]?.primarySessionPendingAction).toBeUndefined(); }); + // The server-rendered boot payload is the first thing a board paints from, + // so a dropped assignee here shows every card as unassigned until the first + // WS update happens to arrive. + it("hydrates the human assignee into the initial kanban state", () => { + const snapshot = snapshotWithPendingAction(undefined); + snapshot.tasks[0].assignee_user_id = "user-7"; + + const state = snapshotToState(snapshot); + + expect(state.kanban?.tasks[0]?.assigneeUserId).toBe("user-7"); + }); + it("hydrates task metadata into the initial kanban state", () => { const snapshot = snapshotWithPendingAction(undefined); snapshot.tasks[0].metadata = { diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index 8a059dd892..97387aff94 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -1,7 +1,7 @@ import type { AppState, KanbanState } from "@/lib/state/store"; import { primaryTaskRepository } from "@/lib/types/http"; import type { WorkflowSnapshot, Message, Task } from "@/lib/types/http"; -import { pickPendingAction, workspaceModeFromMetadata } from "@/lib/kanban/map-task"; +import { pickAssignee, pickPendingAction, workspaceModeFromMetadata } from "@/lib/kanban/map-task"; import { isPRReviewFromMetadata, isIssueWatchFromMetadata, @@ -104,6 +104,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { sessionCount: task.session_count ?? undefined, reviewStatus: task.review_status ?? undefined, statusSummary: task.status_summary, + assigneeUserId: pickAssignee(task.assignee_user_id), parentTaskId: task.parent_id ?? undefined, metadata: task.metadata, workspaceMode: workspaceModeFromMetadata(task.metadata), diff --git a/apps/web/lib/state/slices/features/types.ts b/apps/web/lib/state/slices/features/types.ts index 597abd67e9..94717eb505 100644 --- a/apps/web/lib/state/slices/features/types.ts +++ b/apps/web/lib/state/slices/features/types.ts @@ -9,6 +9,7 @@ export const defaultFeatureFlags = { // overwrites this with the backend's effective values after startup. office: false, auth: false, + multiTenancy: false, dynamicAgentRouting: false, claudeBackgroundPromptHandoff: false, claudeMidTurnSteering: false, diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index 67ebf305c0..f89cbe5928 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -126,6 +126,8 @@ export type KanbanState = { labels?: string[]; origin?: string; isRemoteExecutor?: boolean; + /** Human assignee (user id). Independent of any agent assignment. */ + assigneeUserId?: string; parentTaskId?: string | null; workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; updatedAt?: string; diff --git a/apps/web/lib/state/slices/office/office-task-type.ts b/apps/web/lib/state/slices/office/office-task-type.ts index 9150aa253a..ee0e7fbab5 100644 --- a/apps/web/lib/state/slices/office/office-task-type.ts +++ b/apps/web/lib/state/slices/office/office-task-type.ts @@ -16,6 +16,11 @@ export type OfficeTask = { parentId?: string; projectId?: string; assigneeAgentProfileId?: string; + /** + * The human assignee's user id. Independent of the agent assignee above: + * a task can carry both, and setting one never clears the other. + */ + assigneeUserId?: string; labels?: TaskLabel[] | string[]; blockedBy?: string[]; children?: OfficeTask[]; diff --git a/apps/web/lib/state/slices/workspace/types.ts b/apps/web/lib/state/slices/workspace/types.ts index e706845bca..4da93df5ee 100644 --- a/apps/web/lib/state/slices/workspace/types.ts +++ b/apps/web/lib/state/slices/workspace/types.ts @@ -12,6 +12,13 @@ export type WorkspaceState = { name: string; description?: string | null; owner_id: string; + /** The unit this workspace sits in; reach follows the tree. */ + unit_id?: string; + /** The requesting user's role here. */ + viewer_role?: string; + /** Scopes the requesting user holds here; gates every owner-only control. */ + scopes?: string[]; + member_count?: number; default_executor_id?: string | null; default_environment_id?: string | null; default_agent_profile_id?: string | null; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index d97e51a8bd..06c94c16d6 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -217,6 +217,7 @@ export type WorkspacePayload = { name: string; description?: string; owner_id?: string; + unit_id?: string; default_executor_id?: string | null; default_environment_id?: string | null; default_agent_profile_id?: string | null; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index a54a49a515..ae9097fc9d 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -274,6 +274,14 @@ export type Workspace = { name: string; description?: string | null; owner_id: string; + /** "private" (owner + explicit members) or "org" (every non-guest user). */ + /** The organization unit this workspace sits in; reach follows the tree. */ + unit_id?: string; + /** The requesting user's role here; drives owner-only controls. */ + viewer_role?: string; + /** Scopes the requesting user holds here. The server is authoritative. */ + scopes?: string[]; + member_count?: number; default_executor_id?: string | null; default_environment_id?: string | null; default_agent_profile_id?: AgentProfileId | null; @@ -435,6 +443,11 @@ export type Task = ActiveSubagentCountFields & { primary_working_directory?: string | null; is_remote_executor?: boolean; is_ephemeral?: boolean; + /** + * The human assignee's user id, independent of the agent assignee. Advisory: + * it records who owns the task and gates nothing. + */ + assignee_user_id?: string; parent_id?: TaskId; archived_at?: string | null; created_at: string; diff --git a/apps/web/lib/types/org.ts b/apps/web/lib/types/org.ts new file mode 100644 index 0000000000..df8ff38188 --- /dev/null +++ b/apps/web/lib/types/org.ts @@ -0,0 +1,25 @@ +/** Organizations: the tenant boundary above users. */ + +export type OrgStatus = "active" | "suspended"; + +export type Org = { + id: string; + name: string; + /** Lowercase identifier; typed verbatim to confirm deletion. */ + slug: string; + status: OrgStatus; + is_default: boolean; + created_at: string; + updated_at: string; +}; + +export type CurrentOrgResponse = { + org: Org | null; + /** + * The instance operator tier: managing organizations themselves. It grants + * no access inside any organization, including the operator's own. + */ + is_operator: boolean; +}; + +export type ListOrgsResponse = { orgs: Org[]; total: number }; diff --git a/apps/web/lib/types/team-access.test.ts b/apps/web/lib/types/team-access.test.ts new file mode 100644 index 0000000000..f1be5fb69e --- /dev/null +++ b/apps/web/lib/types/team-access.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { ASSIGNABLE_WORKSPACE_ROLES, hasScope, SCOPE } from "./team-access"; + +describe("hasScope", () => { + it("grants a scope the server issued", () => { + expect(hasScope([SCOPE.workspaceRead, SCOPE.taskWrite], SCOPE.taskWrite)).toBe(true); + }); + + it("denies a scope the server withheld", () => { + expect(hasScope([SCOPE.workspaceRead], SCOPE.sessionExec)).toBe(false); + }); + + // A payload from a backend that predates scopes, or one where the field was + // dropped, must hide controls rather than show ones that would 403. + it("denies every scope when the field is absent", () => { + expect(hasScope(undefined, SCOPE.workspaceRead)).toBe(false); + expect(hasScope([], SCOPE.workspaceManage)).toBe(false); + }); +}); + +describe("scope identifiers", () => { + // These strings are compared with === against the backend registry, so a + // rename here silently breaks every gated control rather than failing loudly. + it("match the backend registry values", () => { + expect(SCOPE).toEqual({ + workspaceRead: "workspace.read", + workspaceManage: "workspace.manage", + taskWrite: "task.write", + sessionPrompt: "session.prompt", + sessionControl: "session.control", + sessionExec: "session.exec", + repositoryManage: "repository.manage", + secretManage: "secret.manage", + memberManage: "member.manage", + }); + }); + + // Owner is reached by transferring ownership, never by assigning a role. + it("does not offer owner as an assignable role", () => { + expect(ASSIGNABLE_WORKSPACE_ROLES).toEqual(["collaborator", "viewer"]); + expect(ASSIGNABLE_WORKSPACE_ROLES).not.toContain("owner"); + }); +}); diff --git a/apps/web/lib/types/team-access.ts b/apps/web/lib/types/team-access.ts new file mode 100644 index 0000000000..583310de93 --- /dev/null +++ b/apps/web/lib/types/team-access.ts @@ -0,0 +1,66 @@ +/** + * Team access: workspace unit placement, membership, and the caller's + * resolved scopes. + * + * Scope strings are compared with `===` against the values the backend + * registry emits, so they must never be translated or reformatted. + */ + +/** The caller's role in one workspace. Empty means unreachable. */ +export type WorkspaceRole = "owner" | "collaborator" | "viewer" | ""; + +/** Roles that can be assigned through the member API. Owner comes from transfer. */ +export const ASSIGNABLE_WORKSPACE_ROLES = ["collaborator", "viewer"] as const; +export type AssignableWorkspaceRole = (typeof ASSIGNABLE_WORKSPACE_ROLES)[number]; + +/** + * Scope identifiers mirrored from `internal/authz`. + * i18n-exempt: wire identifiers compared with ===, never displayed raw. + */ +export const SCOPE = { + workspaceRead: "workspace.read", + workspaceManage: "workspace.manage", + taskWrite: "task.write", + sessionPrompt: "session.prompt", + sessionControl: "session.control", + sessionExec: "session.exec", + repositoryManage: "repository.manage", + secretManage: "secret.manage", + memberManage: "member.manage", +} as const; + +export type Scope = (typeof SCOPE)[keyof typeof SCOPE]; + +export type WorkspaceMember = { + user_id: string; + display_name?: string; + role: WorkspaceRole; + added_by?: string; +}; + +export type ListWorkspaceMembersResponse = { + members: WorkspaceMember[]; + total: number; +}; + +export type DirectoryUser = { + id: string; + display_name: string; +}; + +export type ListDirectoryUsersResponse = { + users: DirectoryUser[]; + total: number; +}; + +/** + * Whether the caller holds a scope on a workspace. + * + * The server is authoritative; this only decides whether to render a control. + * Absent scopes (an older backend, or a payload that predates this field) read + * as "not granted", so a stale client hides controls rather than showing ones + * that would 403. + */ +export function hasScope(scopes: readonly string[] | undefined, scope: Scope): boolean { + return Boolean(scopes?.includes(scope)); +} diff --git a/apps/web/lib/user-initials.ts b/apps/web/lib/user-initials.ts new file mode 100644 index 0000000000..adae550359 --- /dev/null +++ b/apps/web/lib/user-initials.ts @@ -0,0 +1,16 @@ +/** + * Initials for a person's display name, for avatar fallbacks. + * + * First and last word, so "Ana Maria Ferreira" reads as "AF" rather than "AM": + * a middle name should not displace the family name people recognise. + * + * Note the deliberately different `getInitials` in commit-detail-panel.tsx, + * which takes the first two words of a git author string. That is a separate + * rule for separate input, not a copy of this one. + */ +export function initialsFor(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); + return `${parts[0]![0]}${parts[parts.length - 1]![0]}`.toUpperCase(); +} diff --git a/apps/web/lib/ws/handlers/tasks-assignee.test.ts b/apps/web/lib/ws/handlers/tasks-assignee.test.ts new file mode 100644 index 0000000000..8dda8dbf0f --- /dev/null +++ b/apps/web/lib/ws/handlers/tasks-assignee.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from "vitest"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import { registerTasksHandlers } from "./tasks"; + +type Listener = (state: AppState) => void; + +/** + * Minimal in-memory store for the tasks WS handler tests. + * The handler reads kanban tasks, kanbanMulti snapshots, and tasks.activeTaskId/activeSessionId, + * and calls setActiveSession; everything else can stay default. + */ +function makeStore(initial: Partial = {}) { + let state = { + kanban: { workflowId: "wf1", steps: [], tasks: [] }, + kanbanMulti: { snapshots: {}, isLoading: false }, + tasks: { + activeTaskId: null, + activeSessionId: null, + pinnedSessionId: null, + lastSessionByTaskId: {}, + }, + taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} }, + environmentIdBySessionId: {}, + setActiveSession: vi.fn((taskId: string, sessionId: string | null) => { + state = { + ...state, + tasks: { + ...state.tasks, + activeTaskId: taskId, + activeSessionId: sessionId, + pinnedSessionId: sessionId, + lastSessionByTaskId: sessionId + ? { ...state.tasks.lastSessionByTaskId, [taskId]: sessionId } + : state.tasks.lastSessionByTaskId, + }, + }; + }), + setActiveSessionAuto: vi.fn((taskId: string, sessionId: string | null) => { + state = { + ...state, + tasks: { + ...state.tasks, + activeTaskId: taskId, + activeSessionId: sessionId, + }, + }; + }), + removeTaskFromSidebarPrefs: vi.fn(), + setTaskDeletedNotification: vi.fn(), + ...initial, + } as unknown as AppState; + + const listeners = new Set(); + return { + getState: () => state, + setState: (updater: AppState | ((s: AppState) => AppState)) => { + const next = + typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater; + state = { ...state, ...next }; + for (const l of listeners) l(state); + }, + subscribe: (l: Listener) => { + listeners.add(l); + return () => listeners.delete(l); + }, + destroy: vi.fn(), + getInitialState: vi.fn(), + } as unknown as StoreApi & { getState: () => AppState }; +} + +function makeTask(id: string, primarySessionId: string | null, workflowId = "wf1") { + return { + task_id: id, + workflow_id: workflowId, + workflow_step_id: "step1", + title: "Test", + description: "", + state: "IN_PROGRESS", + primary_session_id: primarySessionId, + is_ephemeral: false, + } as Record; +} + +function makeMessage(payload: Record) { + return { + id: "msg-1", + type: "notification" as const, + action: "task.updated" as const, + payload, + } as Parameters["task.updated"]>>[0]; +} + +// Shared setup for the primary-session focus-follow tests: a single task t1 +// whose kanban primary, plus the active/pinned session ids, are the only knobs +// that vary between cases. + +function makeAssigneeStore(assigneeUserId?: string) { + const existingTask = { + id: "t1", + workflowStepId: "step1", + title: "Old title", + position: 0, + primarySessionId: "session-1", + assigneeUserId, + }; + return makeStore({ + kanban: { + workflowId: "wf1", + steps: [], + tasks: [existingTask], + } as unknown as AppState["kanban"], + kanbanMulti: { + isLoading: false, + snapshots: { + wf1: { workflowId: "wf1", workflowName: "WF1", steps: [], tasks: [existingTask] }, + }, + } as unknown as AppState["kanbanMulti"], + }); +} + +describe("human assignee preservation", () => { + // Observed live before this guard: taking a task over showed the new owner, + // then the next unrelated task.updated blanked the top bar back to + // "Unassigned" while the database still held the assignment. + it("preserves the assignee when a partial update omits assignee_user_id", () => { + const store = makeAssigneeStore("user-7"); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ ...makeTask("t1", "session-1"), title: "Retitled task" }), + ); + + const state = store.getState(); + expect(state.kanban.tasks[0]).toMatchObject({ assigneeUserId: "user-7" }); + expect(state.kanbanMulti.snapshots.wf1.tasks[0]).toMatchObject({ assigneeUserId: "user-7" }); + }); + + it("adopts an explicit reassignment", () => { + const store = makeAssigneeStore("user-7"); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ ...makeTask("t1", "session-1"), assignee_user_id: "user-9" }), + ); + + expect(store.getState().kanban.tasks[0]).toMatchObject({ assigneeUserId: "user-9" }); + }); + + it("clears the assignee when the event says it is empty", () => { + const store = makeAssigneeStore("user-7"); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ ...makeTask("t1", "session-1"), assignee_user_id: "" }), + ); + + expect(store.getState().kanban.tasks[0]).toMatchObject({ assigneeUserId: undefined }); + }); +}); diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 4086410e37..6fae6498d7 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -101,6 +101,13 @@ function mergeTaskUpdate( payloadKey: "parent_id", taskField: "parentTaskId", }); + // Same contract for the human assignee: a lightweight update that does not + // mention it must not read as "unassigned". Without this, taking a task over + // showed the new owner until the next unrelated event, then blanked. + preserveOmittedField(existing, merged, payload, nextTask, { + payloadKey: "assignee_user_id", + taskField: "assigneeUserId", + }); if (!hasPayloadField(payload, "primary_session_id") && nextTask.primarySessionId === undefined) { merged.primarySessionId = existing.primarySessionId; } diff --git a/apps/web/lib/ws/handlers/workspaces.test.ts b/apps/web/lib/ws/handlers/workspaces.test.ts new file mode 100644 index 0000000000..e6c86e71a2 --- /dev/null +++ b/apps/web/lib/ws/handlers/workspaces.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { createStore } from "zustand"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import { registerWorkspacesHandlers } from "@/lib/ws/handlers/workspaces"; +import type { WsHandlers } from "@/lib/ws/handlers/types"; + +// The handler map is partial by type; a missing entry is a real failure here, +// not something to silently skip. +function dispatch(handlers: WsHandlers, type: string, payload: unknown) { + const handler = handlers[type as keyof WsHandlers]; + if (!handler) throw new Error(`no handler registered for ${type}`); + (handler as (message: unknown) => void)({ type, payload }); +} + +type WorkspaceItem = AppState["workspaces"]["items"][number]; + +function storeWith(items: WorkspaceItem[]): StoreApi { + return createStore( + () => + ({ + workspaces: { items, activeId: items[0]?.id ?? null }, + }) as unknown as AppState, + ); +} + +function workspace(overrides: Partial = {}): WorkspaceItem { + return { + id: "ws-1", + name: "Platform", + description: null, + owner_id: "user-1", + default_executor_id: null, + default_environment_id: null, + default_agent_profile_id: null, + default_config_agent_profile_id: null, + unit_id: "unit-old", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + } as WorkspaceItem; +} + +describe("workspace.updated placement", () => { + // Placement is reach: a move made by another admin, or in another tab, is + // what grants and withdraws access. If the merge drops unit_id the board + // keeps rendering against the unit it just left. + it("applies a new unit_id", () => { + const store = storeWith([workspace()]); + const handlers = registerWorkspacesHandlers(store); + + dispatch(handlers, "workspace.updated", { id: "ws-1", name: "Platform", unit_id: "unit-new" }); + + expect(store.getState().workspaces.items[0].unit_id).toBe("unit-new"); + }); + + // An older backend omits the key entirely. Reading a missing key as "" + // would unplace the workspace, which reads as "reaches nobody". + it("keeps the current placement when the payload omits unit_id", () => { + const store = storeWith([workspace()]); + const handlers = registerWorkspacesHandlers(store); + + dispatch(handlers, "workspace.updated", { id: "ws-1", name: "Platform" }); + + expect(store.getState().workspaces.items[0].unit_id).toBe("unit-old"); + }); + + it("carries placement onto a workspace created in another tab", () => { + const store = storeWith([]); + const handlers = registerWorkspacesHandlers(store); + + dispatch(handlers, "workspace.created", { id: "ws-2", name: "Runtime", unit_id: "unit-new" }); + + expect(store.getState().workspaces.items[0].unit_id).toBe("unit-new"); + }); +}); diff --git a/apps/web/lib/ws/handlers/workspaces.ts b/apps/web/lib/ws/handlers/workspaces.ts index 0a1299e41d..0b55a549ee 100644 --- a/apps/web/lib/ws/handlers/workspaces.ts +++ b/apps/web/lib/ws/handlers/workspaces.ts @@ -18,6 +18,7 @@ export function registerWorkspacesHandlers(store: StoreApi): WsHandler default_environment_id: payload.default_environment_id ?? null, default_agent_profile_id: payload.default_agent_profile_id ?? null, default_config_agent_profile_id: payload.default_config_agent_profile_id ?? null, + unit_id: payload.unit_id ?? "", created_at: payload.created_at ?? new Date().toISOString(), updated_at: payload.updated_at ?? new Date().toISOString(), }; @@ -55,6 +56,15 @@ export function registerWorkspacesHandlers(store: StoreApi): WsHandler "default_config_agent_profile_id" in message.payload ? (message.payload.default_config_agent_profile_id ?? null) : (item.default_config_agent_profile_id ?? null), + // Placement decides who reaches this workspace, so a move + // made in another tab has to land here. Presence-checked + // like default_config_agent_profile_id above: an older + // backend omits the key entirely, and reading it as "" + // would silently unplace the workspace. + unit_id: + "unit_id" in message.payload + ? (message.payload.unit_id ?? "") + : (item.unit_id ?? ""), updated_at: message.payload.updated_at ?? item.updated_at, } : item, diff --git a/apps/web/scripts/demo-team-access-shots.mjs b/apps/web/scripts/demo-team-access-shots.mjs new file mode 100644 index 0000000000..b745f4d985 --- /dev/null +++ b/apps/web/scripts/demo-team-access-shots.mjs @@ -0,0 +1,119 @@ +// Capture screenshots of the team-access surfaces from the seeded demo +// instance. Run after scripts/demo-team-access.sh up: +// +// cd apps/web && node scripts/demo-team-access-shots.mjs +// +// It lives under apps/web because Node resolves imports relative to the +// script file, and Playwright is installed in this workspace. +import { chromium } from "@playwright/test"; +import { mkdirSync } from "node:fs"; + +const BASE = process.env.KANDEV_DEMO_URL ?? "http://127.0.0.1:8231"; +const OUT = process.env.SHOT_DIR ?? "/tmp/kandev-team-access-demo/shots"; +const PASSWORD = "kandev-demo-pw-1"; + +mkdirSync(OUT, { recursive: true }); + +async function signIn(context, email) { + const page = await context.newPage(); + await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); + await page.getByLabel(/email/i).fill(email); + await page.getByLabel(/password/i).fill(PASSWORD); + await Promise.all([ + page.waitForResponse( + (r) => r.url().includes("/api/v1/auth/login") && r.request().method() === "POST", + ), + page.getByRole("button", { name: /sign in|log in/i }).click(), + ]); + await page.waitForLoadState("networkidle"); + await dismissOnboarding(page); + return page; +} + +// Each account hits the first-run wizard on its first sign-in, which would +// otherwise sit on top of every board screenshot. +async function dismissOnboarding(page) { + for (let step = 0; step < 6; step += 1) { + const skip = page.getByRole("button", { name: /^skip$/i }); + if (!(await skip.count())) return; + await skip + .first() + .click() + .catch(() => {}); + await page.waitForTimeout(500); + } +} + +async function shot(page, name, focus) { + await page.waitForTimeout(1200); // let the SPA settle before capture + if (focus) { + const target = page.locator(focus).first(); + if (await target.count()) { + await target.scrollIntoViewIfNeeded(); + await page.waitForTimeout(400); + } + } + await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: false }); + console.log(`captured ${name}.png`); +} + +// The Team access card sits below the workspace form, so scroll it into frame. +// Scroll the *bottom* of the card into frame: the heading alone is already +// on screen, so targeting it scrolls nothing and the members list stays cut off. +const TEAM_CARD = '#add-member-user, [data-testid="workspace-member-row"]:last-of-type'; + +const browser = await chromium.launch(); +try { + // 1. Owner: the Team access card on the shared workspace. + const anaCtx = await browser.newContext({ viewport: { width: 1440, height: 1250 } }); + const ana = await signIn(anaCtx, "ana@example.com"); + + const listResponse = await ana.request.get(`${BASE}/api/v1/workspaces`); + const { workspaces } = await listResponse.json(); + const team = workspaces.find((w) => w.name === "Platform Team"); + const priv = workspaces.find((w) => w.name.startsWith("Ana - security")); + + await ana.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(ana, "01-owner-team-access-shared", TEAM_CARD); + + await ana.goto(`${BASE}/settings/workspace/${priv.id}`, { waitUntil: "networkidle" }); + await shot(ana, "02-owner-team-access-private", TEAM_CARD); + + await ana.goto(`${BASE}/`, { waitUntil: "networkidle" }); + await shot(ana, "03-owner-board"); + + // 2. Member with no invitation: the shared board is simply there. + const brunoCtx = await browser.newContext({ viewport: { width: 1440, height: 1250 } }); + const bruno = await signIn(brunoCtx, "bruno@example.com"); + await bruno.goto(`${BASE}/`, { waitUntil: "networkidle" }); + await shot(bruno, "04-member-sees-shared-board"); + await bruno.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(bruno, "05-member-team-access-readonly", TEAM_CARD); + + // 3. Viewer: reads the same workspace, holds one scope. + const carlaCtx = await browser.newContext({ viewport: { width: 1440, height: 1250 } }); + const carla = await signIn(carlaCtx, "carla@example.com"); + await carla.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(carla, "06-viewer-team-access-readonly", TEAM_CARD); + + // 4. Guest: the shared workspace does not exist for her. + const danaCtx = await browser.newContext({ viewport: { width: 1440, height: 1250 } }); + const dana = await signIn(danaCtx, "dana@example.com"); + await dana.goto(`${BASE}/`, { waitUntil: "networkidle" }); + await shot(dana, "07-guest-sees-only-invited-workspace"); + + // 5. Mobile composition of the members surface. + const mobileCtx = await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 2, + }); + const mobile = await signIn(mobileCtx, "ana@example.com"); + await mobile.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(mobile, "08-mobile-team-access", TEAM_CARD); + + console.log(`\nscreenshots written to ${OUT}`); +} finally { + await browser.close(); +} diff --git a/apps/web/scripts/team-access-docs-shots.mjs b/apps/web/scripts/team-access-docs-shots.mjs new file mode 100644 index 0000000000..e43d1c4cf6 --- /dev/null +++ b/apps/web/scripts/team-access-docs-shots.mjs @@ -0,0 +1,175 @@ +// Capture the documentation screenshots for docs/public/team-access.md from the +// seeded demo instance. Run after scripts/demo-team-access.sh up: +// +// cd apps/web && node scripts/team-access-docs-shots.mjs +import { chromium } from "@playwright/test"; +import { mkdirSync } from "node:fs"; + +const BASE = process.env.KANDEV_DEMO_URL ?? "http://127.0.0.1:8231"; +const OUT = process.env.SHOT_DIR ?? "../../docs/screenshots"; +const PASSWORD = "kandev-demo-pw-1"; +mkdirSync(OUT, { recursive: true }); + +async function signIn(context, email) { + const page = await context.newPage(); + // The hermetic demo has no agent CLIs installed, so the board route would + // otherwise open the onboarding dialog over every shot. + await page.addInitScript(() => + window.localStorage.setItem("kandev.onboarding.completed", "true"), + ); + await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); + await page.getByLabel(/email/i).fill(email); + await page.getByLabel(/password/i).fill(PASSWORD); + await Promise.all([ + page.waitForResponse( + (r) => r.url().includes("/api/v1/auth/login") && r.request().method() === "POST", + ), + page.getByRole("button", { name: /sign in|log in/i }).click(), + ]); + await page.waitForLoadState("networkidle"); + // Fail loudly. Without this a wrong password or a seed that uses different + // emails leaves the browser sitting on /login, and every screenshot below + // silently captures the sign-in card over a good committed one. + if (new URL(page.url()).pathname.startsWith("/login")) { + throw new Error(`sign in as ${email} did not leave /login: wrong seed or password?`); + } + for (let i = 0; i < 6; i += 1) { + const skip = page.getByRole("button", { name: /^skip$/i }); + if (!(await skip.count())) break; + await skip + .first() + .click() + .catch(() => {}); + await page.waitForTimeout(500); + } + return page; +} + +// shot captures `focus` when given, and the whole page otherwise. +// +// It used to scroll the focused element into view and then screenshot the +// page, which meant every "card" shot was really a full page: two shots of the +// same route came out byte-identical, and the subject was a small part of a +// tall image. Clipping to the element is the whole point of naming one. +async function shot(page, name, focus) { + await page.waitForTimeout(1200); + if (focus) { + const target = page.locator(focus).first(); + if (await target.count()) { + await target.scrollIntoViewIfNeeded(); + await page.waitForTimeout(400); + await target.screenshot({ path: `${OUT}/${name}.png` }); + console.log(`captured ${name}.png (clipped to ${focus})`); + return; + } + throw new Error(`shot "${name}": focus selector ${focus} matched nothing`); + } + await page.screenshot({ path: `${OUT}/${name}.png` }); + console.log(`captured ${name}.png`); +} + +// The card itself, not a bottom anchor: shots clip to what they name. +const TEAM_ACCESS_CARD = '[data-testid="workspace-team-access-card"]'; +const browser = await chromium.launch(); +try { + const desktop = { width: 1440, height: 1250 }; + + const ana = await signIn(await browser.newContext({ viewport: desktop }), "ana@example.com"); + const { workspaces } = await (await ana.request.get(`${BASE}/api/v1/workspaces`)).json(); + const team = workspaces.find((w) => w.name === "Platform Team"); + + // The tree replaced per-workspace visibility, so the first two shots are the + // structure and who is in it. + await ana.goto(`${BASE}/settings/units`, { waitUntil: "networkidle" }); + await ana.getByTestId("unit-row").first().waitFor({ state: "visible", timeout: 20000 }); + await shot(ana, "team-access-units-tree"); + + const platformRow = ana.getByTestId("unit-row").filter({ hasText: "Platform" }).first(); + await platformRow.getByTestId("unit-members").click(); + await ana.getByTestId("unit-members-dialog").waitFor({ state: "visible", timeout: 20000 }); + await ana.waitForTimeout(600); + await shot(ana, "team-access-unit-members", '[data-testid="unit-members-dialog"]'); + await ana.keyboard.press("Escape"); + + // Creating a unit, and the placement control that decides who reaches a + // workspace. Placement is the only way to narrow access, so it earns a shot. + await ana.getByTestId("unit-add-child").first().click(); + await ana.getByTestId("unit-name-input").waitFor({ state: "visible", timeout: 20000 }); + await ana.getByTestId("unit-name-input").fill("Security"); + await ana.waitForTimeout(400); + await shot(ana, "team-access-unit-create"); + await ana.getByRole("button", { name: /cancel/i }).click(); + + await ana.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await ana.getByTestId("workspace-placement-card").waitFor({ state: "visible", timeout: 20000 }); + await shot(ana, "team-access-workspace-placement", '[data-testid="workspace-placement-card"]'); + + await ana.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(ana, "team-access-owner-card", TEAM_ACCESS_CARD); + + await ana.goto(`${BASE}/settings/system/users`, { waitUntil: "networkidle" }); + await ana.getByTestId("users-table-card").waitFor({ state: "visible", timeout: 20000 }); + await shot(ana, "team-access-user-roles"); + + const bruno = await signIn(await browser.newContext({ viewport: desktop }), "bruno@example.com"); + await bruno + .context() + .addCookies([{ name: "kandev-active-workspace", value: team.id, url: BASE }]); + await bruno.goto(`${BASE}/`, { waitUntil: "networkidle" }); + await shot(bruno, "team-access-shared-board"); + + // The human assignee, on both boards. Ana assigns Bruno through the API so + // the shots show a task somebody else owns, which is what the "Assign to me" + // affordance is for. + const tasks = await (await ana.request.get(`${BASE}/api/v1/workspaces/${team.id}/tasks`)).json(); + const task = (tasks.tasks ?? tasks)[0]; + const { users } = await (await ana.request.get(`${BASE}/api/v1/users/directory`)).json(); + const brunoId = users.find((u) => u.display_name.includes("Bruno"))?.id; + if (task && brunoId) { + await ana.request.patch(`${BASE}/api/v1/tasks/${task.id}`, { + data: { assignee_user_id: brunoId }, + }); + await ana.goto(`${BASE}/t/${task.id}`, { waitUntil: "networkidle" }); + await ana.getByTestId("task-assignee-control").waitFor({ state: "visible", timeout: 25000 }); + await ana.waitForTimeout(800); + await ana.screenshot({ + path: `${OUT}/team-access-kanban-assignee-topbar.png`, + clip: { x: 540, y: 0, width: 900, height: 56 }, + }); + console.log("captured team-access-kanban-assignee-topbar.png"); + + // The board shot needs the shared workspace selected; the root route + // otherwise opens whichever workspace the cookie last named. + await ana + .context() + .addCookies([{ name: "kandev-active-workspace", value: team.id, url: BASE }]); + await ana.goto(`${BASE}/`, { waitUntil: "networkidle" }); + const badge = ana.getByTestId("kanban-card-assignee").first(); + await badge.waitFor({ state: "visible", timeout: 25000 }); + const card = badge.locator("xpath=ancestor::*[@data-testid][1]"); + await ((await card.count()) ? card : badge).screenshot({ + path: `${OUT}/team-access-kanban-assignee-card.png`, + }); + console.log("captured team-access-kanban-assignee-card.png"); + } + + const carla = await signIn(await browser.newContext({ viewport: desktop }), "carla@example.com"); + await carla.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(carla, "team-access-viewer-card", TEAM_ACCESS_CARD); + + const mobile = await signIn( + await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 2, + }), + "ana@example.com", + ); + await mobile.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(mobile, "team-access-mobile", TEAM_ACCESS_CARD); + + console.log(`\nwritten to ${OUT}`); +} finally { + await browser.close(); +} diff --git a/apps/web/scripts/tenancy-docs-shots.mjs b/apps/web/scripts/tenancy-docs-shots.mjs new file mode 100644 index 0000000000..8145d4d75a --- /dev/null +++ b/apps/web/scripts/tenancy-docs-shots.mjs @@ -0,0 +1,110 @@ +// Capture the multi-tenancy screenshots from the seeded demo instance. +// cd apps/web && node scripts/tenancy-docs-shots.mjs +import { chromium } from "@playwright/test"; +import { mkdirSync } from "node:fs"; + +const BASE = process.env.KANDEV_DEMO_URL ?? "http://127.0.0.1:8232"; +const OUT = process.env.SHOT_DIR ?? "../../docs/screenshots"; +const PASSWORD = "kandev-demo-pw-1"; +// The operator's email differs by seed: the tenancy seed uses acme.test, the +// team-access seed used for the Organizations shot uses example.com. +const OPERATOR = process.env.KANDEV_DEMO_ADMIN ?? "ana@acme.test"; +mkdirSync(OUT, { recursive: true }); + +async function signIn(context, email) { + const page = await context.newPage(); + await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); + await page.getByLabel(/email/i).fill(email); + await page.getByLabel(/password/i).fill(PASSWORD); + await Promise.all([ + page.waitForResponse( + (r) => r.url().includes("/api/v1/auth/login") && r.request().method() === "POST", + ), + page.getByRole("button", { name: /sign in|log in/i }).click(), + ]); + await page.waitForLoadState("networkidle"); + // Fail loudly. Without this a wrong password or a seed that uses different + // emails leaves the browser sitting on /login, and every screenshot below + // silently captures the sign-in card over a good committed one. + if (new URL(page.url()).pathname.startsWith("/login")) { + throw new Error(`sign in as ${email} did not leave /login: wrong seed or password?`); + } + for (let i = 0; i < 6; i += 1) { + const skip = page.getByRole("button", { name: /^skip$/i }); + if (!(await skip.count())) break; + await skip + .first() + .click() + .catch(() => {}); + await page.waitForTimeout(500); + } + return page; +} + +async function shot(page, name) { + await page.waitForTimeout(1200); + await page.screenshot({ path: `${OUT}/${name}.png` }); + console.log(`captured ${name}.png`); +} + +const desktop = { width: 1440, height: 1100 }; +const browser = await chromium.launch(); +try { + // Operator: the organization list. + const ana = await signIn(await browser.newContext({ viewport: desktop }), OPERATOR); + await ana.goto(`${BASE}/settings/system/organizations`, { waitUntil: "networkidle" }); + await shot(ana, "tenancy-organizations"); + + // The type-to-confirm delete dialog. + // The default organization's delete button is disabled on purpose, so pick + // a row that can actually be deleted. + const deleteButtons = ana.getByRole("button", { name: /delete organization/i }); + const count = await deleteButtons.count(); + let target = null; + for (let i = 0; i < count; i += 1) { + if (await deleteButtons.nth(i).isEnabled()) { + target = deleteButtons.nth(i); + break; + } + } + if (target) { + await target.click(); + await ana.waitForTimeout(700); + await shot(ana, "tenancy-delete-confirm"); + await ana.keyboard.press("Escape"); + await ana.waitForTimeout(400); + } + + // The first-administrator dialog. + const addAdmin = ana.getByRole("button", { name: /add administrator/i }); + if (await addAdmin.count()) { + await addAdmin.first().click(); + await ana.waitForTimeout(700); + await shot(ana, "tenancy-first-admin"); + await ana.keyboard.press("Escape"); + } + + // Acme's shared board. + const bruno = await signIn(await browser.newContext({ viewport: desktop }), "bruno@acme.test"); + await bruno.goto(`${BASE}/`, { waitUntil: "networkidle" }); + await shot(bruno, "tenancy-acme-board"); + + // Globex sees only its own world, on the same server. + const gary = await signIn(await browser.newContext({ viewport: desktop }), "gary@globex.test"); + await gary.goto(`${BASE}/`, { waitUntil: "networkidle" }); + await shot(gary, "tenancy-globex-board"); + + // A suspended organization refuses login. + const ivan = await browser.newContext({ viewport: desktop }); + const page = await ivan.newPage(); + await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); + await page.getByLabel(/email/i).fill("ivan@initech.test"); + await page.getByLabel(/password/i).fill(PASSWORD); + await page.getByRole("button", { name: /sign in|log in/i }).click(); + await page.waitForTimeout(1500); + await shot(page, "tenancy-suspended-login"); + + console.log(`\nwritten to ${OUT}`); +} finally { + await browser.close(); +} diff --git a/apps/web/src/locales/_verbatim.json b/apps/web/src/locales/_verbatim.json index bb3acdb8e7..ba681787fc 100644 --- a/apps/web/src/locales/_verbatim.json +++ b/apps/web/src/locales/_verbatim.json @@ -93,6 +93,8 @@ "office:telegram": "Brand or product name, spelled the same in every language.", "office:wakeHeartbeat": "Technical term kept in English across localized developer tooling.", "office:webhook": "Technical term kept in English across localized developer tooling.", + "orgs:adminEmail": "Loanword spelled identically in Portuguese.", + "orgs:namePlaceholder": "Fictional company name used as an example; brand nouns are spelled the same in every language.", "plugins:manifestWebhooks": "Technical term kept in English across localized developer tooling.", "plugins:repo": "Technical term kept in English across localized developer tooling.", "settings:destinationGlobal": "Technical term kept in English across localized developer tooling.", diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json index 222fcaaa71..0f800c0d0c 100644 --- a/apps/web/src/locales/en/agents.json +++ b/apps/web/src/locales/en/agents.json @@ -295,5 +295,6 @@ "savingProfile": "Saving...", "saveProfile": "Save profile", "createProfile": "Create profile", - "creatingProfile": "Creating..." + "creatingProfile": "Creating...", + "adminOnly": "Only administrators can change agents and agent profiles." } diff --git a/apps/web/src/locales/en/auth.json b/apps/web/src/locales/en/auth.json index 3313bc3c1c..7c80b26518 100644 --- a/apps/web/src/locales/en/auth.json +++ b/apps/web/src/locales/en/auth.json @@ -13,16 +13,17 @@ "invalidEmailOrPassword": "Invalid email or password.", "inviteLinkInvalidOrExpired": "This invite link is invalid or has expired. Ask an admin for a new one.", "inviteLinkMissingToken": "This invite link is missing its token.", - "joining": "Joining...", "joinThisKandevDeployment": "Join this Kandev deployment.", + "joining": "Joining...", "orContinueWith": "or continue with", + "organizationUnavailable": "Your organization is currently unavailable. Contact your Kandev administrator.", "password": "Password", - "setupFailed": "Setup failed. Please try again.", "setUpKandev": "Set up Kandev", + "setupFailed": "Setup failed. Please try again.", "setupNoLongerAvailable": "Setup is no longer available - an account already exists.", "signIn": "Sign in", - "signingIn": "Signing in...", "signInToYourKandevAccount": "Sign in to your Kandev account to continue.", + "signingIn": "Signing in...", "somethingWentWrong": "Something went wrong. Please try again.", "tooManyAttempts": "Too many attempts. Please wait a moment and try again.", "youVeBeenInvitedAs": "You've been invited as {{role}}." diff --git a/apps/web/src/locales/en/orgs.json b/apps/web/src/locales/en/orgs.json new file mode 100644 index 0000000000..1a8d3fa294 --- /dev/null +++ b/apps/web/src/locales/en/orgs.json @@ -0,0 +1,38 @@ +{ + "addAdmin": "Add administrator", + "adminCreate": "Create administrator", + "adminCreated": "Administrator created.", + "adminDescription": "An administrator can only create accounts in their own organization, so a new organization needs its first one created here.", + "adminEmail": "Email", + "adminName": "Display name", + "adminPassword": "Password", + "adminPasswordHint": "At least 8 characters. Share it with them and have them change it after signing in.", + "adminTitle": "First administrator for {{name}}", + "boundaryDescription": "Workspaces, tasks, sessions, secrets and accounts are scoped to one organization. As the instance operator you manage organizations themselves; you do not gain access inside any of them, including your own.", + "boundaryTitle": "What an organization separates", + "create": "Create", + "createDescription": "Create the organization first, then add its first administrator. From then on it administers itself.", + "createTitle": "New organization", + "created": "Organization created.", + "defaultBadge": "Default", + "delete": "Delete organization", + "deleteConfirm": "Delete organization", + "deleteConfirmLabel": "Type {{slug}} to confirm", + "deleteTitle": "Delete {{name}}", + "deleteWarning": "This removes every workspace, task, session and account in this organization. It cannot be undone.", + "deleted": "Organization deleted.", + "description": "Each organization is a separate tenant. Users belong to exactly one, and organizations cannot see each other's workspaces, tasks or secrets.", + "genericError": "That change could not be saved.", + "nameLabel": "Organization name", + "namePlaceholder": "Acme Corp", + "navOrganizations": "Organizations", + "operatorOnly": "Only the instance operator can manage organizations.", + "renamed": "Organization renamed.", + "resume": "Resume", + "resumed": "Organization resumed.", + "statusActive": "Active", + "statusSuspended": "Suspended", + "suspend": "Suspend", + "suspended": "Organization suspended.", + "title": "Organizations" +} diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index dc63b8c5c5..67e30cf50f 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -750,5 +750,25 @@ "keyValueKeyPlaceholder": "Key", "keyValueValuePlaceholder": "Value", "externalMcpToolListPendingAgentPermissions": "List live command and tool permission requests for a task, including safe action details and the agent's exact choices.", - "externalMcpToolResolveAgentPermission": "Resolve one live permission request by selecting an exact option returned by the list tool." + "externalMcpToolResolveAgentPermission": "Resolve one live permission request by selecting an exact option returned by the list tool.", + "navUnits": "Organization units", + "unitsTitle": "Organization units", + "unitsDescription": "A unit holds teams, workspaces and people. Being a member of a unit reaches every workspace beneath it, including ones created later.", + "unitsEmpty": "No units yet.", + "unitRootBadge": "Whole organization", + "unitPersonalBadge": "Personal", + "unitAddChild": "Add unit", + "unitMembers": "Members", + "unitDelete": "Delete unit", + "unitCreateTitle": "New unit in {{parent}}", + "unitCreateDescription": "A department and a team are both units. They differ only in where they sit.", + "unitNamePlaceholder": "Platform", + "unitCreate": "Create", + "unitMembersTitle": "Members of {{unit}}", + "unitMembersDescription": "Everyone here reaches every workspace under this unit, at the role you give them.", + "unitNoMembers": "Nobody is in this unit yet.", + "unitAddMember": "Add", + "unitRemoveMember": "Remove member", + "unitPickPerson": "Pick someone", + "unitActionFailed": "That did not work." } diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 768045db09..03e5034f06 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1943,6 +1943,12 @@ "branchPolicySelected": "{{name}} (from {{base}})", "branchPolicyUnavailable": "Base branch unavailable. Refresh branches or edit this policy.", "branchPolicyRequiresSingleRepository": "Branch policies require a single repository with the local executor.", + "assignedTo": "Assigned to", + "unassigned": "Unassigned", + "assignToMe": "Assign to me", + "searchPeople": "Search people", + "noPeopleFound": "No people found", + "assignedToName": "Assigned to {{name}}", "uploadConflictTitle_one": "{{count}} file already exists", "uploadConflictTitle_other": "{{count}} files already exist", "uploadConflictBody": "Choose what to do with each one. Nothing is uploaded until you confirm.", diff --git a/apps/web/src/locales/en/workspaces.json b/apps/web/src/locales/en/workspaces.json index 37ebc87564..bc82593606 100644 --- a/apps/web/src/locales/en/workspaces.json +++ b/apps/web/src/locales/en/workspaces.json @@ -200,5 +200,45 @@ "branchPolicyGitflowCreate": "Create Gitflow policies", "branchPolicyGitflowCreated": "Gitflow policies created", "branchPolicyGitflowFailed": "Could not create the Gitflow policies.", - "branchPolicySummary": "Base: {{base}}. Template: {{template}}. Pull request target: {{target}}." + "branchPolicySummary": "Base: {{base}}. Template: {{template}}. Pull request target: {{target}}.", + "defaultVisibility.description": "Applies to workspaces created from now on. Existing workspaces keep the visibility they already have.", + "defaultVisibility.existingUnaffected": "Changing this never changes an existing workspace.", + "defaultVisibility.label": "New workspaces are", + "defaultVisibility.savedOrg": "New workspaces will be visible to the organization.", + "defaultVisibility.savedPrivate": "New workspaces will be private.", + "defaultVisibility.title": "New workspace defaults", + "teamAccess.addMember": "Add", + "teamAccess.addMemberLabel": "Add a colleague", + "teamAccess.addMemberPlaceholder": "Select a person", + "teamAccess.description": "Choose who can reach this workspace, and give colleagues a role in it.", + "teamAccess.genericError": "That change could not be saved.", + "teamAccess.makeOwner": "Make owner", + "teamAccess.memberAdded": "Member added.", + "teamAccess.memberRemoved": "Member removed.", + "teamAccess.membersLabel": "Members", + "teamAccess.noMembers": "No members yet. The owner always has full access.", + "teamAccess.noUsersToAdd": "Everyone is already a member", + "teamAccess.nowOrgVisible": "This workspace is now visible to the organization.", + "teamAccess.nowPrivate": "This workspace is now private.", + "teamAccess.ownershipTransferred": "{{name}} is now the owner.", + "teamAccess.removeMember": "Remove member", + "teamAccess.roleCollaborator": "Collaborator", + "teamAccess.roleLabel": "Role", + "teamAccess.roleOwner": "Owner", + "teamAccess.roleUpdated": "Role updated.", + "teamAccess.roleViewer": "Viewer", + "teamAccess.title": "Team access", + "teamAccess.visibilityLabel": "Visibility", + "teamAccess.visibilityOrg": "Everyone in the organization", + "teamAccess.visibilityOrgHint": "Every user except guests can open this workspace and work on its tasks.", + "teamAccess.visibilityPrivate": "Private", + "teamAccess.visibilityPrivateHint": "Only the owner and the members listed below can open this workspace.", + "placement.title": "Placement", + "placement.description": "Which unit this workspace sits in, and therefore who reaches it. Moving it is the only way to change that: roles combine by taking the strongest, so nothing can be taken away in place.", + "placement.pick": "Pick a unit", + "placement.search": "Search units", + "placement.none": "No units found", + "placement.moved": "Workspace moved.", + "placement.failed": "The workspace could not be moved.", + "placement.ownerOnly": "Only the workspace owner can move it" } diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json index b24a7821f5..4977eb60a9 100644 --- a/apps/web/src/locales/pseudo/agents.json +++ b/apps/web/src/locales/pseudo/agents.json @@ -295,5 +295,6 @@ "savingProfile": "Śàvĩńĝ...", "saveProfile": "Śàvē ƥŕōƒĩĺē", "createProfile": "Ćŕēàţē ƥŕōƒĩĺē", - "creatingProfile": "Ćŕēàţĩńĝ..." + "creatingProfile": "Ćŕēàţĩńĝ...", + "adminOnly": "Ōńĺŷ àďḿĩńĩśţŕàţōŕś ćàń ćĥàńĝē àĝēńţś àńď àĝēńţ ƥŕōƒĩĺēś." } diff --git a/apps/web/src/locales/pseudo/auth.json b/apps/web/src/locales/pseudo/auth.json index 4ba48ca020..0fdaa08dd3 100644 --- a/apps/web/src/locales/pseudo/auth.json +++ b/apps/web/src/locales/pseudo/auth.json @@ -13,16 +13,17 @@ "invalidEmailOrPassword": "Ĩńvàĺĩď ēḿàĩĺ ōŕ ƥàśśŵōŕď.", "inviteLinkInvalidOrExpired": "Ţĥĩś ĩńvĩţē ĺĩńķ ĩś ĩńvàĺĩď ōŕ ĥàś ēxƥĩŕēď. Àśķ àń àďḿĩń ƒōŕ à ńēŵ ōńē.", "inviteLinkMissingToken": "Ţĥĩś ĩńvĩţē ĺĩńķ ĩś ḿĩśśĩńĝ ĩţś ţōķēń.", - "joining": "Ĵōĩńĩńĝ...", "joinThisKandevDeployment": "Ĵōĩń ţĥĩś Ķàńďēv ďēƥĺōŷḿēńţ.", + "joining": "Ĵōĩńĩńĝ...", "orContinueWith": "ōŕ ćōńţĩńũē ŵĩţĥ", + "organizationUnavailable": "Ŷōũŕ ōŕĝàńĩźàţĩōń ĩś ćũŕŕēńţĺŷ ũńàvàĩĺàƀĺē. Ćōńţàćţ ŷōũŕ Ķàńďēv àďḿĩńĩśţŕàţōŕ.", "password": "Ƥàśśŵōŕď", - "setupFailed": "Śēţũƥ ƒàĩĺēď. Ƥĺēàśē ţŕŷ àĝàĩń.", "setUpKandev": "Śēţ ũƥ Ķàńďēv", + "setupFailed": "Śēţũƥ ƒàĩĺēď. Ƥĺēàśē ţŕŷ àĝàĩń.", "setupNoLongerAvailable": "Śēţũƥ ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē - àń àććōũńţ àĺŕēàďŷ ēxĩśţś.", "signIn": "Śĩĝń ĩń", - "signingIn": "Śĩĝńĩńĝ ĩń...", "signInToYourKandevAccount": "Śĩĝń ĩń ţō ŷōũŕ Ķàńďēv àććōũńţ ţō ćōńţĩńũē.", + "signingIn": "Śĩĝńĩńĝ ĩń...", "somethingWentWrong": "Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ. Ƥĺēàśē ţŕŷ àĝàĩń.", "tooManyAttempts": "Ţōō ḿàńŷ àţţēḿƥţś. Ƥĺēàśē ŵàĩţ à ḿōḿēńţ àńď ţŕŷ àĝàĩń.", "youVeBeenInvitedAs": "Ŷōũ'vē ƀēēń ĩńvĩţēď àś {{role}}." diff --git a/apps/web/src/locales/pseudo/orgs.json b/apps/web/src/locales/pseudo/orgs.json new file mode 100644 index 0000000000..a07b889720 --- /dev/null +++ b/apps/web/src/locales/pseudo/orgs.json @@ -0,0 +1,38 @@ +{ + "addAdmin": "Àďď àďḿĩńĩśţŕàţōŕ", + "adminCreate": "Ćŕēàţē àďḿĩńĩśţŕàţōŕ", + "adminCreated": "Àďḿĩńĩśţŕàţōŕ ćŕēàţēď.", + "adminDescription": "Àń àďḿĩńĩśţŕàţōŕ ćàń ōńĺŷ ćŕēàţē àććōũńţś ĩń ţĥēĩŕ ōŵń ōŕĝàńĩźàţĩōń, śō à ńēŵ ōŕĝàńĩźàţĩōń ńēēďś ĩţś ƒĩŕśţ ōńē ćŕēàţēď ĥēŕē.", + "adminEmail": "Ēḿàĩĺ", + "adminName": "Ďĩśƥĺàŷ ńàḿē", + "adminPassword": "Ƥàśśŵōŕď", + "adminPasswordHint": "Àţ ĺēàśţ 8 ćĥàŕàćţēŕś. Śĥàŕē ĩţ ŵĩţĥ ţĥēḿ àńď ĥàvē ţĥēḿ ćĥàńĝē ĩţ àƒţēŕ śĩĝńĩńĝ ĩń.", + "adminTitle": "Ƒĩŕśţ àďḿĩńĩśţŕàţōŕ ƒōŕ {{name}}", + "boundaryDescription": "Ŵōŕķśƥàćēś, ţàśķś, śēśśĩōńś, śēćŕēţś àńď àććōũńţś àŕē śćōƥēď ţō ōńē ōŕĝàńĩźàţĩōń. Àś ţĥē ĩńśţàńćē ōƥēŕàţōŕ ŷōũ ḿàńàĝē ōŕĝàńĩźàţĩōńś ţĥēḿśēĺvēś; ŷōũ ďō ńōţ ĝàĩń àććēśś ĩńśĩďē àńŷ ōƒ ţĥēḿ, ĩńćĺũďĩńĝ ŷōũŕ ōŵń.", + "boundaryTitle": "Ŵĥàţ àń ōŕĝàńĩźàţĩōń śēƥàŕàţēś", + "create": "Ćŕēàţē", + "createDescription": "Ćŕēàţē ţĥē ōŕĝàńĩźàţĩōń ƒĩŕśţ, ţĥēń àďď ĩţś ƒĩŕśţ àďḿĩńĩśţŕàţōŕ. Ƒŕōḿ ţĥēń ōń ĩţ àďḿĩńĩśţēŕś ĩţśēĺƒ.", + "createTitle": "Ńēŵ ōŕĝàńĩźàţĩōń", + "created": "Ōŕĝàńĩźàţĩōń ćŕēàţēď.", + "defaultBadge": "Ďēƒàũĺţ", + "delete": "Ďēĺēţē ōŕĝàńĩźàţĩōń", + "deleteConfirm": "Ďēĺēţē ōŕĝàńĩźàţĩōń", + "deleteConfirmLabel": "Ţŷƥē {{slug}} ţō ćōńƒĩŕḿ", + "deleteTitle": "Ďēĺēţē {{name}}", + "deleteWarning": "Ţĥĩś ŕēḿōvēś ēvēŕŷ ŵōŕķśƥàćē, ţàśķ, śēśśĩōń àńď àććōũńţ ĩń ţĥĩś ōŕĝàńĩźàţĩōń. Ĩţ ćàńńōţ ƀē ũńďōńē.", + "deleted": "Ōŕĝàńĩźàţĩōń ďēĺēţēď.", + "description": "Ēàćĥ ōŕĝàńĩźàţĩōń ĩś à śēƥàŕàţē ţēńàńţ. Ũśēŕś ƀēĺōńĝ ţō ēxàćţĺŷ ōńē, àńď ōŕĝàńĩźàţĩōńś ćàńńōţ śēē ēàćĥ ōţĥēŕ'ś ŵōŕķśƥàćēś, ţàśķś ōŕ śēćŕēţś.", + "genericError": "Ţĥàţ ćĥàńĝē ćōũĺď ńōţ ƀē śàvēď.", + "nameLabel": "Ōŕĝàńĩźàţĩōń ńàḿē", + "namePlaceholder": "Àćḿē Ćōŕƥ", + "navOrganizations": "Ōŕĝàńĩźàţĩōńś", + "operatorOnly": "Ōńĺŷ ţĥē ĩńśţàńćē ōƥēŕàţōŕ ćàń ḿàńàĝē ōŕĝàńĩźàţĩōńś.", + "renamed": "Ōŕĝàńĩźàţĩōń ŕēńàḿēď.", + "resume": "Ŕēśũḿē", + "resumed": "Ōŕĝàńĩźàţĩōń ŕēśũḿēď.", + "statusActive": "Àćţĩvē", + "statusSuspended": "Śũśƥēńďēď", + "suspend": "Śũśƥēńď", + "suspended": "Ōŕĝàńĩźàţĩōń śũśƥēńďēď.", + "title": "Ōŕĝàńĩźàţĩōńś" +} diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index e32b154254..146b2e303a 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -750,5 +750,25 @@ "keyValueKeyPlaceholder": "Ķēŷ", "keyValueValuePlaceholder": "Vàĺũē", "externalMcpToolListPendingAgentPermissions": "Ĺĩśţ ĺĩvē ćōḿḿàńď àńď ţōōĺ ƥēŕḿĩśśĩōń ŕēqũēśţś ƒōŕ à ţàśķ, ĩńćĺũďĩńĝ śàƒē àćţĩōń ďēţàĩĺś àńď ţĥē àĝēńţ'ś ēxàćţ ćĥōĩćēś.", - "externalMcpToolResolveAgentPermission": "Ŕēśōĺvē ōńē ĺĩvē ƥēŕḿĩśśĩōń ŕēqũēśţ ƀŷ śēĺēćţĩńĝ àń ēxàćţ ōƥţĩōń ŕēţũŕńēď ƀŷ ţĥē ĺĩśţ ţōōĺ." + "externalMcpToolResolveAgentPermission": "Ŕēśōĺvē ōńē ĺĩvē ƥēŕḿĩśśĩōń ŕēqũēśţ ƀŷ śēĺēćţĩńĝ àń ēxàćţ ōƥţĩōń ŕēţũŕńēď ƀŷ ţĥē ĺĩśţ ţōōĺ.", + "navUnits": "Ōŕĝàńĩźàţĩōń ũńĩţś", + "unitsTitle": "Ōŕĝàńĩźàţĩōń ũńĩţś", + "unitsDescription": "À ũńĩţ ĥōĺďś ţēàḿś, ŵōŕķśƥàćēś àńď ƥēōƥĺē. Ɓēĩńĝ à ḿēḿƀēŕ ōƒ à ũńĩţ ŕēàćĥēś ēvēŕŷ ŵōŕķśƥàćē ƀēńēàţĥ ĩţ, ĩńćĺũďĩńĝ ōńēś ćŕēàţēď ĺàţēŕ.", + "unitsEmpty": "Ńō ũńĩţś ŷēţ.", + "unitRootBadge": "Ŵĥōĺē ōŕĝàńĩźàţĩōń", + "unitPersonalBadge": "Ƥēŕśōńàĺ", + "unitAddChild": "Àďď ũńĩţ", + "unitMembers": "Ḿēḿƀēŕś", + "unitDelete": "Ďēĺēţē ũńĩţ", + "unitCreateTitle": "Ńēŵ ũńĩţ ĩń {{parent}}", + "unitCreateDescription": "À ďēƥàŕţḿēńţ àńď à ţēàḿ àŕē ƀōţĥ ũńĩţś. Ţĥēŷ ďĩƒƒēŕ ōńĺŷ ĩń ŵĥēŕē ţĥēŷ śĩţ.", + "unitNamePlaceholder": "Ƥĺàţƒōŕḿ", + "unitCreate": "Ćŕēàţē", + "unitMembersTitle": "Ḿēḿƀēŕś ōƒ {{unit}}", + "unitMembersDescription": "Ēvēŕŷōńē ĥēŕē ŕēàćĥēś ēvēŕŷ ŵōŕķśƥàćē ũńďēŕ ţĥĩś ũńĩţ, àţ ţĥē ŕōĺē ŷōũ ĝĩvē ţĥēḿ.", + "unitNoMembers": "Ńōƀōďŷ ĩś ĩń ţĥĩś ũńĩţ ŷēţ.", + "unitAddMember": "Àďď", + "unitRemoveMember": "Ŕēḿōvē ḿēḿƀēŕ", + "unitPickPerson": "Ƥĩćķ śōḿēōńē", + "unitActionFailed": "Ţĥàţ ďĩď ńōţ ŵōŕķ." } diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index ff366fd694..644d199d0f 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -1943,6 +1943,12 @@ "branchPolicySelected": "{{name}} (ƒŕōḿ {{base}})", "branchPolicyUnavailable": "Ɓàśē ƀŕàńćĥ ũńàvàĩĺàƀĺē. Ŕēƒŕēśĥ ƀŕàńćĥēś ōŕ ēďĩţ ţĥĩś ƥōĺĩćŷ.", "branchPolicyRequiresSingleRepository": "Ɓŕàńćĥ ƥōĺĩćĩēś ŕēqũĩŕē à śĩńĝĺē ŕēƥōśĩţōŕŷ ŵĩţĥ ţĥē ĺōćàĺ ēxēćũţōŕ.", + "assignedTo": "Àśśĩĝńēď ţō", + "unassigned": "Ũńàśśĩĝńēď", + "assignToMe": "Àśśĩĝń ţō ḿē", + "searchPeople": "Śēàŕćĥ ƥēōƥĺē", + "noPeopleFound": "Ńō ƥēōƥĺē ƒōũńď", + "assignedToName": "Àśśĩĝńēď ţō {{name}}", "uploadConflictTitle_one": "{{count}} ƒĩĺē àĺŕēàďŷ ēxĩśţś", "uploadConflictTitle_other": "{{count}} ƒĩĺēś àĺŕēàďŷ ēxĩśţ", "uploadConflictBody": "Ćĥōōśē ŵĥàţ ţō ďō ŵĩţĥ ēàćĥ ōńē. Ńōţĥĩńĝ ĩś ũƥĺōàďēď ũńţĩĺ ŷōũ ćōńƒĩŕḿ.", diff --git a/apps/web/src/locales/pseudo/workspaces.json b/apps/web/src/locales/pseudo/workspaces.json index d5bc695058..bdd863ecc7 100644 --- a/apps/web/src/locales/pseudo/workspaces.json +++ b/apps/web/src/locales/pseudo/workspaces.json @@ -200,5 +200,45 @@ "branchPolicyGitflowCreate": "Ćŕēàţē Ĝĩţƒĺōŵ ƥōĺĩćĩēś", "branchPolicyGitflowCreated": "Ĝĩţƒĺōŵ ƥōĺĩćĩēś ćŕēàţēď", "branchPolicyGitflowFailed": "Ćōũĺď ńōţ ćŕēàţē ţĥē Ĝĩţƒĺōŵ ƥōĺĩćĩēś.", - "branchPolicySummary": "Ɓàśē: {{base}}. Ţēḿƥĺàţē: {{template}}. Ƥũĺĺ ŕēqũēśţ ţàŕĝēţ: {{target}}." + "branchPolicySummary": "Ɓàśē: {{base}}. Ţēḿƥĺàţē: {{template}}. Ƥũĺĺ ŕēqũēśţ ţàŕĝēţ: {{target}}.", + "defaultVisibility.description": "Àƥƥĺĩēś ţō ŵōŕķśƥàćēś ćŕēàţēď ƒŕōḿ ńōŵ ōń. Ēxĩśţĩńĝ ŵōŕķśƥàćēś ķēēƥ ţĥē vĩśĩƀĩĺĩţŷ ţĥēŷ àĺŕēàďŷ ĥàvē.", + "defaultVisibility.existingUnaffected": "Ćĥàńĝĩńĝ ţĥĩś ńēvēŕ ćĥàńĝēś àń ēxĩśţĩńĝ ŵōŕķśƥàćē.", + "defaultVisibility.label": "Ńēŵ ŵōŕķśƥàćēś àŕē", + "defaultVisibility.savedOrg": "Ńēŵ ŵōŕķśƥàćēś ŵĩĺĺ ƀē vĩśĩƀĺē ţō ţĥē ōŕĝàńĩźàţĩōń.", + "defaultVisibility.savedPrivate": "Ńēŵ ŵōŕķśƥàćēś ŵĩĺĺ ƀē ƥŕĩvàţē.", + "defaultVisibility.title": "Ńēŵ ŵōŕķśƥàćē ďēƒàũĺţś", + "teamAccess.addMember": "Àďď", + "teamAccess.addMemberLabel": "Àďď à ćōĺĺēàĝũē", + "teamAccess.addMemberPlaceholder": "Śēĺēćţ à ƥēŕśōń", + "teamAccess.description": "Ćĥōōśē ŵĥō ćàń ŕēàćĥ ţĥĩś ŵōŕķśƥàćē, àńď ĝĩvē ćōĺĺēàĝũēś à ŕōĺē ĩń ĩţ.", + "teamAccess.genericError": "Ţĥàţ ćĥàńĝē ćōũĺď ńōţ ƀē śàvēď.", + "teamAccess.makeOwner": "Ḿàķē ōŵńēŕ", + "teamAccess.memberAdded": "Ḿēḿƀēŕ àďďēď.", + "teamAccess.memberRemoved": "Ḿēḿƀēŕ ŕēḿōvēď.", + "teamAccess.membersLabel": "Ḿēḿƀēŕś", + "teamAccess.noMembers": "Ńō ḿēḿƀēŕś ŷēţ. Ţĥē ōŵńēŕ àĺŵàŷś ĥàś ƒũĺĺ àććēśś.", + "teamAccess.noUsersToAdd": "Ēvēŕŷōńē ĩś àĺŕēàďŷ à ḿēḿƀēŕ", + "teamAccess.nowOrgVisible": "Ţĥĩś ŵōŕķśƥàćē ĩś ńōŵ vĩśĩƀĺē ţō ţĥē ōŕĝàńĩźàţĩōń.", + "teamAccess.nowPrivate": "Ţĥĩś ŵōŕķśƥàćē ĩś ńōŵ ƥŕĩvàţē.", + "teamAccess.ownershipTransferred": "{{name}} ĩś ńōŵ ţĥē ōŵńēŕ.", + "teamAccess.removeMember": "Ŕēḿōvē ḿēḿƀēŕ", + "teamAccess.roleCollaborator": "Ćōĺĺàƀōŕàţōŕ", + "teamAccess.roleLabel": "Ŕōĺē", + "teamAccess.roleOwner": "Ōŵńēŕ", + "teamAccess.roleUpdated": "Ŕōĺē ũƥďàţēď.", + "teamAccess.roleViewer": "Vĩēŵēŕ", + "teamAccess.title": "Ţēàḿ àććēśś", + "teamAccess.visibilityLabel": "Vĩśĩƀĩĺĩţŷ", + "teamAccess.visibilityOrg": "Ēvēŕŷōńē ĩń ţĥē ōŕĝàńĩźàţĩōń", + "teamAccess.visibilityOrgHint": "Ēvēŕŷ ũśēŕ ēxćēƥţ ĝũēśţś ćàń ōƥēń ţĥĩś ŵōŕķśƥàćē àńď ŵōŕķ ōń ĩţś ţàśķś.", + "teamAccess.visibilityPrivate": "Ƥŕĩvàţē", + "teamAccess.visibilityPrivateHint": "Ōńĺŷ ţĥē ōŵńēŕ àńď ţĥē ḿēḿƀēŕś ĺĩśţēď ƀēĺōŵ ćàń ōƥēń ţĥĩś ŵōŕķśƥàćē.", + "placement.title": "Ƥĺàćēḿēńţ", + "placement.description": "Ŵĥĩćĥ ũńĩţ ţĥĩś ŵōŕķśƥàćē śĩţś ĩń, àńď ţĥēŕēƒōŕē ŵĥō ŕēàćĥēś ĩţ. Ḿōvĩńĝ ĩţ ĩś ţĥē ōńĺŷ ŵàŷ ţō ćĥàńĝē ţĥàţ: ŕōĺēś ćōḿƀĩńē ƀŷ ţàķĩńĝ ţĥē śţŕōńĝēśţ, śō ńōţĥĩńĝ ćàń ƀē ţàķēń àŵàŷ ĩń ƥĺàćē.", + "placement.pick": "Ƥĩćķ à ũńĩţ", + "placement.search": "Śēàŕćĥ ũńĩţś", + "placement.none": "Ńō ũńĩţś ƒōũńď", + "placement.moved": "Ŵōŕķśƥàćē ḿōvēď.", + "placement.failed": "Ţĥē ŵōŕķśƥàćē ćōũĺď ńōţ ƀē ḿōvēď.", + "placement.ownerOnly": "Ōńĺŷ ţĥē ŵōŕķśƥàćē ōŵńēŕ ćàń ḿōvē ĩţ" } diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json index 3cb07df1bc..d2828cb911 100644 --- a/apps/web/src/locales/pt-pt/agents.json +++ b/apps/web/src/locales/pt-pt/agents.json @@ -295,5 +295,6 @@ "dynamicRouteRetryDescription": "Tenta novamente o mesmo candidato depois de um erro do fornecedor. Use esta opção quando a falha puder ser temporária.", "dynamicRouteTryNextDescription": "Ignora este candidato nesta execução e continua com o próximo perfil elegível.", "dynamicRouteStopDescription": "Para o encaminhamento quando este candidato falha. A tarefa aguarda uma ação manual de recuperação.", - "dynamicProfileSaved": "Perfil dinâmico guardado." + "dynamicProfileSaved": "Perfil dinâmico guardado.", + "adminOnly": "Só os administradores podem alterar agentes e perfis de agente." } diff --git a/apps/web/src/locales/pt-pt/auth.json b/apps/web/src/locales/pt-pt/auth.json index daedfdd1a1..213cd052e1 100644 --- a/apps/web/src/locales/pt-pt/auth.json +++ b/apps/web/src/locales/pt-pt/auth.json @@ -4,26 +4,27 @@ "authenticationIsEnabledForThisDeployment": "A autenticação está ativada nesta instalação. Crie a primeira conta abaixo; torna-se a conta de administrador e pode convidar mais utilizadores.", "checkingInvite": "A verificar o convite...", "continueWithProvider": "Continuar com {{provider}}", + "couldNotAcceptInvite": "Não foi possível aceitar este convite.", + "couldNotLoadInvite": "Não foi possível carregar este convite. Tente novamente.", "createAdminAccount": "Criar conta de administrador", "creatingAdminAccount": "A criar a conta de administrador...", "displayName": "Nome a apresentar", "email": "E-mail", - "joining": "A aderir...", - "joinThisKandevDeployment": "Adira a esta instalação do Kandev.", - "orContinueWith": "ou continuar com", - "password": "Palavra-passe", - "setUpKandev": "Configurar o Kandev", - "signIn": "Iniciar sessão", - "signingIn": "A iniciar sessão...", - "signInToYourKandevAccount": "Inicie sessão na sua conta Kandev para continuar.", - "youVeBeenInvitedAs": "Foi convidado como {{role}}.", - "couldNotAcceptInvite": "Não foi possível aceitar este convite.", - "couldNotLoadInvite": "Não foi possível carregar este convite. Tente novamente.", "invalidEmailOrPassword": "E-mail ou palavra-passe inválidos.", "inviteLinkInvalidOrExpired": "Este link de convite é inválido ou expirou. Peça um novo a um administrador.", "inviteLinkMissingToken": "Falta o token a este link de convite.", + "joinThisKandevDeployment": "Adira a esta instalação do Kandev.", + "joining": "A aderir...", + "orContinueWith": "ou continuar com", + "organizationUnavailable": "A sua organização está indisponível de momento. Contacte o administrador do Kandev.", + "password": "Palavra-passe", + "setUpKandev": "Configurar o Kandev", "setupFailed": "A configuração falhou. Tente novamente.", "setupNoLongerAvailable": "A configuração já não está disponível - já existe uma conta.", + "signIn": "Iniciar sessão", + "signInToYourKandevAccount": "Inicie sessão na sua conta Kandev para continuar.", + "signingIn": "A iniciar sessão...", "somethingWentWrong": "Algo correu mal. Tente novamente.", - "tooManyAttempts": "Demasiadas tentativas. Aguarde um momento e tente novamente." + "tooManyAttempts": "Demasiadas tentativas. Aguarde um momento e tente novamente.", + "youVeBeenInvitedAs": "Foi convidado como {{role}}." } diff --git a/apps/web/src/locales/pt-pt/orgs.json b/apps/web/src/locales/pt-pt/orgs.json new file mode 100644 index 0000000000..354d95a45f --- /dev/null +++ b/apps/web/src/locales/pt-pt/orgs.json @@ -0,0 +1,38 @@ +{ + "addAdmin": "Adicionar administrador", + "adminCreate": "Criar administrador", + "adminCreated": "Administrador criado.", + "adminDescription": "Um administrador só pode criar contas na sua própria organização, por isso uma organização nova precisa que o primeiro seja criado aqui.", + "adminEmail": "Email", + "adminName": "Nome a apresentar", + "adminPassword": "Palavra-passe", + "adminPasswordHint": "Pelo menos 8 caracteres. Partilhe-a e peça que a altere após iniciar sessão.", + "adminTitle": "Primeiro administrador de {{name}}", + "boundaryDescription": "Áreas de trabalho, tarefas, sessões, segredos e contas pertencem a uma organização. Como operador da instância, gere as organizações em si; não obtém acesso dentro de nenhuma delas, incluindo a sua.", + "boundaryTitle": "O que uma organização separa", + "create": "Criar", + "createDescription": "Crie primeiro a organização e depois adicione o seu primeiro administrador. A partir daí, administra-se a si própria.", + "createTitle": "Nova organização", + "created": "Organização criada.", + "defaultBadge": "Predefinida", + "delete": "Eliminar organização", + "deleteConfirm": "Eliminar organização", + "deleteConfirmLabel": "Escreva {{slug}} para confirmar", + "deleteTitle": "Eliminar {{name}}", + "deleteWarning": "Isto remove todas as áreas de trabalho, tarefas, sessões e contas desta organização. Não pode ser anulado.", + "deleted": "Organização eliminada.", + "description": "Cada organização é um inquilino separado. Os utilizadores pertencem a exatamente uma e as organizações não conseguem ver as áreas de trabalho, tarefas ou segredos umas das outras.", + "genericError": "Não foi possível guardar essa alteração.", + "nameLabel": "Nome da organização", + "namePlaceholder": "Acme Corp", + "navOrganizations": "Organizações", + "operatorOnly": "Apenas o operador da instância pode gerir organizações.", + "renamed": "Organização renomeada.", + "resume": "Retomar", + "resumed": "Organização retomada.", + "statusActive": "Ativa", + "statusSuspended": "Suspensa", + "suspend": "Suspender", + "suspended": "Organização suspensa.", + "title": "Organizações" +} diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json index bff91b5dc2..c4e52d21f6 100644 --- a/apps/web/src/locales/pt-pt/settings.json +++ b/apps/web/src/locales/pt-pt/settings.json @@ -750,5 +750,25 @@ "taskBehavior": "Comportamento das tarefas", "terminalAndEditors": "Terminal e editores", "workspacesAndAccess": "Workspaces e acesso", - "profileStartModelNotAdvertisedOnHost": "A sondagem do anfitrião não anunciou {{model}}. O executor selecionado decidirá o modelo no arranque." + "profileStartModelNotAdvertisedOnHost": "A sondagem do anfitrião não anunciou {{model}}. O executor selecionado decidirá o modelo no arranque.", + "navUnits": "Unidades da organização", + "unitsTitle": "Unidades da organização", + "unitsDescription": "Uma unidade contém equipas, áreas de trabalho e pessoas. Pertencer a uma unidade dá acesso a todas as áreas de trabalho abaixo dela, incluindo as criadas depois.", + "unitsEmpty": "Ainda não há unidades.", + "unitRootBadge": "Toda a organização", + "unitPersonalBadge": "Pessoal", + "unitAddChild": "Adicionar unidade", + "unitMembers": "Membros", + "unitDelete": "Eliminar unidade", + "unitCreateTitle": "Nova unidade em {{parent}}", + "unitCreateDescription": "Um departamento e uma equipa são ambos unidades. Diferem apenas na posição.", + "unitNamePlaceholder": "Plataforma", + "unitCreate": "Criar", + "unitMembersTitle": "Membros de {{unit}}", + "unitMembersDescription": "Todos aqui alcançam todas as áreas de trabalho sob esta unidade, com o perfil que lhes atribuir.", + "unitNoMembers": "Ainda não há ninguém nesta unidade.", + "unitAddMember": "Adicionar", + "unitRemoveMember": "Remover membro", + "unitPickPerson": "Escolher alguém", + "unitActionFailed": "Não foi possível concluir." } diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index cfb94f3b9f..44737f3db7 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -1943,6 +1943,12 @@ "branchPolicySelected": "{{name}} (de {{base}})", "branchPolicyUnavailable": "O branch base não está disponível. Atualize os branches ou edite esta política.", "branchPolicyRequiresSingleRepository": "As políticas de branches requerem um único repositório com o executor local.", + "assignedTo": "Atribuído a", + "unassigned": "Sem atribuição", + "assignToMe": "Atribuir a mim", + "searchPeople": "Procurar pessoas", + "noPeopleFound": "Nenhuma pessoa encontrada", + "assignedToName": "Atribuído a {{name}}", "uploadConflictTitle_one": "{{count}} ficheiro já existe", "uploadConflictTitle_other": "{{count}} ficheiros já existem", "uploadConflictBody": "Escolha o que fazer com cada um. Nada é carregado até confirmar.", diff --git a/apps/web/src/locales/pt-pt/workspaces.json b/apps/web/src/locales/pt-pt/workspaces.json index 8e6c9f3b8d..1f6cd96af0 100644 --- a/apps/web/src/locales/pt-pt/workspaces.json +++ b/apps/web/src/locales/pt-pt/workspaces.json @@ -200,5 +200,45 @@ "branchPolicyGitflowCreate": "Criar políticas Gitflow", "branchPolicyGitflowCreated": "Políticas Gitflow criadas", "branchPolicyGitflowFailed": "Não foi possível criar as políticas Gitflow.", - "branchPolicySummary": "Base: {{base}}. Modelo: {{template}}. Destino do pull request: {{target}}." + "branchPolicySummary": "Base: {{base}}. Modelo: {{template}}. Destino do pull request: {{target}}.", + "defaultVisibility.description": "Aplica-se às áreas de trabalho criadas a partir de agora. As existentes mantêm a visibilidade que já têm.", + "defaultVisibility.existingUnaffected": "Alterar isto nunca altera uma área de trabalho existente.", + "defaultVisibility.label": "As novas áreas de trabalho são", + "defaultVisibility.savedOrg": "As novas áreas de trabalho ficarão visíveis para a organização.", + "defaultVisibility.savedPrivate": "As novas áreas de trabalho ficarão privadas.", + "defaultVisibility.title": "Predefinições de novas áreas de trabalho", + "teamAccess.addMember": "Adicionar", + "teamAccess.addMemberLabel": "Adicionar um colega", + "teamAccess.addMemberPlaceholder": "Selecione uma pessoa", + "teamAccess.description": "Escolha quem pode aceder a esta área de trabalho e atribua uma função aos colegas.", + "teamAccess.genericError": "Não foi possível guardar essa alteração.", + "teamAccess.makeOwner": "Tornar proprietário", + "teamAccess.memberAdded": "Membro adicionado.", + "teamAccess.memberRemoved": "Membro removido.", + "teamAccess.membersLabel": "Membros", + "teamAccess.noMembers": "Ainda não há membros. O proprietário tem sempre acesso total.", + "teamAccess.noUsersToAdd": "Todos já são membros", + "teamAccess.nowOrgVisible": "Esta área de trabalho está agora visível para a organização.", + "teamAccess.nowPrivate": "Esta área de trabalho é agora privada.", + "teamAccess.ownershipTransferred": "{{name}} é agora o proprietário.", + "teamAccess.removeMember": "Remover membro", + "teamAccess.roleCollaborator": "Colaborador", + "teamAccess.roleLabel": "Função", + "teamAccess.roleOwner": "Proprietário", + "teamAccess.roleUpdated": "Função atualizada.", + "teamAccess.roleViewer": "Observador", + "teamAccess.title": "Acesso da equipa", + "teamAccess.visibilityLabel": "Visibilidade", + "teamAccess.visibilityOrg": "Todos na organização", + "teamAccess.visibilityOrgHint": "Todos os utilizadores, exceto convidados, podem abrir esta área de trabalho e trabalhar nas suas tarefas.", + "teamAccess.visibilityPrivate": "Privada", + "teamAccess.visibilityPrivateHint": "Apenas o proprietário e os membros listados abaixo podem abrir esta área de trabalho.", + "placement.title": "Localização", + "placement.description": "A unidade onde esta área de trabalho está e, por isso, quem lhe acede. Movê-la é a única forma de mudar isso: os perfis combinam-se pelo mais forte, nada pode ser retirado no lugar.", + "placement.pick": "Escolher unidade", + "placement.search": "Procurar unidades", + "placement.none": "Nenhuma unidade encontrada", + "placement.moved": "Área de trabalho movida.", + "placement.failed": "Não foi possível mover a área de trabalho.", + "placement.ownerOnly": "Só o proprietário pode mover" } diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json index c7a3310507..b365628c5b 100644 --- a/apps/web/src/locales/zh-cn/agents.json +++ b/apps/web/src/locales/zh-cn/agents.json @@ -295,5 +295,6 @@ "dynamicRouteRetryDescription": "提供商出错后重试同一候选配置。适用于可能暂时恢复的故障。", "dynamicRouteTryNextDescription": "本次执行跳过此候选配置,继续使用下一个符合条件的配置。", "dynamicRouteStopDescription": "此候选配置失败后停止路由。任务会等待手动恢复操作。", - "dynamicProfileSaved": "动态配置已保存。" + "dynamicProfileSaved": "动态配置已保存。", + "adminOnly": "只有管理员可以更改代理和代理配置文件。" } diff --git a/apps/web/src/locales/zh-cn/auth.json b/apps/web/src/locales/zh-cn/auth.json index 5d56e027c0..27d4bf09a0 100644 --- a/apps/web/src/locales/zh-cn/auth.json +++ b/apps/web/src/locales/zh-cn/auth.json @@ -4,26 +4,27 @@ "authenticationIsEnabledForThisDeployment": "此部署已启用身份验证。请在下方创建首个账户;该账户将成为管理员,并可邀请其他用户。", "checkingInvite": "正在检查邀请…", "continueWithProvider": "使用 {{provider}} 继续", + "couldNotAcceptInvite": "无法接受此邀请。", + "couldNotLoadInvite": "无法加载此邀请。请重试。", "createAdminAccount": "创建管理员账户", "creatingAdminAccount": "正在创建管理员账户…", "displayName": "显示名称", "email": "邮箱", - "joining": "正在加入…", - "joinThisKandevDeployment": "加入此 Kandev 部署。", - "orContinueWith": "或使用以下方式继续", - "password": "密码", - "setUpKandev": "设置 Kandev", - "signIn": "登录", - "signingIn": "正在登录…", - "signInToYourKandevAccount": "登录你的 Kandev 账户以继续。", - "youVeBeenInvitedAs": "你已被邀请为 {{role}}。", - "couldNotAcceptInvite": "无法接受此邀请。", - "couldNotLoadInvite": "无法加载此邀请。请重试。", "invalidEmailOrPassword": "邮箱或密码不正确。", "inviteLinkInvalidOrExpired": "此邀请链接无效或已过期。请向管理员索取新的链接。", "inviteLinkMissingToken": "此邀请链接缺少令牌。", + "joinThisKandevDeployment": "加入此 Kandev 部署。", + "joining": "正在加入…", + "orContinueWith": "或使用以下方式继续", + "organizationUnavailable": "您的组织当前不可用。请联系您的 Kandev 管理员。", + "password": "密码", + "setUpKandev": "设置 Kandev", "setupFailed": "初始化失败。请重试。", "setupNoLongerAvailable": "初始化已不可用 - 账号已存在。", + "signIn": "登录", + "signInToYourKandevAccount": "登录你的 Kandev 账户以继续。", + "signingIn": "正在登录…", "somethingWentWrong": "出了点问题。请重试。", - "tooManyAttempts": "尝试次数过多。请稍候片刻再试。" + "tooManyAttempts": "尝试次数过多。请稍候片刻再试。", + "youVeBeenInvitedAs": "你已被邀请为 {{role}}。" } diff --git a/apps/web/src/locales/zh-cn/orgs.json b/apps/web/src/locales/zh-cn/orgs.json new file mode 100644 index 0000000000..8b3e5d57f3 --- /dev/null +++ b/apps/web/src/locales/zh-cn/orgs.json @@ -0,0 +1,38 @@ +{ + "addAdmin": "添加管理员", + "adminCreate": "创建管理员", + "adminCreated": "管理员已创建。", + "adminDescription": "管理员只能在自己的组织中创建账户,因此新组织需要在此创建首位管理员。", + "adminEmail": "邮箱", + "adminName": "显示名称", + "adminPassword": "密码", + "adminPasswordHint": "至少 8 个字符。请告知对方并要求其登录后修改。", + "adminTitle": "{{name}} 的首位管理员", + "boundaryDescription": "工作区、任务、会话、密钥和账户均归属于一个组织。作为实例运维人员,您管理组织本身;但不会获得任何组织内部的访问权限,包括您自己的组织。", + "boundaryTitle": "组织隔离的内容", + "create": "创建", + "createDescription": "先创建组织,然后添加其首位管理员。此后该组织将自行管理。", + "createTitle": "新建组织", + "created": "组织已创建。", + "defaultBadge": "默认", + "delete": "删除组织", + "deleteConfirm": "删除组织", + "deleteConfirmLabel": "输入 {{slug}} 以确认", + "deleteTitle": "删除 {{name}}", + "deleteWarning": "这将移除该组织中的所有工作区、任务、会话和账户,且无法撤销。", + "deleted": "组织已删除。", + "description": "每个组织都是独立的租户。用户仅属于一个组织,组织之间无法看到彼此的工作区、任务或密钥。", + "genericError": "无法保存该更改。", + "nameLabel": "组织名称", + "namePlaceholder": "Acme Corp", + "navOrganizations": "组织", + "operatorOnly": "只有实例运维人员可以管理组织。", + "renamed": "组织已重命名。", + "resume": "恢复", + "resumed": "组织已恢复。", + "statusActive": "活动", + "statusSuspended": "已暂停", + "suspend": "暂停", + "suspended": "组织已暂停。", + "title": "组织" +} diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index d42a979a40..bc779fc291 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -286,7 +286,7 @@ "externalMcpToolListPendingAgentPermissions": "列出任务中实时待处理的命令与工具权限请求,包括安全的操作详情及代理提供的确切选项。", "externalMcpToolListPendingQuestions": "列出仍在等待回答的澄清问题集。", "externalMcpToolListRepositories": "列出工作区中的仓库,包括每个仓库的默认分支。", - "externalMcpToolListTaskSessions": "按最近启动的顺序列出任务的所有代理会话,以便其他工具通过 ID 定位会话。", + "externalMcpToolListTaskSessions": "按最近启动的顺序列出任务的所有智能体会话,以便其他工具通过 ID 定位会话。", "externalMcpToolResolveAgentPermission": "选择列表工具返回的确切选项,以处理一个实时权限请求。", "externalMcpToolListTasks": "列出工作流中的任务。", "externalMcpToolListWorkflowSteps": "列出工作流中的所有步骤。", @@ -713,7 +713,6 @@ "visualEditorUnavailable": "可视化编辑器不可用", "vsCodeRemoteSsh": "VS Code Remote SSH", "whenEnabledInstallYourProjectS": "启用后,会通过仓库设置脚本安装项目依赖(例如运行 <2>{{command}}),以避免缺少类型错误。", - "externalMcpToolListTaskSessions": "按最近启动的顺序列出任务的所有智能体会话,以便其他工具通过 ID 定位会话。", "shortcutBottomTerminal": "切换底部终端", "shortcutCommandPanel": "命令面板", "shortcutCommandPanelAlt": "命令面板(备用)", @@ -751,5 +750,25 @@ "keyValueAddItem": "添加条目", "keyValueKeyPlaceholder": "键", "keyValueValuePlaceholder": "值", - "profileStartModelNotAdvertisedOnHost": "主机探测未公布 {{model}}。所选执行器将在启动时决定使用的模型。" + "profileStartModelNotAdvertisedOnHost": "主机探测未公布 {{model}}。所选执行器将在启动时决定使用的模型。", + "navUnits": "组织单元", + "unitsTitle": "组织单元", + "unitsDescription": "单元包含团队、工作区和成员。加入某个单元即可访问其下的所有工作区,包括之后创建的。", + "unitsEmpty": "尚无单元。", + "unitRootBadge": "整个组织", + "unitPersonalBadge": "个人", + "unitAddChild": "添加单元", + "unitMembers": "成员", + "unitDelete": "删除单元", + "unitCreateTitle": "在 {{parent}} 中新建单元", + "unitCreateDescription": "部门和团队都是单元,区别只在于所处位置。", + "unitNamePlaceholder": "平台", + "unitCreate": "创建", + "unitMembersTitle": "{{unit}} 的成员", + "unitMembersDescription": "这里的每个人都能以你指定的角色访问该单元下的所有工作区。", + "unitNoMembers": "该单元暂无成员。", + "unitAddMember": "添加", + "unitRemoveMember": "移除成员", + "unitPickPerson": "选择成员", + "unitActionFailed": "操作未成功。" } diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 221b1a71e9..18dd400ff0 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -1943,6 +1943,12 @@ "branchPolicySelected": "{{name}}(从 {{base}})", "branchPolicyUnavailable": "基础分支不可用。请刷新分支或编辑此策略。", "branchPolicyRequiresSingleRepository": "使用本地执行器时,分支策略需要单个仓库。", + "assignedTo": "指派给", + "unassigned": "未指派", + "assignToMe": "指派给我", + "searchPeople": "搜索成员", + "noPeopleFound": "未找到成员", + "assignedToName": "指派给 {{name}}", "uploadConflictTitle_one": "{{count}} 个文件已存在", "uploadConflictTitle_other": "{{count}} 个文件已存在", "uploadConflictBody": "请选择如何处理每个文件。确认之前不会上传任何内容。", diff --git a/apps/web/src/locales/zh-cn/workspaces.json b/apps/web/src/locales/zh-cn/workspaces.json index c718631a6c..620f023857 100644 --- a/apps/web/src/locales/zh-cn/workspaces.json +++ b/apps/web/src/locales/zh-cn/workspaces.json @@ -200,5 +200,45 @@ "branchPolicyGitflowCreate": "创建 Gitflow 策略", "branchPolicyGitflowCreated": "Gitflow 策略已创建", "branchPolicyGitflowFailed": "无法创建 Gitflow 策略。", - "branchPolicySummary": "基础:{{base}}。模板:{{template}}。拉取请求目标:{{target}}。" + "branchPolicySummary": "基础:{{base}}。模板:{{template}}。拉取请求目标:{{target}}。", + "defaultVisibility.description": "适用于此后创建的工作区。现有工作区保持其当前可见性。", + "defaultVisibility.existingUnaffected": "更改此设置绝不会更改现有工作区。", + "defaultVisibility.label": "新工作区为", + "defaultVisibility.savedOrg": "新工作区将对组织可见。", + "defaultVisibility.savedPrivate": "新工作区将为私有。", + "defaultVisibility.title": "新工作区默认设置", + "teamAccess.addMember": "添加", + "teamAccess.addMemberLabel": "添加同事", + "teamAccess.addMemberPlaceholder": "选择一个人", + "teamAccess.description": "选择谁可以访问此工作区,并为同事分配角色。", + "teamAccess.genericError": "无法保存该更改。", + "teamAccess.makeOwner": "设为所有者", + "teamAccess.memberAdded": "成员已添加。", + "teamAccess.memberRemoved": "成员已移除。", + "teamAccess.membersLabel": "成员", + "teamAccess.noMembers": "暂无成员。所有者始终拥有完全访问权限。", + "teamAccess.noUsersToAdd": "所有人都已是成员", + "teamAccess.nowOrgVisible": "此工作区现在对组织可见。", + "teamAccess.nowPrivate": "此工作区现在为私有。", + "teamAccess.ownershipTransferred": "{{name}} 现在是所有者。", + "teamAccess.removeMember": "移除成员", + "teamAccess.roleCollaborator": "协作者", + "teamAccess.roleLabel": "角色", + "teamAccess.roleOwner": "所有者", + "teamAccess.roleUpdated": "角色已更新。", + "teamAccess.roleViewer": "查看者", + "teamAccess.title": "团队访问", + "teamAccess.visibilityLabel": "可见性", + "teamAccess.visibilityOrg": "组织中的所有人", + "teamAccess.visibilityOrgHint": "除访客外的所有用户都可以打开此工作区并处理其任务。", + "teamAccess.visibilityPrivate": "私有", + "teamAccess.visibilityPrivateHint": "只有所有者和下方列出的成员可以打开此工作区。", + "placement.title": "位置", + "placement.description": "该工作区所属的单元,也就决定了谁能访问它。移动它是改变访问范围的唯一方式:角色按最高权限合并,无法就地收回。", + "placement.pick": "选择单元", + "placement.search": "搜索单元", + "placement.none": "未找到单元", + "placement.moved": "工作区已移动。", + "placement.failed": "无法移动该工作区。", + "placement.ownerOnly": "仅工作区所有者可移动" } diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json index 7de077a4db..cb09eee932 100644 --- a/apps/web/src/locales/zh-hk/agents.json +++ b/apps/web/src/locales/zh-hk/agents.json @@ -295,5 +295,6 @@ "dynamicRouteRetryDescription": "供應商發生錯誤後重試相同候選設定檔。適用於可能暫時恢復的故障。", "dynamicRouteTryNextDescription": "本次執行略過此候選設定檔,繼續使用下一個符合條件的設定檔。", "dynamicRouteStopDescription": "此候選設定檔失敗後停止路由。工作會等待手動恢復操作。", - "dynamicProfileSaved": "動態設定檔已儲存。" + "dynamicProfileSaved": "動態設定檔已儲存。", + "adminOnly": "只有管理員可以更改代理和代理設定檔。" } diff --git a/apps/web/src/locales/zh-hk/auth.json b/apps/web/src/locales/zh-hk/auth.json index 5132b82b2c..2112e4e4d1 100644 --- a/apps/web/src/locales/zh-hk/auth.json +++ b/apps/web/src/locales/zh-hk/auth.json @@ -4,26 +4,27 @@ "authenticationIsEnabledForThisDeployment": "此部署已啓用身份驗證。請在下方建立首個帳戶;該帳戶將成為管理員,並可邀請其他用戶。", "checkingInvite": "正在檢查邀請…", "continueWithProvider": "使用 {{provider}} 繼續", + "couldNotAcceptInvite": "無法接受此邀請。", + "couldNotLoadInvite": "無法載入此邀請。請重試。", "createAdminAccount": "建立管理員帳戶", "creatingAdminAccount": "正在建立管理員帳戶…", "displayName": "顯示名稱", "email": "電郵地址", - "joining": "正在加入…", - "joinThisKandevDeployment": "加入此 Kandev 部署。", - "orContinueWith": "或使用以下方式繼續", - "password": "密碼", - "setUpKandev": "設定 Kandev", - "signIn": "登入", - "signingIn": "正在登入…", - "signInToYourKandevAccount": "登入你的 Kandev 帳戶以繼續。", - "youVeBeenInvitedAs": "你已被邀請為 {{role}}。", - "couldNotAcceptInvite": "無法接受此邀請。", - "couldNotLoadInvite": "無法載入此邀請。請重試。", "invalidEmailOrPassword": "電郵地址或密碼不正確。", "inviteLinkInvalidOrExpired": "此邀請連結無效或已過期。請向管理員索取新的連結。", "inviteLinkMissingToken": "此邀請連結缺少權杖。", + "joinThisKandevDeployment": "加入此 Kandev 部署。", + "joining": "正在加入…", + "orContinueWith": "或使用以下方式繼續", + "organizationUnavailable": "您的組織目前無法使用。請聯絡您的 Kandev 管理員。", + "password": "密碼", + "setUpKandev": "設定 Kandev", "setupFailed": "初始化失敗。請重試。", "setupNoLongerAvailable": "初始化已不可用 - 帳戶已存在。", + "signIn": "登入", + "signInToYourKandevAccount": "登入你的 Kandev 帳戶以繼續。", + "signingIn": "正在登入…", "somethingWentWrong": "出了點問題。請重試。", - "tooManyAttempts": "嘗試次數過多。請稍候片刻再試。" + "tooManyAttempts": "嘗試次數過多。請稍候片刻再試。", + "youVeBeenInvitedAs": "你已被邀請為 {{role}}。" } diff --git a/apps/web/src/locales/zh-hk/orgs.json b/apps/web/src/locales/zh-hk/orgs.json new file mode 100644 index 0000000000..610d930b43 --- /dev/null +++ b/apps/web/src/locales/zh-hk/orgs.json @@ -0,0 +1,38 @@ +{ + "addAdmin": "新增管理員", + "adminCreate": "建立管理員", + "adminCreated": "管理員已建立。", + "adminDescription": "管理員只能在自己的組織中建立帳戶,因此新組織需要在此建立首位管理員。", + "adminEmail": "電子郵件", + "adminName": "顯示名稱", + "adminPassword": "密碼", + "adminPasswordHint": "至少 8 個字元。請告知對方並要求其登入後變更。", + "adminTitle": "{{name}} 的首位管理員", + "boundaryDescription": "工作區、任務、工作階段、密鑰和帳戶均屬於一個組織。身為執行個體維運人員,您管理組織本身;但不會取得任何組織內部的存取權限,包括您自己的組織。", + "boundaryTitle": "組織隔離的內容", + "create": "建立", + "createDescription": "先建立組織,然後新增其首位管理員。此後該組織將自行管理。", + "createTitle": "新增組織", + "created": "組織已建立。", + "defaultBadge": "預設", + "delete": "刪除組織", + "deleteConfirm": "刪除組織", + "deleteConfirmLabel": "輸入 {{slug}} 以確認", + "deleteTitle": "刪除 {{name}}", + "deleteWarning": "這將移除該組織中的所有工作區、任務、工作階段和帳戶,且無法復原。", + "deleted": "組織已刪除。", + "description": "每個組織都是獨立的租戶。使用者僅屬於一個組織,組織之間無法看到彼此的工作區、任務或密鑰。", + "genericError": "無法儲存該變更。", + "nameLabel": "組織名稱", + "namePlaceholder": "Acme Corp", + "navOrganizations": "組織", + "operatorOnly": "只有執行個體維運人員可以管理組織。", + "renamed": "組織已重新命名。", + "resume": "恢復", + "resumed": "組織已恢復。", + "statusActive": "使用中", + "statusSuspended": "已暫停", + "suspend": "暫停", + "suspended": "組織已暫停。", + "title": "組織" +} diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json index f11255165b..e02269aa40 100644 --- a/apps/web/src/locales/zh-hk/settings.json +++ b/apps/web/src/locales/zh-hk/settings.json @@ -750,5 +750,25 @@ "keyValueAddItem": "新增條目", "keyValueKeyPlaceholder": "鍵", "keyValueValuePlaceholder": "值", - "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啓動時決定使用的模型。" + "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啓動時決定使用的模型。", + "navUnits": "組織單位", + "unitsTitle": "組織單位", + "unitsDescription": "單位包含團隊、工作區和成員。加入某個單位即可存取其下的所有工作區,包括之後建立的。", + "unitsEmpty": "尚無單位。", + "unitRootBadge": "整個組織", + "unitPersonalBadge": "個人", + "unitAddChild": "新增單位", + "unitMembers": "成員", + "unitDelete": "刪除單位", + "unitCreateTitle": "在 {{parent}} 中新增單位", + "unitCreateDescription": "部門和團隊都是單位,差別只在於所處位置。", + "unitNamePlaceholder": "平台", + "unitCreate": "建立", + "unitMembersTitle": "{{unit}} 的成員", + "unitMembersDescription": "這裡的每個人都能以你指定的角色存取該單位下的所有工作區。", + "unitNoMembers": "該單位尚無成員。", + "unitAddMember": "新增", + "unitRemoveMember": "移除成員", + "unitPickPerson": "選擇成員", + "unitActionFailed": "操作未成功。" } diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 9a3e0d12f9..567547af6d 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -1942,7 +1942,13 @@ "branchPolicyOptionSummary": "從 {{base}} 到 {{target}}", "branchPolicySelected": "{{name}}(從 {{base}})", "branchPolicyUnavailable": "基礎分支不可用。請刷新分支或編輯此策略。", - "branchPolicyRequiresSingleRepository": "使用本地執行器時,分支策略需要單個儲存庫。", + "branchPolicyRequiresSingleRepository": "使用本機執行器時,分支策略需要單一儲存庫。", + "assignedTo": "指派給", + "unassigned": "未指派", + "assignToMe": "指派給我", + "searchPeople": "搜尋成員", + "noPeopleFound": "找不到成員", + "assignedToName": "指派給 {{name}}", "uploadConflictTitle_one": "{{count}} 個檔案已存在", "uploadConflictTitle_other": "{{count}} 個檔案已存在", "uploadConflictBody": "請選擇如何處理每個檔案。確認之前不會上載任何內容。", diff --git a/apps/web/src/locales/zh-hk/workspaces.json b/apps/web/src/locales/zh-hk/workspaces.json index 46bb6a118e..d897b86a8d 100644 --- a/apps/web/src/locales/zh-hk/workspaces.json +++ b/apps/web/src/locales/zh-hk/workspaces.json @@ -200,5 +200,45 @@ "branchPolicyGitflowCreate": "建立 Gitflow 策略", "branchPolicyGitflowCreated": "Gitflow 策略已建立", "branchPolicyGitflowFailed": "無法建立 Gitflow 策略。", - "branchPolicySummary": "基礎:{{base}}。模板:{{template}}。拉取要求目標:{{target}}。" + "branchPolicySummary": "基礎:{{base}}。模板:{{template}}。拉取要求目標:{{target}}。", + "defaultVisibility.description": "適用於此後建立的工作區。現有工作區維持其目前的可見性。", + "defaultVisibility.existingUnaffected": "變更此設定絕不會變更現有工作區。", + "defaultVisibility.label": "新工作區為", + "defaultVisibility.savedOrg": "新工作區將對組織可見。", + "defaultVisibility.savedPrivate": "新工作區將為私人。", + "defaultVisibility.title": "新工作區預設設定", + "teamAccess.addMember": "新增", + "teamAccess.addMemberLabel": "新增同事", + "teamAccess.addMemberPlaceholder": "選擇一位人員", + "teamAccess.description": "選擇誰可以存取此工作區,並為同事指派角色。", + "teamAccess.genericError": "無法儲存該變更。", + "teamAccess.makeOwner": "設為擁有者", + "teamAccess.memberAdded": "已新增成員。", + "teamAccess.memberRemoved": "已移除成員。", + "teamAccess.membersLabel": "成員", + "teamAccess.noMembers": "尚無成員。擁有者一律具有完整存取權限。", + "teamAccess.noUsersToAdd": "所有人都已是成員", + "teamAccess.nowOrgVisible": "此工作區現在對組織可見。", + "teamAccess.nowPrivate": "此工作區現在為私人。", + "teamAccess.ownershipTransferred": "{{name}} 現在是擁有者。", + "teamAccess.removeMember": "移除成員", + "teamAccess.roleCollaborator": "協作者", + "teamAccess.roleLabel": "角色", + "teamAccess.roleOwner": "擁有者", + "teamAccess.roleUpdated": "已更新角色。", + "teamAccess.roleViewer": "檢視者", + "teamAccess.title": "團隊存取", + "teamAccess.visibilityLabel": "可見性", + "teamAccess.visibilityOrg": "組織中的所有人", + "teamAccess.visibilityOrgHint": "除訪客外的所有使用者都可以開啟此工作區並處理其任務。", + "teamAccess.visibilityPrivate": "私人", + "teamAccess.visibilityPrivateHint": "只有擁有者和下方列出的成員可以開啟此工作區。", + "placement.title": "位置", + "placement.description": "該工作區所屬的單位,也就決定了誰能存取它。移動它是改變存取範圍的唯一方式:角色按最高權限合併,無法就地收回。", + "placement.pick": "選擇單位", + "placement.search": "搜尋單位", + "placement.none": "找不到單位", + "placement.moved": "工作區已移動。", + "placement.failed": "無法移動該工作區。", + "placement.ownerOnly": "僅工作區擁有者可移動" } diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json index 3ebce4a002..b8d65d0356 100644 --- a/apps/web/src/locales/zh-tw/agents.json +++ b/apps/web/src/locales/zh-tw/agents.json @@ -295,5 +295,6 @@ "dynamicRouteRetryDescription": "供應商發生錯誤後重試相同候選設定檔。適用於可能暫時恢復的故障。", "dynamicRouteTryNextDescription": "本次執行略過此候選設定檔,繼續使用下一個符合條件的設定檔。", "dynamicRouteStopDescription": "此候選設定檔失敗後停止路由。工作會等待手動恢復操作。", - "dynamicProfileSaved": "動態設定檔已儲存。" + "dynamicProfileSaved": "動態設定檔已儲存。", + "adminOnly": "只有管理員可以更改代理和代理設定檔。" } diff --git a/apps/web/src/locales/zh-tw/auth.json b/apps/web/src/locales/zh-tw/auth.json index d8d3b3dd58..5783fcc7c5 100644 --- a/apps/web/src/locales/zh-tw/auth.json +++ b/apps/web/src/locales/zh-tw/auth.json @@ -4,26 +4,27 @@ "authenticationIsEnabledForThisDeployment": "此部署已啟用身分驗證。請在下方建立首個帳戶;該帳戶將成為管理員,並可邀請其他使用者。", "checkingInvite": "正在檢查邀請…", "continueWithProvider": "使用 {{provider}} 繼續", + "couldNotAcceptInvite": "無法接受此邀請。", + "couldNotLoadInvite": "無法載入此邀請。請重試。", "createAdminAccount": "建立管理員帳戶", "creatingAdminAccount": "正在建立管理員帳戶…", "displayName": "顯示名稱", "email": "電子郵件地址", - "joining": "正在加入…", - "joinThisKandevDeployment": "加入此 Kandev 部署。", - "orContinueWith": "或使用以下方式繼續", - "password": "密碼", - "setUpKandev": "設定 Kandev", - "signIn": "登入", - "signingIn": "正在登入…", - "signInToYourKandevAccount": "登入你的 Kandev 帳戶以繼續。", - "youVeBeenInvitedAs": "你已被邀請為 {{role}}。", - "couldNotAcceptInvite": "無法接受此邀請。", - "couldNotLoadInvite": "無法載入此邀請。請重試。", "invalidEmailOrPassword": "電子郵件地址或密碼不正確。", "inviteLinkInvalidOrExpired": "此邀請連結無效或已過期。請向管理員索取新的連結。", "inviteLinkMissingToken": "此邀請連結缺少權杖。", + "joinThisKandevDeployment": "加入此 Kandev 部署。", + "joining": "正在加入…", + "orContinueWith": "或使用以下方式繼續", + "organizationUnavailable": "您的組織目前無法使用。請聯絡您的 Kandev 管理員。", + "password": "密碼", + "setUpKandev": "設定 Kandev", "setupFailed": "初始化失敗。請重試。", "setupNoLongerAvailable": "初始化已不可用 - 帳戶已存在。", + "signIn": "登入", + "signInToYourKandevAccount": "登入你的 Kandev 帳戶以繼續。", + "signingIn": "正在登入…", "somethingWentWrong": "出了點問題。請重試。", - "tooManyAttempts": "嘗試次數過多。請稍候片刻再試。" + "tooManyAttempts": "嘗試次數過多。請稍候片刻再試。", + "youVeBeenInvitedAs": "你已被邀請為 {{role}}。" } diff --git a/apps/web/src/locales/zh-tw/orgs.json b/apps/web/src/locales/zh-tw/orgs.json new file mode 100644 index 0000000000..610d930b43 --- /dev/null +++ b/apps/web/src/locales/zh-tw/orgs.json @@ -0,0 +1,38 @@ +{ + "addAdmin": "新增管理員", + "adminCreate": "建立管理員", + "adminCreated": "管理員已建立。", + "adminDescription": "管理員只能在自己的組織中建立帳戶,因此新組織需要在此建立首位管理員。", + "adminEmail": "電子郵件", + "adminName": "顯示名稱", + "adminPassword": "密碼", + "adminPasswordHint": "至少 8 個字元。請告知對方並要求其登入後變更。", + "adminTitle": "{{name}} 的首位管理員", + "boundaryDescription": "工作區、任務、工作階段、密鑰和帳戶均屬於一個組織。身為執行個體維運人員,您管理組織本身;但不會取得任何組織內部的存取權限,包括您自己的組織。", + "boundaryTitle": "組織隔離的內容", + "create": "建立", + "createDescription": "先建立組織,然後新增其首位管理員。此後該組織將自行管理。", + "createTitle": "新增組織", + "created": "組織已建立。", + "defaultBadge": "預設", + "delete": "刪除組織", + "deleteConfirm": "刪除組織", + "deleteConfirmLabel": "輸入 {{slug}} 以確認", + "deleteTitle": "刪除 {{name}}", + "deleteWarning": "這將移除該組織中的所有工作區、任務、工作階段和帳戶,且無法復原。", + "deleted": "組織已刪除。", + "description": "每個組織都是獨立的租戶。使用者僅屬於一個組織,組織之間無法看到彼此的工作區、任務或密鑰。", + "genericError": "無法儲存該變更。", + "nameLabel": "組織名稱", + "namePlaceholder": "Acme Corp", + "navOrganizations": "組織", + "operatorOnly": "只有執行個體維運人員可以管理組織。", + "renamed": "組織已重新命名。", + "resume": "恢復", + "resumed": "組織已恢復。", + "statusActive": "使用中", + "statusSuspended": "已暫停", + "suspend": "暫停", + "suspended": "組織已暫停。", + "title": "組織" +} diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json index 77dcbcaf53..eae1ea586b 100644 --- a/apps/web/src/locales/zh-tw/settings.json +++ b/apps/web/src/locales/zh-tw/settings.json @@ -750,5 +750,25 @@ "keyValueAddItem": "新增條目", "keyValueKeyPlaceholder": "鍵", "keyValueValuePlaceholder": "值", - "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啟動時決定使用的模型。" + "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啟動時決定使用的模型。", + "navUnits": "組織單位", + "unitsTitle": "組織單位", + "unitsDescription": "單位包含團隊、工作區和成員。加入某個單位即可存取其下的所有工作區,包括之後建立的。", + "unitsEmpty": "尚無單位。", + "unitRootBadge": "整個組織", + "unitPersonalBadge": "個人", + "unitAddChild": "新增單位", + "unitMembers": "成員", + "unitDelete": "刪除單位", + "unitCreateTitle": "在 {{parent}} 中新增單位", + "unitCreateDescription": "部門和團隊都是單位,差別只在於所處位置。", + "unitNamePlaceholder": "平台", + "unitCreate": "建立", + "unitMembersTitle": "{{unit}} 的成員", + "unitMembersDescription": "這裡的每個人都能以你指定的角色存取該單位下的所有工作區。", + "unitNoMembers": "該單位尚無成員。", + "unitAddMember": "新增", + "unitRemoveMember": "移除成員", + "unitPickPerson": "選擇成員", + "unitActionFailed": "操作未成功。" } diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 84947f79f5..00eb50a622 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -1942,7 +1942,13 @@ "branchPolicyOptionSummary": "從 {{base}} 到 {{target}}", "branchPolicySelected": "{{name}}(從 {{base}})", "branchPolicyUnavailable": "基礎分支不可用。請重新整理分支或編輯此策略。", - "branchPolicyRequiresSingleRepository": "使用本地執行器時,分支策略需要單個儲存庫。", + "branchPolicyRequiresSingleRepository": "使用本機執行器時,分支策略需要單一儲存庫。", + "assignedTo": "指派給", + "unassigned": "未指派", + "assignToMe": "指派給我", + "searchPeople": "搜尋成員", + "noPeopleFound": "找不到成員", + "assignedToName": "指派給 {{name}}", "uploadConflictTitle_one": "{{count}} 個檔案已存在", "uploadConflictTitle_other": "{{count}} 個檔案已存在", "uploadConflictBody": "請選擇如何處理每個檔案。確認之前不會上傳任何內容。", diff --git a/apps/web/src/locales/zh-tw/workspaces.json b/apps/web/src/locales/zh-tw/workspaces.json index 1e4ea4dd4a..7e15808efd 100644 --- a/apps/web/src/locales/zh-tw/workspaces.json +++ b/apps/web/src/locales/zh-tw/workspaces.json @@ -200,5 +200,45 @@ "branchPolicyGitflowCreate": "建立 Gitflow 策略", "branchPolicyGitflowCreated": "Gitflow 策略已建立", "branchPolicyGitflowFailed": "無法建立 Gitflow 策略。", - "branchPolicySummary": "基礎:{{base}}。模板:{{template}}。提取要求目標:{{target}}。" + "branchPolicySummary": "基礎:{{base}}。模板:{{template}}。提取要求目標:{{target}}。", + "defaultVisibility.description": "適用於此後建立的工作區。現有工作區維持其目前的可見性。", + "defaultVisibility.existingUnaffected": "變更此設定絕不會變更現有工作區。", + "defaultVisibility.label": "新工作區為", + "defaultVisibility.savedOrg": "新工作區將對組織可見。", + "defaultVisibility.savedPrivate": "新工作區將為私人。", + "defaultVisibility.title": "新工作區預設設定", + "teamAccess.addMember": "新增", + "teamAccess.addMemberLabel": "新增同事", + "teamAccess.addMemberPlaceholder": "選擇一位人員", + "teamAccess.description": "選擇誰可以存取此工作區,並為同事指派角色。", + "teamAccess.genericError": "無法儲存該變更。", + "teamAccess.makeOwner": "設為擁有者", + "teamAccess.memberAdded": "已新增成員。", + "teamAccess.memberRemoved": "已移除成員。", + "teamAccess.membersLabel": "成員", + "teamAccess.noMembers": "尚無成員。擁有者一律具有完整存取權限。", + "teamAccess.noUsersToAdd": "所有人都已是成員", + "teamAccess.nowOrgVisible": "此工作區現在對組織可見。", + "teamAccess.nowPrivate": "此工作區現在為私人。", + "teamAccess.ownershipTransferred": "{{name}} 現在是擁有者。", + "teamAccess.removeMember": "移除成員", + "teamAccess.roleCollaborator": "協作者", + "teamAccess.roleLabel": "角色", + "teamAccess.roleOwner": "擁有者", + "teamAccess.roleUpdated": "已更新角色。", + "teamAccess.roleViewer": "檢視者", + "teamAccess.title": "團隊存取", + "teamAccess.visibilityLabel": "可見性", + "teamAccess.visibilityOrg": "組織中的所有人", + "teamAccess.visibilityOrgHint": "除訪客外的所有使用者都可以開啟此工作區並處理其任務。", + "teamAccess.visibilityPrivate": "私人", + "teamAccess.visibilityPrivateHint": "只有擁有者和下方列出的成員可以開啟此工作區。", + "placement.title": "位置", + "placement.description": "該工作區所屬的單位,也就決定了誰能存取它。移動它是改變存取範圍的唯一方式:角色按最高權限合併,無法就地收回。", + "placement.pick": "選擇單位", + "placement.search": "搜尋單位", + "placement.none": "找不到單位", + "placement.moved": "工作區已移動。", + "placement.failed": "無法移動該工作區。", + "placement.ownerOnly": "僅工作區擁有者可移動" } diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index 37e22d4f59..9b9eafbe50 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -45,6 +45,8 @@ import { UsersTable } from "@/components/settings/system/users-table"; import { DataLogsSettings } from "@/components/settings/system/data-logs-settings"; import { DiskUsageCard } from "@/components/settings/system/disk-usage-card"; import { FeatureTogglesRoute } from "@/components/settings/system/feature-toggles-route"; +import { OrganizationsPage } from "@/components/settings/system/organizations/organizations-page"; +import { UnitsPage } from "@/components/settings/units/units-page"; import { HealthIssuesCard } from "@/components/settings/system/health-issues-card"; import { SystemPageShell } from "@/components/settings/system/system-page-shell"; import { SystemRouteShell } from "@/components/settings/system/system-route-shell"; @@ -208,6 +210,16 @@ const SETTINGS_ROUTES: Record = { ), "/settings/system/backups": () => , "/settings/system/database": () => , + "/settings/units": () => ( + + + + ), + "/settings/system/organizations": () => ( + + + + ), "/settings/system/feature-toggles": () => ( `**, and agent CLIs + authenticate as the backend's OS user. Row scoping does nothing about + either; the isolation waves do. + +## Architecture + +- **The tenant is derived from the identity, never from the request.** + `authn.Identity` gains `OrgID`; no route, payload, header, or WS frame may + name an org. This is the same lesson as `internal/mcp/scope` resolving the + owner from the `AgentExecution` rather than the agent-supplied payload. +- **One tenancy registry, no parallel maps.** `internal/tenancy/registry.go` + classifies every table as `instance`, `tenant-root`, or `descendant` (with + its FK path to `workspaces.org_id`), and records the reason for each + denormalized `org_id`. A completeness test enumerates the live schema and + fails on an unclassified table. This mirrors `internal/runtimeflags/registry.go`. +- **Scope at the service layer, backstop at the transport.** Org checks extend + the existing `authorize*` helpers rather than adding a second mechanism. + The WS dispatch backstop and the MCP scoper gain org resolution; neither + replaces the service-layer check. +- **Denormalize `org_id` only where a query would otherwise leak.** A + descendant table gets its own column when at least one production query + reaches it without joining its root. The registry records which query. +- **Instance templates are shape, org rows are substance.** `org_id = ''` is a + template; an org row shadows one by `template_id`. Every credential-bearing + field is org-only, enforced at the API boundary and by a pinning test. +- **Filesystem tenancy is additive for upgrades.** The default org's + `storage_root` stays `''`, meaning the legacy flat tree, permanently. Only + new orgs get `/orgs//`. No mass file move ships. +- **Standalone executors fail closed.** `local_pc` shares the backend's OS user, + so `HOME` redirection is a convention, not a boundary. Multi-org standalone + requires a per-org OS user or an explicit operator escape hatch. +- **Every wave lands behind the flag, off.** The feature is mergeable at any + point; `features.multiTenancy` only becomes settable once wave 4 completes. + +## Backend Touch Points + +- New packages: `internal/tenancy/` (registry, classification test, org model, + storage-root resolution), `internal/org/` (service, store, HTTP controller, + lifecycle). +- `internal/auth/`: `authn.Identity.OrgID` / `.Instance`, `SystemIdentity`, + `httpmw` org resolution and suspended-org fail-close, invite org binding, + `users.org_id`, per-org email uniqueness. +- `internal/task/service/service_access.go`: org checks in every `authorize*` + helper; identity-free denial under the flag. +- `internal/gateway/websocket/`: `BroadcastToOrg`, subscription org checks, + `//ws:global` allowlist pinning test, port-proxy capability org binding. +- `internal/mcp/scope/`: resolve stream → task → workspace → org; deny on + unresolvable, as it already does for owner. +- `internal/orchestrator/`, `internal/office/`, `internal/jira/`, + `internal/linear/`, `internal/github/`, `internal/gitlab/`, + `internal/azuredevops/`, `internal/sentry/`, `internal/automation/`: per-org + cycle loops carrying `SystemIdentity`. +- `internal/agent/settings/store/`, `internal/agent/executor/`, + `internal/task/repository/sqlite/`: `org_id` + `template_id` columns and + effective-view queries for the eight config kinds. +- `internal/worktree/`, `internal/repoclone/`, `internal/task/service/attachment_service.go`, + `internal/system/` storage maintenance: org storage-root resolution. +- `internal/agent/runtime/lifecycle/`, `internal/agentctl/`: per-org + `HOME`/`XDG_*` for every ACP subprocess and agentctl child; Docker org labels, + naming, volumes, and non-adoption on recovery; SSH executor org ownership; + standalone fail-closed check placed before any process start. +- `internal/githubauth/` broker lease, automation webhook secret, office channel + HMAC, plugin webhook secret: org binding and cross-org redemption refusal. +- `internal/runtimeflags/registry.go`, `profiles.yaml`: `features.multiTenancy` + and `features.multiTenancyTrustedStandalone`, both `false` in every profile. +- `cmd/kandev/e2e_reset.go`: org-aware reset. + +## Frontend Touch Points + +- Boot payload `org` key and store hydration (`apps/web/src/boot-payload.ts`). +- Operator surfaces under Settings > System: Organizations list/create/suspend/ + delete (type-to-confirm on slug), per-org OS user, and instance Templates for + the eight config kinds. +- Org admin surface: Settings > Organization (rename, members, invites already + exist and become org-bound). +- Existing executor / profile / environment / agent / editor / prompt / + notification-provider settings render `source: instance` rows as read-only + with a "Use in this organization" action that creates the shadowing org row. +- Organization-unavailable route for `org_suspended`, distinct from `/login` + so a suspended user is not told their credentials are wrong. +- All new copy through `t()` / `` in five languages + (`pnpm run i18n:zh-hant` for the Traditional Chinese pair), no em dashes. + +## Task Waves + +| Wave | Tasks | Theme | +|---|---|---| +| 1 | 01, 02 | Registry, flag, org table, identity carries the org | +| 2 | 03, 04, 05, 06 | Scoping: background work, service layer, transports, self-authenticating credentials | +| 3 | 07, 08 | Two-tier instance/org configuration | +| 4 | 09, 10, 11, 15 | Runtime isolation: filesystem, credentials, executors, secret keys | +| 5 | 12, 13, 14 | Org lifecycle, frontend, E2E and docs | + +Task 03 is the critical path and the highest-risk item: it changes the meaning +of an identity-free context across the whole backend. It should land before any +wave-2 sibling and be reviewed as a security change. + +## Tasks + +- [ ] [01 — Tenancy registry and feature flag](task-01-tenancy-registry-and-flag.md) +- [ ] [02 — Org entity and identity binding](task-02-org-entity-and-identity.md) +- [ ] [03 — Per-org background work and system identity](task-03-system-identity-background-work.md) +- [ ] [04 — Service-layer org scoping](task-04-service-layer-org-scoping.md) +- [ ] [05 — Transport scoping: WS gateway and MCP](task-05-transport-scoping.md) +- [ ] [06 — Self-authenticating credential org binding](task-06-credential-org-binding.md) +- [ ] [07 — Two-tier configuration storage and effective view](task-07-two-tier-config-storage.md) +- [ ] [08 — Instance template API and operator role](task-08-instance-templates-and-operator.md) +- [ ] [09 — Per-org filesystem roots](task-09-per-org-filesystem-roots.md) +- [ ] [10 — Per-org agent credential home](task-10-per-org-agent-credentials.md) +- [ ] [11 — Executor tenant pinning](task-11-executor-tenant-pinning.md) +- [ ] [12 — Org lifecycle: create, suspend, delete](task-12-org-lifecycle.md) +- [ ] [15 — Per-org secret encryption keys](task-15-per-org-secret-keys.md) +- [ ] [13 — Frontend org and operator surfaces](task-13-frontend-surfaces.md) +- [ ] [14 — E2E, docs, and ADR](task-14-e2e-docs-adr.md) + +## Risks + +- **Inverting the identity-free rule (task 03) can silently break background + work.** A poller that loses its grant fails quietly. Mitigation: the denial + path logs at warn with the caller, and task 03 ships a test that runs each + background entry point with an identity-free context and asserts denial — + the mutation-test standard, not an observed-passing suite. +- **Missed leak surfaces.** The registry completeness test covers tables, not + queries. Task 04 adds a second net: a repository-level test that runs each + list/get method as a foreign org and asserts an empty result or a sentinel. +- **`local_pc` is not a real boundary.** Accepted and specced as fail-closed + rather than papered over. The escape hatch is explicit, operator-only, and + logged. +- **Upgrade path.** The default org keeps the legacy flat tree forever, so no + mass file move ships. The migration aborts the boot rather than leaving an + unassignable row reachable by every tenant; `persistence.Provide`'s + pre-migration backup is the recovery path. +- **Scope creep in the other direction.** Team access already decides who sees + what inside an org, via visibility and scopes. This plan must not add a second + visibility rule keyed on the org; it only bounds the existing one. A request + that starts "when multi-tenancy is on, org admins should also see…" is a + change to `roles-and-scopes.md`, not to this plan. +- **Sequencing pressure from cloud.** If a hosted deployment moves up, the two + items to pull forward are org suspension (already specced as the reversible + billing lever, task 12) and per-org export, which is currently out of scope. + Neither changes the isolation design. diff --git a/docs/plans/multi-tenancy/task-01-tenancy-registry-and-flag.md b/docs/plans/multi-tenancy/task-01-tenancy-registry-and-flag.md new file mode 100644 index 0000000000..3f2a30d9a1 --- /dev/null +++ b/docs/plans/multi-tenancy/task-01-tenancy-registry-and-flag.md @@ -0,0 +1,51 @@ +--- +id: "01-tenancy-registry-and-flag" +title: "Tenancy registry and feature flag" +status: todo +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 01: Tenancy Registry and Feature Flag + +## Acceptance + +- `features.multiTenancy` and `features.multiTenancyTrustedStandalone` are + registered in `internal/runtimeflags/registry.go`, restart-required, and + `"false"` in every profile in `profiles.yaml`. +- Startup aborts with a named configuration error when `features.multiTenancy` + is on and `features.auth` is off. +- `internal/tenancy/registry.go` classifies every live table as `instance`, + `tenant-root`, or `descendant`, and each `descendant` entry records its FK + path to `workspaces.org_id`. +- A completeness test enumerates the tables the running schema actually creates + and fails on any table absent from the registry. +- The completeness test is mutation-verified: adding a throwaway table makes it + fail, and the failure message names the table. + +## Verification + +- `go test ./internal/tenancy/... ./internal/runtimeflags/...` from `apps/backend`. +- `go test ./internal/backendapp/... -run TestStartupRejectsTenancyWithoutAuth`. + +## Files Likely Touched + +- `apps/backend/internal/tenancy/registry.go`, `registry_test.go` +- `apps/backend/internal/runtimeflags/registry.go` +- `profiles.yaml` +- `apps/backend/internal/backendapp/` startup validation +- `apps/web/lib/` feature-flag types (flag metadata is contract-tested) + +## Inputs + +- Spec: Data model (tenancy classification), What (flag semantics). +- Pattern: `internal/runtimeflags/registry.go` typed registration; the + `/runtime-feature-flags` skill's file-by-file checklist. + +## Output Contract + +Report the table classification counts, the mutation used to prove the +completeness test fails, RED/GREEN commands, and set this task plus its plan +checkbox to done. diff --git a/docs/plans/multi-tenancy/task-02-org-entity-and-identity.md b/docs/plans/multi-tenancy/task-02-org-entity-and-identity.md new file mode 100644 index 0000000000..028768b973 --- /dev/null +++ b/docs/plans/multi-tenancy/task-02-org-entity-and-identity.md @@ -0,0 +1,57 @@ +--- +id: "02-org-entity-and-identity" +title: "Org entity and identity binding" +status: todo +wave: 1 +depends_on: ["01-tenancy-registry-and-flag"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 02: Org Entity and Identity Binding + +## Acceptance + +- `orgs` and `org_os_users` tables exist with the spec's columns, replay-safe + on fresh and existing databases, on SQLite and Postgres. +- `users.org_id` and `auth_invites.org_id` are added via idempotent + `ADD COLUMN` migrations; the users email uniqueness becomes + `UNIQUE (org_id, email)` through a table-rebuild migration with a replay + regression test proving values and timestamps survive. +- `authn.Identity` carries `OrgID` and `Instance`; the auth middleware + populates both from the authenticated user. +- A boot migration creates exactly one `is_default` org and assigns every + existing user, workspace, secret, and config row to it. The migration aborts + the boot when any row cannot be assigned. +- With the flag off, `OrgID` is empty, no org route is mounted, and the boot + payload has no `org` key. +- A session or token whose `OrgID` names a missing or suspended org fails + closed with `org_suspended`, distinct from a session challenge. + +## Verification + +- `go test ./internal/tenancy/... ./internal/org/... ./internal/auth/...` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/org/... -run TestMigration` +- `go test ./internal/auth/httpmw/... -run 'TestSuspendedOrg|TestIdentityOrg'` + +## Files Likely Touched + +- `apps/backend/internal/org/{models,store,service,provider}.go` +- `apps/backend/internal/auth/authn/identity.go` +- `apps/backend/internal/auth/httpmw/middleware.go` +- `apps/backend/internal/auth/store/schema.go`, `service_invites.go`, `service_setup.go` +- `apps/backend/internal/user/{models,store}/` +- `apps/backend/internal/tenancy/registry.go` (classify the new tables) + +## Inputs + +- Spec: Data model, State machine, Failure modes. +- Patterns: `internal/auth/store/schema.go` dialect substitution; ADR 0027 + replayable migrations; the destructive-cutover migration conventions in + `apps/backend/AGENTS.md` for the users table rebuild. + +## Output Contract + +Report the migration's fresh-DB and replay results on both dialects, the +abort-on-unassignable-row proof, RED/GREEN commands, and set this task plus its +plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-03-system-identity-background-work.md b/docs/plans/multi-tenancy/task-03-system-identity-background-work.md new file mode 100644 index 0000000000..73cbca9cff --- /dev/null +++ b/docs/plans/multi-tenancy/task-03-system-identity-background-work.md @@ -0,0 +1,61 @@ +--- +id: "03-system-identity-background-work" +title: "Per-org background work and system identity" +status: todo +wave: 2 +depends_on: ["02-org-entity-and-identity"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 03: Per-Org Background Work and System Identity + +Critical path. This task changes what an identity-free context *means* across +the whole backend. Review it as a security change, not a refactor. + +## Acceptance + +- `authn.SystemIdentity(orgID)` exists, is the only constructor for background + contexts, and is unreachable from any HTTP or WS code path (enforced by a + test that greps the import graph or by package placement). +- With `features.multiTenancy` on, a context with no identity reaching any + tenant-scoped service entry point is **denied** with the existing `*NotFound` + sentinels and a warn-level log naming the caller. With the flag off, the + existing "unscoped internal caller" behavior is byte-identical. +- Every background producer iterates active orgs and carries that org's system + identity for the whole cycle: the orchestrator and its scheduler/queue/ + watchers, office schedulers and routines, the Jira / Linear / GitHub / GitLab + / Azure DevOps / Sentry pollers and `healthpoll`, automation runners, workflow + sync, PR status sync, and storage maintenance. +- A suspended org is skipped by every cycle without stopping the loop. +- One org's cycle failure does not abort another org's cycle. + +## Verification + +- `go test ./internal/... -run 'TestIdentityFree|TestPerOrgCycle'` from `apps/backend` +- `go test -race ./internal/orchestrator/... ./internal/office/... ./internal/jira/... ./internal/linear/... ./internal/github/... ./internal/gitlab/...` + +## Files Likely Touched + +- `apps/backend/internal/auth/authn/identity.go` +- `apps/backend/internal/task/service/service_access.go` +- `apps/backend/internal/orchestrator/` (scheduler, queue, watchers, watcher_dispatch) +- `apps/backend/internal/office/{scheduler,routines,runtime,service}/` +- `apps/backend/internal/{jira,linear,github,gitlab,azuredevops,sentry}/` pollers +- `apps/backend/internal/integrations/healthpoll/` +- `apps/backend/internal/workflowsync/`, `internal/automation/` + +## Inputs + +- Spec: What (background work runs per-org), Failure modes (identity-free + denial, suspended org). +- Patterns: the poller `Start`/`Stop` + `goleak` conventions in + `apps/backend/AGENTS.md`; `internal/mcp/scope`'s "never return an + identity-free context from this path" rule, which this task generalizes. + +## Output Contract + +Enumerate every background entry point migrated, with a per-entry-point test +that runs it identity-free and asserts denial. Report which entry points were +found by grep versus by test failure, RED/GREEN commands, and set this task plus +its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-04-service-layer-org-scoping.md b/docs/plans/multi-tenancy/task-04-service-layer-org-scoping.md new file mode 100644 index 0000000000..769abb3897 --- /dev/null +++ b/docs/plans/multi-tenancy/task-04-service-layer-org-scoping.md @@ -0,0 +1,65 @@ +--- +id: "04-service-layer-org-scoping" +title: "Service-layer org scoping" +status: todo +wave: 2 +depends_on: ["02-org-entity-and-identity"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 04: Service-Layer Org Scoping + +## Acceptance + +- `workspaces.org_id` is populated and every `authorize*` helper in + `internal/task/service/service_access.go` checks the org before the owner. + A foreign-org workspace, task, session, environment, repository, or + attachment returns the existing `*NotFound` sentinel. +- Descendant tables listed in the tenancy registry as needing a denormalized + `org_id` receive one, with the registry recording the production query that + justified it. +- `secrets.org_id` is populated; secret reads are org-scoped ahead of the + existing user/workspace scope. +- A repository-level test runs every list and get method as a foreign org and + asserts an empty result or a sentinel — the second net beyond the table + registry. +- Visibility and scopes inside an org are unchanged: this task adds no new + visibility rule, it bounds the existing ones. Every team-access reach-matrix + and scope test still passes unmodified. +- `org` visibility resolves to the workspace's own org: a member of another org + gets 404 on an org-visible workspace. +- Org scopes are held in one org only: `org.members.manage` and + `org.config.manage` grant nothing outside the holder's org. +- Workspace membership is confined to one org: adding a member from another org + is refused with 404, and the tenancy migration drops any membership row whose + member and workspace would land in different orgs, logging each one without + aborting the migration. + +## Verification + +- `go test ./internal/task/... ./internal/secrets/...` from `apps/backend` +- `go test ./internal/task/repository/... -run TestForeignOrgIsolation` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/task/repository/sqlite/...` + +## Files Likely Touched + +- `apps/backend/internal/task/service/service_access.go` +- `apps/backend/internal/task/repository/sqlite/{base_schema,base_migrations,workspace,attachment}.go` +- `apps/backend/internal/task/models/models.go`, `internal/task/dto/` +- `apps/backend/internal/secrets/sqlite_store.go` +- `apps/backend/internal/tenancy/registry.go` + +## Inputs + +- Spec: Data model (denormalization rule), Permissions, Scenarios (foreign-org + 404, operator has no org visibility). +- Patterns: the existing `authorize*` helpers and their `*NotFound` no-leak + convention; the "new user-facing service entry points must apply scoping" + rule in `apps/backend/AGENTS.md`. + +## Output Contract + +Report which descendant tables gained an `org_id` and the query that justified +each, the foreign-org isolation test's coverage count against the method count, +RED/GREEN commands, and set this task plus its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-05-transport-scoping.md b/docs/plans/multi-tenancy/task-05-transport-scoping.md new file mode 100644 index 0000000000..68e56ced76 --- /dev/null +++ b/docs/plans/multi-tenancy/task-05-transport-scoping.md @@ -0,0 +1,54 @@ +--- +id: "05-transport-scoping" +title: "Transport scoping: WS gateway and MCP" +status: todo +wave: 2 +depends_on: ["04-service-layer-org-scoping"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 05: Transport Scoping — WS Gateway and MCP + +## Acceptance + +- `Hub.BroadcastToOrg(orgID, …)` exists and replaces `Hub.Broadcast` for every + tenant-derived event. The remaining `//ws:global` call sites are an explicit + allowlist (release notification, feature-toggle change, restart) pinned by a + test that fails when a new global broadcast is added without an entry. +- The WS dispatch backstop (`internal/gateway/websocket/dispatch_scope.go`) + resolves the org for any payload carrying `task_id`, `session_id`, + `task_environment_id`, or a top-level `task.` `id`, and refuses a + foreign-org reference before dispatch. +- WS subscriptions are org-checked at subscribe time, not only at broadcast + time. +- `internal/mcp/scope` resolves stream → task → workspace → org and attaches an + identity carrying both owner and org. An unresolvable org is denied; the path + never returns an identity-free context. +- A client authenticated to org B receives no frame produced by org A, proven + by a two-client gateway test rather than by inspecting broadcast call sites. + +## Verification + +- `go test -race ./internal/gateway/websocket/... ./internal/mcp/...` from `apps/backend` +- `go test ./internal/gateway/websocket/... -run 'TestGlobalBroadcastAllowlist|TestCrossOrgNoFrame'` + +## Files Likely Touched + +- `apps/backend/internal/gateway/websocket/{hub,dispatch_scope,access}.go` +- `apps/backend/internal/mcp/scope/scope.go` +- Every `hub.Broadcast` call site + +## Inputs + +- Spec: API surface (WS changes), Scenarios (cross-org no frame). +- Patterns: the existing dispatch-backstop rules and their `user_shell.stop` / + `task.state` regression history in `apps/backend/AGENTS.md` — a new action + name silently opts out of the backstop, so the org check must key off the + same ref extraction rather than inventing a parallel one. + +## Output Contract + +Report the global-broadcast allowlist and its justification per entry, the +two-client cross-org test, RED/GREEN commands, and set this task plus its plan +checkbox to done. diff --git a/docs/plans/multi-tenancy/task-06-credential-org-binding.md b/docs/plans/multi-tenancy/task-06-credential-org-binding.md new file mode 100644 index 0000000000..30c6451970 --- /dev/null +++ b/docs/plans/multi-tenancy/task-06-credential-org-binding.md @@ -0,0 +1,59 @@ +--- +id: "06-credential-org-binding" +title: "Self-authenticating credential org binding" +status: todo +wave: 2 +depends_on: ["04-service-layer-org-scoping"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 06: Self-Authenticating Credential Org Binding + +Every credential that authenticates *itself* rather than a session bypasses the +middleware, so each one needs its own org binding. + +## Acceptance + +- The GitHub credential-broker lease (`/api/v1/github/credentials/resolve`) + carries the issuing org, and redemption against a task in another org fails + with no token returned. +- The port-proxy capability HMAC binds the org alongside session and port; + a capability minted in org A is rejected for org B's session even when the + session:port pair matches. +- Automation webhook secrets, office channel HMACs, and plugin webhook secrets + each resolve to exactly one org; a delivery is processed only in that org's + scope. +- The public-endpoint allowlist in `internal/auth/httpmw/middleware.go` is + re-pinned: each public route is annotated with which self-authenticating + credential carries its org, and the pinning test fails when a route is added + without one. +- Task shares and public share links resolve to their org and do not expose + another org's artifact. + +## Verification + +- `go test ./internal/githubauth/... ./internal/github/... ./internal/gateway/websocket/... ./internal/auth/httpmw/...` +- `go test ./internal/... -run 'TestCrossOrgRedemption'` + +## Files Likely Touched + +- `apps/backend/internal/githubauth/`, `internal/github/` broker lease store +- `apps/backend/internal/gateway/websocket/access.go`, port-proxy handler +- `apps/backend/internal/automation/`, `internal/office/channels/`, `internal/plugins/` +- `apps/backend/internal/auth/httpmw/middleware.go` +- `apps/backend/internal/task/` share links + +## Inputs + +- Spec: API surface (changed contracts), Scenarios (cross-org lease, capability, + webhook secret). +- Patterns: the port-proxy capability design and the broker-lease "opaque, + task-scoped, hashed at rest, scope-matched on redeem" contract in + `apps/backend/AGENTS.md` and `docs/specs/auth/spec.md`. + +## Output Contract + +List every self-authenticating credential, its org-binding mechanism, and its +cross-org refusal test. Report RED/GREEN commands and set this task plus its +plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-07-two-tier-config-storage.md b/docs/plans/multi-tenancy/task-07-two-tier-config-storage.md new file mode 100644 index 0000000000..ade70ba0d3 --- /dev/null +++ b/docs/plans/multi-tenancy/task-07-two-tier-config-storage.md @@ -0,0 +1,56 @@ +--- +id: "07-two-tier-config-storage" +title: "Two-tier configuration storage and effective view" +status: todo +wave: 3 +depends_on: ["04-service-layer-org-scoping"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 07: Two-Tier Configuration Storage and Effective View + +## Acceptance + +- `executors`, `executor_profiles`, `environments`, `agents`, `agent_profiles`, + `editors`, `custom_prompts`, and `notification_providers` each gain + `org_id TEXT NOT NULL DEFAULT ''` and `template_id TEXT NOT NULL DEFAULT ''` + via idempotent migrations. +- One shared resolver produces the effective view for an org: own rows, plus + every `org_id = ''` template whose `id` is not the `template_id` of an own + row. There is exactly one implementation; the eight kinds do not each get + their own copy. +- List responses carry `source: "instance" | "org"` and `editable`. +- Creating an org row from a template records `template_id` and shadows it + immediately, with no duplicate appearing in the same response. +- Deleting an org row un-shadows its template in the next response. +- `agent_profiles.workspace_id` (used by Office) keeps working and composes with + the new tier rather than being replaced by it. +- With the flag off, every one of these reads returns exactly today's rows. + +## Verification + +- `go test ./internal/agent/settings/... ./internal/agent/executor/... ./internal/task/repository/...` +- `go test ./internal/... -run 'TestEffectiveConfigView|TestTemplateShadowing'` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/agent/settings/store/...` + +## Files Likely Touched + +- `apps/backend/internal/agent/settings/store/sqlite.go` +- `apps/backend/internal/agent/executor/` +- `apps/backend/internal/task/repository/sqlite/base_migrations.go` +- `apps/backend/internal/notifications/`, `internal/prompts/`, `internal/editors/` +- New shared resolver, e.g. `apps/backend/internal/tenancy/effective.go` +- `apps/backend/internal/tenancy/registry.go` + +## Inputs + +- Spec: What (two-tier configuration), API surface (`source` / `editable`). +- Patterns: existing `deleted_at` soft-delete and `workspace_id` scoping in + `internal/agent/settings/store/sqlite.go`. + +## Output Contract + +Report the single resolver's call sites (must be all eight kinds), the +shadow/un-shadow tests, flag-off byte-identity evidence, RED/GREEN commands, +and set this task plus its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-08-instance-templates-and-operator.md b/docs/plans/multi-tenancy/task-08-instance-templates-and-operator.md new file mode 100644 index 0000000000..589d551f3c --- /dev/null +++ b/docs/plans/multi-tenancy/task-08-instance-templates-and-operator.md @@ -0,0 +1,55 @@ +--- +id: "08-instance-templates-and-operator" +title: "Instance template API and operator role" +status: todo +wave: 3 +depends_on: ["07-two-tier-config-storage"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 08: Instance Template API and Operator Role + +## Acceptance + +- The `operator` instance tier exists on `authn.Identity` and gates + `/api/v1/instance/*`. `RequireOperator()` follows the shape of + `RequireAdmin()` and composes with `RequireRealIdentity()`. +- An operator has **no** read access to any org's workspaces, tasks, sessions, + transcripts, or secrets. A test asserts 404 for each, so the tier cannot + quietly become a visibility role. +- `/api/v1/instance/templates/{kind}` supports list, create, update, delete for + the eight kinds and writes only `org_id = ''` rows. +- A template body carrying a secret value, a sensitive env-var value, or an + integration credential is rejected with + `400 template_may_not_carry_credentials`, and nothing is written. +- The credential-free invariant is a pinning test over the template DTO field + set, so adding a credential-bearing field to a templated kind fails the build + until it is classified. +- On a single-org instance the auth setup wizard's first admin also receives + `operator`. + +## Verification + +- `go test ./internal/org/... ./internal/auth/... -run 'TestOperator|TestTemplate'` +- `go test ./internal/... -run TestTemplateMayNotCarryCredentials` + +## Files Likely Touched + +- `apps/backend/internal/auth/authn/identity.go` +- `apps/backend/internal/org/controller_templates.go`, `service_templates.go` +- `apps/backend/internal/auth/service_setup.go` +- `apps/backend/internal/tenancy/effective.go` + +## Inputs + +- Spec: Permissions table, API surface, What (templates carry shape not + credentials). +- Patterns: `authn.RequireAdmin` / `RequireRealIdentity`; the sensitivity + classification in `internal/common/config/catalog.go`. + +## Output Contract + +Report the operator-cannot-read test matrix, the credential field pinning +test and the mutation used to prove it fails, RED/GREEN commands, and set this +task plus its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-09-per-org-filesystem-roots.md b/docs/plans/multi-tenancy/task-09-per-org-filesystem-roots.md new file mode 100644 index 0000000000..69895c4a3f --- /dev/null +++ b/docs/plans/multi-tenancy/task-09-per-org-filesystem-roots.md @@ -0,0 +1,57 @@ +--- +id: "09-per-org-filesystem-roots" +title: "Per-org filesystem roots" +status: todo +wave: 4 +depends_on: ["04-service-layer-org-scoping"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 09: Per-Org Filesystem Roots + +## Acceptance + +- One resolver owns org storage-root resolution: `orgs.storage_root` when set, + the legacy flat tree when `''`. No caller builds an org path by string + concatenation. +- A new org's root is `/orgs//` created mode `0700`, containing + worktrees, clones, attachments, and the agent credential home. +- An upgraded instance's default org keeps `storage_root = ''` permanently. The + upgrade moves no files, and a test asserts the file count and mtimes under + `` are unchanged across the migration. +- Worktree creation, repository clones, attachment storage, task environments, + and quick terminals all resolve through the resolver. +- Storage maintenance, GC, quarantine, and temp-artifact sweeps run per-org and + never traverse another org's root. Containment checks reject a path that + escapes the resolved root, using the carried `FileInfo` rather than a re-stat. +- A missing or unwritable org root fails task creation and session launch in + that org with a storage error and leaves other orgs working. There is no + fallback to the shared tree. + +## Verification + +- `go test ./internal/worktree/... ./internal/repoclone/... ./internal/system/...` +- `go test ./internal/tenancy/... -run 'TestStorageRoot|TestUpgradeMovesNoFiles'` +- `go test ./internal/task/service/... -run TestAttachmentOrgRoot` + +## Files Likely Touched + +- `apps/backend/internal/tenancy/storage.go` +- `apps/backend/internal/worktree/`, `internal/repoclone/` +- `apps/backend/internal/task/service/attachment_service.go` +- `apps/backend/internal/system/` storage maintenance, quarantine, temp artifacts +- `apps/backend/internal/quickterminal/` + +## Inputs + +- Spec: What (filesystem isolation), Persistence guarantees, Failure modes. +- Patterns: the path/security test conventions in `apps/backend/AGENTS.md` + (fake absolute roots under `t.TempDir()`, carry the `Lstat` result through, + no re-stat before side effects); ADR 0045 install-wide storage maintenance. + +## Output Contract + +Report the resolver's call sites versus a grep for direct `` path +construction, the upgrade no-move evidence, RED/GREEN commands, and set this +task plus its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-10-per-org-agent-credentials.md b/docs/plans/multi-tenancy/task-10-per-org-agent-credentials.md new file mode 100644 index 0000000000..5df6fd5cd0 --- /dev/null +++ b/docs/plans/multi-tenancy/task-10-per-org-agent-credentials.md @@ -0,0 +1,54 @@ +--- +id: "10-per-org-agent-credentials" +title: "Per-org agent credential home" +status: todo +wave: 4 +depends_on: ["09-per-org-filesystem-roots"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 10: Per-Org Agent Credential Home + +## Acceptance + +- Every ACP subprocess and every agentctl child process launched for an org + receives `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, and `XDG_CACHE_HOME` + pointed at that org's credential home. +- The redirection is applied at the runtime-environment seam that covers all + entry points — agent sessions, host-utility probes, sessionless prompts, and + utility agents — not per call site. Coverage is proven at the producer + boundary, not only at one consumer. +- `gh auth`, `claude login`, and provider API-key logins performed inside org A + are invisible to org B. A test asserts an org-B agent reports no credential + when only org A has authenticated. +- Org-scoped secrets and integration credentials are injected from the org's + secret store; instance templates contribute env-var names only. +- With the flag off, the environment of every subprocess is byte-identical to + today. + +## Verification + +- `go test ./internal/agent/runtime/... ./internal/agentctl/server/process/...` +- `go test ./internal/... -run 'TestOrgCredentialHome|TestCrossOrgAgentCredential'` + +## Files Likely Touched + +- `apps/backend/internal/agent/runtime/lifecycle/{process_runner,profile_resolver}.go` +- `apps/backend/internal/agentctl/server/process/` +- `apps/backend/internal/agent/credentials/` +- `apps/backend/internal/secrets/`, `internal/scriptengine/` + +## Inputs + +- Spec: What (agent credentials), Scenarios (`gh auth status` in the wrong org). +- Patterns: the runtime-environment invariant in `apps/backend/AGENTS.md` — + `Agent.Runtime().Env` applies to every ACP subprocess entry point, and new + overrides must be routed through host-utility probes and sessionless prompts + into agentctl child processes before sanitization. + +## Output Contract + +Enumerate the subprocess entry points covered and how the producer-boundary +assertion proves none was missed. Report the flag-off byte-identity evidence, +RED/GREEN commands, and set this task plus its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-11-executor-tenant-pinning.md b/docs/plans/multi-tenancy/task-11-executor-tenant-pinning.md new file mode 100644 index 0000000000..c7069130d7 --- /dev/null +++ b/docs/plans/multi-tenancy/task-11-executor-tenant-pinning.md @@ -0,0 +1,59 @@ +--- +id: "11-executor-tenant-pinning" +title: "Executor tenant pinning" +status: todo +wave: 4 +depends_on: ["10-per-org-agent-credentials"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 11: Executor Tenant Pinning + +## Acceptance + +- `executors_running.org_id` is written on every execution and checked on + recovery. An execution row belonging to another org is not adopted. +- Docker executions are labelled `kandev.org=`, and container names and + volume names are namespaced by org. A container whose label does not match the + recovering org is not adopted, not stopped, and not removed; it is logged. +- Remote and SSH executors are org-owned rows; two orgs cannot both reach one + remote host through Kandev. The remote task directory and prepared checkout + live under the org's remote root. +- `local_pc` executions fail closed: with more than one active org and no + `org_os_users` row for the launching org, the launch is refused **before any + process starts**, with an error naming the missing row and the escape-hatch + flag. The guard is the first statement in the launch path, proven by a + nil-dependency test that fails on a panic if the guard is placed later. +- With `features.multiTenancyTrustedStandalone` set, the launch proceeds using + the org's redirected `HOME` and logs a warning once per org per boot. +- With a per-org OS user configured, the standalone process runs as that user + and its working tree is not readable by another org's user. + +## Verification + +- `go test -race ./internal/agent/runtime/lifecycle/... ./internal/agent/docker/... ./internal/agent/executor/...` +- `go test ./internal/... -run 'TestStandaloneFailsClosed|TestForeignContainerNotAdopted|TestGuardBeforeDependencies'` +- `KANDEV_E2E_CONTAINERS=1` container project for the Docker label path. + +## Files Likely Touched + +- `apps/backend/internal/agent/runtime/lifecycle/{manager,executor_backend,execution_store,process_runner}.go` +- `apps/backend/internal/agent/docker/` +- `apps/backend/internal/ssh/`, remote executor preparation +- `apps/backend/internal/task/repository/sqlite/` (`executors_running.org_id`) + +## Inputs + +- Spec: What (runtime isolation), Failure modes (standalone refusal, foreign + container), Scenarios. +- Patterns: ADR 0003 and ADR 0025 (`executors_running` as the source of truth + for runtime cleanup); the lifecycle-callback identity rule and the + `TestSessionKeyedEntryPointsGuardBeforeDependencies` shape in + `apps/backend/AGENTS.md`. + +## Output Contract + +Report the recovery non-adoption test, the standalone fail-closed guard +position proof, the container-label E2E result, RED/GREEN commands, and set +this task plus its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-12-org-lifecycle.md b/docs/plans/multi-tenancy/task-12-org-lifecycle.md new file mode 100644 index 0000000000..9aa0be5a3a --- /dev/null +++ b/docs/plans/multi-tenancy/task-12-org-lifecycle.md @@ -0,0 +1,56 @@ +--- +id: "12-org-lifecycle" +title: "Org lifecycle: create, suspend, delete" +status: todo +wave: 5 +depends_on: ["08-instance-templates-and-operator", "11-executor-tenant-pinning"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 12: Org Lifecycle — Create, Suspend, Delete + +## Acceptance + +- `POST /api/v1/instance/orgs` creates an org with a unique lowercase slug, its + storage root, and a first-admin invite bound to that org. +- Suspension revokes nothing but fails every session and token closed with + `org_suspended`, stops running executions, and makes background cycles skip + the org. It is fully reversible: resuming restores access with no data change. +- Deletion requires a body naming the org slug verbatim. It stops executions + first; if a stop fails the deletion aborts, the storage root still exists, and + the error names the execution. +- A successful deletion removes every org row across the tenancy registry, + removes the org storage root, and revokes every session and token for its + users. A post-delete sweep asserts no row anywhere still carries the org ID — + this is the registry's payoff and must be driven from the registry, not a + hand-written table list. +- The default org cannot be deleted while it is the only org. +- Org-scoped side tables without a database cascade are deleted explicitly from + the org-deleted handler, and the create → delete lifecycle is covered. + +## Verification + +- `go test ./internal/org/... -run 'TestOrgCreate|TestOrgSuspend|TestOrgDelete'` +- `go test ./internal/org/... -run TestOrgDeleteLeavesNoRows` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/org/...` + +## Files Likely Touched + +- `apps/backend/internal/org/{service_lifecycle,controller,store}.go` +- `apps/backend/internal/tenancy/registry.go` (drives the delete sweep) +- `apps/backend/internal/auth/store/store.go` (session/token revocation) +- `apps/backend/cmd/kandev/e2e_reset.go` + +## Inputs + +- Spec: State machine, Failure modes, Permissions. +- Patterns: the workspace-deletion side-table rule in `apps/backend/AGENTS.md` + (explicit deletion from the deleted handler, lifecycle test, E2E reset + parity); ADR 0009 fail-closed GC semantics. + +## Output Contract + +Report the registry-driven post-delete sweep result, the abort-on-live-execution +proof, the E2E reset parity, RED/GREEN commands, and set this task plus its plan +checkbox to done. diff --git a/docs/plans/multi-tenancy/task-13-frontend-surfaces.md b/docs/plans/multi-tenancy/task-13-frontend-surfaces.md new file mode 100644 index 0000000000..ecb4742b75 --- /dev/null +++ b/docs/plans/multi-tenancy/task-13-frontend-surfaces.md @@ -0,0 +1,63 @@ +--- +id: "13-frontend-surfaces" +title: "Frontend org and operator surfaces" +status: todo +wave: 5 +depends_on: ["12-org-lifecycle"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 13: Frontend Org and Operator Surfaces + +## Acceptance + +- The boot payload carries `org: {id, name, slug}` for authenticated callers + and nothing for anonymous ones; the store hydrates it and a top-level test + proves the value reaches the consuming component, not just the hook. +- Settings > System gains an operator-only Organizations page: list, create, + suspend/resume, per-org OS user, and delete behind a type-to-confirm on the + slug. The confirm token is compared with `===` and therefore NOT translated + (`// i18n-exempt`). +- Settings > System gains an operator-only Templates section for the eight + config kinds. +- Settings > Organization lets an org admin rename the org and manage members + and invites. +- Existing executor / profile / environment / agent / editor / prompt / + notification-provider settings render `source: "instance"` rows read-only + with a "Use in this organization" action that creates the shadowing org row + and re-renders without a duplicate. +- A suspended org lands on a dedicated organization-unavailable route, never on + `/login`. +- With the flag off, no org UI, no operator pages, and no `org` key are present. +- Every string goes through `t()` / `` in all five locales; no em dashes; + `pnpm run i18n:check` and `pnpm run i18n:ratchet` pass. +- Mobile parity: every new page and dialog uses native mobile patterns with no + hover-only or desktop-only required action. + +## Verification + +- From `apps/web`: `pnpm test`, `pnpm run typecheck`, `pnpm run lint`, + `pnpm run i18n:check`, `pnpm run i18n:ratchet` +- `pnpm run i18n:zh-hant` for the Traditional Chinese pair + +## Files Likely Touched + +- `apps/web/src/boot-payload.ts`, `settings-routes.tsx` +- `apps/web/lib/api/domains/org-api.ts`, `apps/web/lib/types/org.ts` +- `apps/web/hooks/domains/org/` +- `apps/web/components/settings/organizations/`, `.../templates/` +- `apps/web/src/locales/*/` + +## Inputs + +- Spec: API surface, Permissions, State machine. +- Patterns: ADR 0021 Go-served SPA boot state; the `/mobile-parity` skill; the + i18n rules in the root `AGENTS.md` (never translate a `===` token, never call + `t()` at module scope, use `count` for plurals). + +## Output Contract + +Report the top-level prop-chain test for the boot `org` value, the i18n gate +results, the mobile parity evidence, and set this task plus its plan checkbox +to done. diff --git a/docs/plans/multi-tenancy/task-14-e2e-docs-adr.md b/docs/plans/multi-tenancy/task-14-e2e-docs-adr.md new file mode 100644 index 0000000000..774874f9c3 --- /dev/null +++ b/docs/plans/multi-tenancy/task-14-e2e-docs-adr.md @@ -0,0 +1,64 @@ +--- +id: "14-e2e-docs-adr" +title: "E2E, docs, and ADR" +status: todo +wave: 5 +depends_on: ["13-frontend-surfaces"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 14: E2E, Docs, and ADR + +## Acceptance + +- A Playwright spec covers the cross-org boundary end to end: two orgs, two + users, and an attempt from org A to open org B's workspace, task, session, + and port preview by URL, each landing on a not-found surface. +- A Playwright spec covers the operator surface: create org, invite its first + admin, suspend (the member sees the organization-unavailable page), resume, + and delete with the slug confirmation. +- A container-project spec (`KANDEV_E2E_CONTAINERS=1`) asserts an org-labelled + container is not adopted by another org's recovery. +- `cmd/kandev/e2e_reset.go` deletes org rows and roots so a worker-scoped + backend starts clean, and no poller recreates rows mid-reset. +- An ADR records the durable decisions: the tenant is derived from the identity + and never from the request, one org per user, identity-free contexts are + denials rather than grants, instance templates carry no credentials, and + `local_pc` fails closed under multi-org. +- `docs/public/**` documents enabling organizations, the operator versus org + admin split, the per-org filesystem and agent-credential model, and the + standalone-executor limitation. Run `/docs-maintainer`. +- Root `AGENTS.md`/`CLAUDE.md` and `apps/backend/AGENTS.md` gain the tenancy + registry rule and the inverted identity-free-context rule, since both change + conventions every future change must follow. +- `docs/specs/auth/spec.md` is amended: its "no workspace sharing" limit stands, + and the shared-agent-credential limit is narrowed to within an org. + +## Verification + +- From `apps/web`: `pnpm run build:e2e` then `pnpm e2e:run` for the new specs + (do not pass flags through `pnpm e2e:raw --`; they are silently dropped) +- `KANDEV_E2E_CONTAINERS=1` container project +- `make -C apps/backend test lint` + +## Files Likely Touched + +- `apps/web/e2e/` new specs and fixtures +- `apps/backend/cmd/kandev/e2e_reset.go` +- `docs/decisions/00XX-organization-tenancy.md`, `docs/decisions/INDEX.md` +- `docs/public/**` +- `AGENTS.md`, `CLAUDE.md`, `apps/backend/AGENTS.md` +- `docs/specs/auth/spec.md`, `docs/specs/INDEX.md` + +## Inputs + +- Spec: Scenarios (the conformance surface for the E2E specs). +- Patterns: `/docs-maintainer`, `/record decision`, `apps/web/e2e/README.md`, + and the E2E reset invariant in `apps/backend/AGENTS.md`. + +## Output Contract + +Report the spec-scenario to E2E-test mapping with any scenario left uncovered +named explicitly, the ADR number, the docs pages touched, and set this task plus +its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-15-per-org-secret-keys.md b/docs/plans/multi-tenancy/task-15-per-org-secret-keys.md new file mode 100644 index 0000000000..0f103c53cd --- /dev/null +++ b/docs/plans/multi-tenancy/task-15-per-org-secret-keys.md @@ -0,0 +1,67 @@ +--- +id: "15-per-org-secret-keys" +title: "Per-org secret encryption keys" +status: todo +wave: 4 +depends_on: ["04-service-layer-org-scoping"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 15: Per-Org Secret Encryption Keys + +Row scoping decides who may read a secret; it does nothing about the +ciphertext. Today one master key seals every row on the instance, so at rest +every tenant collapses to one key sitting beside the database. This is the one +place the spec's "strong enough for untrusted co-tenants" claim does not hold. + +What this task buys is blast-radius reduction on a partial compromise, and the +envelope structure that a real crypto-shred later requires. It does NOT make +deletion shred an org out of existing backups; see the spec's Out of scope for +why and for what would. + +## Acceptance + +- `org_keys` stores one wrapped data encryption key per org, created with the + org and removed with it. The plaintext DEK is never persisted. +- `MasterKeyProvider` and `/master.key` are unchanged: the master key + wraps DEKs instead of sealing rows, so a self-hosted upgrade changes nothing + operationally and no KMS is introduced. +- Secret writes seal under the owning org's DEK; reads unwrap that org's DEK. +- Deleting an org destroys its DEK, and a test proves its secrets are + unreadable **in the live database** afterwards. +- A second test pins the limit rather than hiding it: restoring a pre-deletion + backup onto the same host makes that org's secrets readable again, because + `org_keys` is inside the snapshot and `master.key` is not. A test named for a + shred that the design does not deliver would be worse than no test at all. +- A missing or unusable `org_keys` row fails every secret read and write in + that org with a named error and **never** falls back to the master key. A + fallback would silently restore the shared-key property this removes. +- The tenancy migration re-wraps existing secrets under the default org's DEK + in the same first-boot pass that assigns `org_id`, and is idempotent. +- A cross-org test proves org A's DEK cannot decrypt org B's ciphertext. +- With `features.multiTenancy` off, encryption is byte-identical to today. + +## Verification + +- `go test ./internal/secrets/... ./internal/org/...` from `apps/backend` +- `go test ./internal/secrets/... -run 'TestCrossOrgDecryptFails|TestDeleteDestroysDEK|TestNoMasterKeyFallback|TestRestoredBackupStillDecrypts'` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/secrets/...` + +## Files Likely Touched + +- `apps/backend/internal/secrets/crypto.go`, `sqlite_store.go` +- `apps/backend/internal/org/keys.go`, `store.go`, `service.go` +- `apps/backend/internal/tenancy/registry.go` (classify `org_keys`) + +## Inputs + +- Spec: What (per-org key bullet), Data model (`org_keys`), Failure modes, + Persistence guarantees, Out of scope (the root key is explicitly not moved). + +## Output Contract + +Report the cross-org decryption test, the live-database deletion test, the test +that pins the restored-backup limit, the no-fallback test and its documented +recovery route, the migration's re-wrap counts, RED/GREEN commands, and set +this task plus its plan checkbox to done. diff --git a/docs/plans/org-units/plan.md b/docs/plans/org-units/plan.md new file mode 100644 index 0000000000..58121c4c34 --- /dev/null +++ b/docs/plans/org-units/plan.md @@ -0,0 +1,87 @@ +--- +spec: docs/specs/workspaces/requirements/org-units.md +created: 2026-08-24 +updated: 2026-08-24 +status: draft +--- + +# Organization Units Plan + +## Scope + +Implement [organization units](../../specs/workspaces/requirements/org-units.md) +against its [system design](../../specs/workspaces/system-design/org-units.md): +a per-organization tree of units that carries child units, workspaces, and +members; membership that inherits downward; personal units in place of private +visibility; and an effective role resolved as the maximum of inherited and +direct grants. + +This **replaces** workspace visibility. `workspaces.visibility`, the +organization default-visibility setting, and the bulk visibility endpoint are +removed rather than kept alongside the tree. There is no compatibility path and +no second reach mechanism. + +Unchanged and consumed as they are: the tenant boundary in `internal/org`, and +the scope registry and role-to-scope mapping in `internal/authz`, which gains +one scope (`unit.manage`). + +## Current state + +The branch currently implements visibility (`org` / `private`) with membership +as an exception, resolved in `internal/authz.ResolveWorkspace`. Reach is one +column plus one membership table. The human assignee and actor attribution are +implemented and are unaffected by this plan; they move to +[human assignee](../../specs/tasks/requirements/human-assignee.md) in the task +system, which owns them. + +## Delivery order + +Each wave leaves a working product. The tree is built first and made +authoritative before the mechanism it replaces is removed, so no wave depends on +a half-migrated reach model. + +| Wave | Work orders | Outcome | +| --- | --- | --- | +| 1 | 01 | The tree exists and every workspace has a placement. Reach still comes from visibility. | +| 2 | 02 | Reach comes from the tree. Visibility is no longer consulted. | +| 3 | 03, 04 | Units and placement are manageable through the API and the interface. | +| 4 | 05 | The replaced mechanism is gone from schema, API, interface, and docs. | +| 5 | 06 | End-to-end evidence and public documentation. | + +## Work orders + +- [x] [01 — Unit tree storage and placement](task-01-unit-tree-storage.md) +- [x] [02 — Reach resolution on the tree](task-02-reach-resolution.md) +- [x] [03 — Unit management API](task-03-unit-management-api.md) +- [x] [04 — Unit tree and placement interface](task-04-unit-interface.md) +- [x] [05 — Remove workspace visibility](task-05-remove-visibility.md) +- [x] [06 — End-to-end coverage and public documentation](task-06-e2e-and-docs.md) + +## Risks + +- **A table rebuild drops the new columns.** `internal/office`'s + priority-to-TEXT migration recreates `tasks`, and other migrations recreate + `workspaces`. Any rebuild must carry `unit_id`. This already caused a silent + column loss on this branch once, so wave 1 owns a regression test at the + rebuild itself. +- **The migration widens reach.** Placement must map a private workspace to its + owner's personal unit, never to the root. Wave 1 owns a test asserting that no + workspace becomes reachable by a user who could not reach it before. +- **Stale reach on connected clients.** Moving a workspace or removing a + membership changes reach for people who are already subscribed. Waves 2 and 3 + reuse the existing revocation path rather than adding a second one. +- **Path denormalization drifts.** `org_units.path` has exactly one writer. A + move rewrites descendants in one transaction, and wave 1 owns a test that + reparents a subtree and asserts every descendant path. + +## Verification strategy + +- Wave 1 and 2 are covered by Go unit and repository tests, including a + migration test that asserts no reach widening and a rebuild-survival test. +- Wave 3 and 4 are covered by handler tests for authorization and refusal codes, + and by frontend unit tests for the tree and placement controls. +- Wave 5 is covered by a repository-wide assertion that no `visibility` + reference remains in schema, API, or interface code. +- Wave 6 owns the end-to-end evidence: a seeded instance where a department + member reaches a team board they were never named on, and a personal + workspace stays unreachable. diff --git a/docs/plans/org-units/task-01-unit-tree-storage.md b/docs/plans/org-units/task-01-unit-tree-storage.md new file mode 100644 index 0000000000..73b7d81e97 --- /dev/null +++ b/docs/plans/org-units/task-01-unit-tree-storage.md @@ -0,0 +1,105 @@ +--- +id: "01-unit-tree-storage" +title: "Unit tree storage and placement" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 01: Unit Tree Storage and Placement + +## Outcome + +`org_units` and `unit_members` exist, every organization has a root unit, every +user has a personal unit, and every workspace has a `unit_id`. Reach still comes +from visibility, so behaviour is unchanged at the end of this order. + +## In scope + +- `org_units` and `unit_members` tables with the indexes named in the design. +- `workspaces.unit_id`, not null. +- `internal/orgunit`: unit lifecycle, membership rows, tree invariants (single + parent, no cycles, one organization, empty before delete), and materialized + path maintenance including subtree reparenting. +- Root unit created with an organization; personal unit created with a user. +- The one-shot data migration described in the design's Persistence section. +- Carrying `unit_id` through every migration that recreates `workspaces` or + `tasks`. + +## Out of scope + +- Reading the tree for authorization; that is order 02. +- HTTP surface; that is order 03. +- Removing `workspaces.visibility`; that is order 05. + +## Requirements + +`REQ-WORKSPACES-ORG-UNITS-001`, `REQ-WORKSPACES-ORG-UNITS-003`. + +Acceptance criteria: `AC-WORKSPACES-ORG-UNITS-001.1` through `.7`, +`AC-WORKSPACES-ORG-UNITS-003.1`, `.5`, `.6`. + +## System design + +[Organization units](../../specs/workspaces/system-design/org-units.md), sections +Data and contracts, Personal units, Persistence. + +## Implementation acceptance + +1. A fresh database has one root unit per organization, one personal unit per + user, and no workspace with an empty `unit_id`. +2. Reparenting a unit rewrites the path of every descendant, and a move that + would create a cycle is refused. +3. The migration places every previously private workspace in its owner's + personal unit, and no user reaches a workspace they could not reach before. + +## Verification + +```bash +cd apps/backend +go test ./internal/orgunit/... ./internal/task/repository/sqlite/... ./internal/office/repository/sqlite/... +go test ./internal/task/service/ -run 'TestUnitPlacement|TestMigrationDoesNotWidenReach' +``` + +## Likely files + +- `apps/backend/internal/orgunit/` (new) +- `apps/backend/internal/task/repository/sqlite/` schema and migrations +- `apps/backend/internal/office/repository/sqlite/base_migrations.go` rebuild lists +- `apps/backend/internal/user/store/` personal unit creation +- `apps/backend/internal/org/service.go` root unit creation + +## Results + +Done. `internal/orgunit` owns the tree, its membership, and its invariants: +single root and single personal unit per user enforced by unique indexes, a +cycle refused on move, protected units refused on move and delete, a personal +unit refusing members, and delete failing closed when the occupancy seam is +unwired. Ancestry is a materialized path, and reparenting rewrites the whole +subtree in one transaction. + +`workspaces.unit_id` is added with its index, and the one-shot backfill gives +every organization a root, every account a personal unit, and every existing +workspace a home. The placement rule is the one that keeps an upgrade from +widening access: an owned workspace lands in its owner's personal unit and never +under the root, including when its owner's account is gone. + +New workspaces are placed at creation through a lazy seam rather than a +user-creation hook, so an account created by any path gets a personal unit the +first time it needs one, with no ordering to get wrong. + +Verified on a fresh instance: an unowned pre-authentication workspace sits at +the root, and both workspaces created by a signed-in user sit in her personal +unit. Every test is mutation-checked; two mutants that only broke the build were +rerun as valid mutations rather than counted as caught. + +RED/GREEN: + +- RED: removing the owner branch from the backfill fails + `TestBackfillDoesNotWidenReach` and + `TestBackfillIsolatesWorkspacesOfMissingOwners`. +- RED: dropping the descendant rewrite from `Reparent` fails + `TestReparentRewritesDescendantPaths`. +- GREEN: `go test ./internal/orgunit/... ./internal/task/... ./internal/office/...` diff --git a/docs/plans/org-units/task-02-reach-resolution.md b/docs/plans/org-units/task-02-reach-resolution.md new file mode 100644 index 0000000000..9dd2433f03 --- /dev/null +++ b/docs/plans/org-units/task-02-reach-resolution.md @@ -0,0 +1,90 @@ +--- +id: "02-reach-resolution" +title: "Reach resolution on the tree" +status: done +wave: 2 +depends_on: ["01-unit-tree-storage"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 02: Reach Resolution on the Tree + +## Outcome + +`internal/authz.ResolveWorkspace` derives the effective role from the tree +instead of from `workspaces.visibility`. Roles combine by maximum, and no path +lowers an inherited role. + +## In scope + +- Ancestor role lookup from the workspace unit's path. +- Effective role as the maximum of the root-unit role, ancestor unit + memberships, and any direct workspace grant. +- Cross-organization refusal evaluated before any role, unchanged. +- Disabled accounts and unreachable workspaces answering as not found. +- Registering the `unit.manage` scope and mapping it to the organization + administrator role and to a unit `owner`. + +## Out of scope + +- The HTTP surface for units, order 03. +- Deleting the visibility column, order 05. This order stops consulting it. + +## Requirements + +`REQ-WORKSPACES-ORG-UNITS-002`, `REQ-WORKSPACES-ORG-UNITS-004`. + +Acceptance criteria: `AC-WORKSPACES-ORG-UNITS-002.2`, `.3`, `.4`, +`AC-WORKSPACES-ORG-UNITS-004.1` through `.5`. + +## System design + +[Organization units](../../specs/workspaces/system-design/org-units.md), section +Resolution. + +## Implementation acceptance + +1. A member of a department reaches a workspace under one of its teams at the + department role, with no direct grant present. +2. A direct `viewer` grant on a workspace whose unit already grants + `collaborator` leaves the effective role at `collaborator`. +3. A workspace in another user's personal unit resolves to no reach for an + organization administrator. + +## Verification + +```bash +cd apps/backend +go test ./internal/authz/... +go test ./internal/task/service/ -run 'TestReach|TestResolveWorkspace' +make lint +``` + +## Likely files + +- `apps/backend/internal/authz/resolve.go`, `scopes.go`, `roles.go` +- `apps/backend/internal/task/service/service_access.go` + +## Results + +Done. `authz.WorkspaceRef` carries `InheritedRole` instead of `Visibility`, and +`ResolveWorkspace` returns the maximum of the inherited role, any direct grant, +and ownership. Nothing subtracts, so a grant can only raise what a unit gives. + +Reach is resolved in two queries for a list rather than one per row: the +caller's unit roles and the units' paths are fetched once and matched by +ancestry in memory. Both the single and the bulk path fail closed, and the bulk +path fails closed for the whole list, because a partial answer would hide +workspaces the caller can reach and read as data loss rather than as a +permission error. + +The service tests now build a real tree instead of setting a flag, which also +means the wiring is exercised: a resolver nothing calls protects nothing. + +RED/GREEN: + +- RED: making `highestRole` return the last non-empty role instead of the + strongest fails `TestRolesCombineByMaximum/a direct grant cannot lower it`. +- GREEN: `go test ./internal/authz/... ./internal/task/... ./internal/orgunit/...` +- GREEN: `make lint` reports 0 issues. diff --git a/docs/plans/org-units/task-03-unit-management-api.md b/docs/plans/org-units/task-03-unit-management-api.md new file mode 100644 index 0000000000..a7594bd437 --- /dev/null +++ b/docs/plans/org-units/task-03-unit-management-api.md @@ -0,0 +1,86 @@ +--- +id: "03-unit-management-api" +title: "Unit management API" +status: done +wave: 3 +depends_on: ["02-reach-resolution"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 03: Unit Management API + +## Outcome + +Units, their members, and workspace placement are manageable over HTTP, with +authorization on every route and live revocation when reach narrows. + +## In scope + +- The routes listed in the design's HTTP surface section, each gated on + `unit.manage`, `member.manage`, or `workspace.manage` as stated there. +- Refusals that name their blocking condition: a non-empty unit on delete, a + cycle on move, an organization mismatch on either. +- Publishing the reach-changed event on membership removal and on workspace + move, reusing the existing revocation path. +- Workspace DTOs carrying `unit_id` and the unit display path. + +## Out of scope + +- Interface work, order 04. +- Removing the visibility routes, order 05. + +## Requirements + +`REQ-WORKSPACES-ORG-UNITS-001`, `REQ-WORKSPACES-ORG-UNITS-002`, +`REQ-WORKSPACES-ORG-UNITS-005`. + +Acceptance criteria: `AC-WORKSPACES-ORG-UNITS-001.5`, `.6`, `.8`, +`AC-WORKSPACES-ORG-UNITS-002.1`, `.5`, `.6`, `AC-WORKSPACES-ORG-UNITS-003.2`, +`AC-WORKSPACES-ORG-UNITS-005.1` through `.5`. + +## System design + +[Organization units](../../specs/workspaces/system-design/org-units.md), sections +Data and contracts, Placement and movement, Failure and recovery. + +## Implementation acceptance + +1. A caller without `unit.manage` receives a refusal on create, rename, move and + delete, and nothing is written. +2. Adding a member to a personal unit is refused. +3. Moving a workspace out of a unit drops the WebSocket subscriptions of users + who no longer reach it. + +## Verification + +```bash +cd apps/backend +go test ./internal/orgunit/... ./internal/task/handlers/... +go test ./internal/task/service/ -run 'TestUnitRevocation|TestWorkspaceMove' +make lint +``` + +## Likely files + +- `apps/backend/internal/orgunit/controller.go` (new) +- `apps/backend/internal/task/handlers/` +- `apps/backend/internal/backendapp/helpers.go` route registration + +## Results + +Done. `/api/v1/units` serves the tree, its members, and workspace placement. +Reading needs only an identity, because a member has to see the shape of their +organization to understand why they reach what they reach; every write needs +`unit.manage`, a new scope held by the organization administrator. + +A unit in another tenant answers 404 rather than 403, so one organization's tree +is not enumerable from another, and moving a workspace validates the +destination's organization before it changes anything. + +RED/GREEN: + +- RED: removing the scope gate from the write group fails three assertions in + `TestUnitWritesRequireUnitManage`. +- GREEN: `go test ./internal/orgunit/... ./internal/authz/... ./internal/task/...` +- GREEN: `make lint` reports 0 issues. diff --git a/docs/plans/org-units/task-04-unit-interface.md b/docs/plans/org-units/task-04-unit-interface.md new file mode 100644 index 0000000000..cce1a0731e --- /dev/null +++ b/docs/plans/org-units/task-04-unit-interface.md @@ -0,0 +1,91 @@ +--- +id: "04-unit-interface" +title: "Unit tree and placement interface" +status: done +wave: 3 +depends_on: ["03-unit-management-api"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 04: Unit Tree and Placement Interface + +## Outcome + +An organization administrator can build the tree, manage a unit's members, and +move a workspace between units, on desktop and on a phone. + +## In scope + +- A unit tree in settings under Access Control, with create, rename, move and + delete. +- A unit members view reusing the existing member picker and role control. +- Workspace placement shown on the workspace, with a move control gated on the + caller's scopes. +- Copy in all five locales. + +## Out of scope + +- Removing the visibility control, order 05. +- Screenshots and public documentation, order 06. + +## Requirements + +`REQ-WORKSPACES-ORG-UNITS-001`, `REQ-WORKSPACES-ORG-UNITS-002`, +`REQ-WORKSPACES-ORG-UNITS-005`. + +Acceptance criteria: `AC-WORKSPACES-ORG-UNITS-001.8`, +`AC-WORKSPACES-ORG-UNITS-002.1`, `AC-WORKSPACES-ORG-UNITS-005.2`. + +## System design + +[Organization units](../../specs/workspaces/system-design/org-units.md), section +Components and responsibilities. + +## Implementation acceptance + +1. The tree renders an organization's units in hierarchy order and hides + controls the caller's scopes do not permit. +2. Moving a workspace updates its displayed placement without a reload. +3. The interface works at a phone viewport, per `/mobile-parity`. + +## Verification + +```bash +cd apps/web +pnpm run typecheck && pnpm run lint +pnpm test -- components/settings/units lib/api/domains/org-units-api.test.ts +pnpm run i18n:check && pnpm run i18n:ratchet +``` + +## Likely files + +- `apps/web/components/settings/units/` (new) +- `apps/web/lib/api/domains/org-units-api.ts` (new) +- `apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts` +- `apps/web/src/locales/*/settings.json` + +## Results + +Done. `Settings > Access Control > Organization units` renders the tree with a +row per unit, indented by depth derived from the materialized path, and offers +create, delete and member management. A personal unit and the root show no +control that would only ever be refused: a personal unit takes no members and +cannot be deleted, and deleting the root would leave every workspace beneath it +homeless. + +Copy ships in all five locales. + +A gap found later while auditing screenshots: this order claimed workspace +placement was shown on the workspace, and it was not. The API and the e2e +coverage existed, but nothing in the interface could move a workspace, which is +the only way to narrow access in this design. `WorkspacePlacementCard` closes +it, gated on `workspace.manage` and restoring the previous unit when a move is +refused. + +RED/GREEN: + +- RED: treating a personal unit as an ordinary one fails + "offers no member or delete control on a personal unit". +- GREEN: `pnpm test -- components/settings/units lib/api/domains/org-units-api.test.ts` +- GREEN: typecheck, eslint, i18n:check, i18n:ratchet. diff --git a/docs/plans/org-units/task-05-remove-visibility.md b/docs/plans/org-units/task-05-remove-visibility.md new file mode 100644 index 0000000000..2c85368f71 --- /dev/null +++ b/docs/plans/org-units/task-05-remove-visibility.md @@ -0,0 +1,89 @@ +--- +id: "05-remove-visibility" +title: "Remove workspace visibility" +status: done +wave: 4 +depends_on: ["04-unit-interface"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 05: Remove Workspace Visibility + +## Outcome + +The mechanism the tree replaced is gone. There is one reach model in the schema, +the API, the interface, and the documentation. + +## In scope + +- Dropping `workspaces.visibility` and the organization default-visibility + setting. +- Removing `POST /api/v1/workspaces/visibility/bulk` and the `visibility` field + from workspace DTOs and payloads. +- Removing the visibility control, its store state, and its copy from the + interface, including locale entries that become orphans. +- Removing visibility from `docs/public/team-access.md` and from the coverage + manifest. + +## Out of scope + +- New public documentation for units, order 06. + +## Requirements + +`REQ-WORKSPACES-ORG-UNITS-004`. + +Acceptance criterion: `AC-WORKSPACES-ORG-UNITS-004.1`, in that the resolver has +one input path after this order. + +## System design + +[Organization units](../../specs/workspaces/system-design/org-units.md), section +Purpose and boundaries. + +## Implementation acceptance + +1. No `visibility` symbol remains in backend schema, services, handlers, or + frontend state for workspaces. +2. Every suite passes with the column absent. +3. `pnpm run i18n:check` reports no orphaned visibility copy. + +## Verification + +```bash +cd apps/backend && go test ./... && make lint +cd ../web && pnpm run typecheck && pnpm run lint && pnpm test && pnpm run i18n:check +rg -n 'workspace.*visibility|default_workspace_visibility' apps/ docs/public || true +``` + +## Likely files + +- `apps/backend/internal/task/repository/sqlite/` +- `apps/backend/internal/task/dto/dto.go` +- `apps/web/components/settings/workspaces/` +- `docs/public/team-access.md`, `docs/public/coverage.json` + +## Results + +Done. `workspaces.visibility`, the organization default-visibility setting, its +bulk endpoint, the service surface, the DTO field, the store state and the +settings control are gone. There is one reach model in the schema, the API, the +interface and the documentation. + +Two defects surfaced while removing it, both found by driving the running +instance rather than by a test: + +- The workspace `UPDATE` statement is hand-written and never carried `unit_id`, + so a move returned the new placement and persisted nothing. Pinned by + `TestUpdateWorkspacePersistsUnitPlacement`. +- The human-assignee reach check still asked only about a direct membership + row, so it refused a colleague who plainly reached the board through the + tree. Its refusal was also mapped to 500 on the task route rather than 400. + +RED/GREEN: + +- RED: dropping `unit_id` from the update statement fails + `TestUpdateWorkspacePersistsUnitPlacement`. +- GREEN: `go test ./internal/...` 239 packages, 0 failures; `make lint` 0 issues. +- GREEN: typecheck, eslint, i18n:check across the web app. diff --git a/docs/plans/org-units/task-06-e2e-and-docs.md b/docs/plans/org-units/task-06-e2e-and-docs.md new file mode 100644 index 0000000000..547c7dbfed --- /dev/null +++ b/docs/plans/org-units/task-06-e2e-and-docs.md @@ -0,0 +1,81 @@ +--- +id: "06-e2e-and-docs" +title: "End-to-end coverage and public documentation" +status: done +wave: 5 +depends_on: ["05-remove-visibility"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 06: End-to-End Coverage and Public Documentation + +## Outcome + +The user-facing outcome has browser evidence, and the public documentation +describes units rather than visibility. + +## In scope + +- An end-to-end spec covering the inherited-reach outcome: a department member + opens a team board they were never named on, and cannot open a workspace in + another user's personal unit. +- An end-to-end spec covering placement: moving a workspace changes who reaches + it. +- Rewriting `docs/public/team-access.md` around the tree, with screenshots + captured from a seeded instance. +- Extending the demo seed script to build a small department and team tree. + +## Out of scope + +- Inherited configuration such as executors or secrets, which the requirement + excludes. + +## Requirements + +`REQ-WORKSPACES-ORG-UNITS-002`, `REQ-WORKSPACES-ORG-UNITS-003`, +`REQ-WORKSPACES-ORG-UNITS-005`. + +Acceptance criteria: `AC-WORKSPACES-ORG-UNITS-002.2`, +`AC-WORKSPACES-ORG-UNITS-003.3`, `AC-WORKSPACES-ORG-UNITS-005.3`. + +## System design + +[Organization units](../../specs/workspaces/system-design/org-units.md). + +## Implementation acceptance + +1. The end-to-end specs pass against a seeded tree. +2. The public page contains no visibility instructions and its screenshots show + the tree. +3. `/docs-maintainer` reports no stale public references. + +## Verification + +```bash +cd apps/web +pnpm run build:e2e +pnpm e2e:run tests/access/org-units.spec.ts +cd .. && python3 scripts/lint-spec-files.py --all +``` + +## Likely files + +- `apps/web/e2e/tests/access/org-units.spec.ts` (new) +- `docs/public/team-access.md`, `docs/screenshots/` +- `scripts/demo_team_access_seed.py` + +## Results + +Done. `e2e/tests/auth/org-units.spec.ts` runs in the `auth` project and covers +the outcome the tree exists for: a member of a department reaches a board in a +team beneath it with no invitation, moving that board into a personal unit +withdraws the reach, a member cannot rearrange the organization but can read +it, and the tree renders in settings. + +`docs/public/team-access.md` is rewritten around units, with screenshots +captured from a seeded instance through `scripts/team-access-docs-shots.mjs`. +The demo seed builds a Platform department with a Runtime team rather than +flipping a visibility switch. + +GREEN: `pnpm e2e:raw --project=auth -g "organization units"` reports 5 passed. diff --git a/docs/plans/team-access/plan.md b/docs/plans/team-access/plan.md new file mode 100644 index 0000000000..d52934a4ff --- /dev/null +++ b/docs/plans/team-access/plan.md @@ -0,0 +1,185 @@ +--- +spec: docs/specs/auth/requirements/roles-and-scopes.md +created: 2026-08-22 +updated: 2026-08-22 +status: draft +--- + +# Team Access Plan — Roles, Scopes, and Workspace Visibility + +> **Partly superseded.** Workspace visibility and the membership model it came +> with are replaced by +> [organization units](../org-units/plan.md). The scope registry, the human +> assignee, and actor attribution from this plan stand and are not revisited +> there. Work orders 02, 03 and 08 describe a reach model that no longer ships. + +## Scope + +Implement [org roles and scopes](../../specs/auth/requirements/roles-and-scopes.md) and +[workspace visibility and membership](../../specs/workspaces/requirements/org-units.md): a +named scope registry with fixed org and workspace roles, workspace visibility +(`org` / `private`) with an org-level default, membership as the exception and +narrowing mechanism, a human task assignee with takeover, and actor attribution +on every human action. Closes +[#2824](https://github.com/kdlbs/kandev/issues/2824). + +**Independent of multi-tenancy and ships first.** Everything here works on a +single-org instance and on today's auth model. Organizations are the boundary +above; this is the model within one. + +## Current State + +Per-user segregation is implemented and funnels through one predicate: + +```go +// internal/task/service/service_access.go +func workspaceVisibleTo(workspace *models.Workspace, userID string) bool { + return workspace == nil || workspace.OwnerID == "" || workspace.OwnerID == userID +} +``` + +Every `authorize*` helper, the WS dispatch backstop, the MCP scoper and the +office route middleware route through it. That single choke point is the +leverage — and the reason this lands as a security review, not a refactor. + +Authorization today is one bit: `authn.Identity.IsAdmin()`. There is no scope +vocabulary, and `RequireAdmin` is applied per route rather than per capability. + +Three facts from the code correct assumptions in the issue: + +1. **Task assignee is not reusable.** `tasks.assignee_agent_profile_id` names an + Office *agent profile* (ADR 0005 Wave F), not a person. A human assignee is a + new field. +2. **Reviewer/approver participants are agents too.** + `workflow_step_participants` carries `agent_profile_id`, so those roles do not + compose with human members. Out of scope. +3. **Per-human GitHub identity already exists.** `github_user_connections` is + keyed `(workspace_id, user_id)` with per-user credential generations, so + "Approve as X" works per human the moment a member can reach the workspace. + `internal/github` does not change. + +A fourth fact set the design: a workspace is **coarse**. It owns the default +executor, environment and agent profile, the task prefix and sequence, its +Kanban workflow, and integrations resolve a default workspace. A team has one or +two. That is why visibility is the primary mechanism and per-workspace +invitation is the exception — an invite list on a 1-workspace org is the org +roster retyped. + +## Architecture + +- **One scope registry, one resolver.** `internal/authz/` owns scope + identifiers, descriptions, and the static role→scope tables, plus the single + `ScopesFor(user, workspace)` resolver. No second permission map, no per-route + admin bit surviving as a parallel mechanism. Completeness tests both ways: + every guarded action names a registered scope, every registered scope has a + call site. +- **Reach and permission are separate questions.** `workspaceVisibleTo` answers + reach (owner / explicit row / org-visible). `ScopesFor` answers permission. + Conflating them is how a `viewer` accidentally gets a shell. +- **Explicit rows outrank the org default, in both directions.** That single + rule delivers guests-in-one-workspace and members-narrowed-to-viewer without + a second mechanism. +- **Resolve once per request, never cache across requests.** The resolver is hit + many times per board render, so the result is computed once per request and + carried. It is never cached beyond the request: a cache is how a removed + member keeps access for another minute. +- **Fail closed on resolution errors.** A transient failure loading a + membership row yields no scopes. It must never fall back to the org default + role, which is the branch that would silently widen access under load. +- **`workspaces.owner_id` stays authoritative** for the accountable owner; the + `owner` membership row mirrors it and a consistency test asserts they never + disagree. +- **Attribution is threaded, never inferred.** The acting `user_id` travels from + the identity to the persisted row, with no owner fallback — that fallback is + precisely the shared-login behavior this removes. +- **The upgrade never widens access.** Every existing workspace migrates to + `private`. Opening the board is an explicit act. + +## Backend Touch Points + +- New package `internal/authz/`: scope constants, role tables, `ScopesFor`, + registry completeness tests, gin/WS guards. +- `internal/task/service/service_access.go`: reach predicate learns visibility + and membership; per-request resolution cache. +- `internal/task/repository/sqlite/`: `workspaces.visibility`, + `workspace_members`, owner backfill, `tasks.assignee_user_id`, + `task_session_messages.author_user_id`, `queued_messages.author_user_id`, + `task_step_transitions.actor_user_id`. +- `internal/task/handlers/workspace_handlers.go`: visibility, member, and + transfer routes. +- `internal/user/`: `users.role` value migration, `guest` role, + `/api/v1/users/directory`. +- `internal/auth/`: `me` returns org scopes; self-role-change guard; + last-owner guard. +- `internal/org/` or `internal/system/settings`: org default visibility. +- `internal/orchestrator/messagequeue/`: author attribution through the queue. +- `internal/gateway/websocket/`: fan-out to everyone who can reach the + workspace, `workspace.access.updated`, subscription drop on access loss. +- `internal/mcp/scope/`: the agent's in-session identity stays the **task + owner**, not the last prompting member — widening it would make an agent's + reach depend on message timing. +- `cmd/kandev/e2e_reset.go`. + +## Frontend Touch Points + +- Workspace settings: Visibility control, Members section (add via directory + picker, set role, remove, transfer ownership), gated on `scopes`. +- Org settings: default workspace visibility, and the one-time "make my + workspaces org-visible" action. +- Task detail and kanban: human assignee with "Assign to me" as the takeover + affordance; assignee available to existing sidebar filter dimensions. +- Transcript: author attribution on human messages, distinct from agent output; + unattributed renders neutrally, never as the owner. +- Every control gated on the `scopes` array from the DTO, not on a re-derived + ID comparison. +- Access loss while viewing: route out with a clear message rather than a + stalled panel. +- All copy through `t()` in five locales; no em dashes; mobile parity for the + Members section, role picker, and assignee control. + +## Task Waves + +| Wave | Tasks | Theme | +|---|---|---| +| 1 | 01, 02, 03 | Scope registry, visibility and membership storage, the reach predicate | +| 2 | 04, 05 | Management API and directory; WS fan-out and revocation | +| 3 | 06, 07 | Human assignee and takeover; actor attribution | +| 4 | 08, 09 | Frontend surfaces; E2E, docs, ADR | + +## Tasks + +- [ ] [01 — Scope registry and role mapping](task-01-scope-registry.md) +- [ ] [02 — Visibility and membership storage](task-02-visibility-membership-storage.md) +- [ ] [03 — Reach predicate and scope resolution](task-03-reach-and-scope-resolution.md) +- [ ] [04 — Management API, roles, and user directory](task-04-management-api.md) +- [ ] [05 — WebSocket fan-out and revocation](task-05-ws-fanout-and-revocation.md) +- [x] [06 — Human assignee and takeover](task-06-human-assignee-takeover.md) +- [ ] [07 — Actor attribution and audit](task-07-actor-attribution.md) +- [ ] [08 — Frontend team-access surfaces](task-08-frontend-surfaces.md) +- [ ] [09 — E2E, docs, and ADR](task-09-e2e-docs-adr.md) + +## Risks + +- **The reach predicate is load-bearing for the entire privacy model.** One + mistake widens access everywhere at once. Mitigation: task 03 keeps every + existing per-user isolation test unmodified, adds a matrix over the full + reach table, and lands with a security review. +- **Two mechanisms is the real failure mode.** If `RequireAdmin` survives + alongside scopes on any route, permissions drift. Task 01's completeness test + fails on an unmigrated guarded action, and the migration of every existing + `RequireAdmin` site is part of its acceptance. +- **`session.exec` vs `session.prompt` will be argued about.** Separating them + is deliberate: prompting is bounded by the agent's permissions, a shell in the + worktree is not. Do not collapse them for convenience. +- **N+1 on board render.** Without per-request resolution, a shared board issues + a membership query per row. Task 03 ships a query-count assertion, not a + timing assertion. +- **Attribution gaps read as fixed.** A missing `author_user_id` falls back to + empty and renders as neutral rather than failing. Task 07 asserts attribution + at the producer boundary per write path, not at one consumer. +- **Default-visibility regret.** An org that flips the default to `org` exposes + future workspaces, not past ones. Existing workspaces stay `private` until + changed, and the bulk action is explicit and one-time. +- **Interaction with multi-tenancy.** `org` visibility must mean the workspace's + own org, and cross-org membership must be refused. Owned by the multi-tenancy + plan, task 04. diff --git a/docs/plans/team-access/task-01-scope-registry.md b/docs/plans/team-access/task-01-scope-registry.md new file mode 100644 index 0000000000..b791883304 --- /dev/null +++ b/docs/plans/team-access/task-01-scope-registry.md @@ -0,0 +1,61 @@ +--- +id: "01-scope-registry" +title: "Scope registry and role mapping" +status: todo +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/auth/requirements/roles-and-scopes.md" +--- + +# Task 01: Scope Registry and Role Mapping + +## Acceptance + +- `internal/authz/registry.go` declares every org and workspace scope from the + spec with its identifier and human description, plus the static org-role and + workspace-role mappings. One registry; no parallel map, switch, or per-route + constant elsewhere. +- Gin and WS guards take a scope, not a role: `RequireOrgScope(scope)` and a + workspace-scoped equivalent. +- **Every existing `RequireAdmin` route is migrated** to a named org scope. + Migrating them is part of this task, not a follow-up: two mechanisms in the + tree is the failure mode this design exists to avoid. +- A completeness test fails when a guarded action names an unregistered scope, + and a second one fails when a registered scope has no enforcement call site. + Both are mutation-verified: adding a bogus scope and removing a call site + each produce a named failure. +- `users.role` accepts `owner | admin | member | guest`; the migration maps + `admin -> admin`, `member -> member`, and promotes the instance's first admin + to `owner`. `guest` is never assigned by migration. +- Last-`owner` demotion/disable and self-role-change are refused. +- `GET /api/v1/authz/scopes` returns id + description for UI labels. +- With `features.auth` off, the synthetic identity holds every scope, so + behavior is byte-identical to today. + +## Verification + +- `go test ./internal/authz/... ./internal/auth/... ./internal/user/...` +- `go test ./internal/authz/... -run 'TestScopeCompleteness|TestNoUnenforcedScope'` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/user/...` + +## Files Likely Touched + +- `apps/backend/internal/authz/{registry,scopes,guards}.go` + tests +- `apps/backend/internal/auth/authn/identity.go` +- `apps/backend/internal/user/models/models.go`, store, migration +- Every current `authn.RequireAdmin()` call site + +## Inputs + +- Spec: `docs/specs/auth/requirements/roles-and-scopes.md` — Scope registry, Role mappings, + Permissions, Failure modes. +- Patterns: `internal/runtimeflags/registry.go` typed registration with + completeness tests; the existing `RequireAdmin` / `RequireRealIdentity` + composition; `docs/specs/auth/self-actions-guard.md`. + +## Output Contract + +Report the scope count, the `RequireAdmin` call-site count before and after +(after must be zero), both completeness-test mutations and their failure +messages, RED/GREEN commands, and set this task plus its plan checkbox to done. diff --git a/docs/plans/team-access/task-02-visibility-membership-storage.md b/docs/plans/team-access/task-02-visibility-membership-storage.md new file mode 100644 index 0000000000..c3c3c568ba --- /dev/null +++ b/docs/plans/team-access/task-02-visibility-membership-storage.md @@ -0,0 +1,56 @@ +--- +id: "02-visibility-membership-storage" +title: "Visibility and membership storage" +status: todo +wave: 1 +depends_on: ["01-scope-registry"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 02: Visibility and Membership Storage + +## Acceptance + +- `workspaces.visibility` (`org | private`, `NOT NULL DEFAULT 'private'`) is + added via an idempotent `ADD COLUMN` migration. +- `workspace_members` exists with the spec's columns, + `PRIMARY KEY (workspace_id, user_id)`, and cascade deletes from both + `workspaces` and `users`. Replay-safe on fresh and existing databases, on + SQLite and Postgres. +- **The migration sets every existing workspace to `private`.** A test asserts + no existing workspace becomes org-visible — an upgrade never widens access. +- The migration writes an `owner` row for every workspace with a non-empty + `owner_id`, and nothing for pre-auth workspaces (`owner_id = ''`). +- Workspace creation writes the owner row in the same transaction, and applies + the org's default visibility setting. +- The org default visibility setting is persisted and defaults to `private` on + upgrade, so an existing instance changes nothing until someone opts in. +- A consistency test asserts `workspaces.owner_id` always has a matching `owner` + membership row, across create, transfer, and backfill. +- Workspace deletion removes membership rows both by cascade and explicitly from + the workspace-deleted handler; the create → delete lifecycle is covered. + +## Verification + +- `go test ./internal/task/repository/... -run 'TestWorkspaceMembers|TestVisibilityMigration|TestOwnerBackfill'` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/task/repository/sqlite/...` + +## Files Likely Touched + +- `apps/backend/internal/task/repository/sqlite/{base_schema,base_migrations,workspace}.go` +- `apps/backend/internal/task/repository/sqlite/workspace_members.go` +- `apps/backend/internal/task/models/models.go` +- Org/system settings store for the default-visibility value + +## Inputs + +- Spec: Data model, Persistence guarantees (the upgrade never widens access). +- Patterns: ADR 0027 replayable migrations; the workspace-deletion side-table + rule in `apps/backend/AGENTS.md`. + +## Output Contract + +Report fresh-DB and replay results on both dialects, the backfill counts +including the pre-auth zero case, the no-workspace-became-org-visible assertion, +RED/GREEN commands, and set this task plus its plan checkbox to done. diff --git a/docs/plans/team-access/task-03-reach-and-scope-resolution.md b/docs/plans/team-access/task-03-reach-and-scope-resolution.md new file mode 100644 index 0000000000..6303843c45 --- /dev/null +++ b/docs/plans/team-access/task-03-reach-and-scope-resolution.md @@ -0,0 +1,69 @@ +--- +id: "03-reach-and-scope-resolution" +title: "Reach predicate and scope resolution" +status: todo +wave: 1 +depends_on: ["02-visibility-membership-storage"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 03: Reach Predicate and Scope Resolution + +The load-bearing task. Review as a security change. + +## Acceptance + +- `workspaceVisibleTo` implements the spec's reach table exactly: owner, then + explicit membership row, then `visibility = 'org'` for a non-`guest` org + member, else unreachable. A `guest` never reaches an org-visible workspace + without a row. +- `authz.ScopesFor(user, workspace)` implements the spec's resolution order and + is the **only** place permissions are derived. An explicit membership row + outranks the org default in both directions: it admits a guest and it narrows + a member to `viewer`. +- Every guarded workspace action checks a scope through the resolver. A caller + with `workspace.read` but not the action's scope gets **403**; a caller + without `workspace.read` gets **404**. +- Resolution **fails closed**: a transient error loading the workspace or + membership row yields no scopes. It never falls back to the org default role. + A fault-injection test covers this, because it is the branch that silently + widens access under load. +- Scopes are resolved **once per request** and carried. A board render issues a + constant number of membership queries regardless of row count, proven by a + query-count assertion, not a timing assertion. +- No effective-scope result is cached beyond the request. +- Every existing per-user isolation test passes **unmodified**. +- A reach-and-scope matrix covers every row of the spec's Permissions table + across the full surface: workspace, task, session, transcript, diff, preview, + terminal, environment, repository, attachment. +- The in-session agent MCP identity remains the **task owner**, not the last + prompting member. + +## Verification + +- `go test ./internal/task/... ./internal/authz/... ./internal/mcp/... ./internal/gateway/websocket/...` +- `go test ./internal/task/service/... -run 'TestReachMatrix|TestScopeResolution|TestScopeQueryCount|TestScopeFailsClosed'` +- Mutation check: invert the membership branch, and separately make the + fail-closed branch return the org default. Both must fail the matrix; a suite + that still passes is not testing the resolver. + +## Files Likely Touched + +- `apps/backend/internal/task/service/service_access.go` +- `apps/backend/internal/authz/resolve.go` +- `apps/backend/internal/task/service/` per-action scope checks +- `apps/backend/internal/mcp/scope/scope.go` (assert unchanged behavior) + +## Inputs + +- Spec: Permissions (the reach table), Failure modes (403 vs 404, fail closed); + `roles-and-scopes.md` Resolution. +- Patterns: the `*NotFound` no-leak convention and the "new user-facing service + entry points must apply scoping" rule in `apps/backend/AGENTS.md`. + +## Output Contract + +Report the matrix surface count against the authorize-helper count, the +query-count assertion, both mutations and their failures, RED/GREEN commands, +and set this task plus its plan checkbox to done. diff --git a/docs/plans/team-access/task-04-management-api.md b/docs/plans/team-access/task-04-management-api.md new file mode 100644 index 0000000000..a1b4a61525 --- /dev/null +++ b/docs/plans/team-access/task-04-management-api.md @@ -0,0 +1,65 @@ +--- +id: "04-management-api" +title: "Management API, roles, and user directory" +status: todo +wave: 2 +depends_on: ["03-reach-and-scope-resolution"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 04: Management API, Roles, and User Directory + +## Acceptance + +- `PATCH /api/v1/workspaces/{id}` accepts `visibility`, gated on + `workspace.manage`. +- `POST /api/v1/workspaces/visibility/bulk` sets visibility across the caller's + own workspaces, for the one-time opt-in. +- `PATCH /api/v1/orgs/current/settings` accepts `default_workspace_visibility`, + gated on `org.settings.manage`. +- `GET/PUT/DELETE /api/v1/workspaces/{id}/members[/{userId}]` list, add-or- + re-role, and remove, gated on `workspace.read` / `member.manage`. `PUT` is + idempotent and carries the workspace role. +- `POST /api/v1/workspaces/{id}/transfer-ownership` moves the accountable + owner, demotes the previous owner to `collaborator`, and updates + `workspaces.owner_id` in the same transaction. A target with no row is + refused. +- Removing the last `owner` row, or the row matching `owner_id`, is refused with + a distinct code. +- Setting `visibility = 'org'` on a workspace owned by a `guest` is refused. +- Adding a nonexistent, disabled, or already-present user returns 400 with a + distinct code per case and writes nothing. +- `PATCH /api/v1/users/{id}` accepts the four org roles, gated on + `org.members.manage`, with the last-owner and self-change guards. +- `GET /api/v1/users/directory` returns **only** id and display name for active + users. A pinning test over the response field set fails if email, role, or + status is ever added. +- Workspace DTOs carry `visibility`, `member_count`, `viewer_role`, and + `scopes`; `GET /api/v1/auth/me` carries `org_scopes`. + +## Verification + +- `go test ./internal/task/... ./internal/user/... ./internal/auth/... -run 'TestVisibility|TestMember|TestTransferOwnership|TestUserDirectory|TestRoleChange'` +- `go test ./internal/user/... -run TestUserDirectoryFieldSet` + +## Files Likely Touched + +- `apps/backend/internal/task/handlers/workspace_handlers.go` +- `apps/backend/internal/task/service/service_members.go` +- `apps/backend/internal/task/dto/dto.go` +- `apps/backend/internal/user/handlers/handlers.go`, `internal/user/dto/dto.go` +- `apps/backend/internal/auth/httpapi/handlers.go` + +## Inputs + +- Spec: API surface, Permissions, Failure modes; `roles-and-scopes.md` + Permissions. +- Patterns: existing workspace handler shapes; the no-existence-leak rule for + anything reachable cross-user. + +## Output Contract + +Report the error-code matrix, the directory field pinning test and the mutation +proving it fails, RED/GREEN commands, and set this task plus its plan checkbox +to done. diff --git a/docs/plans/team-access/task-05-ws-fanout-and-revocation.md b/docs/plans/team-access/task-05-ws-fanout-and-revocation.md new file mode 100644 index 0000000000..8c0c9b705b --- /dev/null +++ b/docs/plans/team-access/task-05-ws-fanout-and-revocation.md @@ -0,0 +1,54 @@ +--- +id: "05-ws-fanout-and-revocation" +title: "WebSocket fan-out and revocation" +status: todo +wave: 2 +depends_on: ["03-reach-and-scope-resolution"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 05: WebSocket Fan-Out and Revocation + +## Acceptance + +- `Hub.BroadcastToWorkspace` reaches everyone who can **reach** the workspace — + for an org-visible workspace that is the whole org minus guests — resolved + through the same predicate as HTTP, not a parallel membership lookup. +- WS subscription authorization uses the same reach predicate at subscribe time, + and the dispatch backstop checks the action's scope, so a `viewer` cannot + drive a `task.state` or `session.prompt` action over the socket. +- `workspace.access.updated` is emitted on visibility change, membership add, + re-role, remove, and ownership transfer. +- Losing access drops that user's subscriptions for the workspace immediately, + on every open connection they hold, without disturbing other users' + connections or that user's other workspaces. This covers all three loss paths: + removal, visibility narrowed to `private`, and role narrowed to `viewer`. +- A two-client test proves a user who lost access receives no further frames, + and that a still-current user does. +- A user who never had access never receives a frame, proven by client + assertion rather than by inspecting broadcast call sites. + +## Verification + +- `go test -race ./internal/gateway/websocket/...` +- `go test ./internal/gateway/websocket/... -run 'TestReachFanout|TestAccessLossDropsSubscription|TestViewerCannotDispatch'` + +## Files Likely Touched + +- `apps/backend/internal/gateway/websocket/{hub,dispatch_scope,access}.go` +- `apps/backend/internal/task/service/service_events.go` + +## Inputs + +- Spec: API surface (WS changes), Failure modes (access lost mid-session). +- Patterns: the `Hub.BroadcastToWorkspace` / `//ws:global` conventions and the + dispatch-backstop ref-extraction rules in `apps/backend/AGENTS.md` — an action + that invents a new name silently opts out, so the scope check must key off the + same extraction rather than a parallel one. + +## Output Contract + +Report the two-client fan-out and revocation tests covering all three +access-loss paths, the viewer-cannot-dispatch test, the goroutine-leak result, +RED/GREEN commands, and set this task plus its plan checkbox to done. diff --git a/docs/plans/team-access/task-06-human-assignee-takeover.md b/docs/plans/team-access/task-06-human-assignee-takeover.md new file mode 100644 index 0000000000..01fcbd37f3 --- /dev/null +++ b/docs/plans/team-access/task-06-human-assignee-takeover.md @@ -0,0 +1,130 @@ +--- +id: "06-human-assignee-takeover" +title: "Human assignee and takeover" +status: done +wave: 3 +depends_on: ["04-management-api"] +plan: "plan.md" +spec: "../../specs/tasks/requirements/human-assignee.md" +--- + +# Task 06: Human Assignee and Takeover + +## Acceptance + +- `tasks.assignee_user_id` is added via an idempotent migration and is fully + independent of `assignee_agent_profile_id`. Setting one never clears the + other, and an Office task with an agent assignee can also carry a human one. +- `PATCH /api/v1/tasks/{id}` accepts `assignee_user_id`, gated on `task.write`. + A `viewer` receives 403; any caller holding `task.write` may assign to + themselves. +- Assignment is advisory: it gates nothing. A caller who is not the assignee + retains every scope they already hold. +- Setting the assignee to a user who cannot reach the workspace is refused. +- Takeover is reassign plus continue: the session, worktree, executor, and ACP + session are untouched by a reassignment, proven by asserting the execution ID + and worktree path are unchanged across the operation. +- `assignee_user_id` reaches the task DTO, the boot payload, and the sidebar + filter dimensions. +- Task lifecycle events publish through the existing event path so the kanban + updates live. + +## Verification + +- `go test ./internal/task/... -run 'TestAssigneeUser|TestTakeoverPreservesSession'` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/task/repository/sqlite/...` + +## Files Likely Touched + +- `apps/backend/internal/task/repository/sqlite/base_migrations.go` +- `apps/backend/internal/task/models/models.go`, `internal/task/dto/dto.go` +- `apps/backend/internal/task/service/service_tasks.go` +- `apps/backend/pkg/api/v1/` task DTO + +## Inputs + +- Spec: What (human assignee, takeover), Permissions, Scenarios. +- Patterns: ADR 0005 Wave F on why the agent assignee is not a task column; the + task lifecycle event rule in `apps/backend/AGENTS.md` (every task mutation + publishes, or the kanban goes stale). + +## Output Contract + +Report the independence test between the two assignee fields, the +session-preservation evidence for takeover, RED/GREEN commands, and set this +task plus its plan checkbox to done. + +## Result + +Done. `tasks.assignee_user_id` is added by `ensureTeamAccessSchema` and carried +through `internal/office`'s priority-to-TEXT rebuild, which recreates the tasks +table from a hardcoded column list and had been dropping it silently: +`MigrateLogger.Apply` swallows the error, so the only symptom was "table tasks +has no column named assignee_user_id" from unrelated write paths on a fresh +install. Two regression tests pin it, one at the rebuild +(`TestMigrate_PriorityRebuildPreservesAssignee`) and one at the service level +where both repositories share a database +(`TestUpdateTask_AssigneeSurvivesOfficeMigration`); a repository-only test +cannot see the loss. + +Both write surfaces accept the field: `PATCH /api/v1/tasks/{id}` through the +task service, and `PATCH /api/v1/office/tasks/{id}`, which the properties panel +calls. The office route delegates the whole write to the task service's +`SetHumanAssignee` rather than writing the column itself. That is not only to +avoid duplicating the reach rule: the office route carries no `:wsId`, so +`officeWorkspaceScopeMiddleware` does not gate it, and a direct column write +would have let any signed-in user assign a task in a workspace they cannot +reach. With no writer wired the route refuses the mutation. + +Independence and takeover are asserted directly: setting the human assignee +leaves the agent runner untouched and vice versa, and a reassignment changes +exactly one field (`TestUpdateTask_HumanAndAgentAssigneesAreIndependent`, +`TestUpdateTask_TakeoverLeavesExecutionStateUntouched`). + +Delegating also closed a gap found by driving the live instance rather than +the tests: `officeWorkspaceScopeMiddleware` only gates routes carrying a +`:wsId`, so every office task mutation is ungated for browser callers. Against +a seeded two-role instance, a viewer and a non-member could both be refused +only after the write went through the task service. Their refusals now carry +the right status too: viewer 403, non-member 404 (existence stays hidden), and +an assignee who cannot reach the workspace 400 with a showable reason instead +of a 500. + +The UI is an "Assigned to" row in the task properties panel with an +"Assign to me" button, hidden entirely when authentication is disabled. Copy +ships in all five locales. Names come from the user directory merged with the +workspace member list: the member list alone rendered every colleague who +reaches an org-visible workspace as a raw user id, which the screenshot below +caught and no unit test would have. + +![task properties panel with the Assigned to row](../../screenshots/team-access-task-assignee.png) + +Both boards carry it. Kanban was not a second implementation: the office +picker and the kanban control share `useAssignablePeople`, and the field +travels the one existing kanban mapper (`toKanbanTask`) plus the SSR snapshot +mapper. Each of those three hops has its own test, because a dropped hop shows +every task as unassigned with no error anywhere. + +![kanban task top bar with the assignee control](../../screenshots/team-access-kanban-assignee-topbar.png) + +Driving the live instance found two defects the unit tests had not: the +`task.updated` payload is hand-built and never carried `assignee_user_id`, so a +takeover left the previous owner's name on every open client until a reload; +and the frontend merge treats an absent key as "unchanged", so once the field +did arrive it needed the same preserve guard `parent_id` has, or an unrelated +lightweight update blanked it. Both now have regression tests on the exact +symptom. + +Not done: the sidebar **filter dimension** for the human assignee. The filter +UI is built around agent assignees (`use-tasks-tree.ts` filters on +`assigneeIds`), and adding a second people-shaped dimension is a filter-model +change rather than a field pass-through. The field itself reaches the task DTO +and the detail view. + +RED/GREEN: + +- RED: `go test ./internal/task/service/ -run TestUpdateTask_AssigneeSurvivesOfficeMigration` + fails with "table tasks has no column named assignee_user_id" when the office + rebuild's column list is reverted. +- GREEN: `go test ./internal/task/... ./internal/office/...` +- GREEN: `pnpm test -- components/task/simple/components/human-assignee-picker.test.tsx` diff --git a/docs/plans/team-access/task-07-actor-attribution.md b/docs/plans/team-access/task-07-actor-attribution.md new file mode 100644 index 0000000000..4b0830e31e --- /dev/null +++ b/docs/plans/team-access/task-07-actor-attribution.md @@ -0,0 +1,58 @@ +--- +id: "07-actor-attribution" +title: "Actor attribution and audit" +status: todo +wave: 3 +depends_on: ["04-management-api"] +plan: "plan.md" +spec: "../../specs/tasks/requirements/human-assignee.md" +--- + +# Task 07: Actor Attribution and Audit + +This is the layer a shared login cannot provide, and the reason #2824 is not +solved by "just share the password". + +## Acceptance + +- `task_session_messages.author_user_id`, `queued_messages.author_user_id`, and + `task_step_transitions.actor_user_id` are added via idempotent migrations and + populated from the request identity. +- Attribution survives the message queue: a prompt queued by member B and + dispatched later is still attributed to B, not to the dispatching goroutine or + the workspace owner. +- Agent output carries an empty author; engine-driven step transitions carry an + empty actor. Empty means "not a human", never "unknown human". +- No code path falls back to the workspace owner when the identity is absent. + A test asserts that an identity-free write produces an empty author rather + than the owner's ID. +- Agent stop/cancel and task state changes record the acting user. +- Attribution is asserted at each **producer boundary** — one test per write + path — not only at a single consumer that renders it. +- DTOs carry `author_user_id` plus the resolved display name. + +## Verification + +- `go test ./internal/task/... ./internal/orchestrator/... -run 'TestAttribution|TestQueuedMessageAuthor'` +- `go test ./internal/... -run TestNoOwnerFallbackAttribution` + +## Files Likely Touched + +- `apps/backend/internal/task/repository/sqlite/base_migrations.go` +- `apps/backend/internal/orchestrator/messagequeue/` +- `apps/backend/internal/task/service/service_events.go`, session message writes +- `apps/backend/internal/workflow/` step transition recording + +## Inputs + +- Spec: What (attribution), Data model, Persistence guarantees (attribution is + permanent). +- Patterns: the derived-environment-contract rule in `apps/backend/AGENTS.md` — + drive values through the producer seam and pair consumer coverage with a + producer-boundary assertion so a missing publication fails loudly. + +## Output Contract + +List every write path that gained attribution with its producer-boundary test, +report the no-owner-fallback proof, RED/GREEN commands, and set this task plus +its plan checkbox to done. diff --git a/docs/plans/team-access/task-08-frontend-surfaces.md b/docs/plans/team-access/task-08-frontend-surfaces.md new file mode 100644 index 0000000000..d46e02b7d6 --- /dev/null +++ b/docs/plans/team-access/task-08-frontend-surfaces.md @@ -0,0 +1,74 @@ +--- +id: "08-frontend-surfaces" +title: "Frontend team-access surfaces" +status: todo +wave: 4 +depends_on: ["05-ws-fanout-and-revocation", "06-human-assignee-takeover", "07-actor-attribution"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 08: Frontend Team-Access Surfaces + +## Acceptance + +- Workspace settings gains a Visibility control (`Organization` / `Private`) + with copy that states plainly who gains access, and a Members section: add via + the user-directory picker with a role, change a role, remove, transfer + ownership. +- Org settings gains the default workspace visibility, plus a one-time + "Make my existing workspaces visible to the organization" action that names + the exact count it will change and requires confirmation. +- Org member management gains the four org roles with their descriptions read + from `/api/v1/authz/scopes`, so role copy cannot drift from the registry. +- Task detail and kanban show the human assignee with "Assign to me" as the + takeover affordance; assignee is available to the existing sidebar filter + dimensions. +- The transcript attributes human messages to their author, visually distinct + from agent output. An unattributed human message renders neutrally, never as + the workspace owner. +- **Every control is gated on the `scopes` array from the DTO.** The frontend + does not re-derive permission by comparing the current user ID to + `owner_id`, and does not branch on role names. +- A `viewer` sees the board and transcripts with no prompt box, no terminal + entry point, and no task-edit affordances — hidden, not disabled-with-a- + tooltip. +- `workspace.access.updated` re-evaluates access in place; a user who lost + access is routed out with a clear message rather than left on a stalled panel. +- A single-user install with one private workspace shows no new UI: no Members + section entry point, no visibility control clutter, no role pickers. +- A top-level test proves `scopes`, `viewer_role`, `visibility`, and + `assignee_user_id` reach the consuming components, not just the hooks. +- All copy through `t()` / `` in five locales; no em dashes; + `pnpm run i18n:check` and `pnpm run i18n:ratchet` pass. +- Mobile parity for the Visibility control, Members section, role picker, and + assignee control: no hover-only or desktop-only required action. + +## Verification + +- From `apps/web`: `pnpm test`, `pnpm run typecheck`, `pnpm run lint`, + `pnpm run i18n:check`, `pnpm run i18n:ratchet` +- `pnpm run i18n:zh-hant` for the Traditional Chinese pair + +## Files Likely Touched + +- `apps/web/components/settings/workspace/{visibility,members}/` +- `apps/web/components/settings/organization/` +- `apps/web/components/tasks/` assignee control, transcript attribution +- `apps/web/lib/api/domains/{workspace,authz}-api.ts`, `apps/web/lib/types/` +- `apps/web/hooks/domains/workspace/` +- `apps/web/src/locales/*/` + +## Inputs + +- Spec: API surface, Permissions; `roles-and-scopes.md` (scopes are a UI hint, + the server is authoritative). +- Patterns: the `/mobile-parity` skill; the slot/prop-chain rule that a mocked + leaf hides a dropped hop, so the prop chain needs a top-level test. + +## Output Contract + +Report the top-level prop-chain tests, a grep proving no component branches on +`owner_id` comparison or a role name, the i18n gate results, the mobile parity +evidence, the single-user no-new-UI check, and set this task plus its plan +checkbox to done. diff --git a/docs/plans/team-access/task-09-e2e-docs-adr.md b/docs/plans/team-access/task-09-e2e-docs-adr.md new file mode 100644 index 0000000000..e9bb60e63b --- /dev/null +++ b/docs/plans/team-access/task-09-e2e-docs-adr.md @@ -0,0 +1,82 @@ +--- +id: "09-e2e-docs-adr" +title: "E2E, docs, and ADR" +status: todo +wave: 4 +depends_on: ["08-frontend-surfaces"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 09: E2E, Docs, and ADR + +## Acceptance + +- A Playwright spec covers the #2824 golden path with **no invitation**: an org + whose default visibility is `org`, Ana creates a workspace and a task, Bruno + logs in and sees it on his board, opens the transcript and the preview, + assigns the task to himself, and prompts — attributed to Bruno. +- A Playwright spec covers the private path: default `private`, Bruno gets the + not-found surface, Ana adds him as `collaborator`, Bruno gains access. +- A Playwright spec covers narrowing: Bruno as `viewer` on an org-visible + workspace sees the board but has no prompt box and no terminal, and the + direct API call returns 403. +- A Playwright spec covers revocation: Bruno loses access mid-view (once by + removal, once by visibility flipped to `private`) and is routed out; a + subsequent direct URL visit returns the not-found surface. +- A Playwright spec covers the guest: a `guest` sees no org-visible workspace + and only the one they hold a row on. +- Seed tasks without an agent and locate by task id, so kanban column + assertions do not race the agent auto-advance. +- `cmd/kandev/e2e_reset.go` deletes membership rows and resets visibility so a + worker-scoped backend starts clean. +- An ADR records the durable decisions: permissions are named scopes in one + registry with fixed roles rather than an admin bit; reach and permission are + separate questions; visibility is the primary sharing mechanism and + membership is the exception and narrowing path, chosen because a Kandev + workspace is coarse; an explicit membership row outranks the org default in + both directions; scope resolution fails closed and never falls back to the org + default role; org admin remains a management role with no extra reach; + attribution never falls back to the workspace owner; concurrency is serialized + through the existing message queue rather than a human session lock; and the + agent's in-session MCP identity stays the task owner rather than the last + prompting member. +- `docs/public/**` documents team collaboration: org roles and what each can do, + workspace visibility and the org default, adding members and narrowing them, + takeover, and what stays owner-only. Run `/docs-maintainer`. +- `docs/specs/auth/spec.md` is amended: the "No workspace sharing/membership — + one owner per workspace" v1 limit is replaced by pointers to + `roles-and-scopes.md` and `workspaces/membership.md`, and the roles bullet is + updated from two roles to four. +- Issue #2824 is answered with what shipped and what stayed out of scope + (custom roles, human workflow approvers, group objects, PAT scopes), + including the correction that the existing assignee and participant fields + name agent profiles rather than people. + +## Verification + +- From `apps/web`: `pnpm run build:e2e` then `pnpm e2e:run` for the new specs. + Do not pass flags through `pnpm e2e:raw --`; they are silently dropped and the + whole suite runs instead. +- `make -C apps/backend test lint` + +## Files Likely Touched + +- `apps/web/e2e/` new specs and fixtures +- `apps/backend/cmd/kandev/e2e_reset.go` +- `docs/decisions/00XX-team-access-roles-and-visibility.md`, `docs/decisions/INDEX.md` +- `docs/public/**` +- `docs/specs/auth/spec.md`, `docs/specs/INDEX.md` + +## Inputs + +- Spec: Scenarios in both `roles-and-scopes.md` and `workspaces/membership.md` + — together they are the conformance surface. +- Patterns: `apps/web/e2e/README.md`; the E2E reset invariant and the + seed-without-an-agent rule for kanban column assertions. + +## Output Contract + +Report the scenario-to-E2E mapping across both specs with any uncovered +scenario named explicitly, the ADR number, the docs pages touched, the issue +reply, and set this task plus its plan checkbox to done. diff --git a/docs/public/authentication.md b/docs/public/authentication.md index c5bf0c549f..351bbe12e5 100644 --- a/docs/public/authentication.md +++ b/docs/public/authentication.md @@ -20,11 +20,15 @@ Authentication is a **runtime feature toggle**: the same system as the other fea ## What changes when authentication is on - Everyone signs in with email + password. Browser sessions last 30 days (sliding) and can be revoked from `Settings > Account`. The signed-in user is shown in the bottom-left of the sidebar, with a log-out menu. -- **Workspaces become per-user.** You only see workspaces you own, including their tasks, sessions, repositories, terminals, previews, and live updates. Existing data is assigned to the admin created during setup. +- **Workspaces become per-user by default.** You only see workspaces you own, including their tasks, sessions, repositories, terminals, previews, and live updates. Existing data is assigned to the admin created during setup. A workspace can then be shared with colleagues on purpose: see [Team Access](team-access.md). - Secrets are per-user. A **Global** secret is user-global across that user's workspaces; a **Workspace** secret belongs to one of their workspaces. With authentication disabled, Global is install-global. Executors and agent profiles remain shared across the instance, so they can reference Global secrets only; repositories may bind Global or same-workspace secrets. -- Admins manage users and instance settings, but do **not** see other users' workspaces. +- Admins manage users and instance settings, but do **not** see other users' private workspaces. Admin is a management role, not a visibility role. - Programmatic clients (external MCP, scripts) authenticate with personal access tokens. +## Sharing work with a team + +Authentication makes each person's work private. To give a team a shared board without a shared login, see [Team Access](team-access.md): workspace visibility, member roles, and scoped permissions. + ## Enabling authentication **From the UI:** open `Settings > System > Feature Toggles`, turn on **Authentication & users**, and restart Kandev when prompted (it is a restart-required flag, like the other feature toggles). After the restart the instance is in setup mode: the setup wizard appears and the account you create becomes the admin. All existing workspaces, settings, and secrets carry over to that admin. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index 86086d2572..5698cf132b 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -576,6 +576,52 @@ "/settings/account/tokens" ] }, + { + "id": "team-access", + "audiences": ["user", "operator"], + "stability": "beta", + "docs": ["team-access"], + "sources": [ + "apps/backend/internal/authz/scopes.go", + "apps/backend/internal/authz/roles.go", + "apps/backend/internal/authz/resolve.go", + "apps/backend/internal/task/service/service_access.go", + "apps/backend/internal/task/service/service_members.go", + "apps/web/components/settings/workspaces/workspace-team-access-card.tsx", + "apps/web/components/task/task-assignee-control.tsx", + "apps/web/components/kanban-card-assignee-badge.tsx", + "apps/web/hooks/domains/users/use-assignable-people.ts", + "apps/web/components/settings/workspaces/workspace-placement-card.tsx" + ], + "tests": [ + "apps/backend/internal/authz/resolve_test.go", + "apps/backend/internal/authz/registry_test.go", + "apps/backend/internal/task/service/service_team_access_test.go", + "apps/web/lib/types/team-access.test.ts", + "apps/backend/internal/task/service/service_events_assignee_test.go", + "apps/web/components/task/task-assignee-control.test.tsx", + "apps/web/lib/ws/handlers/tasks-assignee.test.ts", + "apps/web/components/settings/workspaces/workspace-placement-card.test.tsx" + ], + "settingsRoutes": ["/settings/units"] + }, + { + "id": "organizations", + "audiences": ["operator"], + "stability": "experimental", + "docs": ["organizations"], + "sources": [ + "apps/backend/internal/org/service.go", + "apps/backend/internal/org/store.go", + "apps/backend/internal/org/controller.go", + "apps/web/components/settings/system/organizations/organizations-page.tsx" + ], + "tests": [ + "apps/backend/internal/authz/resolve_test.go", + "apps/backend/internal/task/service/service_team_access_test.go" + ], + "settingsRoutes": ["/settings/system/organizations"] + }, { "id": "system-health-and-maintenance", "audiences": [ diff --git a/docs/public/meta.json b/docs/public/meta.json index 1acf59083e..135c630b77 100644 --- a/docs/public/meta.json +++ b/docs/public/meta.json @@ -18,6 +18,8 @@ "integrations", "automation-and-mcp", "authentication", + "team-access", + "organizations", "configuration", "---Install Kandev---", "cli", diff --git a/docs/public/organizations.md b/docs/public/organizations.md new file mode 100644 index 0000000000..e70cd8493b --- /dev/null +++ b/docs/public/organizations.md @@ -0,0 +1,124 @@ +--- +title: "Organizations" +description: "Run one Kandev server for several independent teams or customers, with a hard boundary between them." +--- + +# Organizations + +An organization is a **tenant**: a boundary above users. Two organizations on the same Kandev server cannot see each other's workspaces, tasks, sessions, secrets or accounts, and no role on either side can cross it. + +Use organizations when one server serves groups that must stay separate: two teams that should not read each other's code, an agency and its clients, or a hosted deployment. If everyone on your server is one team, you do not need this. Use [Team Access](team-access.md) instead, which is about sharing *within* one organization. + +> Organizations require [authentication](authentication.md). Enabling them with authentication off is refused at startup, because a tenant boundary with nobody to belong to it is not a boundary. + +## Quick checklist + +1. Enable **Authentication & users** and complete the setup wizard. +2. Enable **Organizations** in `Settings > System > Feature Toggles` and restart. +3. Everything you already had lands in one default organization; the first admin becomes the instance operator. +4. Create further organizations in `Settings > Access Control > Organizations` and give each one its first administrator. +5. From then on, each organization administers itself. + +## Enabling + +`Settings > System > Feature Toggles`, turn on **Organizations**, restart. Or set the environment variable on a fresh server: + +```bash +KANDEV_FEATURES_AUTH=true +KANDEV_FEATURES_MULTI_TENANCY=true +``` + +On the first boot with the flag on, Kandev migrates itself: + +- A single **default organization** is created. +- Every existing user, workspace and secret is put into it. +- The first admin is granted the **instance operator** tier, so the instance is never left with organizations and nobody able to manage them. +- Any workspace membership that would straddle two organizations is dropped. + +Nothing becomes visible to anyone new. The migration is idempotent, so a second boot changes nothing. + +## Two tiers, deliberately separate + +| Tier | Manages | Can read your workspaces? | +|---|---|---| +| **Instance operator** | Organizations, feature toggles, instance configuration | **No** | +| **Organization admin** | Users, invites, settings and shared configuration inside one organization | Only what team access already grants them | + +The operator tier is an *administration* tier, not a visibility one. An operator creates, suspends and deletes organizations, and holds no permission inside any of them, including their own. Their access to their own workspaces comes from being an ordinary member of their own organization, exactly like everyone else. + +## Managing organizations + +`Settings > Access Control > Organizations` is visible only to the instance operator. A non-operator gets a not-found response from those routes, so the page does not appear for them. The entry sits with the other access settings rather than under System, because an organization is a boundary above users; System is for operating the instance. It is hidden entirely until the feature is enabled. + +![The Organizations settings page listing Acme Corp with a Default badge and Active status, Globex Industries as Active, and Initech as Suspended. Each row has Add administrator, Suspend or Resume, and a delete action. Above the list is a New organization form and a card explaining what an organization separates.](../screenshots/tenancy-organizations.png) + +### Creating one + +Create the organization, then give it a first administrator. That second step is not optional bookkeeping: an ordinary admin can only create accounts inside their own tenant, so a brand-new organization has no way to get its first user. This operator-only path is what breaks that circularity, and after it the organization is self-sufficient. + +![A dialog titled "First administrator for Acme Corp" with display name, email and password fields, explaining that an administrator can only create accounts in their own organization so a new organization needs its first one created here.](../screenshots/tenancy-first-admin.png) + +### Suspending + +Suspension is the reversible lever, for a billing lapse or an investigation. Every session and token for that organization fails closed immediately, running work is refused, and **nothing is deleted**. Resuming restores access exactly as it was. + +A suspended user is told what actually happened rather than that their password is wrong, so they contact an administrator instead of resetting a credential that works. + +![The sign-in form showing the message "Your organization is currently unavailable. Contact your Kandev administrator." beneath a correctly filled email and password.](../screenshots/tenancy-suspended-login.png) + +### Deleting + +Deletion removes every workspace, task, session and account in the organization. It requires typing the organization's slug verbatim, and the last remaining organization cannot be deleted. + +**Deletion is not retroactive across backups.** It is irreversible on the live server, but a backup taken while the organization existed still contains it, secrets included, and restoring that backup brings it back. This is not a temporary gap that a later release closes: erasing an organization from backups already written needs key material kept outside the database snapshot, which is a separate decision with its own recovery tradeoffs. + +If you need an organization's data gone from your backups, the lever is deleting the backup files, not waiting for them to age out. Snapshots do not all expire, and one class never does: + +| Snapshot | Where it comes from | Does it age out? | +|---|---|---| +| `kandev-*.db` | Written automatically before an upgrade migration | Only the newest two survive, and only pruned on a boot that changes version | +| `manual-*.db` | `System > Backups` | **Never.** Delete it deliberately | +| `kandev-pre-reset-*.db` | Factory Reset | Excluded from the Backups delete action on purpose, since it is the lifeline if a reset goes wrong | + +A manual snapshot is exactly what a careful operator takes right before a destructive deletion, and it will hold that organization indefinitely. Remove it from `System > Backups`, or from the `backups/` directory beside the database. + +![A confirmation dialog titled "Delete Globex Industries" warning that this removes every workspace, task, session and account in the organization and cannot be undone, with a field to type the slug to confirm.](../screenshots/tenancy-delete-confirm.png) + +## What the boundary looks like in use + +Two organizations on one server. Ana is in Acme; her team's board is shared with her whole organization through [team access](team-access.md). + +![Acme's kanban board seen by Bruno Costa, showing three Acme tasks.](../screenshots/tenancy-acme-board.png) + +Gary is an administrator of Globex on the **same server**. He sees his own organization and nothing else. Acme's org-visible workspace is not merely hidden from him, it does not exist as far as his session is concerned. + +![Globex's kanban board seen by Gary Vasquez, showing only the Globex Secret Project workspace and one Globex task.](../screenshots/tenancy-globex-board.png) + +## How the boundary is enforced + +- **Your organization comes from your account, never from the request.** No route, payload, header or WebSocket frame may name an organization, so no request can move a caller between tenants. There is no organization switcher, and one person belongs to exactly one organization; someone who needs two uses two accounts. +- **The tenant check runs before any role.** It is not a permission that a role can outrank: a workspace in another organization is unreachable by an org admin, by the instance operator, and even by a stale membership row. +- **It fails closed.** An account somehow carrying no organization reaches nothing while the feature is on, rather than matching everything. +- **New accounts inherit their creator's organization.** Admin-created users and invite links both carry the minting admin's organization, so accepting an invite can never place someone in a tenant the inviter cannot see. +- **Email is unique across the whole server**, not per organization. One address is one person, which is why signing in needs no organization picker. + +## Current limits + +These are real and worth knowing before you put two untrusting groups on one server. + +- **Secrets are scoped per organization, but not encrypted per organization.** + Reads are org-scoped, so one organization cannot fetch another's secret + through the API. At rest, every secret on the instance is sealed under a + single master key in the data directory next to the database, so anyone who + can read that directory can decrypt every organization's secrets. + Per-organization encryption keys are designed but not yet implemented. +- **Filesystem and agent credentials are shared.** Worktrees and clones live under one `~/.kandev` tree owned by the OS user running Kandev, and agent CLI logins (`gh auth`, `claude login`, provider API keys) authenticate as that OS user. Organizations are an **application-layer** boundary over Kandev's data: they are not a sandbox. Two organizations that must not share agent credentials need separate Kandev instances or separate OS users today. +- **Executors and agent profiles are still instance-wide.** They are shared configuration, and a shared agent profile means a shared provider credential. +- **No per-organization billing, quotas or usage metering.** Suspension is the only lever. +- **No per-organization export or backup.** Backups are instance-wide and contain every organization. +- **No cross-organization anything**, by design: no shared workspaces, no moving a user or workspace between organizations, no cross-organization search. +- **Users cannot be moved between organizations.** Moving a person would leave their workspaces behind in the old tenant. + +## Turning it off + +Flip the toggle off and restart. Organization assignments are retained, so re-enabling restores the previous boundary without another migration. With it off, the instance behaves as a single organization again and the Organizations page disappears. diff --git a/docs/public/team-access.md b/docs/public/team-access.md new file mode 100644 index 0000000000..e345096f03 --- /dev/null +++ b/docs/public/team-access.md @@ -0,0 +1,184 @@ +--- +title: "Team Access" +description: "Share Kandev workspaces with colleagues using organization units, member roles, and scoped permissions." +--- + +# Team Access + +Kandev starts private: with [authentication](authentication.md) enabled, each person sees only their own workspaces. **Team access** is how a team on a shared server gets shared boards instead, without falling back to a single login everyone knows. + +The whole model is one idea: **workspaces live in a tree, and so do people.** + +- An **organization unit** holds child units, workspaces, and members. Your departments and your teams are both units; they differ only in depth. +- Being a member of a unit reaches **every workspace beneath it**, including ones created later. +- Work that is nobody else's business lives in your **personal unit**, which takes no members. + +There is no per-workspace visibility switch. Where a workspace sits *is* who reaches it. + +> Team access requires authentication. On a single-user install with authentication off, none of this appears and nothing changes. + +## Quick checklist + +1. Enable [authentication](authentication.md) and create the admin account. +2. Build your structure: `Settings > Access Control > Organization units`. +3. Add people to the unit that matches their team. +4. Move a workspace into that unit to share it, or leave it in your personal unit to keep it to yourself. +5. Remember that secrets, integration credentials, and workspace deletion stay with the owner. + +## Building the tree + +A Kandev workspace is coarse. It owns the default executor, environment and agent profile, the task prefix, and its Kanban workflow, so a team usually has one or two. Units are what group them. + +![The Organization units page showing a tree: the organization root at the top, a Platform department beneath it, a Runtime team beneath that, and a personal unit.](../screenshots/team-access-units-tree.png) + +Add a unit under any other. A department and a team are the same thing at different depths, so adding a level later is a move rather than a migration. + +![The Organization units page with a New unit card open beneath the tree, its name field filled in with Security.](../screenshots/team-access-unit-create.png) + +Two units are created for you and behave differently on purpose: + +- The **root** is the whole organization. A workspace parked here is reached by everyone you put in the root. +- A **personal unit** belongs to one person, takes no members, and cannot be deleted. It replaces the old private flag: a workspace nobody else should see simply lives there. + +## Putting people in a unit + +![The members dialog for the Platform unit, listing Bruno Costa as a collaborator with an add-member row beneath.](../screenshots/team-access-unit-members.png) + +Everyone in a unit reaches every workspace under it, at the role you give them. Add someone once when they join the team, not once per board. + +## Moving a workspace + +A workspace sits in exactly one unit, and `Settings > Workspaces > (workspace)` is where you change it. Moving it is how you hand a board to another team, or take it back. + +![The workspace settings page showing a Placement card set to Runtime, above the Team access card.](../screenshots/team-access-workspace-placement.png) + +## Narrowing access + +There is no way to subtract a role, and that is deliberate. Roles combine by taking the strongest: what you inherit from a unit plus anything granted directly on one workspace. A grant can raise your access; nothing lowers it. + +To give fewer people access to something, **move it to a unit with fewer members**. That keeps "why can this person write here" answerable by reading the tree, instead of by working out which of two mechanisms won. + +## What a colleague sees + +Once a workspace sits in a unit your colleague is in, it simply appears. No invitation, no acceptance step. + +![A kanban board titled Platform Team seen by Bruno Costa, showing three tasks in the In Progress column and the same three tasks in the sidebar task list.](../screenshots/team-access-shared-board.png) + +They can open a colleague's task, read the agent conversation, test the preview, and take it over. **Takeover is reassign plus continue**: no lock is taken and no session is torn down. + +Every human action is recorded against the person who took it, which is what a shared login cannot give you: message attribution comes from the authenticated session, not from the browser, so nobody can post as somebody else. + +## Assigning a task to a person + +A task carries two independent assignees: the **agent** that runs it, and the **person** who owns it. Setting one never changes the other, and a task can have both. + +It works the same on both boards. + +On a **kanban** task, the assignee sits in the task's top bar. Open the control to pick a colleague, or choose **Assign to me** to take the task over. + +![The kanban task top bar showing the workflow stepper and, on the right, an assignee control reading Bruno Costa.](../screenshots/team-access-kanban-assignee-topbar.png) + +Cards on the board carry the same name, so you can see who is on what without opening anything. + +![A kanban card titled Upgrade the Postgres driver with an avatar and the name Bruno Costa under the title.](../screenshots/team-access-kanban-assignee-card.png) + +In **office**, it is the **Assigned to** row in the task properties panel, next to the agent **Assignee** row. + +![The task properties panel showing an Assignee row set to No assignee above an Assigned to row with an Assign to me button and the human assignee Bruno Costa.](../screenshots/team-access-task-assignee.png) + +The list offers everyone in the user directory, because who reaches a workspace is not something the browser can work out on its own. The server is the authority: assigning someone who cannot reach it is refused with "that person cannot see this workspace", so a task is never parked on somebody who will never find it. + +Assignment is advisory. It records who owns the work and gates nothing: anybody with `task.write` can reassign anything, including to themselves, and doing so takes no lock and interrupts no running session. That is the whole of takeover, and it is why the button needs no confirmation. + +The control is hidden when authentication is disabled, because then every visitor is the same anonymous user and there is nobody to assign to. + +## Roles + +Two role tiers combine. Your **org role** is on your account; your **workspace role** applies to one workspace. + +**Org roles** + +| Role | Can | +|---|---| +| `admin` | Manage users and invites, org settings, and shared configuration (executors, agent profiles, environments, editors, prompts, notification providers) | +| `member` | Contribute; no management rights | +| `guest` | Reach **only** workspaces they hold an explicit member row on, including none of the org-visible ones | + +An admin is a **management role, not a reach role**. An admin reaches a workspace because they are in a unit above it, exactly like any member, and never reaches anything in someone else's personal unit. + +**Workspace roles** + +| Role | Read board & transcripts | Edit tasks | Prompt / stop agents | Terminal, shell, previews | Manage members, secrets, settings, delete | +|---|---|---|---|---|---| +| `owner` | yes | yes | yes | yes | yes | +| `collaborator` | yes | yes | yes | yes | no | +| `viewer` | yes | no | no | no | no | + +On an org-visible workspace, `admin` and `member` both act as `collaborator` unless an explicit row says otherwise. + +### Viewer + +A viewer reads the board and the agent conversations and can do nothing else. Terminal and shell access is deliberately a **separate permission** from prompting an agent: prompting is bounded by what that agent is allowed to do, but a shell in the worktree is not, so being able to read a transcript never implies being able to run commands. + +![The Team access card as seen by Carla Nunes, a viewer. The workspace Name field is greyed out, the members list shows no management buttons, there is no "Add a colleague" row, and the Delete Workspace card is absent.](../screenshots/team-access-viewer-card.png) + +The UI hides what you cannot do, and the API refuses it independently, so a viewer who calls the endpoint directly gets `403`. + +### Transferring ownership + +Every workspace has exactly one accountable owner. Use **Make owner** on a member row: the new owner takes over and the previous owner becomes a collaborator. You cannot remove the owner's row; transfer first. + +## Mobile + +The same card works on a phone, including the role pickers and the add-member flow. + +![The Team access card on a narrow phone viewport, with the members list and the add-member controls stacked vertically.](../screenshots/team-access-mobile.png) + +## What stays with the owner + +These are shared credentials and destructive actions, so they do not follow membership: + +- Workspace **secrets** and **integration credentials** +- Adding and removing **repositories** +- Renaming, changing defaults, moving it between units, and **deleting** the workspace +- Adding, re-roling and removing **members** + +A member acts under their **own** GitHub connection wherever per-user identity is supported, so "Approve as ..." on a pull request is that person, not the workspace owner. + +## Upgrading an existing server + +Every workspace you already own moves into **your personal unit**, so it stays exactly as private as it was. A workspace with no owner, created before authentication existed, moves to the root, which is where everyone already reached it. + +An upgrade never widens access. Sharing is an explicit act afterwards: move a workspace into a unit, or add people to the unit it is already in. + +## Permission reference + +Every guarded action names a scope. These strings appear in the API and are what the UI gates on. + +| Scope | Grants | +|---|---| +| `workspace.read` | See the workspace, its board, tasks, and transcripts | +| `workspace.manage` | Rename, change defaults, move between units, delete | +| `task.write` | Create, edit, move, assign, archive, delete tasks | +| `session.prompt` | Start or resume a session and message an agent | +| `session.control` | Stop or cancel a running agent | +| `session.exec` | Terminal, shell, file writes, port previews, VS Code, LSP | +| `repository.manage` | Add or remove workspace repositories | +| `secret.manage` | Workspace secrets and integration credentials | +| `member.manage` | Add or remove members, transfer ownership | +| `org.members.manage` | Invite, create, disable and re-role users | +| `org.settings.manage` | Org-wide settings and defaults | +| `unit.manage` | Create, rename, move and delete units, and place workspaces in them | +| `org.config.manage` | Executors, agent profiles, environments, editors, prompts, notification providers | + +`GET /api/v1/authz/scopes` returns this list with descriptions. + +## Current limits + +- **"Organization" means one tenant.** With the Organizations feature off there is exactly one, implicitly, and its root unit covers everyone on the server. To run one server for several independent teams or customers, see [Organizations](organizations.md). +- **The `guest` role has no UI yet.** `Settings > Users` is not ported to the SPA, so a guest is assigned through the API: `PATCH /api/v1/users/{id}` with `{"role": "guest"}`. +- **Roles are fixed.** There is no custom-role builder; scopes are the extension point and adding one is a code change. +- **No standalone groups.** A unit answers "who is in this team", so a set of people that ignores the tree, like every site reliability engineer across three departments, is not modelled yet. +- **No presence.** There are no cursors, typing indicators, or "who else is viewing" markers. +- **Concurrent prompting is serialized, not blocked.** If two people prompt the same session at once, both messages queue in arrival order and both are attributed. Kandev deliberately does not lock a session to one human: a lock would strand the session when someone closes their laptop. +- **Filesystem and agent credentials are not isolated per user.** As with authentication generally, application-layer scoping is the boundary between users' Kandev data; it does not sandbox the filesystem or per-user agent logins. See the limits in [Authentication & Users](authentication.md). diff --git a/docs/screenshots/team-access-kanban-assignee-card.png b/docs/screenshots/team-access-kanban-assignee-card.png new file mode 100644 index 0000000000..68a9e94b26 Binary files /dev/null and b/docs/screenshots/team-access-kanban-assignee-card.png differ diff --git a/docs/screenshots/team-access-kanban-assignee-topbar.png b/docs/screenshots/team-access-kanban-assignee-topbar.png new file mode 100644 index 0000000000..73c3057a26 Binary files /dev/null and b/docs/screenshots/team-access-kanban-assignee-topbar.png differ diff --git a/docs/screenshots/team-access-mobile.png b/docs/screenshots/team-access-mobile.png new file mode 100644 index 0000000000..fac5468db3 Binary files /dev/null and b/docs/screenshots/team-access-mobile.png differ diff --git a/docs/screenshots/team-access-owner-card.png b/docs/screenshots/team-access-owner-card.png new file mode 100644 index 0000000000..b715f38a52 Binary files /dev/null and b/docs/screenshots/team-access-owner-card.png differ diff --git a/docs/screenshots/team-access-shared-board.png b/docs/screenshots/team-access-shared-board.png new file mode 100644 index 0000000000..86823e9694 Binary files /dev/null and b/docs/screenshots/team-access-shared-board.png differ diff --git a/docs/screenshots/team-access-task-assignee.png b/docs/screenshots/team-access-task-assignee.png new file mode 100644 index 0000000000..36b55a9878 Binary files /dev/null and b/docs/screenshots/team-access-task-assignee.png differ diff --git a/docs/screenshots/team-access-unit-create.png b/docs/screenshots/team-access-unit-create.png new file mode 100644 index 0000000000..1699815011 Binary files /dev/null and b/docs/screenshots/team-access-unit-create.png differ diff --git a/docs/screenshots/team-access-unit-members.png b/docs/screenshots/team-access-unit-members.png new file mode 100644 index 0000000000..57e730d840 Binary files /dev/null and b/docs/screenshots/team-access-unit-members.png differ diff --git a/docs/screenshots/team-access-units-tree.png b/docs/screenshots/team-access-units-tree.png new file mode 100644 index 0000000000..8bd7342c7e Binary files /dev/null and b/docs/screenshots/team-access-units-tree.png differ diff --git a/docs/screenshots/team-access-user-roles.png b/docs/screenshots/team-access-user-roles.png new file mode 100644 index 0000000000..1c81290537 Binary files /dev/null and b/docs/screenshots/team-access-user-roles.png differ diff --git a/docs/screenshots/team-access-viewer-card.png b/docs/screenshots/team-access-viewer-card.png new file mode 100644 index 0000000000..c05f5511af Binary files /dev/null and b/docs/screenshots/team-access-viewer-card.png differ diff --git a/docs/screenshots/team-access-workspace-placement.png b/docs/screenshots/team-access-workspace-placement.png new file mode 100644 index 0000000000..6d1def6f77 Binary files /dev/null and b/docs/screenshots/team-access-workspace-placement.png differ diff --git a/docs/screenshots/tenancy-acme-board.png b/docs/screenshots/tenancy-acme-board.png new file mode 100644 index 0000000000..cff4413b5c Binary files /dev/null and b/docs/screenshots/tenancy-acme-board.png differ diff --git a/docs/screenshots/tenancy-delete-confirm.png b/docs/screenshots/tenancy-delete-confirm.png new file mode 100644 index 0000000000..8fdaaf2615 Binary files /dev/null and b/docs/screenshots/tenancy-delete-confirm.png differ diff --git a/docs/screenshots/tenancy-first-admin.png b/docs/screenshots/tenancy-first-admin.png new file mode 100644 index 0000000000..4f29dcd6aa Binary files /dev/null and b/docs/screenshots/tenancy-first-admin.png differ diff --git a/docs/screenshots/tenancy-globex-board.png b/docs/screenshots/tenancy-globex-board.png new file mode 100644 index 0000000000..defea085b0 Binary files /dev/null and b/docs/screenshots/tenancy-globex-board.png differ diff --git a/docs/screenshots/tenancy-organizations.png b/docs/screenshots/tenancy-organizations.png new file mode 100644 index 0000000000..c828a1e911 Binary files /dev/null and b/docs/screenshots/tenancy-organizations.png differ diff --git a/docs/screenshots/tenancy-suspended-login.png b/docs/screenshots/tenancy-suspended-login.png new file mode 100644 index 0000000000..c8c411ed4c Binary files /dev/null and b/docs/screenshots/tenancy-suspended-login.png differ diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 3d901c552a..74d3535ef3 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -35,6 +35,7 @@ The former legacy size exceptions for migrated sources were removed. All canonic The following specifications were added after this migration and remain in the legacy layout until they move into an owning system: - [Task Cost & Token Ledger](task-cost-ledger/spec.md) (draft) +- [Multi-tenancy](multi-tenancy/spec.md) (draft) - [Startup listener before recovery](startup-listener-before-recovery/spec.md) (draft) - [Workflow on_enter action dispatch](workflow-on-enter-action-dispatch/spec.md) (draft) - [Kubernetes Executor](kubernetes-executor/spec.md) (implemented) diff --git a/docs/specs/auth/README.md b/docs/specs/auth/README.md index fafb4548a0..7f95d69235 100644 --- a/docs/specs/auth/README.md +++ b/docs/specs/auth/README.md @@ -34,6 +34,7 @@ for user and service requests. - [Opt-in Authentication & Multi-User Segregation](requirements/auth.md) +- [Org Roles and Scopes](requirements/roles-and-scopes.md) - [Isolate Kandev cookies between instances on one host](requirements/fix-multi-instance-cookie-isolation.md) - [Public Share Links (v0)](requirements/public-share-links.md) - [Repair secure-context browser fallbacks](requirements/secure-context-browser-fallbacks.md) diff --git a/docs/specs/auth/requirements/roles-and-scopes.md b/docs/specs/auth/requirements/roles-and-scopes.md new file mode 100644 index 0000000000..d7283badd4 --- /dev/null +++ b/docs/specs/auth/requirements/roles-and-scopes.md @@ -0,0 +1,277 @@ +--- +status: draft +system: auth +created: 2026-08-22 +owners: + - tbd +--- + +# Org Roles and Scopes + +Extends [`auth.md`](auth.md). Consumed by +[organization units](../../workspaces/requirements/org-units.md) and +[multi-tenancy](../../multi-tenancy/spec.md). + +## Requirements + +### REQ-AUTH-ROLES-AND-SCOPES-001: Named permission scopes and two role axes + +**Intent:** Sharing a workspace turns "can this person do this here?" into a +question asked on every guarded action. Answering it with an admin boolean +spreads the policy across call sites and cannot express a viewer, so the +permission model needs one registry and one resolver. + +#### Acceptance criteria + +- **AC-AUTH-ROLES-AND-SCOPES-001.1:** Every guarded action maps to one named + scope. Authorization asks whether the caller holds that scope, never whether + the caller is an admin. +- **AC-AUTH-ROLES-AND-SCOPES-001.2:** One registry owns the scope identifiers, + their descriptions and the role-to-scope mapping, with no second permission + map anywhere else. +- **AC-AUTH-ROLES-AND-SCOPES-001.3:** A completeness test asserts that every + guarded action names a registered scope and every registered scope has at + least one call site. +- **AC-AUTH-ROLES-AND-SCOPES-001.4:** Roles are fixed and scopes are the + extension point: there is no custom role builder, no per-user grant table and + no UI for editing a role's scopes. +- **AC-AUTH-ROLES-AND-SCOPES-001.5:** An org role grants org scopes outright + and nothing else; reach comes from unit membership, and a workspace role + grants workspace scopes on one workspace; a single function resolves the + effective scopes per user and workspace. +- **AC-AUTH-ROLES-AND-SCOPES-001.6:** Org admin adds management scopes and never + reach. An admin reaches a workspace because it sits under a unit they belong + to, and + sees nothing of a private workspace they are not a member of. +- **AC-AUTH-ROLES-AND-SCOPES-001.7:** Scopes are enforced server-side and + mirrored to the client for hiding controls only; hiding a control is never the + enforcement. +- **AC-AUTH-ROLES-AND-SCOPES-001.8:** A disabled account holds no scopes. Its + role rows are retained but grant nothing. + +## Why + +Kandev has two roles, `admin` and `member`, and they decide exactly one thing: +whether you can manage users and system settings. Everything else is decided by +sole ownership. A team that wants to work together therefore has no vocabulary +for "Ana runs agents, Bruno reviews the board, Carla administers the server but +should not be able to open a shell in someone's worktree." Roles need to carry +an explicit, auditable set of permissions rather than a single admin bit. + +## What + +- **Permissions are named scopes, not booleans.** Every guarded action maps to + one scope. Authorization asks "does the caller hold scope X here?", never + "is the caller an admin?". +- **Scopes live in one registry.** `internal/authz/registry.go` owns the scope + identifiers, their human descriptions, and the role-to-scope mapping. A + completeness test asserts every guarded action names a registered scope and + every registered scope has at least one call site. There is no second + permission map and no per-flag switch anywhere else. +- **Roles are fixed in v1; scopes are the extension point.** Kandev ships four + org roles and three workspace roles with a static mapping. There is no custom + role builder, no per-user grant table, and no UI for editing a role's scopes. + Adding a capability means adding a scope and mapping it, which is a code + change and a review. +- **Two role axes, resolved by one function.** An org role grants **org + scopes** outright and a **default workspace role** used on workspaces that + are visible to the whole org. A workspace role grants **workspace scopes** on + one workspace. Effective scopes are resolved per (user, workspace) by a + single function; nothing else derives permissions. +- **Org admin is still not a visibility role.** An admin reaches a workspace + because it sits under a unit they belong to, exactly like any member, and + reaches nothing in another user's personal unit. Admin adds management scopes, + never reach. This preserves today's hard-privacy guarantee rather than + quietly dropping it. +- **Scopes are enforced server-side and mirrored to the client.** The API is + authoritative. The boot payload and workspace DTOs carry the caller's + effective scopes so the UI can hide what the caller cannot do, but hiding a + control is never the enforcement. +- **A disabled account holds no scopes.** Role rows are retained; they grant + nothing while the account is disabled, matching how sessions already fail + closed. + +## Data model + +Roles are values, not rows. + +``` +users.role org role: owner | admin | member | guest +workspace_members.role workspace role: owner | collaborator | viewer +``` + +- `users.role` replaces today's `admin` / `member` values. The migration maps + `admin -> admin` and `member -> member`; the first admin on an instance also + becomes `owner`. `guest` is new and is never assigned by migration. +- Exactly one `owner` exists per org. +- Workspace roles are defined by + [workspace membership](../../workspaces/requirements/org-units.md); this spec owns only what + each one grants. + +## Scope registry + +**Org scopes** — granted by the org role, independent of any workspace. + +| Scope | Grants | +|---|---| +| `org.members.manage` | invite, create, disable, and re-role users; mint invites | +| `org.settings.manage` | org name and org-level defaults | +| `org.config.manage` | org executors, executor profiles, environments, agents, agent profiles, editors, prompts, notification providers | +| `org.delete` | delete the org | + +**Workspace scopes** — granted by the workspace role, per workspace. + +| Scope | Grants | +|---|---| +| `workspace.read` | see the workspace, its board, tasks, workflows, transcripts, diffs | +| `workspace.manage` | rename, defaults, placement, delete the workspace | +| `task.write` | create, edit, move, assign, archive, delete tasks | +| `session.prompt` | start or resume a session and message an agent | +| `session.control` | stop or cancel a running agent | +| `session.exec` | terminal, shell, file writes, port previews, VS Code, LSP | +| `repository.manage` | add or remove workspace repositories | +| `secret.manage` | workspace secrets and integration credentials | +| `member.manage` | add or remove workspace members, transfer ownership | + +`session.exec` is deliberately separate from `session.prompt`. Prompting an +agent is bounded by the agent's own permissions; a shell in the worktree is +not. A `viewer` who can read a transcript must not thereby get a shell. + +`secret.manage` is write-only in the sense that already holds today: secret +values are never returned by any API to any role. + +## Role mappings + +**Org role → org scopes and default workspace role** + +| Org role | Org scopes | Reach granted | +|---|---|---| +| `owner` | all four | none: reach comes from unit membership | +| `admin` | `org.members.manage`, `org.settings.manage`, `org.config.manage`, `unit.manage` | none: reach comes from unit membership | +| `member` | none | none: reach comes from unit membership | +| `guest` | none | none, and a guest is not given unit membership | + +**Workspace role → workspace scopes** + +| Workspace role | Scopes | +|---|---| +| `owner` | all nine | +| `collaborator` | `workspace.read`, `task.write`, `session.prompt`, `session.control`, `session.exec` | +| `viewer` | `workspace.read` | + +`repository.manage`, `secret.manage`, `member.manage`, and `workspace.manage` +belong to the workspace owner alone. A team that wants a second person able to +do those transfers ownership or adds them as a workspace `owner` — a workspace +may have more than one `owner` role row even though `workspaces.owner_id` names +a single accountable one. + +## Resolution + +Effective scopes for (user `U`, workspace `W`), evaluated in order: + +1. `U` is disabled, or `U`'s org is suspended → **no scopes**. +2. Org scopes = the mapping for `U.role`, always. +3. Workspace scopes are the **highest** role among: + 1. every `unit_members` row for `U` on a unit that is an ancestor of `W`'s + unit, including that unit itself, + 2. a `workspace_members` row for (`W`, `U`), which is a direct grant, + 3. otherwise → **none**, and `W` is unreachable (404, no existence leak). + +This function is the only place permissions are derived. The roles combine by +maximum, never by subtraction: a direct grant is how a `guest` gets into one +workspace, and narrowing is done by placing the workspace in a unit with fewer +members rather than by a lower grant. Reach itself is owned by +[organization units](../../workspaces/requirements/org-units.md). + +## API surface + +``` +GET /api/v1/auth/me -> adds "org_scopes": [...] +GET /api/v1/workspaces -> each item adds "viewer_role" and "scopes": [...] +GET /api/v1/workspaces/{id} -> adds "viewer_role" and "scopes": [...] +PATCH /api/v1/users/{id} -> accepts "role" (requires org.members.manage) +GET /api/v1/authz/scopes -> the registry: id + description, for UI labels +``` + +The boot payload carries `org_scopes` and, for the active workspace, its +`scopes`. Absence of a scope in the response is a UI hint; the server rejects +the action regardless. + +## Permissions + +Changing a user's org role requires `org.members.manage`. Three guards hold +regardless of scope: + +- The last `owner` cannot be demoted or disabled. +- No user may change their own org role, matching the existing self-actions + guard ([`self-actions-guard.md`](self-actions-guard.md)). +- `org.delete` is never granted to anyone but the `owner`. + +## Failure modes + +- **A guarded action names an unregistered scope** — build failure via the + registry completeness test, not a runtime grant. +- **A registered scope has no call site** — build failure. A scope nobody + enforces is a permission that silently does not exist. +- **A caller lacks a workspace scope but holds `workspace.read`** — 403, not + 404. Existence is already known to them, so hiding it leaks nothing and a 404 + would be confusing. +- **A caller lacks `workspace.read`** — 404. Existence is not disclosed. +- **Scope resolution cannot load the workspace or the membership row** — fail + closed with no scopes; never fall back to the org default role, because that + is the branch that would silently widen access on a transient DB error. +- **The client is stale about its scopes** — the server rejects the action and + the client refreshes from the response; there is no scope cache with an + independent TTL. + +## Persistence guarantees + +- Roles survive restart and survive disabling `features.auth` (they become + inert, exactly as ownership does today). +- The registry is code, not data: it cannot drift per-instance and needs no + migration when scopes are added. +- No effective-scope result is cached across requests. Resolution is cheap and a + cache is how a removed member keeps their access for another minute. + +## Scenarios + +- **GIVEN** a guarded action, **WHEN** it names a scope absent from the + registry, **THEN** the completeness test fails and names the action. +- **GIVEN** a registered scope with no enforcement call site, **WHEN** the + completeness test runs, **THEN** it fails and names the scope. +- **GIVEN** an org admin and a workspace in another user's personal unit, + **WHEN** they request it, **THEN** the response is 404 — admin is not a + reach role. +- **GIVEN** an org admin who is a member of the root unit and a workspace + beneath it, **WHEN** they open it, **THEN** they hold the role that membership + gives, the same as any other member of the root. +- **GIVEN** a `viewer` on a workspace, **WHEN** they read a task transcript, + **THEN** it succeeds; **WHEN** they open a terminal or send a prompt, + **THEN** the response is 403. +- **GIVEN** a `guest`, **WHEN** they list workspaces, **THEN** they see only + workspaces holding a direct grant for them, including ones under the root + unit, because a guest holds no role there. +- **GIVEN** a `member` who inherits `collaborator` from a unit and also holds a + `viewer` direct grant on one of its workspaces, **WHEN** they attempt + `task.write`, **THEN** it succeeds — roles combine by maximum and a grant + never lowers an inherited role. +- **GIVEN** the only `owner` of an org, **WHEN** an admin attempts to demote or + disable them, **THEN** the request is refused. +- **GIVEN** any user, **WHEN** they attempt to change their own org role, + **THEN** the request is refused. +- **GIVEN** a user whose account is disabled, **WHEN** any request is made with + their session or token, **THEN** it fails closed and no scope is granted. +- **GIVEN** a transient failure loading a membership row, **WHEN** scopes are + resolved, **THEN** no scopes are returned and the request fails closed rather + than falling back to the org default role. + +## Out of scope + +- **Custom roles and a role editor.** Roles are fixed; scopes are the + extension point. +- **Per-user scope grants** outside a role. +- **Scopes on personal access tokens.** A PAT carries its user's full scope + set; narrowing a token is a separate feature. +- **Task-level or step-level human permissions.** Existing workflow + participants name agent profiles, not people. +- **Time-bound or conditional grants.** diff --git a/docs/specs/multi-tenancy/spec.md b/docs/specs/multi-tenancy/spec.md new file mode 100644 index 0000000000..311e0d77d9 --- /dev/null +++ b/docs/specs/multi-tenancy/spec.md @@ -0,0 +1,498 @@ +--- +status: draft +created: 2026-08-22 +owner: tbd +--- + +# Multi-Tenancy (Organizations) + +## Why + +Kandev's opt-in authentication gives an instance users, roles, and per-user +workspace privacy, but no boundary above the user: every account on an instance +shares one filesystem tree, one set of agent CLI credentials, one executor pool, +and one instance-wide configuration surface. That is acceptable for colleagues +who already trust each other and unacceptable for anyone who wants to run one +Kandev deployment for two teams that must not reach each other's code, or to +host Kandev for customers at all. Users need to belong to an **organization** +that is a real isolation boundary, not just a label. + +**Sequencing.** Self-hosted is the near-term priority and a hosted deployment is +the reason the boundary must be real rather than cosmetic. Concretely: the +design is built to be strong enough for untrusted co-tenants, but it ships and +is validated on self-hosted instances first. What a self-hosted team actually +asks for today ([#2824](https://github.com/kdlbs/kandev/issues/2824)) is +[org roles and scopes](../auth/requirements/roles-and-scopes.md) plus +[organization units](../workspaces/requirements/org-units.md) — neither needs an org, and +both ship ahead of this spec. Organizations are what make that team board safe +to put on a box shared with another team, and later on a box shared with +another customer. + +## What + +- **An organization ("org") is the tenant.** It owns users, filesystem roots, + agent credentials, executors, and configuration. Cross-org reach is a bug, + not a permission level. +- **Every user belongs to exactly one org.** `users.org_id` is non-empty and + immutable after creation. There is no org switcher, no "acting as org" request + parameter, and no way for a session or token to name an org. The tenant is a + total function of the authenticated identity, so no request payload can move + a caller between tenants. +- **The org is the outer edge of sharing, not a sharing mechanism of its own.** + Who sees what inside an org is decided by + [org roles and scopes](../auth/requirements/roles-and-scopes.md) and + [organization units](../workspaces/requirements/org-units.md), exactly as on a + single-org instance. This spec adds no reach rule; it bounds every + existing one. +- **The unit tree is confined to one org and nothing wider.** A + workspace marked visible to the organization is reachable by members of *its* + org only. Adding a member from another org is refused with 404, so org + membership is not enumerable, and the tenancy migration drops any pre-existing + cross-org membership row it finds, logging each one without aborting. +- **Org scopes are per-org.** A user holding `org.members.manage` or + `org.config.manage` holds it in their own org and nowhere else. The + instance-level `operator` tier is separate and holds no org scopes at all. + Membership and roles are the boundary below the user; tenancy is the boundary + above; neither blocks the other. +- **Multi-tenancy is a runtime feature toggle.** `features.multiTenancy` + ("Organizations", `KANDEV_FEATURES_MULTI_TENANCY`) is restart-required and + OFF in every shipped profile. It requires `features.auth`; enabling it with + auth disabled is refused at startup with a named error. With the flag off, an + instance behaves byte-identically to today — no org UI, no org routes, no + filesystem layout change. +- **A third role tier above the org.** `operator` is instance-level and manages + orgs, feature toggles, instance configuration templates, backups, and storage + maintenance. The org roles (`owner`, `admin`, `member`, `guest`) and their + scopes are defined by [roles and scopes](../auth/requirements/roles-and-scopes.md) and are + re-scoped to one org. An operator holds **no org scopes** and MUST NOT gain + read access to any org's workspaces, tasks, sessions, transcripts, or + secrets; operator is an administration role, not a visibility role, exactly + as org `admin` is. +- **Configuration is two-tier: instance template → org row.** Executors, + executor profiles, environments, agents, agent profiles, editors, custom + prompts, and notification providers each exist as instance-level templates + (`org_id = ''`, managed by an operator) and org-level rows (managed by an org + admin). An org sees the union of its own rows plus every template it has not + shadowed. An org admin may shadow, override, or ignore a template; an + operator cannot see or edit an org's rows. +- **An instance template MUST NOT carry a credential.** Templates hold shape + only: executor type, image reference, agent binary, model, flags, env-var + *names*. Every value that authenticates (secret, token, API key, env-var + value marked sensitive, integration config) is org-owned. This is a hard + invariant with a pinning test, because a template is the one row two orgs + both read. +- **Runtime isolation is tenant-pinned, not just row-scoped.** An agent + launched for org A SHALL NOT run in any process, container, or host that + holds org B's credentials or working tree: + - **Filesystem.** Each org gets a private root (`/orgs//`) + containing its worktrees, clones, attachments, and agent credential home. + Directories are created mode `0700`. The pre-existing flat tree becomes the + default org's root in place, so upgrading an instance moves no files. + - **Agent credentials.** Every ACP subprocess and agentctl child for org A + receives `HOME`/`XDG_CONFIG_HOME`/`XDG_DATA_HOME` pointed at org A's + credential home, so `gh auth`, `claude login`, and provider API keys are + per-org rather than per-OS-user. + - **Containers.** Docker executions are labelled `kandev.org=`, + named and volume-namespaced by org, and never reused across orgs. A + container whose label does not match the launching org is not adopted on + recovery; it is treated as foreign and left alone. + - **Remote/SSH executors.** An executor row is org-owned and reachable only + by its org. Two orgs cannot both target one remote host through Kandev. + - **Standalone (`local_pc`) executors fail closed.** A standalone execution + shares the backend's OS user, so `HOME` redirection is a convention an + agent can walk out of. When more than one org is active, standalone + executions are refused with a named error unless the instance declares a + per-org OS user for the launching org, or an operator has set the + `features.multiTenancyTrustedStandalone` escape hatch for a + mutually-trusting self-hosted deployment. +- **Secrets are sealed under a per-org key.** Row scoping decides who may + *read* a secret; it does nothing about the ciphertext. Today one AES-256 + master key at `/master.key` seals every row on the instance, so at + rest every tenant collapses to one key that sits beside the database. Under + organizations each org gets its own data encryption key (DEK), wrapped by + that same master key: + - The master key and `MasterKeyProvider` are unchanged, so a self-hosted + upgrade changes nothing operationally and no KMS is required. + - A secret is sealed under its own org's DEK, so compromising one org's + plaintext does not yield another's. + - Deleting an org destroys its DEK, so the org's secrets are unreadable **in + the live database**. This is deliberately NOT a crypto-shred against + backups: a backup is `VACUUM INTO` of the database, `org_keys` is a table, + so the wrapped DEK travels inside the same snapshot as the ciphertext it + protects, and `master.key` is untouched by restore. Restoring a + pre-deletion backup on the same host unwraps it again. See Out of scope + for the change that would make deletion a real shred and what it costs. + - The tenancy migration re-wraps existing secrets under the default org's DEK + in the same first-boot pass that assigns `org_id`. +- **Background work runs per-org, never identity-free.** Today an absent + identity means "internal caller, unscoped". Under tenancy that is a + cross-tenant read. Pollers, office schedulers, the orchestrator, watchers, + automation runners, and storage maintenance SHALL iterate orgs and carry a + **system identity bearing an `OrgID`**. An identity-free context reaching a + tenant-scoped service entry point is a denial, not a grant. +- **Migration is automatic and lossless.** On first boot with the flag enabled, + every existing user, workspace, secret, executor, profile, environment, + editor, prompt, notification provider, and integration config is assigned to + a single **default org** named after the instance. No row is orphaned and no + file moves. +- **Deleting an org is explicit and destructive.** An operator deletes an org + only through a type-to-confirm flow naming the org slug. Deletion revokes + sessions and tokens, stops executions, removes org rows, and removes the org + filesystem root. The default org cannot be deleted while it is the only org. + +## Data model + +New and changed tables. Timestamp types follow the existing SQLite/Postgres +substitution (`internal/db/dialect`); all DDL is replay-safe (ADR 0027) and new +columns arrive via idempotent `ADD COLUMN` migrations, never by editing a +`CREATE TABLE` alone. + +``` +orgs + id string PK + name string display name, non-empty + slug string UNIQUE, lowercase [a-z0-9-], used in confirm flows + status enum active | suspended + is_default bool exactly one row true; the migration target + storage_root string abs path; '' means the legacy flat tree + created_at timestamp + updated_at timestamp +``` + +``` +org_keys -- per-org data encryption key, wrapped + org_id string PK, FK -> orgs.id (cascade delete) + wrapped_dek bytes the org's DEK sealed under the instance master key + nonce bytes AEAD nonce for the wrap + created_at timestamp +``` + +Deleting the row destroys the only copy of the wrapped DEK. The plaintext DEK +is never persisted and is held in memory only for the life of a request. + +``` +org_os_users -- optional per-org OS identity, operator-managed + org_id string PK, FK -> orgs.id (cascade delete) + os_user string POSIX user name that standalone executions run as + created_at timestamp +``` + +Changed existing tables: + +| Table | Change | Meaning | +|---|---|---| +| `users` | `+ org_id TEXT NOT NULL DEFAULT ''` | the user's tenant; immutable after create | +| `users` | `email` unique constraint becomes `UNIQUE (org_id, email)` | the same address may exist in two orgs | +| `auth_invites` | `+ org_id TEXT NOT NULL DEFAULT ''` | an invite mints a member of one org | +| `workspaces` | `+ org_id TEXT NOT NULL DEFAULT ''` | the tenant root for all workspace-descended data | +| `secrets` | `+ org_id TEXT NOT NULL DEFAULT ''` | org-owned; no instance tier. Sealed under that org's DEK, not the instance master key | +| `executors`, `executor_profiles`, `environments`, `agents`, `agent_profiles`, `editors`, `custom_prompts`, `notification_providers` | `+ org_id TEXT NOT NULL DEFAULT ''`, `+ template_id TEXT NOT NULL DEFAULT ''` | `org_id = ''` is an instance template; `template_id` names the template this org row shadows | +| `executors_running` | `+ org_id TEXT NOT NULL DEFAULT ''` | recovery must not adopt another org's execution | +| `task_message_attachments` | `+ org_id TEXT NOT NULL DEFAULT ''` | reached by `owner_id` without a workspace join | +| `plugin_settings`, `plugin_state` | `+ org_id TEXT NOT NULL DEFAULT ''` | plugin config is per-org; `plugin_user_state` inherits from its user | +| `settings`, `runtime_flag_overrides`, `kandev_meta`, `storage_*`, `telemetry_activations`, `notification_migrations` | unchanged | instance-global by design | + +**Tenancy classification is exhaustive and pinned.** Every table is exactly one +of: + +- `instance` — one row set for the whole deployment, operator-managed. +- `tenant-root` — carries `org_id` directly. +- `descendant` — reaches its org through a documented FK path (for example + `task_sessions -> tasks -> workspaces.org_id`). + +The classification lives in one registry (`internal/tenancy/registry.go`) and a +completeness test enumerates the live schema and fails when a table is absent +from it. Adding a table without classifying it is a build failure, not a +runtime leak. + +**Denormalization rule.** A descendant table gains its own `org_id` column only +when at least one production query reaches it without joining its root — that +is the query that would otherwise leak. The registry records the reason. + +## API surface + +``` +GET /api/v1/orgs/current -> current caller's org (any member) +PATCH /api/v1/orgs/current -> rename own org (org admin) + +GET /api/v1/instance/orgs -> list orgs (operator) +POST /api/v1/instance/orgs -> create org + first admin invite (operator) +PATCH /api/v1/instance/orgs/{id} -> name, status (operator) +DELETE /api/v1/instance/orgs/{id} -> destructive, requires {"slug": ""} (operator) +PUT /api/v1/instance/orgs/{id}/os-user -> set/clear the per-org OS user (operator) + +GET /api/v1/instance/templates/{kind} -> list instance templates (operator) +POST /api/v1/instance/templates/{kind} -> create template (operator) +PATCH /api/v1/instance/templates/{kind}/{id} -> update template (operator) +DELETE /api/v1/instance/templates/{kind}/{id} -> delete template (operator) +``` + +`{kind}` is one of `executors`, `executor-profiles`, `environments`, `agents`, +`agent-profiles`, `editors`, `prompts`, `notification-providers`. + +Existing collection routes for those kinds are unchanged in shape and return +the org's effective view: own rows first, then every template not shadowed by a +`template_id` match. Each item carries `"source": "instance" | "org"` and +templates are returned with `"editable": false`. + +`POST /api/v1/instance/templates/{kind}` and its `PATCH` reject a body carrying +a secret value, a sensitive env-var value, or an integration credential with +`400 template_may_not_carry_credentials`. + +**Changed contracts** + +- `authn.Identity` gains `OrgID string` and `Instance bool` (the operator tier). + `IdentityFromContext` returning an identity with an empty `OrgID` while the + flag is on is a denial at every tenant-scoped entry point. +- A new `authn.SystemIdentity(orgID)` constructor is the only way background + work obtains a context; it is not reachable from any HTTP or WS path. +- `/api/v1/app-state` gains `org: {id, name, slug}` for authenticated callers + and continues to return only `{features, auth}` anonymously. It never + enumerates other orgs. +- WS: `Hub.BroadcastToWorkspace` is unchanged. `Hub.Broadcast` (global) is + replaced by `Hub.BroadcastToOrg(orgID, …)` for everything tenant-derived. The + `//ws:global` justification comment survives only for genuinely + instance-global events (release notification, feature-toggle change, + restart), and a lint-style test pins that allowlist. +- The GitHub credential broker lease, the port-proxy capability HMAC, automation + webhook secrets, office channel HMACs, and plugin webhook secrets each bind + the issuing org into their payload and refuse redemption under another org. + +## Permissions + +This spec adds only the instance tier. Org-level authority is the scope set in +[roles and scopes](../auth/requirements/roles-and-scopes.md). + +| Action | operator | org owner | org admin | member / guest | +|---|---|---|---|---| +| Create / suspend / delete an org | yes | no | no | no | +| Feature toggles, backups, storage maintenance, restart | yes | no | no | no | +| Manage instance templates | yes | no | no | no | +| Set a per-org OS user | yes | no | no | no | +| Rename own org (`org.settings.manage`) | no | yes | yes | no | +| Manage org config: executors, profiles, environments, agents, editors, prompts, notification providers (`org.config.manage`) | no | yes | yes | no | +| Manage org users, invites, roles (`org.members.manage`) | no | yes | yes | no | +| Delete own org (`org.delete`) | no | yes | no | no | +| Read a workspace they cannot reach | no | no | no | no | + +An operator is a user like any other: they belong to an org, they reach +workspaces there by the ordinary rules, and the instance tier grants them +nothing inside any org. On a self-hosted single-org instance the first admin +created by the auth setup wizard holds `operator` and org `owner`. + +## State machine + +An org has three states: + +| State | Entered by | Effect | +|---|---|---| +| `active` | creation, or an operator resuming a suspended org | normal operation | +| `suspended` | operator | sessions and tokens fail closed with `org_suspended`; running executions are stopped; background work skips the org; rows and files are retained | +| *(deleted)* | operator, type-to-confirm | rows removed, filesystem root removed, sessions and tokens revoked; not reversible | + +Suspension is reversible and is the intended lever for a cloud billing lapse. +Deletion is the only destructive path and is never reached implicitly. + +## Failure modes + +- **`features.multiTenancy` on with `features.auth` off** — startup aborts with + a named configuration error rather than serving an instance whose tenant + boundary has no identity behind it. +- **A tenant-scoped service entry point receives a context with no identity** + (flag on) — denied with the existing `*NotFound` sentinels. This inverts + today's "no identity means internal caller" rule and is the single most + important behavioral change; every background caller must be migrated to + `authn.SystemIdentity` before the flag can be enabled. +- **An identity's `OrgID` names a suspended or missing org** — the session and + any token fail closed on the next request; the browser is sent to a "your + organization is unavailable" page, not to `/login`, so the user is not told + their credentials are wrong. +- **A standalone execution is requested with more than one active org and no + per-org OS user** — refused before the process starts, with an error naming + the missing `org_os_users` row and the escape-hatch flag. No partial launch. +- **A Docker container or `executors_running` row carries a different + `kandev.org` label than the recovering backend expects** — it is not adopted, + not stopped, and not deleted; it is logged and left for its owning org's + recovery pass. +- **An org's storage root is missing or not writable** — task creation and + session launch in that org fail with a storage error; other orgs are + unaffected. The backend does not fall back to the shared tree. +- **The org migration cannot assign a row** (an orphan whose FK path is broken) + — migration aborts and the boot fails rather than leaving a row reachable by + every tenant. The pre-migration backup taken by `persistence.Provide` is the + recovery path. +- **An org's DEK cannot be unwrapped** (missing `org_keys` row, or a master key + that no longer decrypts it) — every secret read and write in that org fails + with a named error. It does NOT fall back to the master key: a silent + fallback would re-create exactly the shared-key property this removes. + The error names the recovery route, because a rule with no recovery story is + a rule the next person deletes under pressure: `org_keys` is an ordinary + table, so a lost row is restored from a database backup like any other row, + and secrets stay readable afterwards because the master key that unwraps it + is not part of the snapshot and did not change. +- **A template edit would introduce a credential** — rejected at the API + boundary; no partial write. +- **An org is deleted while an execution is running** — executions are stopped + first; if a stop fails the deletion aborts and reports the execution, so no + filesystem root is removed under a live agent. + +## Persistence guarantees + +- Org rows, memberships, per-org OS users, and org filesystem roots survive + restart. +- Org storage roots are only removed by explicit org deletion. Storage + maintenance and GC operate per-org and never traverse another org's root. +- The default org's `storage_root` stays `''` (the legacy flat tree) for an + upgraded instance, permanently. A fresh install writes + `/orgs//` for every org including the default. Both shapes are + supported forever; the empty value is not a migration to finish later. +- Backups remain instance-wide (one database), so a backup contains every org. + Per-org export is out of scope. +- **Deleting an org does not erase it from backups already taken**, including + its secrets. The wrapped DEK lives in `org_keys`, which is inside the + snapshot, and the master key that unwraps it is not part of the snapshot and + survives restore. Deletion is irreversible on the live instance; it is not + retroactive across retained backups. +- Suspending an org does not delete anything; deletion is the only data loss. +- Turning `features.multiTenancy` off retains every `org_id`, exactly as + disabling auth retains `owner_id`. Re-enabling restores the previous + boundary without a re-migration. + +## Scenarios + +- **GIVEN** an instance with `features.multiTenancy` off, **WHEN** a user loads + any page or calls any API, **THEN** no org route exists, no `org` key appears + in the boot payload, and every response is byte-identical to the same build + with the tenancy code absent. +- **GIVEN** an instance with `features.auth` off, **WHEN** the operator sets + `features.multiTenancy` on and restarts, **THEN** startup aborts with a named + configuration error and the previous binary state is untouched. +- **GIVEN** an existing auth-enabled instance with three users and eight + workspaces, **WHEN** multi-tenancy is enabled and the backend restarts, + **THEN** one default org exists, all three users and all eight workspaces + carry its `org_id`, no file under `` has moved, and every user's + visible workspace list is unchanged. +- **GIVEN** users A (org 1) and B (org 2) and a workspace owned by B, **WHEN** + A requests that workspace, task, session, transcript, secret, or attachment + by ID over HTTP, WS, or MCP, **THEN** the response is 404 and no row is + serialized. +- **GIVEN** org 1 and org 2, **WHEN** an agent runs for org 1 and reads + `$HOME`, **THEN** it sees org 1's credential home, and no path under org 2's + storage root is readable from that process. +- **GIVEN** an org-1 agent that runs `gh auth status`, **WHEN** only org 2 has + authenticated `gh`, **THEN** the agent reports no credential rather than + authenticating as org 2. +- **GIVEN** two active orgs and no `org_os_users` row, **WHEN** a user launches + a session on a `local_pc` executor, **THEN** the launch is refused before any + process starts and the error names the missing per-org OS user. +- **GIVEN** two active orgs and the `features.multiTenancyTrustedStandalone` + override set, **WHEN** the same launch is attempted, **THEN** it proceeds with + the org's redirected `HOME` and a warning is logged once per org per boot. +- **GIVEN** a Docker container labelled `kandev.org=org-2`, **WHEN** the backend + recovers executions for org 1, **THEN** the container is not adopted, not + stopped, and not removed. +- **GIVEN** an org-scoped poller, scheduler, or automation runner, **WHEN** it + executes a cycle, **THEN** every service call it makes carries a system + identity bearing that org's ID, and a call made with no identity is denied. +- **GIVEN** a workspace-carrying event in org 1, **WHEN** the WS gateway + broadcasts it, **THEN** no client authenticated to org 2 receives a frame. +- **GIVEN** an instance template for an executor profile, **WHEN** an org admin + lists executor profiles, **THEN** the template appears with + `"source": "instance"` and `"editable": false`, and after the org creates a + row with that `template_id` the template no longer appears. +- **GIVEN** an operator, **WHEN** they attempt to save an instance template + whose body contains a secret or sensitive env-var value, **THEN** the request + is rejected with `template_may_not_carry_credentials` and nothing is written. +- **GIVEN** an operator who belongs to org 1, **WHEN** they request any + workspace, task, session, or secret belonging to org 2, **THEN** the response + is 404 — the instance tier grants no org scopes and no reach. +- **GIVEN** a workspace under org 1's root unit, **WHEN** a member of org 2 + requests it, **THEN** the response is 404 — a unit tree spans one org only. +- **GIVEN** a workspace owner in org 1, **WHEN** they try to add a user from + org 2 as a workspace member, **THEN** the response is 404 and no membership + row is written. +- **GIVEN** an instance already using org-visible workspaces and workspace + members, **WHEN** multi-tenancy is enabled and every user lands in the default + org, **THEN** every unit, membership row and placement is retained and + every shared board keeps working exactly as before. +- **GIVEN** a membership row whose member and workspace would land in different + orgs, **WHEN** the tenancy migration runs, **THEN** the row is dropped and + logged, and the migration does not abort. +- **GIVEN** a GitHub credential-broker lease minted for an org-1 task, **WHEN** + it is redeemed against an org-2 task, **THEN** redemption fails and no token + is returned. The same holds for a port-proxy capability, an automation webhook + secret, an office channel HMAC, and a plugin webhook secret. +- **GIVEN** an active user in org 1, **WHEN** an operator suspends org 1, + **THEN** the user's next request fails closed with `org_suspended`, running + executions are stopped, and the browser shows the organization-unavailable + page rather than the sign-in page. +- **GIVEN** two orgs each holding a secret, **WHEN** org A's DEK is used to + decrypt org B's ciphertext, **THEN** the decryption fails: the rows are not + sealed under a shared key. +- **GIVEN** org B deleted from the live instance, **WHEN** any of its secrets + is read, **THEN** the read fails: its DEK row is gone. +- **GIVEN** a backup taken while org B existed, **WHEN** it is restored onto the + same host after B was deleted, **THEN** B's secrets ARE readable again. This + is the documented consequence of `org_keys` living inside the snapshot while + the key that unwraps it lives outside; a test asserts it so nobody mistakes + deletion for a backup-spanning shred. +- **GIVEN** an org whose `org_keys` row is missing, **WHEN** any secret in it is + read, **THEN** the read fails with a named error rather than falling back to + the instance master key. +- **GIVEN** an org with a running execution, **WHEN** an operator confirms + deletion by typing the slug, **THEN** executions are stopped first, all org + rows are removed, the org storage root is removed, and every session and + token for its users is revoked. +- **GIVEN** an org whose execution cannot be stopped, **WHEN** deletion is + confirmed, **THEN** the deletion aborts, the storage root still exists, and + the error names the execution. +- **GIVEN** a new table added to the schema, **WHEN** the tenancy completeness + test runs, **THEN** it fails until the table is classified as `instance`, + `tenant-root`, or `descendant` in the tenancy registry. + +## Out of scope + +- **Workspace sharing, roles, and scopes.** Those are + [roles and scopes](../auth/requirements/roles-and-scopes.md) and + [organization units](../workspaces/requirements/org-units.md), which ship + first and do not require an org. This spec only bounds them to one org. +- **Multiple orgs per user.** One org per user is the design, not a stopgap: it + makes the tenant a total function of the identity and removes an entire class + of "acting as" confusion. Someone who needs two orgs creates two accounts. If + multi-org is ever added, `users.org_id` becomes the primary-org pointer and a + membership table joins it; no data migration is required. +- **Schema-per-tenant or database-per-tenant.** Row-level scoping plus + filesystem and runtime pinning is the boundary. Revisit only if a hosted + deployment demands physical separation. +- **Billing, plans, quotas, and usage metering.** The org is the anchor these + will hang from; none of them ship here. +- **Per-org SSO / IdP pinning and domain-based auto-join.** Plugin-provided + external login (ADR 0050) stays instance-level for now. +- **Per-org data export or per-org backup/restore.** Backups stay instance-wide. +- **Cross-org anything** — no shared workspaces, no transfer of a workspace or + user between orgs, no cross-org search, no cross-org analytics. +- **Crypto-shredding an org out of existing backups.** Per-org DEKs buy two + things: a partial compromise (a leaked key, a bad export, memory disclosure + in one org's context) exposes that org rather than all of them, and the + envelope structure is the prerequisite for a real shred. They do not deliver + the shred, because the wrapping key is in the same snapshot boundary as the + ciphertext. + + The change that would deliver it is specific: wrap each org's DEK with a key + that lives **outside** the database snapshot and dies with the org. The + per-org filesystem root is the natural home, since deletion already removes + it and backups already do not capture it. It is out of scope here because the + cost is not small and lands on every self-hosted operator: a database backup + alone stops being sufficient for recovery, and an org root lost to disk + failure or a partial restore takes that org's secrets with it, permanently. + That tradeoff deserves its own decision rather than riding in on this one. +- **Protecting the root key itself.** `master.key` remains a mode-0600 file in + the data directory beside `kandev.db`, so anyone who can read that directory + can unwrap every org's DEK. `MasterKeyProvider` is the seam a KMS, HSM or + env-supplied root key plugs into. Envelope encryption stops one org reaching + another's plaintext; it does not defend the instance against someone who + already has its disk. +- **Sandboxing an agent from the OS user it runs as.** Tenant pinning stops + org A's agent from reading org B's tree; it does not stop an agent from + escaping its own org's tree if the OS permits it. That remains the executor's + job. diff --git a/docs/specs/tasks/README.md b/docs/specs/tasks/README.md index 185cea4b2d..6a15b2f761 100644 --- a/docs/specs/tasks/README.md +++ b/docs/specs/tasks/README.md @@ -118,6 +118,7 @@ signals, and task-scoped scheduling contracts. - [Workflow Sync — Per-User Workspace Authorization](requirements/workflow-sync-workspace-authz.md) - [Workflow task-step transition ledger scenarios](requirements/workflow-task-step-transition-ledger-scenarios.md) - [Workflow task-step transition ledger](requirements/workflow-task-step-transition-ledger.md) +- [Human Assignee and Actor Attribution](requirements/human-assignee.md) ### System design diff --git a/docs/specs/tasks/requirements/human-assignee.md b/docs/specs/tasks/requirements/human-assignee.md new file mode 100644 index 0000000000..6a4bccc751 --- /dev/null +++ b/docs/specs/tasks/requirements/human-assignee.md @@ -0,0 +1,96 @@ +--- +status: draft +system: tasks +created: 2026-08-24 +owners: + - tbd +--- + +# Human Assignee and Actor Attribution Requirements + +## Overview + +When several people share a board, two questions appear that a single-user +instance never had to answer: who owns this task, and who did this. A task +already names the agent that runs it, which is not a person, and human actions +were previously recorded without an actor because there was only ever one. + +The task system owns both contracts because both are properties of a task and +its session, not of the workspace that contains them. Which workspaces a person +can reach is owned by +[organization units](../../workspaces/requirements/org-units.md). + +## Terminology + +- **Human assignee:** The person who owns a task. Distinct from the agent + assignee, which names an agent profile. +- **Takeover:** One person becoming the human assignee of a task another person + held. +- **Actor:** The authenticated user whose request caused a recorded change. + +## Requirements + +### REQ-TASKS-HUMAN-ASSIGNEE-001: Human assignee and takeover + +**Intent:** A shared board needs to say who owns each task, and handing a task +over must not disturb work already in flight. + +**User story:** As a team member, I want to take a colleague's task over, so +that I can continue it without waiting for them to hand it off. + +#### Acceptance criteria + +- **AC-TASKS-HUMAN-ASSIGNEE-001.1:** The system shall record a task's human + assignee independently of its agent assignee, and shall allow a task to carry + both at once. +- **AC-TASKS-HUMAN-ASSIGNEE-001.2:** When either assignee is set, the system + shall leave the other unchanged. +- **AC-TASKS-HUMAN-ASSIGNEE-001.3:** When a caller holding `task.write` assigns + a task, the system shall accept the assignment, including assigning to + themselves, without additional confirmation. +- **AC-TASKS-HUMAN-ASSIGNEE-001.4:** When a caller assigns a task to a user who + cannot reach the task's workspace, the system shall refuse the assignment and + return a reason suitable for display. +- **AC-TASKS-HUMAN-ASSIGNEE-001.5:** When a task is reassigned, the system shall + leave its session, worktree, executor, and running agent turn untouched, and + shall take no lock. +- **AC-TASKS-HUMAN-ASSIGNEE-001.6:** When a caller lacking `task.write` requests + an assignment, the system shall refuse it and leave the assignee unchanged. +- **AC-TASKS-HUMAN-ASSIGNEE-001.7:** When the assignee changes, the system shall + deliver the new value to connected clients without requiring a reload, + including when the assignee is cleared. +- **AC-TASKS-HUMAN-ASSIGNEE-001.8:** The system shall treat the human assignee + as advisory, granting and withholding no permission on the basis of it. + +### REQ-TASKS-HUMAN-ASSIGNEE-002: Actor attribution + +**Intent:** The audit trail a shared login cannot provide is the reason to have +per-person accounts at all, so every human action has to carry its actor. + +#### Acceptance criteria + +- **AC-TASKS-HUMAN-ASSIGNEE-002.1:** When a user sends a session message, queues + a message, moves a task through a workflow step, changes a task's state, or + stops or cancels an agent, the system shall record the acting user against + that change. +- **AC-TASKS-HUMAN-ASSIGNEE-002.2:** The system shall attribute recorded human + actions in the interface to the user who performed them. +- **AC-TASKS-HUMAN-ASSIGNEE-002.3:** When an action has no human actor, the + system shall record the absence of one, and shall not substitute the workspace + owner or any other user. +- **AC-TASKS-HUMAN-ASSIGNEE-002.4:** When a user's access is later withdrawn or + their account is disabled, the system shall retain their existing attribution + unchanged. +- **AC-TASKS-HUMAN-ASSIGNEE-002.5:** When two users prompt one session at the + same time, the system shall queue both messages in arrival order, attribute + each to its sender, and drop neither. + +## Out of scope + +- **Reach.** Which workspaces, and therefore which tasks, a person can see is + owned by [organization units](../../workspaces/requirements/org-units.md). +- **Assignment as permission.** The human assignee never gates an action. +- **Per-person execution resources.** Sessions, worktrees, and previews stay + shared per workspace, which is what allows takeover to continue work in place. +- **Notification on assignment.** Alerting an assignee is not part of this + capability. diff --git a/docs/specs/workspaces/README.md b/docs/specs/workspaces/README.md index cb1677691d..f248eb3ea7 100644 --- a/docs/specs/workspaces/README.md +++ b/docs/specs/workspaces/README.md @@ -32,30 +32,30 @@ Git state. +- [Branch Policies](requirements/branch-policies.md) - [Create a Local Repository During Task Creation](requirements/create-local-repository.md) - [Empty Remote Repositories](requirements/empty-remote-repositories.md) - [Kanban workspace creation](requirements/creation.md) - [Workspace Deletion](requirements/deletion.md) - [Improve Kandev](requirements/improve-kandev.md) - [Local Workspace Repositories](requirements/local-repositories.md) +- [Organization Units](requirements/org-units.md) - [Repository and Workspace Secrets](requirements/repository-secrets.md) - [Repository Sets](requirements/repository-sets.md) - [Copy and Move Secrets Between Scopes](requirements/secret-scope-transfer.md) - [Workspace Base-Branch Propagation](requirements/workspace-base-branch-propagation.md) - [Worktree Base Refresh](requirements/worktree-base-refresh.md) - [Worktree Branch Templates](requirements/worktree-branch-templates.md) -- [Branch Policies](requirements/branch-policies.md) ### System design - - - [Empty Remote Repositories](system-design/empty-remote-repositories.md) - [Improve Kandev](system-design/improve-kandev.md) - [Copy and Move Secrets Between Scopes](system-design/secret-scope-transfer.md) - [Branch Policies](system-design/branch-policies.md) - [Worktree Base Refresh](system-design/worktree-base-refresh.md) - [Local Workspace Repositories](system-design/local-repositories.md) +- [Organization Units](system-design/org-units.md) ## Migration record diff --git a/docs/specs/workspaces/requirements/org-units.md b/docs/specs/workspaces/requirements/org-units.md new file mode 100644 index 0000000000..78a8d5b58e --- /dev/null +++ b/docs/specs/workspaces/requirements/org-units.md @@ -0,0 +1,186 @@ +--- +status: draft +system: workspaces +created: 2026-08-24 +owners: + - tbd +--- + +# Organization Unit Requirements + +## Overview + +An organization needs its own shape inside Kandev. A company has departments, +departments have teams, and a team owns several workspaces. Without that shape, +sharing a workspace means naming every person on it, and the list has to be +maintained again for every new workspace and every joiner. + +This capability replaces per-workspace visibility with a tree of organization +units that carries both the workspaces and the people. Reach follows the tree: +being a member of a unit reaches everything beneath it. The workspace system +owns this contract because a unit is where a workspace lives, and placement +determines reach. + +The [auth system](../../auth/requirements/roles-and-scopes.md) still owns what a +role may do once reach is established. This document owns only which workspaces +a person reaches. + +## Terminology + +- **Unit:** A node in an organization's tree. It holds child units, workspaces, + and members. A department and a team are both units; they differ only in + depth. +- **Root unit:** The single unit at the top of an organization. Every other unit + descends from it. It takes members like any other unit; belonging to the + organization is not membership of it. If it were, every unit beneath it would + inherit the whole organization and no department could be separate from + another. +- **Personal unit:** A unit belonging to exactly one user, which takes no + members. +- **Reach:** Whether a user can see a workspace at all, before any question of + what they may do in it. +- **Direct grant:** A role recorded against one workspace for one user, rather + than inherited from a unit. + +## Requirements + +### REQ-WORKSPACES-ORG-UNITS-001: Organization unit tree + +**Intent:** An organization's structure must exist as data, so that access can +follow it instead of being restated on every workspace. + +**User story:** As an organization administrator, I want to model my departments +and teams in Kandev, so that access follows the structure I already have. + +#### Acceptance criteria + +- **AC-WORKSPACES-ORG-UNITS-001.1:** When an organization is created, the system + shall create exactly one root unit for it. +- **AC-WORKSPACES-ORG-UNITS-001.2:** The system shall allow a unit to contain + child units to any depth, and shall give every unit except the root exactly + one parent. +- **AC-WORKSPACES-ORG-UNITS-001.3:** The system shall place every workspace in + exactly one unit. +- **AC-WORKSPACES-ORG-UNITS-001.4:** When a request would make a unit its own + ancestor, the system shall refuse the request. +- **AC-WORKSPACES-ORG-UNITS-001.5:** When a request would delete a unit that + still holds child units or workspaces, the system shall refuse the request and + name what remains. +- **AC-WORKSPACES-ORG-UNITS-001.6:** When a request would delete or move the + root unit, the system shall refuse the request. +- **AC-WORKSPACES-ORG-UNITS-001.7:** The system shall keep every unit in exactly + one organization, and shall refuse a parent in a different organization. +- **AC-WORKSPACES-ORG-UNITS-001.8:** When a caller without `unit.manage` on the + target unit requests a create, rename, move, or delete, the system shall + refuse the request. + +### REQ-WORKSPACES-ORG-UNITS-002: Unit membership inherits downward + +**Intent:** Membership recorded once on a department must reach every workspace +under it, including ones created later, so that joining a team is a single act. + +**User story:** As a team lead, I want to add someone to my team once, so that +they reach every board the team owns without further bookkeeping. + +#### Acceptance criteria + +- **AC-WORKSPACES-ORG-UNITS-002.1:** The system shall record a unit membership + as a user and a workspace role of `owner`, `collaborator`, or `viewer`. +- **AC-WORKSPACES-ORG-UNITS-002.2:** When a user is a member of a unit, the + system shall give that user reach at that role to every workspace in that unit + and in all of its descendants, with no per-workspace record. +- **AC-WORKSPACES-ORG-UNITS-002.3:** When a workspace is created under a unit, + the system shall give the unit's existing members reach to it immediately. +- **AC-WORKSPACES-ORG-UNITS-002.4:** The system shall treat the root unit as an + ordinary unit: belonging to an organization grants no membership of it, and + no reach follows from an organization role alone. Sharing something with + everyone means adding them to the root, exactly as with any other unit. +- **AC-WORKSPACES-ORG-UNITS-002.5:** When a unit membership is removed, the + system shall withdraw reach to every workspace the user reached only through + that membership, and shall disconnect their live subscriptions to those + workspaces. +- **AC-WORKSPACES-ORG-UNITS-002.6:** When a caller without `member.manage` on + the unit requests a membership change, the system shall refuse the request. + +### REQ-WORKSPACES-ORG-UNITS-003: Personal units + +**Intent:** Work that belongs to one person needs somewhere to live that no +inherited membership can reach, without a per-workspace privacy flag. + +#### Acceptance criteria + +- **AC-WORKSPACES-ORG-UNITS-003.1:** When a user account is created, the system + shall create a personal unit owned by that user. +- **AC-WORKSPACES-ORG-UNITS-003.2:** The system shall refuse any request to add + a member to a personal unit. +- **AC-WORKSPACES-ORG-UNITS-003.3:** When a workspace is in a personal unit, the + system shall give reach only to the unit's owner and to any user holding a + direct grant on that workspace. +- **AC-WORKSPACES-ORG-UNITS-003.4:** The system shall not give an organization + administrator or an instance operator reach into a personal unit by role + alone. +- **AC-WORKSPACES-ORG-UNITS-003.5:** The system shall refuse a request to move + or delete a personal unit while its owner's account exists. +- **AC-WORKSPACES-ORG-UNITS-003.6:** When no unit is named at workspace + creation, the system shall place the workspace in the caller's personal unit. + +### REQ-WORKSPACES-ORG-UNITS-004: Effective role by union + +**Intent:** With roles arriving from several places at once, the rule that +combines them has to be one sentence a person can hold in their head. + +#### Acceptance criteria + +- **AC-WORKSPACES-ORG-UNITS-004.1:** The system shall resolve a user's effective + role on a workspace as the highest of the roles granted by the root unit, by + every ancestor unit of the workspace, and by any direct grant on the + workspace. +- **AC-WORKSPACES-ORG-UNITS-004.2:** The system shall provide no mechanism that + lowers a role inherited from an ancestor unit. +- **AC-WORKSPACES-ORG-UNITS-004.3:** When a user's organization differs from the + workspace's organization, the system shall give no reach, and shall decide + this before evaluating any role. +- **AC-WORKSPACES-ORG-UNITS-004.4:** When a user account is disabled, the system + shall give that account no reach and no role, while retaining its membership + records. +- **AC-WORKSPACES-ORG-UNITS-004.5:** When a user has no reach to a workspace, + the system shall answer requests naming that workspace as not found rather + than as forbidden. + +### REQ-WORKSPACES-ORG-UNITS-005: Workspace placement + +**Intent:** Moving a workspace between teams is a normal event, and it is the +only way to narrow or widen who reaches it. + +#### Acceptance criteria + +- **AC-WORKSPACES-ORG-UNITS-005.1:** When a caller creates a workspace and names + a unit, the system shall place it there if the caller holds `unit.manage` on + that unit, and shall otherwise refuse. +- **AC-WORKSPACES-ORG-UNITS-005.2:** When a caller moves a workspace, the system + shall require `workspace.manage` on the workspace and `unit.manage` on the + destination unit. +- **AC-WORKSPACES-ORG-UNITS-005.3:** When a workspace moves, the system shall + apply the destination's reach immediately, and shall disconnect live + subscriptions held by users who no longer reach it. +- **AC-WORKSPACES-ORG-UNITS-005.4:** The system shall refuse a move whose + destination unit is in another organization. +- **AC-WORKSPACES-ORG-UNITS-005.5:** When a workspace moves, the system shall + retain its direct grants. + +## Out of scope + +- **Standalone groups.** A set of people that ignores the tree, such as every + site reliability engineer across three departments, is not modelled. A unit + already answers who is in a team. +- **Negative grants.** There is no deny rule and no way to subtract a role. + Narrowing is achieved by placement. +- **Custom roles.** The role vocabulary and the scopes each role holds stay with + the [auth system](../../auth/requirements/roles-and-scopes.md), which fixes + them. +- **Inherited configuration.** Executors, secrets, agent profiles, and default + workflows are not inherited through the tree by this capability. +- **External directory sync.** Units and memberships are not populated from + LDAP, SCIM, or an identity provider. +- **Cross-organization sharing.** The tenant boundary is owned by + [multi-tenancy](../../multi-tenancy/spec.md) and remains absolute. diff --git a/docs/specs/workspaces/system-design/org-units.md b/docs/specs/workspaces/system-design/org-units.md new file mode 100644 index 0000000000..a2e11197d1 --- /dev/null +++ b/docs/specs/workspaces/system-design/org-units.md @@ -0,0 +1,204 @@ +--- +status: draft +system: workspaces +requirements: + - REQ-WORKSPACES-ORG-UNITS-001 + - REQ-WORKSPACES-ORG-UNITS-002 + - REQ-WORKSPACES-ORG-UNITS-003 + - REQ-WORKSPACES-ORG-UNITS-004 + - REQ-WORKSPACES-ORG-UNITS-005 +--- + +# Organization Unit System Design + +## Purpose and boundaries + +This design owns the unit tree, unit membership, workspace placement, and the +resolution of a user's effective role on a workspace. + +It does not own the scope vocabulary or the role-to-scope mapping, which stay in +`internal/authz` under the +[auth system](../../auth/requirements/roles-and-scopes.md). It does not own the +tenant boundary, which stays in `internal/org` under +[multi-tenancy](../../multi-tenancy/spec.md). Both are consumed here unchanged. + +The tree replaces `workspaces.visibility` outright. There is no visibility +column, no organization-level default visibility, and no second reach path to +keep in agreement with this one. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-WORKSPACES-ORG-UNITS-001` | [Data and contracts](#data-and-contracts) | +| `REQ-WORKSPACES-ORG-UNITS-002` | [Resolution](#resolution) | +| `REQ-WORKSPACES-ORG-UNITS-003` | [Personal units](#personal-units) | +| `REQ-WORKSPACES-ORG-UNITS-004` | [Resolution](#resolution) | +| `REQ-WORKSPACES-ORG-UNITS-005` | [Placement and movement](#placement-and-movement) | + +## Components and responsibilities + +| Component | Responsibility | +| --- | --- | +| `internal/orgunit` | Unit lifecycle, membership, tree invariants, materialized path maintenance. | +| `internal/authz` | Scope vocabulary and role-to-scope mapping. Gains `unit.manage`; otherwise unchanged. | +| `internal/authz.ResolveWorkspace` | Reads a precomputed ancestor role set instead of a visibility column. | +| `internal/task/service` | Workspace creation and movement, reach predicates for tasks and sessions. | +| `apps/web` settings tree | Unit tree management, unit membership, workspace placement. | + +## Data and contracts + +``` +org_units + id string PK + org_id string FK -> orgs.id + parent_id string FK -> org_units.id, '' for the root + kind enum root | standard | personal + owner_user_id string the owner for kind='personal', '' otherwise + name string + path string materialized ancestor path, '///' + created_at timestamp + updated_at timestamp + +unit_members + unit_id string PK part, FK -> org_units.id (cascade delete) + user_id string PK part, FK -> users.id (cascade delete) + role enum owner | collaborator | viewer + added_by string + created_at timestamp + +workspaces + + unit_id string FK -> org_units.id, NOT NULL + +workspace_members unchanged, now meaning a direct grant only +``` + +Removed by this design: `workspaces.visibility`, the organization's +`default_workspace_visibility` setting, and `POST /api/v1/workspaces/visibility/bulk`. + +`path` is a materialized path of ancestor ids, maintained on insert and on move. +It is chosen over a recursive query because both SQLite and Postgres are +supported and a prefix match is portable across them; it is a denormalization +whose only writer is `internal/orgunit`. + +Indexes: `org_units(org_id, parent_id)`, `org_units(path)`, +`unit_members(user_id)`, `workspaces(unit_id)`. + +### HTTP surface + +``` +GET /api/v1/units tree for the caller's org +POST /api/v1/units {parent_id, name} unit.manage on parent +PATCH /api/v1/units/{id} {name?, parent_id?} unit.manage on both +DELETE /api/v1/units/{id} unit.manage, refuses when not empty +GET /api/v1/units/{id}/members unit.read +PUT /api/v1/units/{id}/members/{userId} {role} member.manage on unit +DELETE /api/v1/units/{id}/members/{userId} member.manage on unit +PATCH /api/v1/workspaces/{id} {unit_id} to move workspace.manage + unit.manage +``` + +Workspace DTOs carry `unit_id`, the unit's display path, and the caller's +effective `viewer_role` and `scopes`. They no longer carry `visibility`. + +## Resolution + +`ResolveWorkspace(subject, workspace)` decides in this order: + +1. Cross-organization: if the subject's org and the workspace's org are both set + and differ, the result is no reach. This is evaluated first and is unchanged. +2. Unscoped subject (authentication disabled): owner. +3. Otherwise the effective role is the highest of: + - the role the subject's organization role grants at the root unit, + - every `unit_members` row for the subject whose unit is an ancestor of the + workspace's unit, including that unit itself, + - any `workspace_members` row for the subject on this workspace. + +Ancestors come from the workspace unit's `path`, so the membership lookup is a +single query against `unit_members` filtered by the ids in that path. Roles rank +`viewer < collaborator < owner` and the maximum wins, which is why no grant can +lower an inherited role. + +A personal unit contributes nothing to any subject except its owner, because it +holds no membership rows. + +A disabled account resolves to no reach before the union is computed. + +## Personal units + +A personal unit is created in the same transaction as its user, is `kind = +'personal'`, and carries `owner_user_id`. `internal/orgunit` refuses membership +writes against it, refuses to move or delete it while its owner exists, and +places workspaces there when a creator names no unit. + +Deleting a user deletes the personal unit and its workspaces through the +existing workspace deletion path. + +## Placement and movement + +A move updates `workspaces.unit_id`, then recomputes reach. Because reach is +derived rather than stored, no rows need rewriting beyond the single column. + +Moving a unit rewrites the `path` of that unit and of every descendant in one +transaction, and refuses when the destination is a descendant of the unit being +moved. + +Both operations publish a workspace reach-changed event so the WebSocket +gateway can drop subscriptions held by users who no longer reach the workspace, +reusing the revocation path that membership removal already uses. + +## Failure and recovery + +A refused tree operation names the blocking condition: the remaining children or +workspaces for a delete, the ancestor cycle for a move, the organization +mismatch for a cross-tenant parent. None of these are retried automatically. + +A reach lookup that fails to read `unit_members` returns no reach rather than a +permissive default, matching the fail-closed rule the current resolver already +applies to workspace lookups. + +## Persistence + +Schema changes arrive as idempotent migrations per +[ADR 0027](../../../decisions/0027-replayable-schema-migrations.md). The one-shot data +migration: + +1. Creates a root unit per organization. +2. Creates a personal unit per user. +3. Places every existing workspace with a non-empty `owner_id` in that owner's + personal unit, and every workspace with an empty `owner_id` in the root unit, + which preserves the pre-authentication behaviour of a workspace nobody owns. +4. Retains existing `workspace_members` rows as direct grants. +5. Drops `workspaces.visibility`. + +The migration is idempotent and safe to replay. It never widens reach: a +workspace that was private lands in a personal unit, and a workspace that was +organization-visible lands under the root unit only when it had no owner. + +Table rebuilds that recreate `workspaces` or `tasks` must carry the new columns +in both the replacement `CREATE TABLE` and the `INSERT ... SELECT` list, which +is the failure mode recorded in `apps/backend/AGENTS.md`. + +## Security + +The tenant check runs before any role resolution and is unaffected by the tree. +A unit, a membership, and a workspace all carry `org_id`, and every write +validates that the parent, the destination, and the subject share it. + +Reach absence is reported as not found rather than forbidden, so the tree does +not disclose the existence of a workspace or unit that the caller cannot reach. + +`unit.manage` is a new scope in the `internal/authz` registry, held by the +organization administrator role and by a unit `owner`. The completeness test +that asserts every guarded action names a registered scope covers the new +endpoints. + +## Observability + +Unit creation, movement, deletion, and membership changes emit structured logs +carrying the acting user, the unit, and the organization. The reach-changed +event carries the workspace and the set of users whose reach was withdrawn, so a +revocation that fails to disconnect a client is visible. + +## Related decisions + +- [ADR 0027: replayable migrations](../../../decisions/0027-replayable-schema-migrations.md) diff --git a/scripts/demo-team-access.sh b/scripts/demo-team-access.sh new file mode 100755 index 0000000000..232347fc4f --- /dev/null +++ b/scripts/demo-team-access.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Boot an isolated Kandev instance with authentication enabled and seed a small +# team, so workspace visibility and membership can be clicked through. +# +# The instance is deliberately hermetic: `env -i` plus an explicit +# KANDEV_HOME_DIR, because an inherited KANDEV_DATABASE_PATH points at the +# developer's real database. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PORT="${KANDEV_DEMO_PORT:-8231}" +HOME_DIR="${KANDEV_DEMO_HOME:-/tmp/kandev-team-access-demo}" +# Bind loopback plus any extra host (e.g. a Tailscale IP) so the instance is +# reachable from another device on the tailnet. Seeding always talks to +# loopback; BASE is only what the seeder and probes use. +# Loopback is always bound: the health probe and the seeder talk to BASE on +# 127.0.0.1, so an extra host must be added to loopback rather than replace it. +BIND_HOSTS="127.0.0.1${KANDEV_DEMO_BIND:+,${KANDEV_DEMO_BIND}}" +BASE="http://127.0.0.1:${PORT}" +COOKIE_JAR="${HOME_DIR}/admin-cookies.txt" + +log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } + +api() { # api METHOD PATH [JSON] + local method="$1" path="$2" body="${3:-}" + if [ -n "$body" ]; then + curl -sS -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X "$method" \ + -H 'Content-Type: application/json' -d "$body" "${BASE}${path}" + else + curl -sS -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X "$method" "${BASE}${path}" + fi +} + +start_backend() { + # Refuse to boot onto an occupied port. Without this the health probe below + # happily passes against a STALE server still holding the port, and every + # later check silently measures the old binary. + if ss -lptn "sport = :${PORT}" 2>/dev/null | grep -q LISTEN; then + echo "port ${PORT} is already in use; run '$0 stop' or free it first" >&2 + ss -lptn "sport = :${PORT}" >&2 + exit 1 + fi + rm -rf "$HOME_DIR" + mkdir -p "$HOME_DIR" + log "booting kandev on ${BASE} (home ${HOME_DIR})" + env -i \ + HOME="$HOME_DIR" \ + PATH="${REPO_ROOT}/apps/backend/bin:/usr/local/bin:/usr/bin:/bin" \ + KANDEV_HOME_DIR="$HOME_DIR" \ + KANDEV_PORT="$PORT" \ + KANDEV_SERVER_HOST="$BIND_HOSTS" \ + KANDEV_FEATURES_AUTH=true \ + KANDEV_FEATURES_OFFICE="${KANDEV_DEMO_OFFICE:-false}" \ + KANDEV_FEATURES_MULTI_TENANCY="${KANDEV_DEMO_TENANCY:-false}" \ + KANDEV_WEB_DIST_DIR="${REPO_ROOT}/apps/web/dist" \ + "${REPO_ROOT}/apps/backend/bin/kandev" __backend \ + >"${HOME_DIR}/backend.log" 2>&1 & + echo $! >"${HOME_DIR}/backend.pid" + + for _ in $(seq 1 90); do + if curl -sf "${BASE}/health" >/dev/null 2>&1; then + log "backend is up (pid $(cat "${HOME_DIR}/backend.pid"))" + return 0 + fi + sleep 1 + done + echo "backend did not become healthy; last log lines:" >&2 + tail -40 "${HOME_DIR}/backend.log" >&2 + exit 1 +} + +seed() { + log "seeding the team" + python3 "${REPO_ROOT}/scripts/demo_team_access_seed.py" "$BASE" +} + +case "${1:-up}" in + up) start_backend; seed ;; + stop) + # Kill by the port's actual listener, not just the pid file: a crashed or + # replaced launch leaves the pid file naming a process that is not the one + # serving requests. + for pid in $(ss -lptn "sport = :${PORT}" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | sort -u); do + if tr '\0' ' ' <"/proc/${pid}/environ" 2>/dev/null | grep -q "$HOME_DIR"; then + kill "$pid" 2>/dev/null || true + log "stopped pid ${pid}" + fi + done + ;; + *) echo "usage: $0 [up|stop]" >&2; exit 2 ;; +esac diff --git a/scripts/demo_team_access_seed.py b/scripts/demo_team_access_seed.py new file mode 100755 index 0000000000..3e9dbdb357 --- /dev/null +++ b/scripts/demo_team_access_seed.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Seed a Kandev instance with a small team so workspace visibility and +membership can be clicked through. + +Idempotent: re-running against an already-seeded instance reuses the existing +accounts and workspaces instead of failing on "email is already in use". +""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request +from http.cookiejar import CookieJar + +PASSWORD = "kandev-demo-pw-1" + + +class Client: + def __init__(self, base: str) -> None: + self.base = base.rstrip("/") + self.opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(CookieJar()) + ) + + def call(self, method: str, path: str, body: dict | None = None) -> dict: + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request( + f"{self.base}{path}", data=data, method=method, + headers={"Content-Type": "application/json"}, + ) + try: + with self.opener.open(request, timeout=30) as response: + raw = response.read().decode() + except urllib.error.HTTPError as error: + raw = error.read().decode() + try: + return {"_error": json.loads(raw).get("error", raw), "_status": error.code} + except json.JSONDecodeError: + return {"_error": raw, "_status": error.code} + return json.loads(raw) if raw else {} + + +def unwrap_id(payload: dict) -> str | None: + """Accept either {"id": ...} or {"user": {"id": ...}}.""" + if "id" in payload: + return payload["id"] + for key in ("user", "workspace", "task"): + nested = payload.get(key) + if isinstance(nested, dict) and "id" in nested: + return nested["id"] + return None + + +def ensure_user(client: Client, email: str, name: str, role: str) -> str: + created = client.call( + "POST", "/api/v1/users", + {"email": email, "password": PASSWORD, "display_name": name, "role": role}, + ) + user_id = unwrap_id(created) + if user_id: + return user_id + # Already present: find it in the directory. + for user in client.call("GET", "/api/v1/users").get("users", []): + if user.get("email") == email: + return user["id"] + raise SystemExit(f"could not create or find {email}: {created}") + + +def ensure_workspace(client: Client, name: str) -> str: + for workspace in client.call("GET", "/api/v1/workspaces").get("workspaces", []): + if workspace.get("name") == name: + return workspace["id"] + created = client.call("POST", "/api/v1/workspaces", {"name": name}) + workspace_id = unwrap_id(created) + if not workspace_id: + raise SystemExit(f"could not create workspace {name}: {created}") + return workspace_id + + +def first_workflow(client: Client, workspace_id: str) -> str | None: + payload = client.call("GET", f"/api/v1/workspaces/{workspace_id}/workflows") + flows = payload.get("workflows") if isinstance(payload, dict) else payload + if isinstance(flows, list) and flows: + return flows[0].get("id") + return None + + +def main() -> None: + base = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8231" + client = Client(base) + + setup = client.call( + "POST", "/api/v1/auth/setup", + {"email": "ana@example.com", "password": PASSWORD, "display_name": "Ana Ferreira"}, + ) + if "_error" in setup: + login = client.call( + "POST", "/api/v1/auth/login", + {"email": "ana@example.com", "password": PASSWORD}, + ) + if "_error" in login: + raise SystemExit(f"cannot authenticate as the admin: {login}") + print("signed in as Ana (admin)") + + bruno = ensure_user(client, "bruno@example.com", "Bruno Costa", "member") + carla = ensure_user(client, "carla@example.com", "Carla Nunes", "member") + dana = ensure_user(client, "dana@example.com", "Dana Vieira (contractor)", "guest") + print("colleagues ready: Bruno (member), Carla (member), Dana (guest)") + + team = ensure_workspace(client, "Platform Team") + private = ensure_workspace(client, "Ana - security spike") + + # Build the shape a company actually has: a department with a team under + # it, and the shared board placed in the team. Reach follows that tree, so + # nobody is invited to the board itself. + units = client.call("GET", "/api/v1/units")["units"] or [] + root = next(u["id"] for u in units if u["kind"] == "root") + dept = client.call("POST", "/api/v1/units", {"parent_id": root, "name": "Platform"}) + squad = client.call("POST", "/api/v1/units", {"parent_id": dept["id"], "name": "Runtime"}) + client.call("PATCH", f"/api/v1/workspaces/{team}", {"unit_id": squad["id"]}) + client.call("PUT", f"/api/v1/units/{dept['id']}/members/{bruno}", {"role": "collaborator"}) + print("Platform > Runtime holds the shared board; Bruno reaches it through the department") + + client.call("PUT", f"/api/v1/units/{squad['id']}/members/{carla}", {"role": "viewer"}) + print("Carla is a viewer on the Runtime team") + + client.call("PUT", f"/api/v1/workspaces/{private}/members/{dana}", {"role": "collaborator"}) + print("Dana (guest) admitted to the private workspace only") + + workflow = first_workflow(client, team) + if workflow: + for title in ( + "Rate-limit the public search endpoint", + "Flaky checkout test on CI", + "Upgrade the Postgres driver", + ): + client.call("POST", "/api/v1/tasks", { + "title": title, "workspace_id": team, + "workflow_id": workflow, "start_agent": False, + }) + print("seeded 3 tasks on the shared board") + + summary = f""" +Kandev team-access demo + URL: {base} + + Platform Team {team} visibility = org + Ana - security spike {private} visibility = private + +Sign in (password for all: {PASSWORD}): + ana@example.com admin owns both workspaces + bruno@example.com member sees Platform Team with NO invitation (collaborator) + carla@example.com member narrowed to VIEWER on Platform Team + dana@example.com guest sees ONLY the private workspace she was added to +""" + print(summary) + # Honour KANDEV_DEMO_HOME: hardcoding the default made a second instance on + # another port write into (or fail against) the first one's directory. + home = os.environ.get("KANDEV_DEMO_HOME", "/tmp/kandev-team-access-demo") + os.makedirs(home, exist_ok=True) + with open(os.path.join(home, "demo-info.txt"), "w", encoding="utf-8") as handle: + handle.write(summary) + + +if __name__ == "__main__": + main()