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:
João Salavisa
2026-09-05 13:40:43 +00:00
committed by GitHub
parent b72c5ff0c2
commit 481359ddb2
317 changed files with 17615 additions and 461 deletions
@@ -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):
+66 -4
View File
@@ -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,
+55 -20
View File
@@ -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
}
+6 -4
View File
@@ -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
}
+3 -3
View File
@@ -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
}
+48
View File
@@ -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
}
+136
View File
@@ -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
}
+312
View File
@@ -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)
}
}
+139
View File
@@ -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
}
}
+125
View File
@@ -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)
}
}
+3 -2
View File
@@ -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,
+26 -5
View File
@@ -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)
}
}
}
+43 -1
View File
@@ -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
+106
View File
@@ -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,
+19 -13
View File
@@ -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
}
+89 -2
View File
@@ -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 {
+199
View File
@@ -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})
}
}
+71
View File
@@ -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
}
+283
View File
@@ -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
}
+186
View File
@@ -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
}
+187
View File
@@ -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")
}
}
+223
View File
@@ -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")
}
}
+78
View File
@@ -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")
)
+343
View File
@@ -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])
}
}
}
+414
View File
@@ -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()
}
+214
View File
@@ -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",
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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 {
+27 -4
View File
@@ -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])
}
+20 -8
View File
@@ -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