feat(auth): organization units, tenancy, roles and scopes for team access (#2919)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 '',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 '',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, "/<id>/<id>/", 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")
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user