From 481359ddb282fd7d3e8c9cdff99da6f9bdd24c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sat, 5 Sep 2026 13:40:43 +0000 Subject: [PATCH] feat(auth): organization units, tenancy, roles and scopes for team access (#2919) Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 5 (1M context) --- .../internal/agent/handlers/shell_handlers.go | 4 +- .../agent/runtime/lifecycle/manager.go | 56 ++ .../runtime/lifecycle/manager_execution.go | 18 +- .../handlers/agent_settings_org_scope_test.go | 97 ++++ .../handlers/agent_update_handlers_test.go | 1 + .../agent/settings/handlers/handlers.go | 31 +- .../interim_settings_interlock_test.go | 2 + .../handlers/model_config_handlers_test.go | 1 + .../profile_duplicate_handlers_test.go | 35 ++ apps/backend/internal/auth/authn/identity.go | 13 + .../backend/internal/auth/httpapi/handlers.go | 9 +- apps/backend/internal/auth/service.go | 70 ++- .../internal/auth/service_credentials.go | 22 +- apps/backend/internal/auth/service_invites.go | 11 +- .../auth/service_provisioning_review_test.go | 55 ++ apps/backend/internal/auth/service_setup.go | 8 + apps/backend/internal/auth/service_users.go | 75 ++- apps/backend/internal/auth/store/models.go | 10 +- apps/backend/internal/auth/store/schema.go | 8 + apps/backend/internal/auth/store/store.go | 6 +- apps/backend/internal/authz/gin.go | 48 ++ apps/backend/internal/authz/registry_test.go | 140 +++++ apps/backend/internal/authz/resolve.go | 136 +++++ apps/backend/internal/authz/resolve_test.go | 312 +++++++++++ apps/backend/internal/authz/roles.go | 139 +++++ apps/backend/internal/authz/scopes.go | 125 +++++ .../backendapp/adapters_user_directory.go | 72 +++ .../adapters_user_directory_test.go | 69 +++ apps/backend/internal/backendapp/auth.go | 5 +- .../internal/backendapp/boot_state_routes.go | 22 +- apps/backend/internal/backendapp/helpers.go | 31 +- .../lifecycle_access_wiring_test.go | 39 +- apps/backend/internal/backendapp/main.go | 44 +- .../internal/backendapp/orchestrator.go | 12 + apps/backend/internal/backendapp/orgs.go | 106 ++++ .../internal/backendapp/orgs_review_test.go | 129 +++++ apps/backend/internal/backendapp/orgunits.go | 78 +++ apps/backend/internal/backendapp/services.go | 29 + apps/backend/internal/backendapp/types.go | 32 +- apps/backend/internal/common/config/config.go | 8 + .../internal/gateway/websocket/access.go | 28 + .../backend/internal/gateway/websocket/hub.go | 91 +++- .../websocket/hub_access_revocation_test.go | 69 +++ .../internal/gateway/websocket/port_proxy.go | 2 +- .../gateway/websocket/vscode_proxy.go | 2 +- apps/backend/internal/office/dashboard/dto.go | 1 + .../internal/office/dashboard/handler.go | 35 +- .../dashboard/handler_task_assignee_test.go | 170 ++++++ .../internal/office/dashboard/handler_test.go | 1 + .../internal/office/dashboard/service.go | 21 + .../office/dashboard/service_tasks.go | 20 + .../repository/sqlite/base_migrations.go | 15 +- .../migrations_priority_assignee_test.go | 74 +++ .../office/repository/sqlite/tasks.go | 13 +- .../office/repository/sqlite/tasks_test.go | 1 + .../office/scheduler/reactivity_test.go | 3 +- .../internal/office/service/base_test.go | 1 + .../executor/executor_mocks_test.go | 4 + .../internal/orchestrator/queue_send_now.go | 3 +- apps/backend/internal/orchestrator/service.go | 10 +- .../internal/orchestrator/service_scopes.go | 78 +++ .../internal/orchestrator/session_launch.go | 10 +- apps/backend/internal/orchestrator/steer.go | 4 + .../internal/orchestrator/task_operations.go | 2 +- apps/backend/internal/org/controller.go | 199 +++++++ apps/backend/internal/org/models.go | 71 +++ apps/backend/internal/org/service.go | 283 ++++++++++ apps/backend/internal/org/store.go | 186 +++++++ apps/backend/internal/orgunit/backfill.go | 187 +++++++ .../backend/internal/orgunit/backfill_test.go | 157 ++++++ apps/backend/internal/orgunit/controller.go | 223 ++++++++ .../internal/orgunit/controller_test.go | 166 ++++++ apps/backend/internal/orgunit/models.go | 78 +++ apps/backend/internal/orgunit/service.go | 343 ++++++++++++ apps/backend/internal/orgunit/service_test.go | 247 +++++++++ apps/backend/internal/orgunit/store.go | 414 +++++++++++++++ apps/backend/internal/orgunit/store_test.go | 214 ++++++++ apps/backend/internal/plugins/handlers.go | 5 + apps/backend/internal/profiles/profiles.yaml | 9 + .../backend/internal/runtimeflags/handlers.go | 4 +- .../backend/internal/runtimeflags/registry.go | 20 + apps/backend/internal/secrets/service.go | 2 +- apps/backend/internal/system/system.go | 4 +- apps/backend/internal/task/dto/dto.go | 31 +- .../handlers/cancellation_pending_test.go | 4 + apps/backend/internal/task/handlers/errors.go | 12 + .../task/handlers/executor_handlers_test.go | 4 + .../internal/task/handlers/member_handlers.go | 169 ++++++ .../handlers/message_handlers_blocked_test.go | 4 + .../task/handlers/process_handlers_test.go | 4 + .../task/handlers/task_http_handlers.go | 19 +- .../task/handlers/task_ws_handlers.go | 19 +- .../task/handlers/workspace_handlers.go | 18 +- apps/backend/internal/task/models/models.go | 28 +- .../internal/task/models/workspace_members.go | 18 + .../internal/task/repository/interface.go | 14 + .../task/repository/sqlite/base_migrations.go | 3 + .../task/repository/sqlite/base_schema.go | 19 + .../task/repository/sqlite/org_data.go | 55 ++ .../repository/sqlite/org_unit_placement.go | 48 ++ .../sqlite/org_unit_placement_test.go | 106 ++++ .../internal/task/repository/sqlite/task.go | 19 +- .../sqlite/team_access_migration.go | 83 +++ .../task/repository/sqlite/workspace.go | 17 +- .../repository/sqlite/workspace_members.go | 216 ++++++++ .../workspace_members_unsupported.go | 47 ++ apps/backend/internal/task/service/service.go | 4 + .../internal/task/service/service_access.go | 264 ++++++++-- .../task/service/service_branch_update.go | 3 +- .../task/service/service_errors_authz.go | 40 ++ .../internal/task/service/service_events.go | 24 +- .../service/service_events_assignee_test.go | 48 ++ .../service/service_events_placement_test.go | 50 ++ .../internal/task/service/service_members.go | 450 ++++++++++++++++ .../service_members_tenancy_review_test.go | 68 +++ .../internal/task/service/service_messages.go | 26 +- .../internal/task/service/service_requests.go | 10 + .../task/service/service_resources.go | 69 ++- .../task/service/service_resources_test.go | 3 + .../internal/task/service/service_tasks.go | 16 +- .../task/service/service_team_access_test.go | 350 ++++++++++++ .../internal/task/service/service_test.go | 16 + .../task/service/service_unit_placement.go | 94 ++++ .../task/service/service_unit_reach.go | 83 +++ .../internal/task/service/service_workflow.go | 7 +- .../task_assignee_column_regression_test.go | 150 ++++++ apps/backend/internal/user/models/models.go | 38 +- apps/backend/internal/user/store/sqlite.go | 23 +- .../internal/user/store/sqlite_tenancy.go | 102 ++++ apps/backend/internal/user/store/store.go | 27 + apps/web/app/auth/login-page.tsx | 16 +- .../office/tasks/[id]/map-office-task.test.ts | 13 + .../app/office/tasks/[id]/map-office-task.ts | 1 + apps/web/app/office/tasks/[id]/types.ts | 1 + .../app/settings/agents/[agentId]/page.tsx | 19 +- .../profile-mcp-config-card.test.tsx | 6 + .../[agentId]/profile-mcp-config-card.tsx | 7 +- apps/web/app/settings/agents/page.tsx | 66 ++- .../workspace/workspace-edit-client.tsx | 34 ++ .../workspace/workspaces-page-client.tsx | 33 +- .../app-sidebar/current-user-chip.tsx | 8 +- .../settings/settings-menu-sections.ts | 21 +- .../settings/settings-tree-render.test.tsx | 46 +- .../sections/settings/settings-tree.tsx | 13 +- .../components/kanban-card-assignee-badge.tsx | 31 ++ .../components/kanban-card-assignee.test.tsx | 77 +++ apps/web/components/kanban-card-content.tsx | 65 +-- .../kanban-card-repository-chips.tsx | 64 +++ apps/web/components/kanban-card.tsx | 2 + .../agents/agent-admin-gating.test.tsx | 115 ++++ .../settings/agents/agent-install-catalog.tsx | 8 +- .../agents/agent-profiles-section.test.tsx | 7 + .../agents/agent-profiles-section.tsx | 8 +- .../settings/install-agent-card.tsx | 29 +- .../settings/settings-breadcrumb-labels.ts | 2 + .../system/organizations/create-org-card.tsx | 51 ++ .../organizations/delete-org-dialog.tsx | 78 +++ .../system/organizations/org-admin-dialog.tsx | 100 ++++ .../organizations/organizations-page.tsx | 210 ++++++++ .../settings/units/unit-members-dialog.tsx | 124 +++++ .../settings/units/units-page.test.tsx | 92 ++++ .../components/settings/units/units-page.tsx | 163 ++++++ .../workspace-placement-card.test.tsx | 70 +++ .../workspaces/workspace-placement-card.tsx | 84 +++ .../workspaces/workspace-team-access-card.tsx | 232 ++++++++ .../components/human-assignee-picker.test.tsx | 146 +++++ .../components/human-assignee-picker.tsx | 102 ++++ .../task/simple/task-properties.tsx | 17 + .../task/task-assignee-control.test.tsx | 112 ++++ .../components/task/task-assignee-control.tsx | 137 +++++ apps/web/components/task/task-top-bar.tsx | 4 + .../auth/mobile-organizations-layout.spec.ts | 82 +++ apps/web/e2e/tests/auth/org-units.spec.ts | 152 ++++++ apps/web/hooks/domains/org/use-org-units.ts | 77 +++ .../hooks/domains/org/use-organizations.ts | 95 ++++ .../settings/use-settings-discovery.ts | 8 +- .../domains/users/use-assignable-people.ts | 124 +++++ .../workspace/use-workspace-team-access.ts | 162 ++++++ .../web/hooks/use-optimistic-task-mutation.ts | 6 + apps/web/lib/api/domains/kanban-api.ts | 2 + .../lib/api/domains/office-extended-api.ts | 2 + apps/web/lib/api/domains/org-api.ts | 47 ++ .../web/lib/api/domains/org-units-api.test.ts | 40 ++ apps/web/lib/api/domains/org-units-api.ts | 107 ++++ apps/web/lib/api/domains/team-access-api.ts | 52 ++ apps/web/lib/kanban/map-task.test.ts | 18 + apps/web/lib/kanban/map-task.ts | 13 + .../route-bootstrap.team-access.test.ts | 56 ++ apps/web/lib/routing/route-bootstrap.ts | 7 + .../lib/settings-discovery/catalog.test.ts | 7 + .../lib/settings-discovery/catalog/system.ts | 20 + apps/web/lib/settings-discovery/resolve.ts | 1 + apps/web/lib/settings-discovery/types.ts | 3 +- apps/web/lib/ssr/mapper.test.ts | 12 + apps/web/lib/ssr/mapper.ts | 3 +- apps/web/lib/state/slices/features/types.ts | 1 + apps/web/lib/state/slices/kanban/types.ts | 2 + .../state/slices/office/office-task-type.ts | 5 + apps/web/lib/state/slices/workspace/types.ts | 7 + apps/web/lib/types/backend.ts | 1 + apps/web/lib/types/http.ts | 13 + apps/web/lib/types/org.ts | 25 + apps/web/lib/types/team-access.test.ts | 43 ++ apps/web/lib/types/team-access.ts | 66 +++ apps/web/lib/user-initials.ts | 16 + .../lib/ws/handlers/tasks-assignee.test.ts | 157 ++++++ apps/web/lib/ws/handlers/tasks.ts | 7 + apps/web/lib/ws/handlers/workspaces.test.ts | 76 +++ apps/web/lib/ws/handlers/workspaces.ts | 10 + apps/web/scripts/demo-team-access-shots.mjs | 119 +++++ apps/web/scripts/team-access-docs-shots.mjs | 175 ++++++ apps/web/scripts/tenancy-docs-shots.mjs | 110 ++++ apps/web/src/locales/_verbatim.json | 2 + apps/web/src/locales/en/agents.json | 3 +- apps/web/src/locales/en/auth.json | 7 +- apps/web/src/locales/en/orgs.json | 38 ++ apps/web/src/locales/en/settings.json | 22 +- apps/web/src/locales/en/task.json | 6 + apps/web/src/locales/en/workspaces.json | 42 +- apps/web/src/locales/pseudo/agents.json | 3 +- apps/web/src/locales/pseudo/auth.json | 7 +- apps/web/src/locales/pseudo/orgs.json | 38 ++ apps/web/src/locales/pseudo/settings.json | 22 +- apps/web/src/locales/pseudo/task.json | 6 + apps/web/src/locales/pseudo/workspaces.json | 42 +- apps/web/src/locales/pt-pt/agents.json | 3 +- apps/web/src/locales/pt-pt/auth.json | 25 +- apps/web/src/locales/pt-pt/orgs.json | 38 ++ apps/web/src/locales/pt-pt/settings.json | 22 +- apps/web/src/locales/pt-pt/task.json | 6 + apps/web/src/locales/pt-pt/workspaces.json | 42 +- apps/web/src/locales/zh-cn/agents.json | 3 +- apps/web/src/locales/zh-cn/auth.json | 25 +- apps/web/src/locales/zh-cn/orgs.json | 38 ++ apps/web/src/locales/zh-cn/settings.json | 25 +- apps/web/src/locales/zh-cn/task.json | 6 + apps/web/src/locales/zh-cn/workspaces.json | 42 +- apps/web/src/locales/zh-hk/agents.json | 3 +- apps/web/src/locales/zh-hk/auth.json | 25 +- apps/web/src/locales/zh-hk/orgs.json | 38 ++ apps/web/src/locales/zh-hk/settings.json | 22 +- apps/web/src/locales/zh-hk/task.json | 8 +- apps/web/src/locales/zh-hk/workspaces.json | 42 +- apps/web/src/locales/zh-tw/agents.json | 3 +- apps/web/src/locales/zh-tw/auth.json | 25 +- apps/web/src/locales/zh-tw/orgs.json | 38 ++ apps/web/src/locales/zh-tw/settings.json | 22 +- apps/web/src/locales/zh-tw/task.json | 8 +- apps/web/src/locales/zh-tw/workspaces.json | 42 +- apps/web/src/settings-routes.tsx | 12 + docs/plans/multi-tenancy/plan.md | 197 +++++++ .../task-01-tenancy-registry-and-flag.md | 51 ++ .../task-02-org-entity-and-identity.md | 57 ++ ...task-03-system-identity-background-work.md | 61 +++ .../task-04-service-layer-org-scoping.md | 65 +++ .../task-05-transport-scoping.md | 54 ++ .../task-06-credential-org-binding.md | 59 +++ .../task-07-two-tier-config-storage.md | 56 ++ ...task-08-instance-templates-and-operator.md | 55 ++ .../task-09-per-org-filesystem-roots.md | 57 ++ .../task-10-per-org-agent-credentials.md | 54 ++ .../task-11-executor-tenant-pinning.md | 59 +++ .../multi-tenancy/task-12-org-lifecycle.md | 56 ++ .../task-13-frontend-surfaces.md | 63 +++ .../multi-tenancy/task-14-e2e-docs-adr.md | 64 +++ .../task-15-per-org-secret-keys.md | 67 +++ docs/plans/org-units/plan.md | 87 +++ .../org-units/task-01-unit-tree-storage.md | 105 ++++ .../org-units/task-02-reach-resolution.md | 90 ++++ .../org-units/task-03-unit-management-api.md | 86 +++ .../plans/org-units/task-04-unit-interface.md | 91 ++++ .../org-units/task-05-remove-visibility.md | 89 ++++ docs/plans/org-units/task-06-e2e-and-docs.md | 81 +++ docs/plans/team-access/plan.md | 185 +++++++ .../team-access/task-01-scope-registry.md | 61 +++ .../task-02-visibility-membership-storage.md | 56 ++ .../task-03-reach-and-scope-resolution.md | 69 +++ .../team-access/task-04-management-api.md | 65 +++ .../task-05-ws-fanout-and-revocation.md | 54 ++ .../task-06-human-assignee-takeover.md | 130 +++++ .../team-access/task-07-actor-attribution.md | 58 ++ .../team-access/task-08-frontend-surfaces.md | 74 +++ .../plans/team-access/task-09-e2e-docs-adr.md | 82 +++ docs/public/authentication.md | 8 +- docs/public/coverage.json | 46 ++ docs/public/meta.json | 2 + docs/public/organizations.md | 124 +++++ docs/public/team-access.md | 184 +++++++ .../team-access-kanban-assignee-card.png | Bin 0 -> 3777 bytes .../team-access-kanban-assignee-topbar.png | Bin 0 -> 6561 bytes docs/screenshots/team-access-mobile.png | Bin 0 -> 38161 bytes docs/screenshots/team-access-owner-card.png | Bin 0 -> 16536 bytes docs/screenshots/team-access-shared-board.png | Bin 0 -> 53147 bytes .../screenshots/team-access-task-assignee.png | Bin 0 -> 92604 bytes docs/screenshots/team-access-unit-create.png | Bin 0 -> 113413 bytes docs/screenshots/team-access-unit-members.png | Bin 0 -> 11930 bytes docs/screenshots/team-access-units-tree.png | Bin 0 -> 102761 bytes docs/screenshots/team-access-user-roles.png | Bin 0 -> 71321 bytes docs/screenshots/team-access-viewer-card.png | Bin 0 -> 11060 bytes .../team-access-workspace-placement.png | Bin 0 -> 11761 bytes docs/screenshots/tenancy-acme-board.png | Bin 0 -> 51723 bytes docs/screenshots/tenancy-delete-confirm.png | Bin 0 -> 118719 bytes docs/screenshots/tenancy-first-admin.png | Bin 0 -> 125289 bytes docs/screenshots/tenancy-globex-board.png | Bin 0 -> 40927 bytes docs/screenshots/tenancy-organizations.png | Bin 0 -> 118951 bytes docs/screenshots/tenancy-suspended-login.png | Bin 0 -> 23287 bytes docs/specs/INDEX.md | 1 + docs/specs/auth/README.md | 1 + .../auth/requirements/roles-and-scopes.md | 277 ++++++++++ docs/specs/multi-tenancy/spec.md | 498 ++++++++++++++++++ docs/specs/tasks/README.md | 1 + .../tasks/requirements/human-assignee.md | 96 ++++ docs/specs/workspaces/README.md | 6 +- .../workspaces/requirements/org-units.md | 186 +++++++ .../workspaces/system-design/org-units.md | 204 +++++++ scripts/demo-team-access.sh | 91 ++++ scripts/demo_team_access_seed.py | 169 ++++++ 317 files changed, 17615 insertions(+), 461 deletions(-) create mode 100644 apps/backend/internal/agent/settings/handlers/agent_settings_org_scope_test.go create mode 100644 apps/backend/internal/auth/service_provisioning_review_test.go create mode 100644 apps/backend/internal/authz/gin.go create mode 100644 apps/backend/internal/authz/registry_test.go create mode 100644 apps/backend/internal/authz/resolve.go create mode 100644 apps/backend/internal/authz/resolve_test.go create mode 100644 apps/backend/internal/authz/roles.go create mode 100644 apps/backend/internal/authz/scopes.go create mode 100644 apps/backend/internal/backendapp/adapters_user_directory.go create mode 100644 apps/backend/internal/backendapp/adapters_user_directory_test.go create mode 100644 apps/backend/internal/backendapp/orgs.go create mode 100644 apps/backend/internal/backendapp/orgs_review_test.go create mode 100644 apps/backend/internal/backendapp/orgunits.go create mode 100644 apps/backend/internal/gateway/websocket/hub_access_revocation_test.go create mode 100644 apps/backend/internal/office/dashboard/handler_task_assignee_test.go create mode 100644 apps/backend/internal/office/repository/sqlite/migrations_priority_assignee_test.go create mode 100644 apps/backend/internal/orchestrator/service_scopes.go create mode 100644 apps/backend/internal/org/controller.go create mode 100644 apps/backend/internal/org/models.go create mode 100644 apps/backend/internal/org/service.go create mode 100644 apps/backend/internal/org/store.go create mode 100644 apps/backend/internal/orgunit/backfill.go create mode 100644 apps/backend/internal/orgunit/backfill_test.go create mode 100644 apps/backend/internal/orgunit/controller.go create mode 100644 apps/backend/internal/orgunit/controller_test.go create mode 100644 apps/backend/internal/orgunit/models.go create mode 100644 apps/backend/internal/orgunit/service.go create mode 100644 apps/backend/internal/orgunit/service_test.go create mode 100644 apps/backend/internal/orgunit/store.go create mode 100644 apps/backend/internal/orgunit/store_test.go create mode 100644 apps/backend/internal/task/handlers/member_handlers.go create mode 100644 apps/backend/internal/task/models/workspace_members.go create mode 100644 apps/backend/internal/task/repository/sqlite/org_data.go create mode 100644 apps/backend/internal/task/repository/sqlite/org_unit_placement.go create mode 100644 apps/backend/internal/task/repository/sqlite/org_unit_placement_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/team_access_migration.go create mode 100644 apps/backend/internal/task/repository/sqlite/workspace_members.go create mode 100644 apps/backend/internal/task/repository/workspace_members_unsupported.go create mode 100644 apps/backend/internal/task/service/service_errors_authz.go create mode 100644 apps/backend/internal/task/service/service_events_assignee_test.go create mode 100644 apps/backend/internal/task/service/service_events_placement_test.go create mode 100644 apps/backend/internal/task/service/service_members.go create mode 100644 apps/backend/internal/task/service/service_members_tenancy_review_test.go create mode 100644 apps/backend/internal/task/service/service_team_access_test.go create mode 100644 apps/backend/internal/task/service/service_unit_placement.go create mode 100644 apps/backend/internal/task/service/service_unit_reach.go create mode 100644 apps/backend/internal/task/service/task_assignee_column_regression_test.go create mode 100644 apps/backend/internal/user/store/sqlite_tenancy.go create mode 100644 apps/web/components/kanban-card-assignee-badge.tsx create mode 100644 apps/web/components/kanban-card-assignee.test.tsx create mode 100644 apps/web/components/kanban-card-repository-chips.tsx create mode 100644 apps/web/components/settings/agents/agent-admin-gating.test.tsx create mode 100644 apps/web/components/settings/system/organizations/create-org-card.tsx create mode 100644 apps/web/components/settings/system/organizations/delete-org-dialog.tsx create mode 100644 apps/web/components/settings/system/organizations/org-admin-dialog.tsx create mode 100644 apps/web/components/settings/system/organizations/organizations-page.tsx create mode 100644 apps/web/components/settings/units/unit-members-dialog.tsx create mode 100644 apps/web/components/settings/units/units-page.test.tsx create mode 100644 apps/web/components/settings/units/units-page.tsx create mode 100644 apps/web/components/settings/workspaces/workspace-placement-card.test.tsx create mode 100644 apps/web/components/settings/workspaces/workspace-placement-card.tsx create mode 100644 apps/web/components/settings/workspaces/workspace-team-access-card.tsx create mode 100644 apps/web/components/task/simple/components/human-assignee-picker.test.tsx create mode 100644 apps/web/components/task/simple/components/human-assignee-picker.tsx create mode 100644 apps/web/components/task/task-assignee-control.test.tsx create mode 100644 apps/web/components/task/task-assignee-control.tsx create mode 100644 apps/web/e2e/tests/auth/mobile-organizations-layout.spec.ts create mode 100644 apps/web/e2e/tests/auth/org-units.spec.ts create mode 100644 apps/web/hooks/domains/org/use-org-units.ts create mode 100644 apps/web/hooks/domains/org/use-organizations.ts create mode 100644 apps/web/hooks/domains/users/use-assignable-people.ts create mode 100644 apps/web/hooks/domains/workspace/use-workspace-team-access.ts create mode 100644 apps/web/lib/api/domains/org-api.ts create mode 100644 apps/web/lib/api/domains/org-units-api.test.ts create mode 100644 apps/web/lib/api/domains/org-units-api.ts create mode 100644 apps/web/lib/api/domains/team-access-api.ts create mode 100644 apps/web/lib/routing/route-bootstrap.team-access.test.ts create mode 100644 apps/web/lib/types/org.ts create mode 100644 apps/web/lib/types/team-access.test.ts create mode 100644 apps/web/lib/types/team-access.ts create mode 100644 apps/web/lib/user-initials.ts create mode 100644 apps/web/lib/ws/handlers/tasks-assignee.test.ts create mode 100644 apps/web/lib/ws/handlers/workspaces.test.ts create mode 100644 apps/web/scripts/demo-team-access-shots.mjs create mode 100644 apps/web/scripts/team-access-docs-shots.mjs create mode 100644 apps/web/scripts/tenancy-docs-shots.mjs create mode 100644 apps/web/src/locales/en/orgs.json create mode 100644 apps/web/src/locales/pseudo/orgs.json create mode 100644 apps/web/src/locales/pt-pt/orgs.json create mode 100644 apps/web/src/locales/zh-cn/orgs.json create mode 100644 apps/web/src/locales/zh-hk/orgs.json create mode 100644 apps/web/src/locales/zh-tw/orgs.json create mode 100644 docs/plans/multi-tenancy/plan.md create mode 100644 docs/plans/multi-tenancy/task-01-tenancy-registry-and-flag.md create mode 100644 docs/plans/multi-tenancy/task-02-org-entity-and-identity.md create mode 100644 docs/plans/multi-tenancy/task-03-system-identity-background-work.md create mode 100644 docs/plans/multi-tenancy/task-04-service-layer-org-scoping.md create mode 100644 docs/plans/multi-tenancy/task-05-transport-scoping.md create mode 100644 docs/plans/multi-tenancy/task-06-credential-org-binding.md create mode 100644 docs/plans/multi-tenancy/task-07-two-tier-config-storage.md create mode 100644 docs/plans/multi-tenancy/task-08-instance-templates-and-operator.md create mode 100644 docs/plans/multi-tenancy/task-09-per-org-filesystem-roots.md create mode 100644 docs/plans/multi-tenancy/task-10-per-org-agent-credentials.md create mode 100644 docs/plans/multi-tenancy/task-11-executor-tenant-pinning.md create mode 100644 docs/plans/multi-tenancy/task-12-org-lifecycle.md create mode 100644 docs/plans/multi-tenancy/task-13-frontend-surfaces.md create mode 100644 docs/plans/multi-tenancy/task-14-e2e-docs-adr.md create mode 100644 docs/plans/multi-tenancy/task-15-per-org-secret-keys.md create mode 100644 docs/plans/org-units/plan.md create mode 100644 docs/plans/org-units/task-01-unit-tree-storage.md create mode 100644 docs/plans/org-units/task-02-reach-resolution.md create mode 100644 docs/plans/org-units/task-03-unit-management-api.md create mode 100644 docs/plans/org-units/task-04-unit-interface.md create mode 100644 docs/plans/org-units/task-05-remove-visibility.md create mode 100644 docs/plans/org-units/task-06-e2e-and-docs.md create mode 100644 docs/plans/team-access/plan.md create mode 100644 docs/plans/team-access/task-01-scope-registry.md create mode 100644 docs/plans/team-access/task-02-visibility-membership-storage.md create mode 100644 docs/plans/team-access/task-03-reach-and-scope-resolution.md create mode 100644 docs/plans/team-access/task-04-management-api.md create mode 100644 docs/plans/team-access/task-05-ws-fanout-and-revocation.md create mode 100644 docs/plans/team-access/task-06-human-assignee-takeover.md create mode 100644 docs/plans/team-access/task-07-actor-attribution.md create mode 100644 docs/plans/team-access/task-08-frontend-surfaces.md create mode 100644 docs/plans/team-access/task-09-e2e-docs-adr.md create mode 100644 docs/public/organizations.md create mode 100644 docs/public/team-access.md create mode 100644 docs/screenshots/team-access-kanban-assignee-card.png create mode 100644 docs/screenshots/team-access-kanban-assignee-topbar.png create mode 100644 docs/screenshots/team-access-mobile.png create mode 100644 docs/screenshots/team-access-owner-card.png create mode 100644 docs/screenshots/team-access-shared-board.png create mode 100644 docs/screenshots/team-access-task-assignee.png create mode 100644 docs/screenshots/team-access-unit-create.png create mode 100644 docs/screenshots/team-access-unit-members.png create mode 100644 docs/screenshots/team-access-units-tree.png create mode 100644 docs/screenshots/team-access-user-roles.png create mode 100644 docs/screenshots/team-access-viewer-card.png create mode 100644 docs/screenshots/team-access-workspace-placement.png create mode 100644 docs/screenshots/tenancy-acme-board.png create mode 100644 docs/screenshots/tenancy-delete-confirm.png create mode 100644 docs/screenshots/tenancy-first-admin.png create mode 100644 docs/screenshots/tenancy-globex-board.png create mode 100644 docs/screenshots/tenancy-organizations.png create mode 100644 docs/screenshots/tenancy-suspended-login.png create mode 100644 docs/specs/auth/requirements/roles-and-scopes.md create mode 100644 docs/specs/multi-tenancy/spec.md create mode 100644 docs/specs/tasks/requirements/human-assignee.md create mode 100644 docs/specs/workspaces/requirements/org-units.md create mode 100644 docs/specs/workspaces/system-design/org-units.md create mode 100755 scripts/demo-team-access.sh create mode 100755 scripts/demo_team_access_seed.py diff --git a/apps/backend/internal/agent/handlers/shell_handlers.go b/apps/backend/internal/agent/handlers/shell_handlers.go index 890a1cd359..78c73a39ed 100644 --- a/apps/backend/internal/agent/handlers/shell_handlers.go +++ b/apps/backend/internal/agent/handlers/shell_handlers.go @@ -569,7 +569,9 @@ func (h *ShellHandlers) wsUserShellStop(ctx context.Context, msg *ws.Message) (* // GetOrEnsureExecutionForEnvironment where the check normally runs. Without // this, an empty task_id plus a foreign task_environment_id stopped another // user's terminal. - if err := h.lifecycleMgr.CheckEnvironmentAccess(ctx, req.TaskEnvironmentID); err != nil { + // Exec scope, not read: this path tears a PTY down. A caller who may only + // list terminals must not be able to stop one. + if err := h.lifecycleMgr.CheckEnvironmentExecAccess(ctx, req.TaskEnvironmentID); err != nil { return nil, errors.New("task environment not found") } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager.go b/apps/backend/internal/agent/runtime/lifecycle/manager.go index df909ce4d0..c543b1b1cc 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager.go @@ -83,11 +83,23 @@ type Manager struct { // surfaces (opt-in auth). Nil = no scoping. See SetSessionAccessChecker. sessionAccessCheck func(ctx context.Context, sessionID string) error + // sessionExecCheck enforces the session.exec scope on surfaces that hand + // the caller a shell, a file write, or a port preview. Reading a + // transcript and running a command in the worktree are different + // permissions, so they get different checks. + sessionExecCheck func(ctx context.Context, sessionID string) error + // environmentAccessCheck is the environment-keyed sibling of // sessionAccessCheck, used by the terminal environment-shell route which // resolves executions by environment ID. Nil = no scoping. environmentAccessCheck func(ctx context.Context, environmentID string) error + // environmentExecCheck enforces session.exec on the environment-keyed + // surfaces that tear down or hand out a PTY. environmentAccessCheck is the + // read-level sibling used by the SSR terminal lists: listing terminals and + // destroying one are different permissions on the same identifier. + environmentExecCheck func(ctx context.Context, environmentID string) error + // taskAccessCheck is the task-keyed sibling of sessionAccessCheck, used by // the task-keyed SSR terminal list which reads terminal rows by task ID // without resolving an execution at all. Nil = no scoping. @@ -454,6 +466,22 @@ func (m *Manager) SetSessionAccessChecker(check func(ctx context.Context, sessio m.sessionAccessCheck = check } +// SetSessionExecAccessChecker installs the session.exec check used by the +// terminal, shell, file-write, VS Code and port-preview surfaces. +func (m *Manager) SetSessionExecAccessChecker(check func(ctx context.Context, sessionID string) error) { + m.sessionExecCheck = check +} + +// CheckSessionExecAccess authorizes an execution-capable session operation. +// It falls back to the read check when no exec checker is wired, so an +// unwired build is no more permissive than before this scope existed. +func (m *Manager) CheckSessionExecAccess(ctx context.Context, sessionID string) error { + if m.sessionExecCheck == nil { + return m.CheckSessionAccess(ctx, sessionID) + } + return m.sessionExecCheck(ctx, sessionID) +} + // SetAttachmentReader wires the backend attachment reader used by prompt // dispatch. Claimed descriptors are streamed into the active agentctl // session immediately before an ACP prompt is sent. @@ -469,6 +497,22 @@ func (m *Manager) SetEnvironmentAccessChecker(check func(ctx context.Context, en m.environmentAccessCheck = check } +// SetEnvironmentExecAccessChecker installs the session.exec check for the +// environment-keyed surfaces that destroy or open a PTY. +func (m *Manager) SetEnvironmentExecAccessChecker(check func(ctx context.Context, environmentID string) error) { + m.environmentExecCheck = check +} + +// CheckEnvironmentExecAccess authorizes an execution-capable environment +// operation. It falls back to the read check when no exec checker is wired, +// so an unwired build is no more permissive than before this scope existed. +func (m *Manager) CheckEnvironmentExecAccess(ctx context.Context, taskEnvironmentID string) error { + if m.environmentExecCheck == nil { + return m.CheckEnvironmentAccess(ctx, taskEnvironmentID) + } + return m.environmentExecCheck(ctx, taskEnvironmentID) +} + // SetTaskAccessChecker installs the per-user task visibility check used by // the task-keyed SSR terminal route. The checker must return nil for contexts // without a request identity (internal callers). @@ -598,3 +642,15 @@ func (m *Manager) DockerClientProvider() func() *docker.Client { return dockerExec.Client() } } + +// execAccessCheck returns the check that execution-capable surfaces must pass. +// +// It prefers the session.exec checker and falls back to the plain access check +// when no scoped checker is wired. The fallback is never weaker than the +// behavior before session.exec existed; production always wires both. +func (m *Manager) execAccessCheck() func(ctx context.Context, sessionID string) error { + if m.sessionExecCheck != nil { + return m.sessionExecCheck + } + return m.sessionAccessCheck +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go index 3d7ee926be..3f09d6e4c9 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go @@ -41,7 +41,11 @@ func (m *Manager) ResolveSessionRuntime(ctx context.Context, sessionID string) ( if sessionID == "" { return "", fmt.Errorf("session_id is required") } - if check := m.sessionAccessCheck; check != nil { + // Execution surfaces require session.exec, not mere reach. This is the + // chokepoint every workspace-oriented handler (shell, files, ports, VS + // Code, LSP) goes through, so gating it here covers them all rather than + // relying on each handler to remember. + if check := m.execAccessCheck(); check != nil { if err := check(ctx, sessionID); err != nil { return "", err } @@ -81,7 +85,11 @@ func (m *Manager) GetOrEnsureExecution(ctx context.Context, sessionID string) (* // Per-user workspace scoping (opt-in auth): user-facing session surfaces // funnel through here; internal callers pass a ctx without an identity // and are unaffected. - if check := m.sessionAccessCheck; check != nil { + // Execution surfaces require session.exec, not mere reach. This is the + // chokepoint every workspace-oriented handler (shell, files, ports, VS + // Code, LSP) goes through, so gating it here covers them all rather than + // relying on each handler to remember. + if check := m.execAccessCheck(); check != nil { if err := check(ctx, sessionID); err != nil { return nil, err } @@ -446,7 +454,11 @@ func (m *Manager) IsAgentCommandConfigured(executionID string) bool { func (m *Manager) EnsurePassthroughExecution(ctx context.Context, sessionID string) (*AgentExecution, error) { // Per-user scoping (opt-in auth) — before the cache short-circuit so a // cached execution cannot be reached by a non-owner. - if check := m.sessionAccessCheck; check != nil { + // Execution surfaces require session.exec, not mere reach. This is the + // chokepoint every workspace-oriented handler (shell, files, ports, VS + // Code, LSP) goes through, so gating it here covers them all rather than + // relying on each handler to remember. + if check := m.execAccessCheck(); check != nil { if err := check(ctx, sessionID); err != nil { return nil, err } diff --git a/apps/backend/internal/agent/settings/handlers/agent_settings_org_scope_test.go b/apps/backend/internal/agent/settings/handlers/agent_settings_org_scope_test.go new file mode 100644 index 0000000000..0e72e6a874 --- /dev/null +++ b/apps/backend/internal/agent/settings/handlers/agent_settings_org_scope_test.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/common/httpmw" + "github.com/kandev/kandev/internal/common/logger" +) + +// mutatingSettingsRoutes is every state-changing agent-settings route. Agents, +// agent profiles and the runtimes behind them are org configuration, so each +// one is gated on org.config.manage. +var mutatingSettingsRoutes = []struct { + method string + path string +}{ + {http.MethodPost, "/api/v1/agents"}, + {http.MethodPost, "/api/v1/agents/tui"}, + {http.MethodPatch, "/api/v1/agents/tui/agent-1/mcp"}, + {http.MethodPatch, "/api/v1/agents/agent-1"}, + {http.MethodDelete, "/api/v1/agents/agent-1"}, + {http.MethodPost, "/api/v1/agents/agent-1/profiles"}, + {http.MethodPost, "/api/v1/agent-install/agent-1"}, + {http.MethodPost, "/api/v1/agent-update/agent-1"}, + {http.MethodPatch, "/api/v1/agent-profiles/profile-1"}, + {http.MethodDelete, "/api/v1/agent-profiles/profile-1"}, + {http.MethodPost, "/api/v1/agent-profiles/profile-1/duplicate"}, + {http.MethodPost, "/api/v1/agent-profiles/profile-1/mcp-config"}, +} + +func settingsRouterAs(t *testing.T, identity authn.Identity) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("NewLogger: %v", err) + } + router := gin.New() + router.Use(func(c *gin.Context) { + authn.SetOnGin(c, identity) + c.Next() + }) + NewHandlers(nil, nil, log, "test-interlock").registerHTTP(router) + return router +} + +// TestAgentSettingsMutationsRequireOrgConfigManage pins the authorization gate +// on every mutating route. The interlock guard that used to be the only thing +// in front of these handlers answers 403 as well, so asserting the status alone +// would pass even with the scope check removed — the scope name in the body is +// what distinguishes an authorization refusal from a concurrent-edit refusal. +func TestAgentSettingsMutationsRequireOrgConfigManage(t *testing.T) { + router := settingsRouterAs(t, authn.Identity{UserID: "member-1", Role: authn.RoleMember}) + + for _, route := range mutatingSettingsRoutes { + t.Run(route.method+" "+route.path, func(t *testing.T) { + request := httptest.NewRequest(route.method, route.path, nil) + request.Header.Set(httpmw.InterimSettingsInterlockHeader, "test-interlock") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", response.Code, http.StatusForbidden) + } + if !strings.Contains(response.Body.String(), "org.config.manage") { + t.Fatalf("body = %s, want an org.config.manage refusal", response.Body.String()) + } + }) + } +} + +// TestAgentSettingsReadsDoNotRequireOrgConfigManage keeps the gate off the read +// surface: a member must still be able to see the agents a task can run. +func TestAgentSettingsReadsDoNotRequireOrgConfigManage(t *testing.T) { + // A real controller, not the nil one the refusal test can use: these reads + // reach the handler. + router, _, _ := newSettingsHarnessAs(t, newFakeSettingsRepo(), nil, + authn.Identity{UserID: "member-1", Role: authn.RoleMember}) + + for _, path := range []string{"/api/v1/agents", "/api/v1/agents/available"} { + t.Run(path, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, path, nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code == http.StatusForbidden || response.Code == http.StatusUnauthorized { + t.Fatalf("status = %d, want the read to pass the scope gate", response.Code) + } + }) + } +} diff --git a/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go b/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go index efa850e684..7410ac653b 100644 --- a/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/agent_update_handlers_test.go @@ -150,6 +150,7 @@ func newAgentUpdateRouter( ctrl := controller.NewController(nil, discoveryRegistry, reg, nil, log) ctrl.SetRuntimeUpdater(updater) router := gin.New() + useSyntheticSettingsIdentity(router) broadcaster := newUpdateTerminalBroadcaster() RegisterRoutes(router, ctrl, broadcaster, log, "test-interlock") return router, ctrl, broadcaster.completed diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 193eff4cc1..20ab9ec501 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -14,6 +14,7 @@ import ( "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/settings/controller" "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/common/logger" ws "github.com/kandev/kandev/pkg/websocket" @@ -54,34 +55,40 @@ func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, hub Broadca func (h *Handlers) registerHTTP(router *gin.Engine) { api := router.Group("/api/v1") + // Agents, agent profiles and the runtimes behind them are org configuration: + // org.config.manage is the scope that names exactly this surface. Reads stay + // open to any identity; every mutation is gated. h.interlock is a + // concurrent-edit guard, not an authorization check, so it does not stand in + // for this. + cfg := authz.RequireOrgScope(authz.ScopeOrgConfigManage) api.GET("/agents/discovery", h.httpDiscoverAgents) api.GET("/agents/available", h.httpListAvailableAgents) api.GET("/agents", h.httpListAgents) - api.POST("/agents", h.interlock, h.httpCreateAgent) - api.POST("/agents/tui", h.interlock, h.httpCreateCustomTUIAgent) + api.POST("/agents", cfg, h.interlock, h.httpCreateAgent) + api.POST("/agents/tui", cfg, h.interlock, h.httpCreateCustomTUIAgent) api.GET("/agents/tui/mcp-strategies", h.httpListMCPStrategies) - api.PATCH("/agents/tui/:id/mcp", h.interlock, h.httpUpdateCustomTUIAgentMCP) + api.PATCH("/agents/tui/:id/mcp", cfg, h.interlock, h.httpUpdateCustomTUIAgentMCP) api.GET("/agents/:id", h.httpGetAgent) - api.PATCH("/agents/:id", h.interlock, h.httpUpdateAgent) - api.DELETE("/agents/:id", h.interlock, h.httpDeleteAgent) - api.POST("/agents/:id/profiles", h.interlock, h.httpCreateProfile) + api.PATCH("/agents/:id", cfg, h.interlock, h.httpUpdateAgent) + api.DELETE("/agents/:id", cfg, h.interlock, h.httpDeleteAgent) + api.POST("/agents/:id/profiles", cfg, h.interlock, h.httpCreateProfile) api.GET("/agents/:id/logo", h.httpGetAgentLogo) api.GET("/agent-models/:agentName", h.httpGetAgentModels) api.POST("/agent-models/:agentName/resolve", h.httpResolveAgentModelConfig) api.POST("/agent-command-preview/:agentName", h.httpPreviewAgentCommand) - api.POST("/agent-install/:agentName", h.interlock, h.httpInstallAgent) + api.POST("/agent-install/:agentName", cfg, h.interlock, h.httpInstallAgent) api.GET("/agent-update/status", h.httpListAgentUpdateStatuses) api.GET("/agent-update/:agentName/preview", h.httpPreviewAgentUpdate) api.GET("/agent-install/jobs", h.httpListInstallJobs) api.GET("/agent-install/jobs/:id", h.httpGetInstallJob) - api.POST("/agent-update/:agentName", h.interlock, h.httpUpdateAgentRuntime) + api.POST("/agent-update/:agentName", cfg, h.interlock, h.httpUpdateAgentRuntime) api.GET("/agent-update/jobs", h.httpListAgentUpdateJobs) api.GET("/agent-update/jobs/:id", h.httpGetAgentUpdateJob) - api.PATCH("/agent-profiles/:id", h.interlock, h.httpUpdateProfile) - api.DELETE("/agent-profiles/:id", h.interlock, h.httpDeleteProfile) - api.POST("/agent-profiles/:id/duplicate", h.interlock, h.httpDuplicateProfile) + api.PATCH("/agent-profiles/:id", cfg, h.interlock, h.httpUpdateProfile) + api.DELETE("/agent-profiles/:id", cfg, h.interlock, h.httpDeleteProfile) + api.POST("/agent-profiles/:id/duplicate", cfg, h.interlock, h.httpDuplicateProfile) api.GET("/agent-profiles/:id/mcp-config", h.httpGetProfileMcpConfig) - api.POST("/agent-profiles/:id/mcp-config", h.interlock, h.httpUpdateProfileMcpConfig) + api.POST("/agent-profiles/:id/mcp-config", cfg, h.interlock, h.httpUpdateProfileMcpConfig) } func (h *Handlers) httpDiscoverAgents(c *gin.Context) { diff --git a/apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go b/apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go index 268ec96df7..6342d2a190 100644 --- a/apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go +++ b/apps/backend/internal/agent/settings/handlers/interim_settings_interlock_test.go @@ -18,6 +18,7 @@ func TestRegisterRoutesRejectsMutationWithoutInterimSettingsInterlock(t *testing } router := gin.New() + useSyntheticSettingsIdentity(router) NewHandlers(nil, nil, log, "test-interlock").registerHTTP(router) request := httptest.NewRequest(http.MethodPost, "/api/v1/agents", strings.NewReader("{")) @@ -38,6 +39,7 @@ func TestRegisterRoutesProtectsEveryStateChangingAgentSettingsRoute(t *testing.T } router := gin.New() + useSyntheticSettingsIdentity(router) NewHandlers(nil, nil, log, "test-interlock").registerHTTP(router) requests := []struct { method string diff --git a/apps/backend/internal/agent/settings/handlers/model_config_handlers_test.go b/apps/backend/internal/agent/settings/handlers/model_config_handlers_test.go index 4abc569af5..94fa5028a8 100644 --- a/apps/backend/internal/agent/settings/handlers/model_config_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/model_config_handlers_test.go @@ -25,6 +25,7 @@ func newModelConfigRouter(t *testing.T) (*gin.Engine, *registry.Registry) { ctrl := controller.NewController(nil, nil, reg, nil, log) ctrl.SetHostUtility(hostutility.NewManager(reg, "127.0.0.1", 0, nil, log)) router := gin.New() + useSyntheticSettingsIdentity(router) RegisterRoutes(router, ctrl, nil, log, "test-interlock") return router, reg } diff --git a/apps/backend/internal/agent/settings/handlers/profile_duplicate_handlers_test.go b/apps/backend/internal/agent/settings/handlers/profile_duplicate_handlers_test.go index 9ed760b670..249057d3c4 100644 --- a/apps/backend/internal/agent/settings/handlers/profile_duplicate_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/profile_duplicate_handlers_test.go @@ -21,6 +21,7 @@ import ( "github.com/kandev/kandev/internal/agent/settings/dto" "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/common/logger" ws "github.com/kandev/kandev/pkg/websocket" @@ -419,6 +420,27 @@ func (h *workspaceDuplicateHub) BroadcastToWorkspaceOrDrop(workspaceID string, m h.workspaceMsgs[workspaceID] = append(h.workspaceMsgs[workspaceID], msg) } +// syntheticSettingsIdentity is the single-user admin identity the auth +// middleware installs when auth is disabled. +func syntheticSettingsIdentity() authn.Identity { + return authn.Identity{UserID: "default", Role: authn.RoleAdmin, Synthetic: true} +} + +// useSettingsIdentity puts an identity on every request. The agent and +// agent-profile mutations are gated on org.config.manage, so a router built +// without the production middleware chain has no identity at all and every +// mutation answers 401 before reaching the handler under test. +func useSettingsIdentity(router *gin.Engine, identity authn.Identity) { + router.Use(func(c *gin.Context) { + authn.SetOnGin(c, identity) + c.Next() + }) +} + +func useSyntheticSettingsIdentity(router *gin.Engine) { + useSettingsIdentity(router, syntheticSettingsIdentity()) +} + // newSettingsHarness wires the settings handlers to the given repo and hub and // returns the router plus the controller and agent registry, so a test can // register a deterministic agent implementation or attach a dependency checker. @@ -426,6 +448,18 @@ func newSettingsHarness( t *testing.T, repo store.Repository, hub Broadcaster, +) (*gin.Engine, *controller.Controller, *registry.Registry) { + t.Helper() + return newSettingsHarnessAs(t, repo, hub, syntheticSettingsIdentity()) +} + +// newSettingsHarnessAs is newSettingsHarness with the caller's identity, for +// tests that assert what a non-admin may and may not reach. +func newSettingsHarnessAs( + t *testing.T, + repo store.Repository, + hub Broadcaster, + identity authn.Identity, ) (*gin.Engine, *controller.Controller, *registry.Registry) { t.Helper() gin.SetMode(gin.TestMode) @@ -443,6 +477,7 @@ func newSettingsHarness( } ctrl := controller.NewController(repo, discoveryRegistry, reg, nil, log) router := gin.New() + useSettingsIdentity(router, identity) // Register through the production entry point so the install/update job // stores are wired from the hub exactly as they are at startup — with a nil // hub they stay unavailable, which is itself a behaviour under test. diff --git a/apps/backend/internal/auth/authn/identity.go b/apps/backend/internal/auth/authn/identity.go index 8447ed8239..1857182b2e 100644 --- a/apps/backend/internal/auth/authn/identity.go +++ b/apps/backend/internal/auth/authn/identity.go @@ -20,6 +20,9 @@ const ( RoleAdmin Role = "admin" // RoleMember is a regular authenticated user. RoleMember Role = "member" + // RoleGuest holds no org scopes and reaches only workspaces it is an + // explicit member of, including none of the org-visible ones. + RoleGuest Role = "guest" ) // GinContextKey is the gin-context key holding the request Identity. @@ -41,6 +44,16 @@ type Identity struct { SessionID string // TokenID is set when the identity came from a personal access token. TokenID string + // OrgID is the caller's tenant. It comes from the user record and from + // nowhere else: no route, payload, header or WS frame may name an org, so + // the tenant is a total function of the authenticated identity and no + // request can move a caller between tenants. + OrgID string + // Instance marks the operator tier, which manages organizations, feature + // toggles and instance-wide configuration. An operator holds NO org scopes + // and reaches no org's workspaces: it is an administration tier, not a + // visibility one. + Instance bool } // IsAdmin reports whether the identity carries the admin role. diff --git a/apps/backend/internal/auth/httpapi/handlers.go b/apps/backend/internal/auth/httpapi/handlers.go index e7b71291e9..cf97dcdabe 100644 --- a/apps/backend/internal/auth/httpapi/handlers.go +++ b/apps/backend/internal/auth/httpapi/handlers.go @@ -32,6 +32,7 @@ import ( "github.com/kandev/kandev/internal/auth" "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/auth/hostnames" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/logger" ) @@ -67,12 +68,12 @@ func RegisterRoutes(router *gin.Engine, svc *auth.Service, resolver *hostnames.R // RequireAdmin allows the synthetic admin, so RequireRealIdentity must // precede it to keep these locked while auth is disabled. - admin := group.Group("", authn.RequireRealIdentity(), authn.RequireAdmin()) + admin := group.Group("", authn.RequireRealIdentity(), authz.RequireOrgScope(authz.ScopeOrgMembersManage)) admin.GET("/invites", h.listInvites) admin.POST("/invites", h.createInvite) admin.DELETE("/invites/:id", h.deleteInvite) - users := router.Group("/api/v1/users", authn.RequireRealIdentity(), authn.RequireAdmin()) + users := router.Group("/api/v1/users", authn.RequireRealIdentity(), authz.RequireOrgScope(authz.ScopeOrgMembersManage)) users.GET("", h.listUsers) users.POST("", h.createUser) users.PATCH("/:id", h.updateUser) @@ -235,6 +236,10 @@ func (h *Handlers) writeAuthError(c *gin.Context, err error) { c.JSON(http.StatusTooManyRequests, gin.H{"error": err.Error()}) case errors.Is(err, auth.ErrInvalidCredentials): c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + // 403, not 401: the credential was accepted, so the browser must not + // treat this as a session challenge and loop back to the sign-in form. + case errors.Is(err, auth.ErrOrgUnavailable): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error(), "code": "org_suspended"}) case errors.Is(err, auth.ErrSetupNotAvailable): c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) case errors.Is(err, auth.ErrInviteInvalid): diff --git a/apps/backend/internal/auth/service.go b/apps/backend/internal/auth/service.go index 598ef4bb51..08fc470338 100644 --- a/apps/backend/internal/auth/service.go +++ b/apps/backend/internal/auth/service.go @@ -106,6 +106,19 @@ type Service struct { // dummyHash equalizes login timing for unknown emails. dummyHash string + + // orgStatus fails sessions and tokens closed for a suspended + // organization. Nil when organizations are off. + orgStatus OrgStatusChecker + + // adminCreated places the first admin and grants the operator tier before + // setup creates the identity that switches authentication into enabled mode. + adminCreated func(ctx context.Context, userID string) error +} + +// SetAdminCreatedHook installs the pre-commit setup callback. +func (s *Service) SetAdminCreatedHook(hook func(ctx context.Context, userID string) error) { + s.adminCreated = hook } // NewService constructs the service and computes the initial mode. @@ -208,11 +221,60 @@ func validateEmailPassword(email, password string) error { return nil } -func roleOf(user *usermodels.User) authn.Role { - if user.Role == usermodels.RoleAdmin { - return authn.RoleAdmin +// identityOf builds the request identity from the account record. The org and +// the operator tier come from the stored user and from nowhere else, so no +// caller-supplied value can influence which tenant a request belongs to. +// ErrOrgUnavailable reports a correct credential whose organization is +// suspended. It is deliberately distinct from ErrInvalidCredentials: the +// password was right, and saying otherwise would send the user to reset it. +var ErrOrgUnavailable = errors.New("your organization is currently unavailable") + +// OrgStatusChecker reports whether an organization may serve requests. Wired +// from backendapp; nil on instances without organizations. +type OrgStatusChecker func(ctx context.Context, orgID string) bool + +// SetOrgStatusChecker installs the suspended-organization gate. +func (s *Service) SetOrgStatusChecker(check OrgStatusChecker) { s.orgStatus = check } + +// orgUsable reports whether the identity's organization may serve requests. +// A suspended org fails every session and token closed, which is what makes +// suspension a real lever rather than a label. +func (s *Service) orgUsable(ctx context.Context, orgID string) bool { + if s.orgStatus == nil || orgID == "" { + return true } - return authn.RoleMember + return s.orgStatus(ctx, orgID) +} + +// callerOrgID returns the requesting user's organization, or "" when +// organizations are off. Accounts and invites created by an admin inherit it, +// which is why neither path takes an org parameter. +func callerOrgID(ctx context.Context) string { + identity, ok := authn.IdentityFromContext(ctx) + if !ok || identity.Synthetic { + return "" + } + return identity.OrgID +} + +func identityOf(user *usermodels.User, sessionID, tokenID string) authn.Identity { + return authn.Identity{ + UserID: user.ID, + Role: roleOf(user), + OrgID: user.OrgID, + Instance: user.IsOperator, + SessionID: sessionID, + TokenID: tokenID, + } +} + +// roleOf carries the STORED role through unchanged. It deliberately does not +// normalize: authz.NormalizeOrgRole maps an unrecognized role to guest, the +// least privileged one, whereas usermodels.NormalizeRole defaults to member +// for write paths. Normalizing here would turn an unknown stored value into a +// collaborator on every org-visible workspace. +func roleOf(user *usermodels.User) authn.Role { + return authn.Role(user.Role) } func truncateUserAgent(ua string) string { diff --git a/apps/backend/internal/auth/service_credentials.go b/apps/backend/internal/auth/service_credentials.go index a1d6bd1c29..e98c509f1e 100644 --- a/apps/backend/internal/auth/service_credentials.go +++ b/apps/backend/internal/auth/service_credentials.go @@ -37,6 +37,13 @@ func (s *Service) Login(ctx context.Context, email, password, userAgent, ip stri return nil, "", ErrInvalidCredentials } s.limiter.Reset(limiterKey) + // A suspended organization refuses login outright rather than handing out + // a session that fails on the very next request. The message is distinct + // from ErrInvalidCredentials on purpose: the credentials were correct, and + // telling the user their password is wrong would send them to reset it. + if !s.orgUsable(ctx, user.OrgID) { + return nil, "", ErrOrgUnavailable + } token, err := s.createSession(ctx, user.ID, userAgent, ip) if err != nil { return nil, "", err @@ -101,7 +108,10 @@ func (s *Service) ResolveSessionToken(ctx context.Context, token, ip string) (au } _ = s.store.TouchSession(ctx, sess.ID, now, now.Add(s.SessionTTL()), refreshIP) } - return authn.Identity{UserID: user.ID, Role: roleOf(user), SessionID: sess.ID}, true + if !s.orgUsable(ctx, user.OrgID) { + return authn.Identity{}, false + } + return identityOf(user, sess.ID, ""), true } // ResolveBearer authenticates an Authorization bearer credential when it is a @@ -126,7 +136,10 @@ func (s *Service) ResolveBearer(ctx context.Context, token string) (authn.Identi if record.LastUsedAt == nil || now.Sub(*record.LastUsedAt) > sessionTouchInterval { _ = s.store.TouchAPIToken(ctx, record.ID, now) } - return authn.Identity{UserID: user.ID, Role: roleOf(user), TokenID: record.ID}, true + if !s.orgUsable(ctx, user.OrgID) { + return authn.Identity{}, false + } + return identityOf(user, "", record.ID), true } // IdentityForUser resolves a stored user ID to the identity that user carries @@ -148,7 +161,10 @@ func (s *Service) IdentityForUser(ctx context.Context, userID string) (authn.Ide if err != nil { return authn.Identity{}, false } - return authn.Identity{UserID: user.ID, Role: roleOf(user)}, true + if !s.orgUsable(ctx, user.OrgID) { + return authn.Identity{}, false + } + return identityOf(user, "", ""), true } // ListSessions returns the user's sessions for the account page. diff --git a/apps/backend/internal/auth/service_invites.go b/apps/backend/internal/auth/service_invites.go index b266c85ab2..a1a3c45f27 100644 --- a/apps/backend/internal/auth/service_invites.go +++ b/apps/backend/internal/auth/service_invites.go @@ -18,9 +18,7 @@ const ( // CreateInvite mints an invite link token. Email is optional; when set, the // accept step must use the same address. Returns the raw token exactly once. func (s *Service) CreateInvite(ctx context.Context, createdBy, email, role string, ttlHours int) (*store.Invite, string, error) { - if role != usermodels.RoleAdmin { - role = usermodels.RoleMember - } + role = usermodels.NormalizeRole(role) ttl := defaultInviteTTL if ttlHours > 0 { ttl = time.Duration(ttlHours) * time.Hour @@ -36,6 +34,9 @@ func (s *Service) CreateInvite(ctx context.Context, createdBy, email, role strin TokenHash: hash, Email: normalizeEmail(email), Role: role, + // The invite joins the minting admin's organization, so accepting it + // can never place an account in a tenant the inviter cannot see. + OrgID: callerOrgID(ctx), CreatedBy: createdBy, ExpiresAt: time.Now().UTC().Add(ttl), } @@ -76,6 +77,10 @@ func (s *Service) AcceptInvite(ctx context.Context, token, email, password, disp DisplayName: displayName, Role: invite.Role, Status: usermodels.StatusActive, + // The organization comes from the invite, which recorded the minting + // admin's org. Accepting an invite is an anonymous request, so this is + // the only trustworthy source for it. + OrgID: invite.OrgID, } if err := s.users.CreateUser(ctx, user); err != nil { return nil, "", ErrEmailTaken diff --git a/apps/backend/internal/auth/service_provisioning_review_test.go b/apps/backend/internal/auth/service_provisioning_review_test.go new file mode 100644 index 0000000000..964c02224f --- /dev/null +++ b/apps/backend/internal/auth/service_provisioning_review_test.go @@ -0,0 +1,55 @@ +package auth + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/auth/store" + userstore "github.com/kandev/kandev/internal/user/store" +) + +func TestSetupPlacementFailureLeavesSetupRetryable(t *testing.T) { + fixture := newServiceFixture(t, true) + placementErr := errors.New("place setup administrator") + fixture.svc.SetAdminCreatedHook(func(context.Context, string) error { return placementErr }) + + user, token, err := fixture.svc.Setup( + context.Background(), "admin@example.com", "password123", "Admin", "browser", "127.0.0.1", + ) + if !errors.Is(err, placementErr) { + t.Fatalf("setup error = %v, want placement error", err) + } + if user != nil || token != "" { + t.Fatalf("failed setup returned user=%+v token=%q", user, token) + } + if fixture.svc.Mode() != ModeSetup { + t.Fatalf("mode after failed placement = %q, want setup", fixture.svc.Mode()) + } + + fixture.svc.SetAdminCreatedHook(func(context.Context, string) error { return nil }) + if _, retryToken, retryErr := fixture.svc.Setup( + context.Background(), "admin@example.com", "password123", "Admin", "browser", "127.0.0.1", + ); retryErr != nil || retryToken == "" { + t.Fatalf("retry setup token=%q error=%v", retryToken, retryErr) + } +} + +func TestCreateUserInOrgRollsBackAccountWhenIdentityCreationFails(t *testing.T) { + fixture := newServiceFixture(t, false) + ctx := context.Background() + const email = "first-admin@example.com" + if err := fixture.svc.store.CreateIdentity(ctx, &store.LoginIdentity{ + UserID: userstore.DefaultUserID, Provider: store.ProviderLocal, Subject: email, + }); err != nil { + t.Fatalf("seed colliding identity: %v", err) + } + + err := fixture.svc.CreateUserInOrg(ctx, "org-a", email, "password123", "First Admin") + if err == nil { + t.Fatal("CreateUserInOrg succeeded despite identity collision") + } + if user, lookupErr := fixture.svc.users.GetUserByEmail(ctx, email); lookupErr == nil || user != nil { + t.Fatalf("failed provisioning left account user=%+v error=%v", user, lookupErr) + } +} diff --git a/apps/backend/internal/auth/service_setup.go b/apps/backend/internal/auth/service_setup.go index b04116d68f..22882f858d 100644 --- a/apps/backend/internal/auth/service_setup.go +++ b/apps/backend/internal/auth/service_setup.go @@ -42,6 +42,14 @@ func (s *Service) Setup(ctx context.Context, email, password, displayName, userA if err != nil { return nil, "", err } + // Tenant placement must complete before the identity commit point below. + // If it fails, setup remains retryable instead of returning an org-less, + // non-operator session that cannot repair itself. + if s.adminCreated != nil { + if err := s.adminCreated(ctx, adminID); err != nil { + return nil, "", err + } + } identity := &store.LoginIdentity{ UserID: adminID, Provider: store.ProviderLocal, diff --git a/apps/backend/internal/auth/service_users.go b/apps/backend/internal/auth/service_users.go index 978134d588..21a6393f55 100644 --- a/apps/backend/internal/auth/service_users.go +++ b/apps/backend/internal/auth/service_users.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "time" "github.com/google/uuid" @@ -21,9 +22,7 @@ func (s *Service) AdminCreateUser(ctx context.Context, email, password, displayN if err := validateEmailPassword(email, password); err != nil { return nil, err } - if role != usermodels.RoleAdmin { - role = usermodels.RoleMember - } + role = usermodels.NormalizeRole(role) if _, err := s.users.GetUserByEmail(ctx, email); err == nil { return nil, ErrEmailTaken } @@ -33,21 +32,13 @@ func (s *Service) AdminCreateUser(ctx context.Context, email, password, displayN DisplayName: displayName, Role: role, Status: usermodels.StatusActive, + // A new account joins the creating admin's organization. There is no + // org parameter on this path on purpose: an admin can only ever create + // users in their own tenant, so the org comes from the caller's + // identity rather than from the request. + OrgID: callerOrgID(ctx), } - if err := s.users.CreateUser(ctx, user); err != nil { - return nil, ErrEmailTaken - } - hash, err := HashPassword(password) - if err != nil { - return nil, err - } - identity := &store.LoginIdentity{ - UserID: user.ID, - Provider: store.ProviderLocal, - Subject: email, - PasswordHash: hash, - } - if err := s.store.CreateIdentity(ctx, identity); err != nil { + if err := s.createLocalUser(ctx, user, password); err != nil { return nil, err } return user, nil @@ -66,9 +57,7 @@ func (s *Service) AdminSetRoleStatus(ctx context.Context, targetID, role, status if status == "" { status = target.Status } - if role != usermodels.RoleAdmin { - role = usermodels.RoleMember - } + role = usermodels.NormalizeRole(role) if status != usermodels.StatusDisabled { status = usermodels.StatusActive } @@ -144,3 +133,49 @@ func (s *Service) ensureAnotherAdmin(ctx context.Context, excludingID string) er } return ErrLastAdmin } + +// CreateUserInOrg provisions an account in a named organization. +// +// It exists for exactly one caller: the operator creating an organization's +// first administrator. Every other creation path takes the org from the +// requesting admin's identity, because an admin may only ever create accounts +// in their own tenant. +func (s *Service) CreateUserInOrg( + ctx context.Context, orgID, email, password, displayName string, +) error { + email = normalizeEmail(email) + if err := validateEmailPassword(email, password); err != nil { + return err + } + if _, err := s.users.GetUserByEmail(ctx, email); err == nil { + return ErrEmailTaken + } + user := &usermodels.User{ + ID: uuid.New().String(), + Email: email, + DisplayName: displayName, + Role: usermodels.RoleAdmin, + Status: usermodels.StatusActive, + OrgID: orgID, + } + return s.createLocalUser(ctx, user, password) +} + +func (s *Service) createLocalUser(ctx context.Context, user *usermodels.User, password string) error { + hash, err := HashPassword(password) + if err != nil { + return err + } + if err := s.users.CreateUser(ctx, user); err != nil { + return ErrEmailTaken + } + if err := s.store.CreateIdentity(ctx, &store.LoginIdentity{ + UserID: user.ID, + Provider: store.ProviderLocal, + Subject: user.Email, + PasswordHash: hash, + }); err != nil { + return errors.Join(err, s.users.DeleteUser(ctx, user.ID)) + } + return nil +} diff --git a/apps/backend/internal/auth/store/models.go b/apps/backend/internal/auth/store/models.go index d771f8dcf2..f43615b67c 100644 --- a/apps/backend/internal/auth/store/models.go +++ b/apps/backend/internal/auth/store/models.go @@ -51,10 +51,12 @@ type APIToken struct { // Invite is a tokenized invitation URL minted by an admin. type Invite struct { - ID string `db:"id" json:"id"` - TokenHash string `db:"token_sha256" json:"-"` - Email string `db:"email" json:"email"` - Role string `db:"role" json:"role"` + ID string `db:"id" json:"id"` + TokenHash string `db:"token_sha256" json:"-"` + Email string `db:"email" json:"email"` + Role string `db:"role" json:"role"` + // OrgID is the organization the accepted account will join. + OrgID string `db:"org_id" json:"org_id,omitempty"` CreatedBy string `db:"created_by" json:"created_by"` CreatedAt time.Time `db:"created_at" json:"created_at"` ExpiresAt time.Time `db:"expires_at" json:"expires_at"` diff --git a/apps/backend/internal/auth/store/schema.go b/apps/backend/internal/auth/store/schema.go index 6f391a2285..d01e2bfbf7 100644 --- a/apps/backend/internal/auth/store/schema.go +++ b/apps/backend/internal/auth/store/schema.go @@ -3,6 +3,7 @@ package store import ( "fmt" + "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/db/dialect" ) @@ -65,5 +66,12 @@ func (s *Store) initSchema() error { return fmt.Errorf("auth schema: %w", err) } } + // An invite mints a member of exactly one organization, so it carries the + // minting admin's org. CREATE TABLE IF NOT EXISTS is a no-op on an + // existing database, so the column also needs an ADD COLUMN (ADR 0027). + db.NewMigrateLogger(s.db, nil).Apply( + "auth_invites.org_id", + `ALTER TABLE auth_invites ADD COLUMN org_id TEXT NOT NULL DEFAULT ''`, + ) return nil } diff --git a/apps/backend/internal/auth/store/store.go b/apps/backend/internal/auth/store/store.go index 0ecc14dc53..6d8f6146f2 100644 --- a/apps/backend/internal/auth/store/store.go +++ b/apps/backend/internal/auth/store/store.go @@ -246,9 +246,9 @@ func (s *Store) CreateInvite(ctx context.Context, invite *Invite) error { } invite.CreatedAt = time.Now().UTC() _, err := s.db.ExecContext(ctx, s.db.Rebind(` - INSERT INTO auth_invites (id, token_sha256, email, role, created_by, created_at, expires_at, used_by, used_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `), invite.ID, invite.TokenHash, invite.Email, invite.Role, invite.CreatedBy, invite.CreatedAt, invite.ExpiresAt, invite.UsedBy, invite.UsedAt) + INSERT INTO auth_invites (id, token_sha256, email, role, org_id, created_by, created_at, expires_at, used_by, used_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), invite.ID, invite.TokenHash, invite.Email, invite.Role, invite.OrgID, invite.CreatedBy, invite.CreatedAt, invite.ExpiresAt, invite.UsedBy, invite.UsedAt) return err } diff --git a/apps/backend/internal/authz/gin.go b/apps/backend/internal/authz/gin.go new file mode 100644 index 0000000000..c9df36982d --- /dev/null +++ b/apps/backend/internal/authz/gin.go @@ -0,0 +1,48 @@ +package authz + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/auth/authn" +) + +// SubjectFromGin builds the authz subject for a gin request. A request with no +// identity, or the synthetic identity injected while authentication is +// disabled, is unscoped and holds every scope — preserving pre-auth behavior +// byte for byte. +func SubjectFromGin(c *gin.Context) Subject { + identity, ok := authn.FromGin(c) + if !ok || identity.Synthetic { + return Subject{Unscoped: true} + } + return Subject{ + UserID: identity.UserID, + OrgID: identity.OrgID, + OrgRole: NormalizeOrgRole(string(identity.Role)), + } +} + +// RequireOrgScope aborts with 403 unless the caller holds the org scope. +// Requests with no identity at all are rejected with 401. +// +// This replaces the previous per-route admin bit. Naming the capability rather +// than the role is the point: "which routes need org.config.manage" is a +// question the registry can answer, "which routes called RequireAdmin" was not. +func RequireOrgScope(scope Scope) gin.HandlerFunc { + return func(c *gin.Context) { + if _, ok := authn.FromGin(c); !ok { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + if !SubjectOrgScopes(SubjectFromGin(c)).Has(scope) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "insufficient permissions", + "scope": string(scope), + }) + return + } + c.Next() + } +} diff --git a/apps/backend/internal/authz/registry_test.go b/apps/backend/internal/authz/registry_test.go new file mode 100644 index 0000000000..7698b0626c --- /dev/null +++ b/apps/backend/internal/authz/registry_test.go @@ -0,0 +1,140 @@ +package authz + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// scopeConstNames maps each registered scope to the Go identifier that names +// it, so the enforcement sweep can look for real call sites rather than string +// literals. +var scopeConstNames = map[Scope]string{ + ScopeOrgMembersManage: "ScopeOrgMembersManage", + ScopeOrgSettingsManage: "ScopeOrgSettingsManage", + ScopeOrgConfigManage: "ScopeOrgConfigManage", + ScopeUnitManage: "ScopeUnitManage", + ScopeWorkspaceRead: "ScopeWorkspaceRead", + ScopeWorkspaceManage: "ScopeWorkspaceManage", + ScopeTaskWrite: "ScopeTaskWrite", + ScopeSessionPrompt: "ScopeSessionPrompt", + ScopeSessionControl: "ScopeSessionControl", + ScopeSessionExec: "ScopeSessionExec", + ScopeRepositoryManage: "ScopeRepositoryManage", + ScopeSecretManage: "ScopeSecretManage", + ScopeMemberManage: "ScopeMemberManage", +} + +// TestEveryRegisteredScopeIsNamed fails when a scope is added to the registry +// without a matching entry here, which would let it slip past the enforcement +// sweep below unnoticed. +func TestEveryRegisteredScopeIsNamed(t *testing.T) { + for _, def := range Registry() { + if _, ok := scopeConstNames[def.Scope]; !ok { + t.Errorf("scope %q is registered but has no constant name entry in scopeConstNames", def.Scope) + } + } + if len(scopeConstNames) != len(registry) { + t.Errorf("scopeConstNames has %d entries, registry has %d", len(scopeConstNames), len(registry)) + } +} + +// TestEveryRegisteredScopeIsEnforced sweeps the backend for a use of each +// scope constant outside this package. A scope nobody enforces is a permission +// that silently does not exist, which is worse than no scope at all: it reads +// as protection in the registry and in the API response. +func TestEveryRegisteredScopeIsEnforced(t *testing.T) { + root := backendRoot(t) + sources := goSourcesOutsideAuthz(t, root) + if len(sources) == 0 { + t.Fatal("found no Go sources outside internal/authz; the sweep would pass vacuously") + } + + for scope, constName := range scopeConstNames { + needle := "authz." + constName + if !anyFileContains(t, sources, needle) { + t.Errorf("scope %q (authz.%s) has no enforcement call site outside internal/authz", scope, constName) + } + } +} + +// TestScopeIdentifiersAreStable pins the wire values. They appear in API +// responses and are compared with ==, so a rename is a breaking change and +// must be a deliberate one. +func TestScopeIdentifiersAreStable(t *testing.T) { + want := []string{ + "org.config.manage", "org.members.manage", "org.settings.manage", + "unit.manage", + "member.manage", "repository.manage", "secret.manage", + "session.control", "session.exec", "session.prompt", + "task.write", "workspace.manage", "workspace.read", + } + got := make([]string, 0, len(registry)) + for _, def := range Registry() { + got = append(got, string(def.Scope)) + } + if len(got) != len(want) { + t.Fatalf("registry has %d scopes, pinned list has %d: %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("scope %d = %q, want %q", i, got[i], want[i]) + } + } +} + +func backendRoot(t *testing.T) string { + t.Helper() + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + // .../apps/backend/internal/authz -> .../apps/backend + root := filepath.Dir(filepath.Dir(wd)) + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { + t.Fatalf("expected go.mod at %s: %v", root, err) + } + return root +} + +func goSourcesOutsideAuthz(t *testing.T, root string) []string { + t.Helper() + authzDir := filepath.Join(root, "internal", "authz") + var files []string + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if entry.Name() == "node_modules" || entry.Name() == "bin" || path == authzDir { + return filepath.SkipDir + } + return nil + } + // Production call sites only. A scope referenced solely from a test + // would otherwise look enforced while guarding nothing. + if strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") { + files = append(files, path) + } + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", root, err) + } + return files +} + +func anyFileContains(t *testing.T, files []string, needle string) bool { + t.Helper() + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + if strings.Contains(string(data), needle) { + return true + } + } + return false +} diff --git a/apps/backend/internal/authz/resolve.go b/apps/backend/internal/authz/resolve.go new file mode 100644 index 0000000000..fc4e7cb2d3 --- /dev/null +++ b/apps/backend/internal/authz/resolve.go @@ -0,0 +1,136 @@ +package authz + +// Subject is the caller, as far as authorization is concerned. +type Subject struct { + UserID string + OrgRole OrgRole + // OrgID is the caller's tenant, empty when organizations are off. + OrgID string + // TenancyEnforced mirrors features.multiTenancy. When it is on, a subject + // with no org is DENIED rather than treated as org-less: an account that + // somehow escaped the migration must not become a key to every tenant. + TenancyEnforced bool + // Unscoped marks a caller that predates or bypasses per-user scoping: an + // internal caller (event bus, pollers, schedulers) or the synthetic + // identity injected while authentication is disabled. Such a caller gets + // everything, which is exactly today's behavior. + Unscoped bool +} + +// WorkspaceRef is the workspace state authorization needs. +type WorkspaceRef struct { + OwnerID string + // OrgID is the workspace's tenant, empty when organizations are off. + OrgID string + // InheritedRole is the highest role the caller holds on any unit that is + // an ancestor of the workspace's unit, including that unit itself. It is + // WorkspaceRoleNone when they are a member of none of them. + InheritedRole WorkspaceRole + // MemberRole is a direct grant recorded against this workspace alone. It + // can only raise the result, never lower it. + MemberRole WorkspaceRole +} + +// Decision is the resolved access for one (subject, workspace) pair. +type Decision struct { + Role WorkspaceRole + Scopes Set +} + +// CanRead reports whether the caller reaches the workspace at all. A false +// result must surface as 404, never 403: existence is not disclosed. +func (d Decision) CanRead() bool { return d.Scopes.Has(ScopeWorkspaceRead) } + +// Has reports whether the decision grants a scope. +func (d Decision) Has(scope Scope) bool { return d.Scopes.Has(scope) } + +// ResolveWorkspace is the only place workspace permissions are derived. +// +// Roles combine by maximum. Nothing subtracts, so no grant can lower what a +// unit already gives: narrowing is done by placing a workspace in a unit with +// fewer members, which keeps the rule to one sentence and keeps a permission +// question answerable by reading the tree. +func ResolveWorkspace(subject Subject, ref WorkspaceRef) Decision { + // The tenant boundary is checked first and is absolute. It is not a + // permission level that a role can outrank: a workspace in another org is + // not visible to anybody, including an org admin or an instance operator. + if crossOrg(subject, ref) { + return decide(WorkspaceRoleNone) + } + + switch { + case subject.Unscoped: + return decide(WorkspaceRoleOwner) + + // A workspace created before authentication was enabled has no owner and + // stays visible to everyone until the setup wizard claims it. Preserving + // this keeps single-user upgrades byte-identical. + case ref.OwnerID == "": + return decide(WorkspaceRoleOwner) + + case subject.UserID != "" && ref.OwnerID == subject.UserID: + return decide(WorkspaceRoleOwner) + + default: + return decide(highestRole(ref.InheritedRole, ref.MemberRole)) + } +} + +// highestRole returns the strongest of the roles it is given. It is the whole +// of the combining rule. +func highestRole(roles ...WorkspaceRole) WorkspaceRole { + best := WorkspaceRoleNone + for _, role := range roles { + if workspaceRoleRank(role) > workspaceRoleRank(best) { + best = role + } + } + return best +} + +// Denied is the fail-closed decision. Resolution errors return this rather +// than falling back to the org default role, which is the branch that would +// silently widen access under a transient database failure. +func Denied() Decision { return decide(WorkspaceRoleNone) } + +func decide(role WorkspaceRole) Decision { + return Decision{Role: role, Scopes: WorkspaceScopes(role)} +} + +// SubjectOrgScopes returns the org scopes a subject holds. An unscoped caller +// holds every org scope. +func SubjectOrgScopes(subject Subject) Set { + if subject.Unscoped { + return orgScopeSet() + } + return OrgScopes(subject.OrgRole) +} + +func orgScopeSet() Set { + out := NewSet() + for _, def := range registry { + if def.Kind == KindOrg { + out[def.Scope] = struct{}{} + } + } + return out +} + +// crossOrg reports whether the subject and the workspace belong to different +// tenants. Both sides must carry an org for the check to apply, so a +// single-org instance (organizations off) is unaffected. +func crossOrg(subject Subject, ref WorkspaceRef) bool { + if subject.Unscoped { + return false + } + // With tenancy on, a caller carrying no org reaches nothing. Treating them + // as org-less would make an unmigrated account a key to every tenant, + // because an empty org matches everything. + if subject.TenancyEnforced && subject.OrgID == "" { + return true + } + if subject.OrgID == "" || ref.OrgID == "" { + return false + } + return subject.OrgID != ref.OrgID +} diff --git a/apps/backend/internal/authz/resolve_test.go b/apps/backend/internal/authz/resolve_test.go new file mode 100644 index 0000000000..0d875c0d4e --- /dev/null +++ b/apps/backend/internal/authz/resolve_test.go @@ -0,0 +1,312 @@ +package authz + +import "testing" + +const ( + userAna = "user-ana" + userBruno = "user-bruno" +) + +func member(role WorkspaceRole) WorkspaceRole { return role } + +func TestResolveWorkspaceReachTable(t *testing.T) { + cases := []struct { + name string + subject Subject + ref WorkspaceRef + wantRole WorkspaceRole + wantRead bool + }{ + { + name: "owner reaches own private workspace", + subject: Subject{UserID: userAna, OrgRole: OrgRoleMember}, + ref: WorkspaceRef{OwnerID: userAna}, + wantRole: WorkspaceRoleOwner, + wantRead: true, + }, + { + name: "non-member cannot reach a private workspace", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleMember}, + ref: WorkspaceRef{OwnerID: userAna}, + wantRole: WorkspaceRoleNone, + wantRead: false, + }, + { + name: "member reaches a workspace through a unit they belong to", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleMember}, + ref: WorkspaceRef{OwnerID: userAna, InheritedRole: WorkspaceRoleCollaborator}, + wantRole: WorkspaceRoleCollaborator, + wantRead: true, + }, + { + name: "admin inherits the same role as any other member, not more", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleAdmin}, + ref: WorkspaceRef{OwnerID: userAna, InheritedRole: WorkspaceRoleCollaborator}, + wantRole: WorkspaceRoleCollaborator, + wantRead: true, + }, + { + name: "admin does NOT reach a workspace in a unit they are not in", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleAdmin}, + ref: WorkspaceRef{OwnerID: userAna}, + wantRole: WorkspaceRoleNone, + wantRead: false, + }, + { + name: "guest inherits nothing, so a shared unit does not reach them", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleGuest}, + ref: WorkspaceRef{OwnerID: userAna}, + wantRole: WorkspaceRoleNone, + wantRead: false, + }, + { + name: "guest reaches a workspace it holds a row on", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleGuest}, + ref: WorkspaceRef{OwnerID: userAna, MemberRole: member(WorkspaceRoleCollaborator)}, + wantRole: WorkspaceRoleCollaborator, + wantRead: true, + }, + { + name: "a direct viewer grant cannot lower what a unit already gives", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleMember}, + ref: WorkspaceRef{OwnerID: userAna, InheritedRole: WorkspaceRoleCollaborator, MemberRole: member(WorkspaceRoleViewer)}, + wantRole: WorkspaceRoleCollaborator, + wantRead: true, + }, + { + name: "unscoped internal caller reaches everything", + subject: Subject{Unscoped: true}, + ref: WorkspaceRef{OwnerID: userAna}, + wantRole: WorkspaceRoleOwner, + wantRead: true, + }, + { + name: "pre-auth unowned workspace stays visible to everyone", + subject: Subject{UserID: userBruno, OrgRole: OrgRoleGuest}, + ref: WorkspaceRef{OwnerID: ""}, + wantRole: WorkspaceRoleOwner, + wantRead: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ResolveWorkspace(tc.subject, tc.ref) + if got.Role != tc.wantRole { + t.Errorf("role = %q, want %q", got.Role, tc.wantRole) + } + if got.CanRead() != tc.wantRead { + t.Errorf("CanRead() = %v, want %v", got.CanRead(), tc.wantRead) + } + }) + } +} + +// A viewer may read a transcript; a shell in the worktree is a different +// question, and collapsing the two is the mistake this test exists to catch. +func TestViewerHasNoExecOrWrite(t *testing.T) { + viewer := ResolveWorkspace( + Subject{UserID: userBruno, OrgRole: OrgRoleMember}, + WorkspaceRef{OwnerID: userAna, MemberRole: WorkspaceRoleViewer}, + ) + if !viewer.Has(ScopeWorkspaceRead) { + t.Error("viewer should hold workspace.read") + } + for _, denied := range []Scope{ScopeSessionExec, ScopeSessionPrompt, ScopeTaskWrite, ScopeSessionControl} { + if viewer.Has(denied) { + t.Errorf("viewer must not hold %q", denied) + } + } +} + +// Managing a workspace, its members, repositories and secrets belongs to the +// owner. A collaborator contributes; it does not administer. +func TestCollaboratorCannotAdminister(t *testing.T) { + collab := WorkspaceScopes(WorkspaceRoleCollaborator) + for _, denied := range []Scope{ScopeWorkspaceManage, ScopeMemberManage, ScopeSecretManage, ScopeRepositoryManage} { + if collab.Has(denied) { + t.Errorf("collaborator must not hold %q", denied) + } + } + owner := WorkspaceScopes(WorkspaceRoleOwner) + for _, granted := range []Scope{ScopeWorkspaceManage, ScopeMemberManage, ScopeSecretManage, ScopeRepositoryManage} { + if !owner.Has(granted) { + t.Errorf("owner should hold %q", granted) + } + } +} + +func TestDeniedGrantsNothing(t *testing.T) { + if Denied().CanRead() { + t.Error("Denied() must not grant workspace.read") + } + if len(Denied().Scopes) != 0 { + t.Errorf("Denied() granted %v", Denied().Scopes.List()) + } +} + +func TestOrgScopes(t *testing.T) { + admin := SubjectOrgScopes(Subject{OrgRole: OrgRoleAdmin}) + if !admin.Has(ScopeOrgMembersManage) || !admin.Has(ScopeOrgConfigManage) { + t.Errorf("admin org scopes = %v", admin.List()) + } + for _, role := range []OrgRole{OrgRoleMember, OrgRoleGuest} { + if scopes := SubjectOrgScopes(Subject{OrgRole: role}); len(scopes) != 0 { + t.Errorf("%s should hold no org scopes, got %v", role, scopes.List()) + } + } + if unscoped := SubjectOrgScopes(Subject{Unscoped: true}); !unscoped.Has(ScopeOrgMembersManage) { + t.Error("unscoped caller should hold every org scope") + } +} + +// Unknown stored values must fail closed rather than widen access. +func TestNormalizersFailClosed(t *testing.T) { + if got := NormalizeOrgRole("superuser"); got != OrgRoleGuest { + t.Errorf("NormalizeOrgRole(unknown) = %q, want guest", got) + } + if got := NormalizeOrgRole(""); got != OrgRoleGuest { + t.Errorf("NormalizeOrgRole(empty) = %q, want guest", got) + } + if got := NormalizeWorkspaceRole("superuser"); got != WorkspaceRoleNone { + t.Errorf("NormalizeWorkspaceRole(unknown) = %q, want none", got) + } +} + +func TestIsAssignableWorkspaceRole(t *testing.T) { + if IsAssignableWorkspaceRole(WorkspaceRoleOwner) { + t.Error("owner must be reached by transfer, not assignment") + } + if !IsAssignableWorkspaceRole(WorkspaceRoleCollaborator) || !IsAssignableWorkspaceRole(WorkspaceRoleViewer) { + t.Error("collaborator and viewer must be assignable") + } +} + +// The tenant boundary is absolute: it is checked before any role, so no role +// can outrank it. These cases are the whole point of organizations. +func TestCrossOrgIsAbsolute(t *testing.T) { + inOrgA := Subject{UserID: userBruno, OrgRole: OrgRoleAdmin, OrgID: "org-a"} + workspaceInB := WorkspaceRef{OwnerID: userAna, OrgID: "org-b"} + + if ResolveWorkspace(inOrgA, workspaceInB).CanRead() { + t.Error("an org-visible workspace in another org must not be readable") + } + + // Even an explicit membership row cannot cross the boundary: the tenancy + // migration drops such rows, and a stale one must not grant access. + withRow := workspaceInB + withRow.MemberRole = WorkspaceRoleCollaborator + if ResolveWorkspace(inOrgA, withRow).CanRead() { + t.Error("a stale cross-org membership row must not grant access") + } + + // Owning it does not help either: owner_id and org_id disagreeing is + // corrupt data, and the safe reading is no access. + owned := WorkspaceRef{OwnerID: userBruno, OrgID: "org-b"} + if ResolveWorkspace(inOrgA, owned).CanRead() { + t.Error("a workspace in another org must not be readable even by its owner_id") + } + + // Same org resolves normally: the tenant check is a gate in front of the + // tree, not a replacement for it, so an inherited role still applies. + sameOrg := WorkspaceRef{OwnerID: userAna, OrgID: "org-a", InheritedRole: WorkspaceRoleCollaborator} + if !ResolveWorkspace(inOrgA, sameOrg).CanRead() { + t.Error("a same-org workspace inherited through a unit should be readable") + } +} + +// With organizations off nothing carries an org, and behavior is unchanged. +func TestSingleOrgInstanceUnaffectedByTenancyCheck(t *testing.T) { + subject := Subject{UserID: userBruno, OrgRole: OrgRoleMember} + ref := WorkspaceRef{OwnerID: userAna, InheritedRole: WorkspaceRoleCollaborator} + if !ResolveWorkspace(subject, ref).CanRead() { + t.Error("org-less instance must resolve through the tree like any other") + } +} + +// A workspace with no org (created before the migration) stays reachable by +// its own org's users rather than becoming invisible to everyone. +func TestWorkspaceWithoutOrgStaysReachable(t *testing.T) { + subject := Subject{UserID: userAna, OrgRole: OrgRoleMember, OrgID: "org-a"} + ref := WorkspaceRef{OwnerID: userAna} + if !ResolveWorkspace(subject, ref).CanRead() { + t.Error("an unmigrated workspace must stay reachable by its owner") + } +} + +// An account that escaped the migration must not become a key to every tenant: +// with tenancy enforced, no org means no access. +func TestOrglessSubjectDeniedWhenTenancyEnforced(t *testing.T) { + orgless := Subject{UserID: userBruno, OrgRole: OrgRoleAdmin, TenancyEnforced: true} + for _, ref := range []WorkspaceRef{ + {OwnerID: userAna, OrgID: "org-a"}, + {OwnerID: userAna}, + {OwnerID: userBruno, OrgID: "org-a"}, + } { + if ResolveWorkspace(orgless, ref).CanRead() { + t.Errorf("org-less subject reached %+v under enforced tenancy", ref) + } + } + // Without tenancy enforced the same subject behaves as before. + legacy := Subject{UserID: userBruno, OrgRole: OrgRoleMember} + if !ResolveWorkspace(legacy, WorkspaceRef{OwnerID: userAna, InheritedRole: WorkspaceRoleCollaborator}).CanRead() { + t.Error("org-less subject must be unaffected when tenancy is off") + } +} + +// An unrecognized stored role must resolve to the LEAST privileged role, not +// to member. Identity construction carries the stored value through unchanged +// precisely so this normalization is the one that decides. +func TestUnknownStoredRoleResolvesToGuest(t *testing.T) { + unknown := Subject{UserID: userBruno, OrgRole: NormalizeOrgRole("superuser")} + orgVisible := WorkspaceRef{OwnerID: userAna} + if ResolveWorkspace(unknown, orgVisible).CanRead() { + t.Error("an unknown stored role must not reach an org-visible workspace") + } + if got := NormalizeOrgRole("superuser"); got != OrgRoleGuest { + t.Errorf("NormalizeOrgRole(unknown) = %q, want guest", got) + } +} + +// The combining rule in one test: what you inherit and what you were granted +// are unioned, and the strongest wins. A model where a grant could lower an +// inherited role would need a deny concept, which is the thing this design +// removed on purpose. +func TestRolesCombineByMaximum(t *testing.T) { + cases := []struct { + name string + inherited WorkspaceRole + direct WorkspaceRole + want WorkspaceRole + }{ + {"a direct grant raises what a unit gives", WorkspaceRoleViewer, WorkspaceRoleCollaborator, WorkspaceRoleCollaborator}, + {"a direct grant cannot lower it", WorkspaceRoleCollaborator, WorkspaceRoleViewer, WorkspaceRoleCollaborator}, + {"inheritance alone is enough", WorkspaceRoleCollaborator, WorkspaceRoleNone, WorkspaceRoleCollaborator}, + {"a grant alone is enough", WorkspaceRoleNone, WorkspaceRoleViewer, WorkspaceRoleViewer}, + {"neither reaches nothing", WorkspaceRoleNone, WorkspaceRoleNone, WorkspaceRoleNone}, + {"owner outranks everything", WorkspaceRoleViewer, WorkspaceRoleOwner, WorkspaceRoleOwner}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ResolveWorkspace( + Subject{UserID: userBruno, OrgRole: OrgRoleMember}, + WorkspaceRef{OwnerID: userAna, InheritedRole: tc.inherited, MemberRole: tc.direct}, + ) + if got.Role != tc.want { + t.Errorf("role = %q, want %q", got.Role, tc.want) + } + }) + } +} + +// An unknown stored role must not outrank a real one. Ranking the zero value +// lowest is what makes a corrupt row fail closed rather than silently win. +func TestUnknownRoleDoesNotOutrank(t *testing.T) { + got := ResolveWorkspace( + Subject{UserID: userBruno, OrgRole: OrgRoleMember}, + WorkspaceRef{OwnerID: userAna, InheritedRole: WorkspaceRoleCollaborator, MemberRole: WorkspaceRole("superuser")}, + ) + if got.Role != WorkspaceRoleCollaborator { + t.Errorf("role = %q, want collaborator: an unrecognized role must not win", got.Role) + } +} diff --git a/apps/backend/internal/authz/roles.go b/apps/backend/internal/authz/roles.go new file mode 100644 index 0000000000..71994616fc --- /dev/null +++ b/apps/backend/internal/authz/roles.go @@ -0,0 +1,139 @@ +package authz + +// OrgRole is the instance-wide role carried on the user record. It grants org +// scopes outright and supplies the default workspace role used on workspaces +// that are visible to the whole organization. +// +// The `owner` role and the `org.delete` scope described in +// docs/specs/auth/requirements/roles-and-scopes.md arrive with multi-tenancy: without an +// org entity there is nothing to own or delete, and shipping an unreachable +// role would only clutter the role picker. +type OrgRole string + +const ( + // OrgRoleAdmin manages users, org settings, and shared configuration. It + // is NOT a reach role: an admin reaches a workspace the same way anyone + // else does, through the unit tree, and never reaches one sitting in + // someone's personal unit. + OrgRoleAdmin OrgRole = "admin" + // OrgRoleMember is a regular contributor. + OrgRoleMember OrgRole = "member" + // OrgRoleGuest holds no org scopes and reaches only workspaces they are an + // explicit member of, never one reached through the unit tree. + OrgRoleGuest OrgRole = "guest" +) + +// WorkspaceRole is the per-workspace role, held either by an explicit +// workspace_members row or inherited from membership of a unit above the one +// the workspace sits in. Where both apply the stronger of the two wins. +type WorkspaceRole string + +const ( + WorkspaceRoleOwner WorkspaceRole = "owner" + WorkspaceRoleCollaborator WorkspaceRole = "collaborator" + WorkspaceRoleViewer WorkspaceRole = "viewer" + // WorkspaceRoleNone means the workspace is unreachable. + WorkspaceRoleNone WorkspaceRole = "" +) + +// NormalizeOrgRole coerces an unknown or empty stored value to the least +// privileged real role. Unknown input must never widen access. +func NormalizeOrgRole(value string) OrgRole { + switch OrgRole(value) { + case OrgRoleAdmin: + return OrgRoleAdmin + case OrgRoleMember: + return OrgRoleMember + case OrgRoleGuest: + return OrgRoleGuest + default: + return OrgRoleGuest + } +} + +// NormalizeWorkspaceRole coerces an unknown stored value to no access. +func NormalizeWorkspaceRole(value string) WorkspaceRole { + switch WorkspaceRole(value) { + case WorkspaceRoleOwner: + return WorkspaceRoleOwner + case WorkspaceRoleCollaborator: + return WorkspaceRoleCollaborator + case WorkspaceRoleViewer: + return WorkspaceRoleViewer + default: + return WorkspaceRoleNone + } +} + +// IsAssignableWorkspaceRole reports whether a role may be set through the +// member API. Owner is reached by transfer, not by assignment. +func IsAssignableWorkspaceRole(role WorkspaceRole) bool { + return role == WorkspaceRoleCollaborator || role == WorkspaceRoleViewer +} + +// orgRoleScopes maps an org role to the org scopes it grants. +var orgRoleScopes = map[OrgRole]Set{ + OrgRoleAdmin: NewSet(ScopeOrgMembersManage, ScopeOrgSettingsManage, ScopeOrgConfigManage, ScopeUnitManage), + OrgRoleMember: NewSet(), + OrgRoleGuest: NewSet(), +} + +// workspaceRoleScopes maps a workspace role to the workspace scopes it grants. +// +// session.exec is deliberately absent from viewer and present on collaborator: +// prompting an agent is bounded by that agent's own permissions, a shell in the +// worktree is not, so reading a transcript must never imply a shell. +var workspaceRoleScopes = map[WorkspaceRole]Set{ + WorkspaceRoleOwner: NewSet( + ScopeWorkspaceRead, ScopeWorkspaceManage, ScopeTaskWrite, + ScopeSessionPrompt, ScopeSessionControl, ScopeSessionExec, + ScopeRepositoryManage, ScopeSecretManage, ScopeMemberManage, + ), + WorkspaceRoleCollaborator: NewSet( + ScopeWorkspaceRead, ScopeTaskWrite, + ScopeSessionPrompt, ScopeSessionControl, ScopeSessionExec, + ), + WorkspaceRoleViewer: NewSet(ScopeWorkspaceRead), + WorkspaceRoleNone: NewSet(), +} + +// OrgScopes returns the org scopes granted by an org role. +func OrgScopes(role OrgRole) Set { return copySet(orgRoleScopes[role]) } + +// WorkspaceScopes returns the workspace scopes granted by a workspace role. +func WorkspaceScopes(role WorkspaceRole) Set { return copySet(workspaceRoleScopes[role]) } + +// AllScopes returns every scope both role tables can grant. Used by the +// synthetic (auth-disabled) identity, which must behave exactly as the +// pre-auth single user did. +func AllScopes() Set { + all := NewSet() + for _, def := range registry { + all[def.Scope] = struct{}{} + } + return all +} + +func copySet(src Set) Set { + out := make(Set, len(src)) + for scope := range src { + out[scope] = struct{}{} + } + return out +} + +// workspaceRoleRank orders the workspace roles so they can be combined by +// maximum. The zero value is the weakest, so an unknown role never outranks a +// real one. +func workspaceRoleRank(role WorkspaceRole) int { + switch role { + case WorkspaceRoleOwner: + return 3 + case WorkspaceRoleCollaborator: + return 2 + case WorkspaceRoleViewer: + return 1 + default: + return 0 + } +} diff --git a/apps/backend/internal/authz/scopes.go b/apps/backend/internal/authz/scopes.go new file mode 100644 index 0000000000..c1ff44ed62 --- /dev/null +++ b/apps/backend/internal/authz/scopes.go @@ -0,0 +1,125 @@ +// Package authz owns Kandev's permission vocabulary: the named scopes that +// guard every user-facing action, the fixed roles that grant them, and the +// single resolver that answers "what may this user do in this workspace". +// +// There is deliberately one registry. Authorization used to be a single admin +// bit applied per route; a second mechanism living alongside the first is how +// permissions drift, so every guarded action names a scope from this file and +// nothing else decides. +package authz + +import "sort" + +// Scope is a named permission. Values are stable wire identifiers: they appear +// in API responses so the UI can label and gate controls, and they are compared +// with ==, so they are never translated. +type Scope string + +// Org scopes are granted by the org role and are independent of any workspace. +const ( + ScopeOrgMembersManage Scope = "org.members.manage" + ScopeUnitManage Scope = "unit.manage" + ScopeOrgSettingsManage Scope = "org.settings.manage" + ScopeOrgConfigManage Scope = "org.config.manage" +) + +// Workspace scopes are granted by the workspace role, per workspace. +const ( + ScopeWorkspaceRead Scope = "workspace.read" + ScopeWorkspaceManage Scope = "workspace.manage" + ScopeTaskWrite Scope = "task.write" + ScopeSessionPrompt Scope = "session.prompt" + ScopeSessionControl Scope = "session.control" + ScopeSessionExec Scope = "session.exec" + ScopeRepositoryManage Scope = "repository.manage" + ScopeSecretManage Scope = "secret.manage" + ScopeMemberManage Scope = "member.manage" +) + +// Definition is a registry entry. Description is developer- and UI-facing +// English; the frontend translates by scope ID rather than displaying this. +type Definition struct { + Scope Scope `json:"scope"` + Kind Kind `json:"kind"` + Description string `json:"description"` +} + +// Kind separates scopes granted org-wide from scopes granted per workspace. +type Kind string + +const ( + KindOrg Kind = "org" + KindWorkspace Kind = "workspace" +) + +// registry is the complete set of scopes Kandev enforces. A scope that is not +// here cannot be required, and a scope here with no enforcement call site fails +// the completeness test in registry_test.go. +var registry = []Definition{ + {ScopeOrgMembersManage, KindOrg, "Invite, create, disable, and re-role users"}, + {ScopeOrgSettingsManage, KindOrg, "Change organization-wide settings and defaults"}, + {ScopeOrgConfigManage, KindOrg, "Manage executors, agent profiles, environments, editors, prompts, and notification providers"}, + {ScopeUnitManage, KindOrg, "Create, rename, move, and delete organization units, and place workspaces in them"}, + + {ScopeWorkspaceRead, KindWorkspace, "See the workspace, its board, tasks, and transcripts"}, + {ScopeWorkspaceManage, KindWorkspace, "Rename the workspace, change its defaults, move it between units, delete it"}, + {ScopeTaskWrite, KindWorkspace, "Create, edit, move, assign, archive, and delete tasks"}, + {ScopeSessionPrompt, KindWorkspace, "Start or resume a session and message an agent"}, + {ScopeSessionControl, KindWorkspace, "Stop or cancel a running agent"}, + {ScopeSessionExec, KindWorkspace, "Open a terminal, run shell commands, write files, and open port previews"}, + {ScopeRepositoryManage, KindWorkspace, "Add or remove workspace repositories"}, + {ScopeSecretManage, KindWorkspace, "Manage workspace secrets and integration credentials"}, + {ScopeMemberManage, KindWorkspace, "Add or remove workspace members and transfer ownership"}, +} + +// Registry returns every defined scope, ordered by kind then ID. +func Registry() []Definition { + out := make([]Definition, len(registry)) + copy(out, registry) + sort.Slice(out, func(i, j int) bool { + if out[i].Kind != out[j].Kind { + return out[i].Kind < out[j].Kind + } + return out[i].Scope < out[j].Scope + }) + return out +} + +// Set is an unordered collection of scopes. +type Set map[Scope]struct{} + +// NewSet builds a Set from the given scopes. +func NewSet(scopes ...Scope) Set { + set := make(Set, len(scopes)) + for _, scope := range scopes { + set[scope] = struct{}{} + } + return set +} + +// Has reports whether the set contains the scope. The zero Set holds nothing, +// which is the fail-closed default every caller depends on. +func (s Set) Has(scope Scope) bool { + _, ok := s[scope] + return ok +} + +// List returns the set's scopes sorted, for stable API responses and tests. +func (s Set) List() []Scope { + out := make([]Scope, 0, len(s)) + for scope := range s { + out = append(out, scope) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// Strings returns List as plain strings, for JSON payloads. +func (s Set) Strings() []string { + scopes := s.List() + out := make([]string, len(scopes)) + for i, scope := range scopes { + out[i] = string(scope) + } + return out +} diff --git a/apps/backend/internal/backendapp/adapters_user_directory.go b/apps/backend/internal/backendapp/adapters_user_directory.go new file mode 100644 index 0000000000..423599a9b3 --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_user_directory.go @@ -0,0 +1,72 @@ +package backendapp + +import ( + "context" + + "github.com/kandev/kandev/internal/task/service" + usermodels "github.com/kandev/kandev/internal/user/models" + userstore "github.com/kandev/kandev/internal/user/store" +) + +// directoryAccounts is the one method this adapter needs. Narrowing it from +// the full account repository is what makes the org filter testable without +// standing up an account store. +type directoryAccounts interface { + ListUsers(ctx context.Context) ([]*usermodels.User, error) + GetUser(ctx context.Context, id string) (*usermodels.User, error) +} + +// userDirectoryAdapter exposes the minimum account information workspace +// membership needs, and nothing more. +// +// It lives in backendapp rather than internal/user because the contract it +// satisfies belongs to the task service: putting it in internal/user would +// make the account package depend on the task package. +type userDirectoryAdapter struct { + accounts directoryAccounts +} + +func newUserDirectoryAdapter(accounts userstore.AccountRepository) *userDirectoryAdapter { + return &userDirectoryAdapter{accounts: accounts} +} + +// ListDirectory returns active users as ID plus display name. Email, role and +// status are deliberately dropped: a member picker needs a name, and anything +// more would hand every authenticated user a full account dump. +func (a *userDirectoryAdapter) ListDirectory(ctx context.Context, orgID string) ([]service.DirectoryUser, error) { + users, err := a.accounts.ListUsers(ctx) + if err != nil { + return nil, err + } + out := make([]service.DirectoryUser, 0, len(users)) + for _, user := range users { + if user == nil || user.Status == "disabled" { + continue + } + // Filter here rather than in the store: with organizations off every + // account carries an empty org and the comparison is a no-op, so one + // code path covers both shapes. + if orgID != "" && user.OrgID != orgID { + continue + } + name := user.DisplayName + if name == "" { + name = user.Email + } + out = append(out, service.DirectoryUser{ID: user.ID, DisplayName: name}) + } + return out, nil +} + +// LookupStatus reports whether a user exists and, if so, their status and role. +func (a *userDirectoryAdapter) LookupStatus(ctx context.Context, userID string) (string, string, bool, error) { + user, err := a.accounts.GetUser(ctx, userID) + if err != nil || user == nil { + // A missing account is an expected outcome here, not a failure: the + // caller turns it into a "user not found" validation error. + return "", "", false, nil //nolint:nilerr // absence is not an error on this path + } + return user.Status, user.Role, true, nil +} + +var _ service.UserDirectory = (*userDirectoryAdapter)(nil) diff --git a/apps/backend/internal/backendapp/adapters_user_directory_test.go b/apps/backend/internal/backendapp/adapters_user_directory_test.go new file mode 100644 index 0000000000..e1c68ad960 --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_user_directory_test.go @@ -0,0 +1,69 @@ +package backendapp + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/user/models" +) + +type stubAccounts struct{ users []*models.User } + +func (s stubAccounts) ListUsers(context.Context) ([]*models.User, error) { return s.users, nil } + +func (s stubAccounts) GetUser(_ context.Context, id string) (*models.User, error) { + for _, u := range s.users { + if u.ID == id { + return u, nil + } + } + return nil, nil +} + +// The member picker is the one place a person's name is shown to someone who +// has no other relationship with them. Listing it unscoped makes every account +// on the instance enumerable from any tenant, which is the cross-tenant leak +// this filter exists to close. +func TestDirectoryIsScopedToTheCallersOrg(t *testing.T) { + adapter := &userDirectoryAdapter{accounts: stubAccounts{users: []*models.User{ + {ID: "ada", DisplayName: "Ada", OrgID: "org-a", Status: "active"}, + {ID: "grace", DisplayName: "Grace", OrgID: "org-a", Status: "active"}, + {ID: "eve", DisplayName: "Eve", OrgID: "org-b", Status: "active"}, + {ID: "gone", DisplayName: "Gone", OrgID: "org-a", Status: "disabled"}, + }}} + + got, err := adapter.ListDirectory(context.Background(), "org-a") + if err != nil { + t.Fatalf("list directory: %v", err) + } + ids := map[string]bool{} + for _, u := range got { + ids[u.ID] = true + } + if !ids["ada"] || !ids["grace"] { + t.Fatalf("own organization missing from the directory: %+v", got) + } + if ids["eve"] { + t.Fatal("another organization's account is enumerable through the directory") + } + if ids["gone"] { + t.Fatal("a disabled account is pickable") + } +} + +// With organizations off every account carries an empty org, so the same code +// path must return the whole instance rather than nothing. +func TestDirectoryReturnsEveryoneWhenOrgsAreOff(t *testing.T) { + adapter := &userDirectoryAdapter{accounts: stubAccounts{users: []*models.User{ + {ID: "ada", DisplayName: "Ada", Status: "active"}, + {ID: "grace", DisplayName: "Grace", Status: "active"}, + }}} + + got, err := adapter.ListDirectory(context.Background(), "") + if err != nil { + t.Fatalf("list directory: %v", err) + } + if len(got) != 2 { + t.Fatalf("directory = %+v, want both accounts on a single-org instance", got) + } +} diff --git a/apps/backend/internal/backendapp/auth.go b/apps/backend/internal/backendapp/auth.go index c174edf510..1af239d550 100644 --- a/apps/backend/internal/backendapp/auth.go +++ b/apps/backend/internal/backendapp/auth.go @@ -121,7 +121,8 @@ func gatewayAuthPolicy( Session: taskSvc.AuthorizeSessionAccess, Run: runSubscriptionCheck(taskSvc, officeRepo), }, - WorkspaceOwner: newWorkspaceOwnerResolver(taskRepo), + WorkspaceOwner: newWorkspaceOwnerResolver(taskRepo), + WorkspaceReaders: taskSvc.WorkspaceReaderIDs, // The user-shell actions name a task environment and treat task_id as // optional, so the dispatch backstop needs an environment-keyed check. ActionEnvironment: taskSvc.AuthorizeEnvironmentAccess, @@ -129,7 +130,7 @@ func gatewayAuthPolicy( } // runSubscriptionCheck resolves a run's owning workspace (via its agent -// profile) and defers to the task service's workspace visibility rule. +// profile) and defers to the task service's workspace reach rule. // Unlike WorkspaceOwner (which runs on every workspace broadcast), this // runs once per run.subscribe — a rare control message — so it is not // cached. diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index f7ddf3b7ed..a6bfd28890 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -54,7 +54,7 @@ func (b bootStateBuilder) tasksPageBootData(ctx context.Context, req *http.Reque ) state := map[string]any{ "workspaces": map[string]any{ - "items": workspaceItemStates(workspaces), + "items": b.workspaceItemStates(ctx, workspaces), "activeId": nullString(activeWorkspaceID), }, } @@ -122,7 +122,7 @@ func (b bootStateBuilder) routeContextBootData(ctx context.Context, req *http.Re ) state := map[string]any{ "workspaces": map[string]any{ - "items": workspaceItemStates(workspaces), + "items": b.workspaceItemStates(ctx, workspaces), "activeId": nullString(activeWorkspaceID), }, } @@ -171,11 +171,21 @@ func workspaceIDSet(workspaces []*taskmodels.Workspace) map[string]bool { } // workspaceItemStates maps each workspace to its boot state shape. -func workspaceItemStates(workspaces []*taskmodels.Workspace) []map[string]any { +// workspaceItemStates maps each workspace to its boot state shape, including +// the caller's resolved role and scopes. +// +// The projection is threaded through here rather than left to the HTTP list +// endpoint: this is a second, independent serialization path into the same +// store, and a field added only to the DTO silently never reaches a +// first-paint render. +func (b bootStateBuilder) workspaceItemStates(ctx context.Context, workspaces []*taskmodels.Workspace) []map[string]any { + access := b.p.taskSvc.ProjectWorkspaceAccess(ctx, workspaces) items := make([]map[string]any, 0, len(workspaces)) for _, workspace := range workspaces { if workspace != nil { - items = append(items, mapWorkspaceItemState(taskdto.FromWorkspace(workspace))) + items = append(items, mapWorkspaceItemState(taskdto.FromWorkspaceWithAccess( + workspace, access.Decision(workspace.ID), access.MemberCounts[workspace.ID], + ))) } } return items @@ -694,6 +704,10 @@ func mapWorkspaceItemState(workspace taskdto.WorkspaceDTO) map[string]any { "name": workspace.Name, "description": workspace.Description, "owner_id": workspace.OwnerID, + "unit_id": workspace.UnitID, + "viewer_role": workspace.ViewerRole, + "scopes": workspace.Scopes, + "member_count": workspace.MemberCount, "default_executor_id": workspace.DefaultExecutorID, "default_environment_id": workspace.DefaultEnvironmentID, "default_agent_profile_id": workspace.DefaultAgentProfileID, diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index e7722dcc2b..4a06a5caf2 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -33,6 +33,7 @@ import ( "github.com/kandev/kandev/internal/auth" "github.com/kandev/kandev/internal/auth/authn" authhttpapi "github.com/kandev/kandev/internal/auth/httpapi" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/azuredevops" "github.com/kandev/kandev/internal/clarification" @@ -66,6 +67,8 @@ import ( officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" officetestharness "github.com/kandev/kandev/internal/office/testharness" "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/org" + "github.com/kandev/kandev/internal/orgunit" "github.com/kandev/kandev/internal/plugins" pluginstore "github.com/kandev/kandev/internal/plugins/store" "github.com/kandev/kandev/internal/profiles" @@ -1242,6 +1245,13 @@ func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, han p.log.Warn("prompt attachment routes disabled: attachment service is unavailable") } taskhandlers.RegisterWorkspaceRoutes(p.router, p.gateway.Dispatcher, p.taskSvc, p.log) + taskhandlers.RegisterMemberRoutes(p.router, p.taskSvc, p.log) + if p.services != nil && p.services.Org != nil { + org.NewController(p.services.Org, p.log).RegisterRoutes(p.router) + } + if p.services.OrgUnits != nil { + orgunit.NewController(p.services.OrgUnits, p.log).RegisterRoutes(p.router) + } if p.services != nil { registerMentionRoutes(p.router, p.services.Mentions) } @@ -1625,17 +1635,28 @@ type lifecycleAccessAuthorizer interface { AuthorizeEnvironmentAccess(ctx context.Context, taskEnvironmentID string) error AuthorizeTaskAccess(ctx context.Context, taskID string) error AuthorizeTaskEnvironmentAccess(ctx context.Context, taskID, taskEnvironmentID string) error + AuthorizeSessionScope(ctx context.Context, sessionID string, scope authz.Scope) error + AuthorizeEnvironmentScope(ctx context.Context, taskEnvironmentID string, scope authz.Scope) error } // wireLifecycleAccessCheckers installs every per-user visibility check on the // lifecycle manager. Kept together so a surface that needs a new kind of check // has one place to add it, rather than another setter call somewhere else in // startup that nothing asserts on. -func wireLifecycleAccessCheckers(lifecycleMgr *lifecycle.Manager, authz lifecycleAccessAuthorizer) { - lifecycleMgr.SetSessionAccessChecker(authz.AuthorizeSessionAccess) - lifecycleMgr.SetEnvironmentAccessChecker(authz.AuthorizeEnvironmentAccess) - lifecycleMgr.SetTaskAccessChecker(authz.AuthorizeTaskAccess) - lifecycleMgr.SetTaskEnvironmentAccessChecker(authz.AuthorizeTaskEnvironmentAccess) +func wireLifecycleAccessCheckers(lifecycleMgr *lifecycle.Manager, access lifecycleAccessAuthorizer) { + lifecycleMgr.SetSessionAccessChecker(access.AuthorizeSessionAccess) + lifecycleMgr.SetEnvironmentAccessChecker(access.AuthorizeEnvironmentAccess) + lifecycleMgr.SetTaskAccessChecker(access.AuthorizeTaskAccess) + lifecycleMgr.SetTaskEnvironmentAccessChecker(access.AuthorizeTaskEnvironmentAccess) + // Execution surfaces (terminal, shell, file writes, VS Code, port + // previews) require session.exec, which a viewer does not hold. They are + // keyed either by session or by environment, so both slots get one. + lifecycleMgr.SetSessionExecAccessChecker(func(ctx context.Context, sessionID string) error { + return access.AuthorizeSessionScope(ctx, sessionID, authz.ScopeSessionExec) + }) + lifecycleMgr.SetEnvironmentExecAccessChecker(func(ctx context.Context, environmentID string) error { + return access.AuthorizeEnvironmentScope(ctx, environmentID, authz.ScopeSessionExec) + }) } func dockerTaskTitleProvider(taskRepo *sqliterepo.Repository, log *logger.Logger) docker.TaskTitleProvider { diff --git a/apps/backend/internal/backendapp/lifecycle_access_wiring_test.go b/apps/backend/internal/backendapp/lifecycle_access_wiring_test.go index c7ac91c646..1652733b34 100644 --- a/apps/backend/internal/backendapp/lifecycle_access_wiring_test.go +++ b/apps/backend/internal/backendapp/lifecycle_access_wiring_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/authz" ) // recordingAuthorizer records which authorizer the lifecycle manager reached @@ -31,6 +32,16 @@ func (r *recordingAuthorizer) AuthorizeTaskEnvironmentAccess(_ context.Context, return nil } +func (r *recordingAuthorizer) AuthorizeSessionScope(_ context.Context, sessionID string, scope authz.Scope) error { + r.calls = append(r.calls, "session-scope:"+sessionID+"/"+string(scope)) + return nil +} + +func (r *recordingAuthorizer) AuthorizeEnvironmentScope(_ context.Context, envID string, scope authz.Scope) error { + r.calls = append(r.calls, "environment-scope:"+envID+"/"+string(scope)) + return nil +} + // TestWireLifecycleAccessCheckers pins the hop between the task service and the // lifecycle manager. The three single-ID checkers share the signature // `func(context.Context, string) error`, so wiring session visibility into the @@ -39,10 +50,10 @@ func (r *recordingAuthorizer) AuthorizeTaskEnvironmentAccess(_ context.Context, // Checking each check reaches its own authorizer is the only thing that catches // a crossed or dropped wire. func TestWireLifecycleAccessCheckers(t *testing.T) { - authz := &recordingAuthorizer{} + access := &recordingAuthorizer{} mgr := &lifecycle.Manager{} - wireLifecycleAccessCheckers(mgr, authz) + wireLifecycleAccessCheckers(mgr, access) ctx := context.Background() if err := mgr.CheckSessionAccess(ctx, "sess-1"); err != nil { @@ -57,14 +68,28 @@ func TestWireLifecycleAccessCheckers(t *testing.T) { if err := mgr.CheckTaskEnvironmentAccess(ctx, "task-1", "env-1"); err != nil { t.Fatalf("CheckTaskEnvironmentAccess: %v", err) } + // The exec checkers fall back to the read check when unwired, so a dropped + // wire here is silent: the surface still authorizes, just at the wrong + // scope. Asserting the scope-carrying authorizer is the one reached (and + // with session.exec, not a read scope) is what catches that. + if err := mgr.CheckSessionExecAccess(ctx, "sess-1"); err != nil { + t.Fatalf("CheckSessionExecAccess: %v", err) + } + if err := mgr.CheckEnvironmentExecAccess(ctx, "env-1"); err != nil { + t.Fatalf("CheckEnvironmentExecAccess: %v", err) + } - want := []string{"session:sess-1", "environment:env-1", "task:task-1", "pair:task-1/env-1"} - if len(authz.calls) != len(want) { - t.Fatalf("calls = %v, want %v", authz.calls, want) + want := []string{ + "session:sess-1", "environment:env-1", "task:task-1", "pair:task-1/env-1", + "session-scope:sess-1/" + string(authz.ScopeSessionExec), + "environment-scope:env-1/" + string(authz.ScopeSessionExec), + } + if len(access.calls) != len(want) { + t.Fatalf("calls = %v, want %v", access.calls, want) } for i, call := range want { - if authz.calls[i] != call { - t.Errorf("call %d = %q, want %q (crossed wire)", i, authz.calls[i], call) + if access.calls[i] != call { + t.Errorf("call %d = %q, want %q (crossed wire)", i, access.calls[i], call) } } } diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 2aaf88d50c..54ca98ccb4 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -23,8 +23,10 @@ import ( "github.com/kandev/kandev/internal/auth" "github.com/kandev/kandev/internal/auth/hostnames" authhttpmw "github.com/kandev/kandev/internal/auth/httpmw" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/entityrefs" + "github.com/kandev/kandev/internal/org" "go.uber.org/zap" // Common packages @@ -323,6 +325,14 @@ func setBuildInfo(build BuildInfo) { // run initializes all services and runs the server. Returns false on fatal startup error. func run(cfg *config.Config, log *logger.Logger, cleanups *[]func() error, runCleanups func()) bool { + // Organizations without authentication is the one configuration that + // cannot work: a tenant boundary needs identities to belong to it. Refuse + // at startup rather than booting an instance whose boundary is decorative. + if err := org.ValidateStartup(cfg.Features.MultiTenancy, cfg.Features.Auth); err != nil { + log.Error("Invalid feature configuration", zap.Error(err)) + return false + } + addCleanup := func(fn func() error) { *cleanups = append(*cleanups, fn) } // 3. Create context with cancellation @@ -417,6 +427,30 @@ func startServices( //nolint:cyclop return false } warnIfExposedWithoutAuth(cfg, services.Auth, log) + taskservice.SetTenancyEnforced(cfg.Features.MultiTenancy) + if services.Org != nil && services.Org.Enabled() { + services.Auth.SetAdminCreatedHook(services.Org.ClaimSetupAdmin) + services.Org.SetFirstAdminCreator(services.Auth.CreateUserInOrg) + // Workspace fan-out needs to know which organization a user belongs + // to, so an org-visible workspace reaches its own org only. + services.Task.SetUserOrgResolver(func(ctx context.Context, userID string) (string, error) { + user, err := repos.UserAccounts.GetUser(ctx, userID) + if err != nil || user == nil { + return "", err + } + return user.OrgID, nil + }) + } + if services.Org != nil && services.Org.Enabled() { + // A suspended organization fails every session and token closed, which + // is what makes suspension a real lever rather than a label. An + // unreadable status fails closed too: an org we cannot verify does not + // get to keep serving requests. + services.Auth.SetOrgStatusChecker(func(ctx context.Context, orgID string) bool { + record, err := services.Org.Get(ctx, orgID) + return err == nil && record.Active() + }) + } hostnameStore, err := hostnames.NewStore(dbPool.Writer(), dbPool.Reader()) if err != nil { log.Error("Failed to initialize session hostname cache", zap.Error(err)) @@ -1418,6 +1452,11 @@ func wireOfficeSvcsDependencies( // Route the Office "No parent" mutation through the canonical task detach // operation so inherited workspace sharing remains valid. services.OfficeSvcs.Dashboard.SetTaskDetacher(services.Task) + // Human assignee writes from the office PATCH surface go through the task + // service, which authorizes the caller and validates the assignee. That + // route carries no :wsId, so it is not covered by the office + // workspace-scope middleware and must not write the column directly. + services.OfficeSvcs.Dashboard.SetHumanAssigneeWriter(services.Task) // Wire the reactivity pipeline so property mutations queue downstream runs. services.OfficeSvcs.Dashboard.SetReactivityApplier( officescheduler.NewDashboardReactivityAdapter(services.OfficeSvcs.Scheduler), @@ -2269,8 +2308,11 @@ func buildHTTPServer( // packages are importable: the task service's not-found sentinel becomes // the secrets sentinel (404), while raw lookup/storage errors pass through // unclassified (sanitized 500). + // Workspace secrets are a shared credential the workspace owner is + // accountable for, so they require secret.manage rather than mere reach: + // a collaborator contributes to the workspace, they do not hold its keys. secretsSvc.SetWorkspaceAuthorizer(func(ctx context.Context, workspaceID string) error { - err := services.Task.AuthorizeWorkspaceAccess(ctx, workspaceID) + err := services.Task.AuthorizeWorkspaceScope(ctx, workspaceID, authz.ScopeSecretManage) if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { return secrets.ErrWorkspaceAccessDenied } diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index e91950aed0..02ae800cb0 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + "github.com/kandev/kandev/internal/authz" "path/filepath" "strings" @@ -183,6 +184,17 @@ func provideOrchestrator( // resolves sessions through its own repo handle, so it does not inherit the // task service's authorize* checks. orchestratorSvc.SetSessionAccessChecker(taskSvc.AuthorizeSessionAccess) + orchestratorSvc.SetSessionControlChecker(func(ctx context.Context, sessionID string) error { + return taskSvc.AuthorizeSessionScope(ctx, sessionID, authz.ScopeSessionControl) + }) + // Starting, resuming, steering and dispatching a turn are writes, so they + // need session.prompt rather than mere reach. + orchestratorSvc.SetSessionPromptChecker(func(ctx context.Context, sessionID string) error { + return taskSvc.AuthorizeSessionScope(ctx, sessionID, authz.ScopeSessionPrompt) + }) + orchestratorSvc.SetTaskPromptChecker(func(ctx context.Context, taskID string) error { + return taskSvc.AuthorizeTaskScope(ctx, taskID, authz.ScopeSessionPrompt) + }) orchestratorSvc.SetTaskAccessChecker(taskSvc.AuthorizeTaskAccess) // Publish task.updated when the first session is marked primary so the diff --git a/apps/backend/internal/backendapp/orgs.go b/apps/backend/internal/backendapp/orgs.go new file mode 100644 index 0000000000..b59b2c16be --- /dev/null +++ b/apps/backend/internal/backendapp/orgs.go @@ -0,0 +1,106 @@ +package backendapp + +import ( + "context" + "errors" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/org" + userstore "github.com/kandev/kandev/internal/user/store" +) + +// orgAccountAdapter exposes the account operations the organization service +// needs without making internal/user depend on internal/org. +type orgAccountAdapter struct { + accounts userstore.AccountRepository +} + +func (a orgAccountAdapter) AssignUsersWithoutOrg(ctx context.Context, orgID string) (int64, error) { + return a.tenancy().AssignUsersWithoutOrg(ctx, orgID) +} + +func (a orgAccountAdapter) SetOperator(ctx context.Context, id string, operator bool) error { + return a.tenancy().SetOperator(ctx, id, operator) +} + +func (a orgAccountAdapter) CountOperators(ctx context.Context) (int, error) { + return a.tenancy().CountOperators(ctx) +} + +func (a orgAccountAdapter) FirstAdminID(ctx context.Context) (string, error) { + return a.tenancy().FirstAdminID(ctx) +} + +func (a orgAccountAdapter) DeleteUsersByOrg(ctx context.Context, orgID string) error { + return a.tenancy().DeleteUsersByOrg(ctx, orgID) +} + +// tenancy narrows the account repository to its tenancy operations. The +// concrete SQLite repository implements them; the interface assertion keeps +// existing AccountRepository fakes unaffected. +func (a orgAccountAdapter) tenancy() userstore.TenancyRepository { + if t, ok := a.accounts.(userstore.TenancyRepository); ok { + return t + } + return userstore.NoopTenancy{} +} + +var _ org.AccountMigrator = orgAccountAdapter{} + +type orgMigrationData interface { + AssignWorkspacesWithoutOrg(ctx context.Context, orgID string) (int64, error) + DropCrossOrgWorkspaceMembers(ctx context.Context) (int64, error) +} + +type orgWorkspaceLifecycle interface { + DeleteOrganizationWorkspaces(ctx context.Context, orgID string) error +} + +// orgDataAdapter keeps migration SQL in the repository while routing +// destructive workspace removal through the task service's full lifecycle. +type orgDataAdapter struct { + migrations orgMigrationData + workspaces orgWorkspaceLifecycle +} + +func (a orgDataAdapter) AssignWorkspacesWithoutOrg(ctx context.Context, orgID string) (int64, error) { + return a.migrations.AssignWorkspacesWithoutOrg(ctx, orgID) +} + +func (a orgDataAdapter) DropCrossOrgWorkspaceMembers(ctx context.Context) (int64, error) { + return a.migrations.DropCrossOrgWorkspaceMembers(ctx) +} + +func (a orgDataAdapter) DeleteOrgData(ctx context.Context, orgID string) error { + if a.workspaces == nil { + return errors.New("workspace deletion lifecycle is unavailable") + } + return a.workspaces.DeleteOrganizationWorkspaces(ctx, orgID) +} + +var _ org.DataMigrator = orgDataAdapter{} + +// buildOrgService constructs the organization service. It is built even when +// organizations are off so callers never branch on nil; the service itself +// reports Enabled() false and every lifecycle operation is a no-op. +func buildOrgService( + cfg *config.Config, + pool *db.Pool, + repos *Repositories, + workspaces orgWorkspaceLifecycle, + log *logger.Logger, +) (*org.Service, error) { + store, err := org.NewStore(pool) + if err != nil { + return nil, err + } + return org.NewService( + store, + orgAccountAdapter{accounts: repos.UserAccounts}, + orgDataAdapter{migrations: repos.Task, workspaces: workspaces}, + cfg.Features.MultiTenancy, + log, + ), nil +} diff --git a/apps/backend/internal/backendapp/orgs_review_test.go b/apps/backend/internal/backendapp/orgs_review_test.go new file mode 100644 index 0000000000..074f0d73d3 --- /dev/null +++ b/apps/backend/internal/backendapp/orgs_review_test.go @@ -0,0 +1,129 @@ +package backendapp + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + taskservice "github.com/kandev/kandev/internal/task/service" + userstore "github.com/kandev/kandev/internal/user/store" +) + +type orgReviewSecretDeleter struct { + workspaceIDs []string +} + +func (d *orgReviewSecretDeleter) DeleteWorkspaceSecrets(_ context.Context, workspaceID string) error { + d.workspaceIDs = append(d.workspaceIDs, workspaceID) + return nil +} + +func TestOrganizationDeletionUsesWorkspaceLifecycle(t *testing.T) { + ctx := context.Background() + raw, err := db.OpenSQLite(filepath.Join(t.TempDir(), "org-delete.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + database := sqlx.NewDb(raw, "sqlite3") + t.Cleanup(func() { _ = database.Close() }) + taskRepo, cleanupTaskRepo, err := repository.Provide(database, database, nil) + if err != nil { + t.Fatalf("task repository: %v", err) + } + t.Cleanup(func() { _ = cleanupTaskRepo() }) + accounts, cleanupAccounts, err := userstore.Provide(database, database) + if err != nil { + t.Fatalf("user repository: %v", err) + } + t.Cleanup(func() { _ = cleanupAccounts() }) + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger: %v", err) + } + eventBus := bus.NewMemoryEventBus(log) + t.Cleanup(eventBus.Close) + taskSvc := taskservice.NewService(taskservice.Repos{ + Workspaces: taskRepo, + Tasks: taskRepo, + TaskRepos: taskRepo, + WorkspaceFolders: taskRepo, + Workflows: taskRepo, + Messages: taskRepo, + Attachments: taskRepo, + Turns: taskRepo, + Sessions: taskRepo, + GitSnapshots: taskRepo, + RepoEntities: taskRepo, + RepositorySets: taskRepo, + BranchPolicies: taskRepo, + RepositoryCleanup: taskRepo, + Executors: taskRepo, + Environments: taskRepo, + TaskEnvironments: taskRepo, + Reviews: taskRepo, + ResourceCleanups: taskRepo, + StatusSummaries: taskRepo, + TaskActivity: taskRepo, + SubagentContexts: taskRepo, + Usage: taskRepo, + }, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) + secretDeleter := &orgReviewSecretDeleter{} + taskSvc.SetWorkspaceSecretDeleter(secretDeleter) + + cfg := &config.Config{} + cfg.Features.MultiTenancy = true + pool := db.NewPool(database, database) + orgSvc, err := buildOrgService(cfg, pool, &Repositories{Task: taskRepo, UserAccounts: accounts}, taskSvc, log) + if err != nil { + t.Fatalf("organization service: %v", err) + } + defaultOrg, err := orgSvc.EnsureDefaultOrg(ctx, "Default organization") + if err != nil { + t.Fatalf("create default organization: %v", err) + } + doomedOrg, err := orgSvc.Create(ctx, "Doomed", "doomed") + if err != nil { + t.Fatalf("create doomed organization: %v", err) + } + if err := taskRepo.CreateWorkspace(ctx, &models.Workspace{ + ID: "ws-doomed", Name: "Doomed workspace", OrgID: doomedOrg.ID, + }); err != nil { + t.Fatalf("create doomed workspace: %v", err) + } + if err := taskRepo.CreateWorkspace(ctx, &models.Workspace{ + ID: "ws-keep", Name: "Kept workspace", OrgID: defaultOrg.ID, + }); err != nil { + t.Fatalf("create kept workspace: %v", err) + } + deletedWorkspaceID := "" + subscription, err := eventBus.Subscribe(events.WorkspaceDeleted, func(_ context.Context, event *bus.Event) error { + if data, ok := event.Data.(map[string]interface{}); ok { + deletedWorkspaceID, _ = data["id"].(string) + } + return nil + }) + if err != nil { + t.Fatalf("subscribe to workspace deletion: %v", err) + } + t.Cleanup(func() { _ = subscription.Unsubscribe() }) + + if err := orgSvc.Delete(ctx, doomedOrg.ID, doomedOrg.Slug); err != nil { + t.Fatalf("delete organization: %v", err) + } + if len(secretDeleter.workspaceIDs) != 1 || secretDeleter.workspaceIDs[0] != "ws-doomed" || deletedWorkspaceID != "ws-doomed" { + t.Fatalf("workspace lifecycle secret deletes=%v deleted event=%q", secretDeleter.workspaceIDs, deletedWorkspaceID) + } + if _, err := taskRepo.GetWorkspace(ctx, "ws-keep"); err != nil { + t.Fatalf("unrelated organization workspace was removed: %v", err) + } +} diff --git a/apps/backend/internal/backendapp/orgunits.go b/apps/backend/internal/backendapp/orgunits.go new file mode 100644 index 0000000000..6f8feeff0d --- /dev/null +++ b/apps/backend/internal/backendapp/orgunits.go @@ -0,0 +1,78 @@ +package backendapp + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/orgunit" + tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" + "github.com/kandev/kandev/internal/user/store" +) + +// buildOrgUnitService creates the unit tree, wires its occupancy seam, and runs +// the one-shot placement so no workspace is left without a home. +// +// It runs after the tenancy migration, which is what assigns organization ids: +// placing a workspace before it knows its organization would put it under the +// wrong root. +func buildOrgUnitService( + pool *db.Pool, + taskRepo *tasksqlite.Repository, + accounts store.AccountRepository, + log *logger.Logger, +) (*orgunit.Service, error) { + unitStore, err := orgunit.NewStore(pool) + if err != nil { + return nil, err + } + svc := orgunit.NewService(unitStore, log) + svc.SetWorkspaceCounter(taskRepo) + + if _, err := svc.Backfill( + context.Background(), + unitAccountAdapter{accounts: accounts}, + unitPlacementAdapter{repo: taskRepo}, + ); err != nil { + return nil, fmt.Errorf("organization unit backfill: %w", err) + } + return svc, nil +} + +// unitAccountAdapter narrows the user repository to what a personal unit needs. +type unitAccountAdapter struct{ accounts store.AccountRepository } + +func (a unitAccountAdapter) ListUnitUsers(ctx context.Context) ([]orgunit.UserRef, error) { + users, err := a.accounts.ListUsers(ctx) + if err != nil { + return nil, err + } + out := make([]orgunit.UserRef, 0, len(users)) + for _, u := range users { + if u == nil { + continue + } + out = append(out, orgunit.UserRef{ID: u.ID, OrgID: u.OrgID, DisplayName: u.DisplayName}) + } + return out, nil +} + +// unitPlacementAdapter narrows the task repository to workspace placement. +type unitPlacementAdapter struct{ repo *tasksqlite.Repository } + +func (a unitPlacementAdapter) UnplacedWorkspaces(ctx context.Context) ([]orgunit.WorkspaceRef, error) { + rows, err := a.repo.UnplacedWorkspaces(ctx) + if err != nil { + return nil, err + } + out := make([]orgunit.WorkspaceRef, 0, len(rows)) + for _, r := range rows { + out = append(out, orgunit.WorkspaceRef{ID: r.ID, OrgID: r.OrgID, OwnerID: r.OwnerID}) + } + return out, nil +} + +func (a unitPlacementAdapter) PlaceWorkspace(ctx context.Context, workspaceID, unitID string) error { + return a.repo.PlaceWorkspace(ctx, workspaceID, unitID) +} diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index fc9fdba485..963b3e4591 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -159,6 +159,33 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories }, ) taskSvc.SetPendingActionProjectionEpoch(pendingActionProjectionEpoch) + // Workspace membership needs to resolve colleague names and reject + // disabled or unknown accounts before writing a row. + taskSvc.SetUserDirectory(newUserDirectoryAdapter(repos.UserAccounts)) + // The default organization is named generically: an instance has no + // company name to borrow, and an operator renames it in one click. + const defaultOrgName = "Default organization" + orgSvc, orgErr := buildOrgService(cfg, dbPool, repos, taskSvc, log) + if orgErr != nil { + return nil, nil, fmt.Errorf("initialize organizations: %w", orgErr) + } + // The tenancy migration runs once at boot: it creates the default + // organization and puts every pre-tenancy user and workspace into it. + if _, err := orgSvc.EnsureDefaultOrg(context.Background(), defaultOrgName); err != nil { + return nil, nil, fmt.Errorf("organization migration: %w", err) + } + // The unit tree is built after the tenancy migration, which is what stamps + // organization ids: placing a workspace before it knows its organization + // would put it under the wrong root. + unitSvc, unitErr := buildOrgUnitService(dbPool, repos.Task, repos.UserAccounts, log) + if unitErr != nil { + return nil, nil, fmt.Errorf("initialize organization units: %w", unitErr) + } + taskSvc.SetUnitPlacer(unitSvc) + taskSvc.SetUnitReach(unitSvc) + // Deleting an organization must take its unit tree with it. + orgSvc.SetUnitDeleter(unitSvc) + taskSvc.SetSecretStore(userSecretStore) if deleter, ok := userSecretStore.(taskservice.WorkspaceSecretDeleter); ok { taskSvc.SetWorkspaceSecretDeleter(deleter) @@ -309,6 +336,8 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories DynamicProfileResolver: dynamicResolver, DynamicBindingResolver: dynamicBindingResolver, Task: taskSvc, + Org: orgSvc, + OrgUnits: unitSvc, User: userSvc, Editor: editorSvc, Prompts: promptSvc, diff --git a/apps/backend/internal/backendapp/types.go b/apps/backend/internal/backendapp/types.go index f85530bd10..d420c747a9 100644 --- a/apps/backend/internal/backendapp/types.go +++ b/apps/backend/internal/backendapp/types.go @@ -25,6 +25,8 @@ import ( office "github.com/kandev/kandev/internal/office" officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" officeservice "github.com/kandev/kandev/internal/office/service" + "github.com/kandev/kandev/internal/org" + "github.com/kandev/kandev/internal/orgunit" "github.com/kandev/kandev/internal/plugins" promptservice "github.com/kandev/kandev/internal/prompts/service" promptstore "github.com/kandev/kandev/internal/prompts/store" @@ -74,19 +76,23 @@ type Services struct { DynamicProfileResolver *agentruntime.ProfileExecutionResolver DynamicBindingResolver *dynamicruntime.CredentialBindingResolver Task *taskservice.Service - User *userservice.Service - Editor *editorservice.Service - Notification *notificationservice.Service - Prompts *promptservice.Service - Utility *utilityservice.Service - Workflow *workflowservice.Service - GitHub *github.Service - GitLab *gitlab.Service - GitLabCleanup func() error - AzureDevOps *azuredevops.Service - Jira *jira.Service - Linear *linear.Service - Sentry *sentry.Service + // Org owns organizations. Always non-nil; Enabled() reports whether the + // multi-tenancy feature is on. + Org *org.Service + OrgUnits *orgunit.Service + User *userservice.Service + Editor *editorservice.Service + Notification *notificationservice.Service + Prompts *promptservice.Service + Utility *utilityservice.Service + Workflow *workflowservice.Service + GitHub *github.Service + GitLab *gitlab.Service + GitLabCleanup func() error + AzureDevOps *azuredevops.Service + Jira *jira.Service + Linear *linear.Service + Sentry *sentry.Service // WorkflowSync keeps workspace workflows in sync with definition files // in a configured GitHub repository. Nil when GitHub is unavailable. WorkflowSync *workflowsync.Service diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 2c4039c07b..b5271d0f2e 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -442,6 +442,14 @@ type FeaturesConfig struct { // opts in. Auth bool `mapstructure:"auth" json:"auth"` + // MultiTenancy gates organizations: a tenant boundary above users, where + // every user belongs to exactly one org and cross-org reach is a bug + // rather than a permission level. It requires Auth; enabling it without + // authentication is refused at startup, because a tenant boundary with no + // identity behind it is not a boundary. Set via the runtime feature toggle + // KANDEV_FEATURES_MULTI_TENANCY. Off in every shipped profile. + MultiTenancy bool `mapstructure:"multi_tenancy" json:"multiTenancy"` + // DynamicAgentRouting gates dynamic profile configuration, execution // routing, and the shared route health service. It is disabled in every // embedded profile until the complete feature is ready. diff --git a/apps/backend/internal/gateway/websocket/access.go b/apps/backend/internal/gateway/websocket/access.go index 02e36fd86b..87892b21df 100644 --- a/apps/backend/internal/gateway/websocket/access.go +++ b/apps/backend/internal/gateway/websocket/access.go @@ -29,6 +29,11 @@ type SubscriptionAccessPolicy struct { // pre-auth row, delivered to everyone). type WorkspaceOwnerResolver func(ctx context.Context, workspaceID string) (string, error) +// WorkspaceReadersResolver resolves the set of user IDs that may currently +// read a workspace. An empty, non-nil slice means "nobody", which is a real +// answer and must not be treated as "everybody". +type WorkspaceReadersResolver func(ctx context.Context, workspaceID string) ([]string, error) + // AuthPolicy groups the gateway's auth hooks. type AuthPolicy struct { // Enforced reports whether authentication is currently required @@ -47,6 +52,14 @@ type AuthPolicy struct { Subscriptions SubscriptionAccessPolicy // WorkspaceOwner powers BroadcastToWorkspace owner routing. WorkspaceOwner WorkspaceOwnerResolver + // WorkspaceReaders resolves every user that can currently read a + // workspace: its owner plus explicit members plus, when the workspace is + // visible to the organization, that organization's non-guest users. + // + // Owner-only routing predates team access and would silently deliver a + // shared board's updates to nobody but its owner, so this is the resolver + // BroadcastToWorkspace uses when it is wired. + WorkspaceReaders WorkspaceReadersResolver // ActionEnvironment gates dispatched actions that name a task environment // rather than a task or session — the user-shell actions, which treat // task_id as optional. Kept off SubscriptionAccessPolicy because there is @@ -167,3 +180,18 @@ func clientMayReceive(client *Client, owner string) bool { } return client.identity.UserID == owner } + +// clientMayReceiveAny reports whether a client is one of the users allowed to +// receive a workspace broadcast. +// +// Synthetic identities (authentication disabled) and identity-less clients +// still receive everything, preserving pre-auth behavior. An authenticated +// client receives only when its user is in the set, so a member who just lost +// access stops receiving on the next broadcast rather than at disconnect. +func clientMayReceiveAny(client *Client, allowed map[string]struct{}) bool { + if client.identity.Synthetic || client.identity.UserID == "" { + return true + } + _, ok := allowed[client.identity.UserID] + return ok +} diff --git a/apps/backend/internal/gateway/websocket/hub.go b/apps/backend/internal/gateway/websocket/hub.go index 7d7456936a..53ce1d3583 100644 --- a/apps/backend/internal/gateway/websocket/hub.go +++ b/apps/backend/internal/gateway/websocket/hub.go @@ -307,6 +307,17 @@ func (h *Hub) setAuthPolicy(policy AuthPolicy) { // event never disappears entirely because ownership could not be resolved; // it only stops crossing user boundaries once ownership is known. func (h *Hub) BroadcastToWorkspace(workspaceID string, msg *ws.Message) { + // Prefer the reach set: with team access a workspace is readable by its + // members and, when it is org-visible, by the whole organization. Routing + // to the owner alone would deliver a shared board's updates to nobody but + // the person who happened to create it. + if readers := h.authPolicy.WorkspaceReaders; readers != nil && workspaceID != "" { + recipients, err := readers(h.DispatchContext(), workspaceID) + if err == nil { + h.broadcastToUsers(recipients, msg) + return + } + } resolver := h.authPolicy.WorkspaceOwner if resolver == nil || workspaceID == "" { h.Broadcast(msg) @@ -320,6 +331,30 @@ func (h *Hub) BroadcastToWorkspace(workspaceID string, msg *ws.Message) { h.broadcastToOwner(owner, msg) } +// broadcastToUsers delivers to exactly the given user IDs. An empty set +// delivers to nobody: "no one may read this workspace" is a real answer, and +// falling back to a global broadcast would invert it. +func (h *Hub) broadcastToUsers(userIDs []string, msg *ws.Message) { + data, err := json.Marshal(msg) + if err != nil { + h.logger.Error("Failed to marshal workspace broadcast", zap.Error(err)) + return + } + allowed := make(map[string]struct{}, len(userIDs)) + for _, id := range userIDs { + allowed[id] = struct{}{} + } + h.mu.RLock() + recipients := make([]*Client, 0, len(h.clients)) + for client := range h.clients { + if clientMayReceiveAny(client, allowed) { + recipients = append(recipients, client) + } + } + h.mu.RUnlock() + h.sendToClients(data, recipients, msg.Action) +} + // BroadcastToWorkspaceOrDrop is the fail-closed sibling of // BroadcastToWorkspace: when per-user auth is enforced it DROPS the message // unless the workspace is non-empty AND resolves to a known owner — it never @@ -410,7 +445,7 @@ func (h *Hub) BroadcastToTask(taskID string, msg *ws.Message) { h.logger.Error("Failed to marshal message", zap.Error(err)) return } - clients := h.getSubscribersLocked(h.taskSubscribers, taskID) + clients := h.authorizedTaskRecipients(taskID) h.logger.Debug("BroadcastToTask", zap.String("task_id", taskID), zap.String("action", msg.Action), @@ -458,7 +493,7 @@ func (h *Hub) BroadcastToSession(sessionID string, msg *ws.Message) { h.logger.Error("Failed to marshal message", zap.Error(err)) return } - clients := h.getSessionRecipientsLocked(sessionID) + clients := h.authorizedSessionRecipients(sessionID) h.logger.Debug("BroadcastToSession", zap.String("session_id", sessionID), zap.String("action", msg.Action), @@ -466,6 +501,58 @@ func (h *Hub) BroadcastToSession(sessionID string, msg *ws.Message) { h.sendToClients(data, clients, msg.Action) } +func (h *Hub) authorizedTaskRecipients(taskID string) []*Client { + clients := h.getSubscribersLocked(h.taskSubscribers, taskID) + allowed, denied := h.partitionAuthorized(clients, taskID, h.authPolicy.Subscriptions.Task) + if len(denied) == 0 { + return allowed + } + h.mu.Lock() + for _, client := range denied { + removeClientFromSubscriberMap(h.taskSubscribers, taskID, client) + delete(client.subscriptions, taskID) + } + h.mu.Unlock() + return allowed +} + +func (h *Hub) authorizedSessionRecipients(sessionID string) []*Client { + clients := h.getSessionRecipientsLocked(sessionID) + allowed, denied := h.partitionAuthorized(clients, sessionID, h.authPolicy.Subscriptions.Session) + if len(denied) == 0 { + return allowed + } + h.mu.Lock() + for _, client := range denied { + removeClientFromSubscriberMap(h.sessionSubscribers, sessionID, client) + removeClientFromSubscriberMap(h.sessionMode.focusByClient, sessionID, client) + delete(client.sessionSubscriptions, sessionID) + delete(client.sessionFocus, sessionID) + } + h.mu.Unlock() + h.recomputeSessionMode(sessionID) + return allowed +} + +func (h *Hub) partitionAuthorized( + clients []*Client, + topicID string, + authorize func(context.Context, string) error, +) (allowed, denied []*Client) { + if authorize == nil { + return clients, nil + } + allowed = make([]*Client, 0, len(clients)) + for _, client := range clients { + if err := authorize(client.dispatchContext(), topicID); err != nil { + denied = append(denied, client) + continue + } + allowed = append(allowed, client) + } + return allowed, denied +} + // BroadcastToUser sends a notification to clients subscribed to a specific user func (h *Hub) BroadcastToUser(userID string, msg *ws.Message) bool { data, err := json.Marshal(msg) diff --git a/apps/backend/internal/gateway/websocket/hub_access_revocation_test.go b/apps/backend/internal/gateway/websocket/hub_access_revocation_test.go new file mode 100644 index 0000000000..423ff40333 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/hub_access_revocation_test.go @@ -0,0 +1,69 @@ +package websocket + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestTaskBroadcastEvictsSubscriberAfterAccessRevocation(t *testing.T) { + hub := newTestHub(t) + allowed := true + hub.setAuthPolicy(AuthPolicy{Subscriptions: SubscriptionAccessPolicy{ + Task: func(ctx context.Context, _ string) error { + identity, _ := authn.IdentityFromContext(ctx) + if identity.UserID == "revoked-user" && !allowed { + return errors.New("access revoked") + } + return nil + }, + }}) + client := newTestClient("revoked-task-client") + client.hub = hub + client.identity = authn.Identity{UserID: "revoked-user", Role: authn.RoleMember} + hub.SubscribeToTask(client, "task-1") + allowed = false + + message, err := ws.NewNotification(ws.ActionTaskUpdated, map[string]string{"task_id": "task-1"}) + if err != nil { + t.Fatalf("build notification: %v", err) + } + hub.BroadcastToTask("task-1", message) + + assertNoMoreFrames(t, client) + if client.subscriptions["task-1"] { + t.Fatal("revoked client retained its task subscription") + } + if clients := hub.getSubscribersLocked(hub.taskSubscribers, "task-1"); len(clients) != 0 { + t.Fatalf("revoked task subscribers = %d, want 0", len(clients)) + } +} + +func TestSessionBroadcastEvictsSubscriberAndFocusAfterAccessRevocation(t *testing.T) { + hub := newTestHub(t) + hub.setAuthPolicy(AuthPolicy{Subscriptions: SubscriptionAccessPolicy{ + Session: func(context.Context, string) error { return errors.New("access revoked") }, + }}) + client := newTestClient("revoked-session-client") + client.hub = hub + client.identity = authn.Identity{UserID: "revoked-user", Role: authn.RoleMember} + hub.SubscribeToSession(client, "session-1") + hub.FocusSession(client, "session-1") + + message, err := ws.NewNotification(ws.ActionSessionMessageAdded, map[string]string{"session_id": "session-1"}) + if err != nil { + t.Fatalf("build notification: %v", err) + } + hub.BroadcastToSession("session-1", message) + + assertNoMoreFrames(t, client) + if client.sessionSubscriptions["session-1"] || client.sessionFocus["session-1"] { + t.Fatal("revoked client retained its session subscription or focus") + } + if clients := hub.getSessionRecipientsLocked("session-1"); len(clients) != 0 { + t.Fatalf("revoked session recipients = %d, want 0", len(clients)) + } +} diff --git a/apps/backend/internal/gateway/websocket/port_proxy.go b/apps/backend/internal/gateway/websocket/port_proxy.go index 69811f78ab..eb3b33dea3 100644 --- a/apps/backend/internal/gateway/websocket/port_proxy.go +++ b/apps/backend/internal/gateway/websocket/port_proxy.go @@ -138,7 +138,7 @@ func (h *PortProxyHandler) HandlePortProxy(c *gin.Context) { // Per-user scoping (opt-in auth): authorize session ownership before the // bare execution lookup / per-session proxy cache (see vscode_proxy.go). - if err := h.lifecycleMgr.CheckSessionAccess(c.Request.Context(), sessionID); err != nil { + if err := h.lifecycleMgr.CheckSessionExecAccess(c.Request.Context(), sessionID); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) return } diff --git a/apps/backend/internal/gateway/websocket/vscode_proxy.go b/apps/backend/internal/gateway/websocket/vscode_proxy.go index 40d0dc2bea..8cab7bc6e4 100644 --- a/apps/backend/internal/gateway/websocket/vscode_proxy.go +++ b/apps/backend/internal/gateway/websocket/vscode_proxy.go @@ -56,7 +56,7 @@ func (h *VscodeProxyHandler) HandleVscodeProxy(c *gin.Context) { // caller is authenticated, not that they own this session. This proxy // resolves the execution by a bare in-memory lookup (and a per-session // cache), so authorize here before anything is served. - if err := h.lifecycleMgr.CheckSessionAccess(c.Request.Context(), sessionID); err != nil { + if err := h.lifecycleMgr.CheckSessionExecAccess(c.Request.Context(), sessionID); err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) return } diff --git a/apps/backend/internal/office/dashboard/dto.go b/apps/backend/internal/office/dashboard/dto.go index e530ef3bc8..b8932a6f29 100644 --- a/apps/backend/internal/office/dashboard/dto.go +++ b/apps/backend/internal/office/dashboard/dto.go @@ -374,6 +374,7 @@ type TaskDTO struct { ParentID string `json:"parentId,omitempty"` ProjectID string `json:"projectId,omitempty"` AssigneeAgentProfileID string `json:"assigneeAgentProfileId,omitempty"` + AssigneeUserID string `json:"assigneeUserId,omitempty"` Labels []LabelDTO `json:"labels"` Children []*TaskDTO `json:"children,omitempty"` BlockedBy []string `json:"blockedBy,omitempty"` diff --git a/apps/backend/internal/office/dashboard/handler.go b/apps/backend/internal/office/dashboard/handler.go index 8753b06630..f43a14a03d 100644 --- a/apps/backend/internal/office/dashboard/handler.go +++ b/apps/backend/internal/office/dashboard/handler.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/office/models" "github.com/kandev/kandev/internal/office/repository/sqlite" "github.com/kandev/kandev/internal/office/shared" + "github.com/kandev/kandev/internal/task/repository/repoerrors" taskservice "github.com/kandev/kandev/internal/task/service" "go.uber.org/zap" @@ -538,6 +539,7 @@ func taskRowToDTO(r *sqlite.TaskRow, lbls []*sqlite.Label) *TaskDTO { ParentID: r.ParentID, ProjectID: r.ProjectID, AssigneeAgentProfileID: r.AssigneeAgentProfileID, + AssigneeUserID: r.AssigneeUserID, Labels: labels, Reviewers: []string{}, Approvers: []string{}, @@ -569,6 +571,10 @@ type UpdateTaskRequest struct { // string → clear). The picker UI sends "" when the user picks // "No assignee". AssigneeAgentProfileID *string `json:"assignee_agent_profile_id"` + // AssigneeUserID is the HUMAN assignee and is entirely separate from the + // agent assignee above: sending one never clears the other. Same pointer + // convention — nil is "leave alone", "" is "clear". + AssigneeUserID *string `json:"assignee_user_id,omitempty"` // Priority is one of "critical" | "high" | "medium" | "low" when set. Priority *string `json:"priority,omitempty"` // ProjectID empty string clears the project; otherwise must be a project @@ -593,7 +599,7 @@ func (req *UpdateTaskRequest) hasAnyField() bool { if req.Status != "" || req.Comment != "" { return true } - if req.AssigneeAgentProfileID != nil || + if req.AssigneeAgentProfileID != nil || req.AssigneeUserID != nil || req.Priority != nil || req.ProjectID != nil || req.ParentID != nil { return true } @@ -631,7 +637,7 @@ func (h *Handler) updateTask(c *gin.Context) { // whether all writes succeeded. When a mutation fails the handler responds // with the appropriate status code and applyTaskMutations returns false. // -// Order: assignee → priority → project → parent → status. +// Order: assignee (agent, then human) → priority → project → parent → status. // Status is last because it triggers the reactivity pipeline. func (h *Handler) applyTaskMutations(c *gin.Context, taskID, actorAgentID string, req *UpdateTaskRequest) bool { ctx := c.Request.Context() @@ -641,6 +647,12 @@ func (h *Handler) applyTaskMutations(c *gin.Context, taskID, actorAgentID string return false } } + if req.AssigneeUserID != nil { + if err := h.svc.SetTaskAssigneeUser(ctx, taskID, *req.AssigneeUserID); err != nil { + respondMutationError(c, err) + return false + } + } if req.Priority != nil { if err := h.svc.UpdateTaskPriority(ctx, taskID, *req.Priority); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -696,10 +708,27 @@ func (h *Handler) respondStatusUpdateError(c *gin.Context, err error) { // respondMutationError translates a mutation error into the matching HTTP // response. Forbidden bubbles up as 403; everything else as 500. func respondMutationError(c *gin.Context, err error) { - if errors.Is(err, shared.ErrForbidden) { + if errors.Is(err, shared.ErrForbidden) || taskservice.IsForbidden(err) { c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) return } + // The human-assignee mutation delegates to the task service, which applies + // the 404-vs-403 rule: a caller who cannot see the task at all gets + // "not found" so its existence is not leaked, while a caller who can see it + // but lacks task.write gets a plain 403. Without this branch both surface + // as 500, which reads as a server fault rather than a decision. + if errors.Is(err, repoerrors.ErrTaskNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + // A rejected assignee is the caller picking a person who cannot reach the + // workspace, not a server fault. The picker lists everyone in the user + // directory because reach is not computable client-side, so this is a + // reachable outcome and its message is written to be shown as-is. + if errors.Is(err, taskservice.ErrAssigneeCannotReachWorkspace) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } diff --git a/apps/backend/internal/office/dashboard/handler_task_assignee_test.go b/apps/backend/internal/office/dashboard/handler_task_assignee_test.go new file mode 100644 index 0000000000..3f44ad5b6c --- /dev/null +++ b/apps/backend/internal/office/dashboard/handler_task_assignee_test.go @@ -0,0 +1,170 @@ +package dashboard_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/kandev/kandev/internal/task/repository/repoerrors" + taskservice "github.com/kandev/kandev/internal/task/service" +) + +// stubAssigneeWriter stands in for the task service, which owns both the +// caller authorization and the assignee validation. It records what it was +// asked and can reject, so the handler test covers the refusal path without +// building a workspace, a membership table and an authenticated session. +type stubAssigneeWriter struct { + gotTaskID string + gotUserID string + reject error + apply func(taskID, userID string) +} + +func (s *stubAssigneeWriter) SetHumanAssignee(_ context.Context, taskID, userID string) error { + s.gotTaskID, s.gotUserID = taskID, userID + if s.reject != nil { + return s.reject + } + if s.apply != nil { + s.apply(taskID, userID) + } + return nil +} + +func patchTask(t *testing.T, deps *testDeps, taskID, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPatch, "/api/v1/office/tasks/"+taskID, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + deps.router.ServeHTTP(w, req) + return w +} + +func taskAssigneeUserID(t *testing.T, deps *testDeps, taskID string) string { + t.Helper() + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/office/tasks/"+taskID, nil) + deps.router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("get task: status = %d body = %s", w.Code, w.Body.String()) + } + var body struct { + Task struct { + AssigneeUserID string `json:"assigneeUserId"` + } `json:"task"` + } + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode task: %v", err) + } + return body.Task.AssigneeUserID +} + +// TestUpdateTask_SetsHumanAssignee covers the round trip: PATCH stores the +// human assignee, the resolver is consulted, and GET returns it. +func TestUpdateTask_SetsHumanAssignee(t *testing.T) { + deps := newTestDeps(t) + writer := &stubAssigneeWriter{apply: func(taskID, userID string) { + // Stand in for the task service's persistence so the GET assertions + // below observe a real column write. + if _, err := deps.db.Exec(`UPDATE tasks SET assignee_user_id = ? WHERE id = ?`, userID, taskID); err != nil { + t.Fatalf("stub persist: %v", err) + } + }} + deps.svc.SetHumanAssigneeWriter(writer) + insertTestTask(t, deps.db, "ha1", "ws-d", "Assignable", "todo", 2) + + if w := patchTask(t, deps, "ha1", `{"assignee_user_id":"user-42"}`); w.Code != http.StatusOK { + t.Fatalf("patch: status = %d body = %s", w.Code, w.Body.String()) + } + if writer.gotTaskID != "ha1" || writer.gotUserID != "user-42" { + t.Fatalf("writer saw (%q, %q), want (ha1, user-42)", writer.gotTaskID, writer.gotUserID) + } + if got := taskAssigneeUserID(t, deps, "ha1"); got != "user-42" { + t.Fatalf("assignee = %q, want user-42", got) + } + + // Empty string is the documented "unassign" value, distinct from an + // omitted field, which must leave the assignee untouched. + if w := patchTask(t, deps, "ha1", `{"priority":"high"}`); w.Code != http.StatusOK { + t.Fatalf("patch priority: status = %d body = %s", w.Code, w.Body.String()) + } + if got := taskAssigneeUserID(t, deps, "ha1"); got != "user-42" { + t.Fatalf("omitted field cleared the assignee: got %q", got) + } + if w := patchTask(t, deps, "ha1", `{"assignee_user_id":""}`); w.Code != http.StatusOK { + t.Fatalf("patch unassign: status = %d body = %s", w.Code, w.Body.String()) + } + if got := taskAssigneeUserID(t, deps, "ha1"); got != "" { + t.Fatalf("assignee = %q after unassign, want empty", got) + } +} + +// TestUpdateTask_UnreachableAssigneeIs400 covers the one rejection a normal +// user can trigger by accident: the picker offers the whole user directory +// because reach cannot be computed in the browser, so picking someone outside +// the workspace must read as a bad request with a showable message, not a 500. +func TestUpdateTask_UnreachableAssigneeIs400(t *testing.T) { + deps := newTestDeps(t) + deps.svc.SetHumanAssigneeWriter(&stubAssigneeWriter{reject: taskservice.ErrAssigneeCannotReachWorkspace}) + insertTestTask(t, deps.db, "ha5", "ws-d", "Assignable", "todo", 2) + + w := patchTask(t, deps, "ha5", `{"assignee_user_id":"outsider"}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s, want 400", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "cannot see this workspace") { + t.Fatalf("body = %s, want the reason to be showable", w.Body.String()) + } +} + +// TestUpdateTask_HumanAssigneeNotFoundIsNot500 pins the other half of the +// 404-vs-403 rule: a caller who cannot see the task at all must not learn it +// exists, and must not be told the server broke either. +func TestUpdateTask_HumanAssigneeNotFoundIsNot500(t *testing.T) { + deps := newTestDeps(t) + deps.svc.SetHumanAssigneeWriter(&stubAssigneeWriter{reject: repoerrors.ErrTaskNotFound}) + insertTestTask(t, deps.db, "ha4", "ws-d", "Assignable", "todo", 2) + + w := patchTask(t, deps, "ha4", `{"assignee_user_id":"user-42"}`) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d body = %s, want 404", w.Code, w.Body.String()) + } +} + +// TestUpdateTask_HumanAssigneeRejectedByWriter is the reach rule surfacing +// through the office endpoint: a user who cannot see the workspace is not a +// valid assignee, and the write must not land. +func TestUpdateTask_HumanAssigneeRejectedByWriter(t *testing.T) { + deps := newTestDeps(t) + deps.svc.SetHumanAssigneeWriter(&stubAssigneeWriter{reject: taskservice.ErrForbidden}) + insertTestTask(t, deps.db, "ha2", "ws-d", "Assignable", "todo", 2) + + w := patchTask(t, deps, "ha2", `{"assignee_user_id":"outsider"}`) + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d body = %s, want 403", w.Code, w.Body.String()) + } + if got := taskAssigneeUserID(t, deps, "ha2"); got != "" { + t.Fatalf("assignee = %q after a rejected write, want empty", got) + } +} + +// TestUpdateTask_HumanAssigneeWithoutWriterIsRefused pins the fail-closed +// contract. It matters more here than on the sibling fields: PATCH +// /office/tasks/:id carries no :wsId, so the office workspace-scope middleware +// does not gate it, and writing the column directly would let any signed-in +// user assign a task in a workspace they cannot reach. +func TestUpdateTask_HumanAssigneeWithoutWriterIsRefused(t *testing.T) { + deps := newTestDeps(t) + insertTestTask(t, deps.db, "ha3", "ws-d", "Assignable", "todo", 2) + + w := patchTask(t, deps, "ha3", `{"assignee_user_id":"user-42"}`) + if w.Code == http.StatusOK { + t.Fatalf("unvalidated assignment returned 200: %s", w.Body.String()) + } + if got := taskAssigneeUserID(t, deps, "ha3"); got != "" { + t.Fatalf("assignee = %q with no writer wired, want empty", got) + } +} diff --git a/apps/backend/internal/office/dashboard/handler_test.go b/apps/backend/internal/office/dashboard/handler_test.go index 09fccbb05e..4c79e9066f 100644 --- a/apps/backend/internal/office/dashboard/handler_test.go +++ b/apps/backend/internal/office/dashboard/handler_test.go @@ -140,6 +140,7 @@ func newTestDeps(t *testing.T) *testDeps { parent_id TEXT DEFAULT '', project_id TEXT DEFAULT '', assignee_agent_profile_id TEXT DEFAULT '', + assignee_user_id TEXT NOT NULL DEFAULT '', labels TEXT DEFAULT '[]', metadata TEXT DEFAULT '{}', identifier TEXT DEFAULT '', diff --git a/apps/backend/internal/office/dashboard/service.go b/apps/backend/internal/office/dashboard/service.go index 2e7bdac104..2c20f8658f 100644 --- a/apps/backend/internal/office/dashboard/service.go +++ b/apps/backend/internal/office/dashboard/service.go @@ -173,6 +173,18 @@ type TaskCanceller interface { CancelTaskExecution(ctx context.Context, taskID, reason string, force bool) error } +// HumanAssigneeWriter applies a human assignee on behalf of the calling user. +// +// Both the authorization (the caller needs task.write on the workspace) and +// the validation (the assignee must be able to reach it) are owned by the task +// service. Office delegates the whole write rather than reimplementing either, +// which also covers a gap specific to this route: PATCH /office/tasks/:id +// carries no `:wsId`, so the office workspace-scope middleware does not gate +// it. +type HumanAssigneeWriter interface { + SetHumanAssignee(ctx context.Context, taskID, userID string) error +} + // TaskDetacher applies the canonical task hierarchy/workspace detachment and // publishes the task lifecycle and Office refresh events. type TaskDetacher interface { @@ -379,6 +391,7 @@ type DashboardService struct { routingProvider RoutingProvider // optional; nil disables /routing endpoints (503) attemptLister RouteAttemptLister // optional; nil disables attempt embedding on run-detail responses runResolver RunResolver // optional; nil means status-change activity rows have no run_id + assigneeWriter HumanAssigneeWriter // optional; nil rejects human-assignee writes rather than skipping authorization projectBudget ProjectBudgetEvaluator // optional; nil means reassignment doesn't re-evaluate the destination project's budget policies // officeSessionIdentity gates RecordAgentDecision's use of the caller's // own session id. Defaults false (zero value); set via @@ -542,6 +555,14 @@ func (s *DashboardService) SetTaskCanceller(c TaskCanceller) { s.taskCanceller = c } +// SetHumanAssigneeWriter wires the human-assignee write seam. It is optional +// only in the sense that office can be constructed without it; when it is +// absent the mutation is refused rather than applied unauthorized, because +// this route is not otherwise workspace-gated. +func (s *DashboardService) SetHumanAssigneeWriter(w HumanAssigneeWriter) { + s.assigneeWriter = w +} + // SetTaskDetacher wires the canonical task detachment operation used when the // Office parent picker selects "No parent". func (s *DashboardService) SetTaskDetacher(d TaskDetacher) { diff --git a/apps/backend/internal/office/dashboard/service_tasks.go b/apps/backend/internal/office/dashboard/service_tasks.go index 9ca0ab4f03..64b0837cc1 100644 --- a/apps/backend/internal/office/dashboard/service_tasks.go +++ b/apps/backend/internal/office/dashboard/service_tasks.go @@ -41,6 +41,26 @@ func (s *DashboardService) UpdateTaskPriority(ctx context.Context, taskID, prior return nil } +// SetTaskAssigneeUser sets (or clears, on empty string) the human assignee. +// +// The human assignee is advisory and independent of the agent assignee: it +// records who on the team owns the task, gates nothing, and setting it never +// touches the runner participant. Taking a task over is this write plus a +// prompt, not a lock. +func (s *DashboardService) SetTaskAssigneeUser(ctx context.Context, taskID, userID string) error { + if s.assigneeWriter == nil { + return errors.New("human assignee is not available: no assignee writer configured") + } + // The task service authorizes the caller and validates the assignee, then + // persists. Office only mirrors the result onto its own event stream so the + // board and the open task detail refresh. + if err := s.assigneeWriter.SetHumanAssignee(ctx, taskID, userID); err != nil { + return err + } + s.publishTaskUpdated(ctx, taskID, []string{"assignee_user_id"}) + return nil +} + // UpdateTaskProjectID sets the project_id field. Empty string clears the // project. When non-empty, validates that the project belongs to the same // workspace as the task. diff --git a/apps/backend/internal/office/repository/sqlite/base_migrations.go b/apps/backend/internal/office/repository/sqlite/base_migrations.go index 724543c3e5..5f16269736 100644 --- a/apps/backend/internal/office/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/office/repository/sqlite/base_migrations.go @@ -452,6 +452,11 @@ func (r *Repository) runTaskPriorityRecreate() error { // priority-migration fixtures predate them too. _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN external_id TEXT COLLATE BINARY`) _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN external_id_settled_at TIMESTAMP`) + // Same defensive add for the human assignee (docs/specs/tasks/requirements/human-assignee.md). + // It is applied by task/repository/sqlite ensureTeamAccessSchema() before + // the office migrations run on a real install, but priority-migration + // fixtures seed their own legacy tasks table and never see it. + _, _ = conn.ExecContext(ctx, `ALTER TABLE tasks ADD COLUMN assignee_user_id TEXT NOT NULL DEFAULT ''`) for _, stmt := range taskPriorityMigrationStatements() { if _, err := conn.ExecContext(ctx, stmt); err != nil { @@ -507,12 +512,13 @@ func taskPriorityMigrationStatements() []string { checkout_at TIMESTAMP, checkout_run_id TEXT, external_id TEXT COLLATE BINARY, - external_id_settled_at TIMESTAMP + external_id_settled_at TIMESTAMP, + assignee_user_id TEXT NOT NULL DEFAULT '' )`, // archived_by_cascade_id and external_id/external_id_settled_at are // added to the task schema by task/repository/sqlite/base.go // runMigrations() via idempotent ALTER ADD COLUMN calls that run - // BEFORE this office recreate. If the recreate omitted them from the + // BEFORE this office recreate, as is assignee_user_id. If the recreate omitted them from the // new shape: archived_by_cascade_id missing would 500 // httpArchiveTask -> HandoffService.ArchiveTaskTree (the CAS update // in ArchiveTaskIfActive references it); external_id missing would @@ -527,7 +533,7 @@ func taskPriorityMigrationStatements() []string { origin, project_id, labels, identifier, checkout_agent_id, checkout_at, checkout_run_id, - external_id, external_id_settled_at + external_id, external_id_settled_at, assignee_user_id ) SELECT id, COALESCE(workspace_id,''), COALESCE(workflow_id,''), COALESCE(workflow_step_id,''), title, COALESCE(description,''), @@ -541,7 +547,8 @@ func taskPriorityMigrationStatements() []string { COALESCE(project_id,''), COALESCE(labels,'[]'), identifier, checkout_agent_id, checkout_at, checkout_run_id, - external_id, external_id_settled_at + external_id, external_id_settled_at, + COALESCE(assignee_user_id,'') FROM tasks`, `DROP TABLE tasks`, `ALTER TABLE tasks_priority_new RENAME TO tasks`, diff --git a/apps/backend/internal/office/repository/sqlite/migrations_priority_assignee_test.go b/apps/backend/internal/office/repository/sqlite/migrations_priority_assignee_test.go new file mode 100644 index 0000000000..8a9d57676c --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/migrations_priority_assignee_test.go @@ -0,0 +1,74 @@ +package sqlite_test + +import ( + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/office/repository/sqlite" +) + +// TestMigrate_PriorityRebuildPreservesAssignee is a regression test for the +// same hazard the index test above covers, one column further along: the +// priority-to-TEXT rebuild recreates tasks via DROP TABLE + CREATE TABLE, so +// any column missing from taskPriorityMigrationStatements' shape and copy list +// is dropped along with the data in it. For the human assignee that would mean +// every existing assignment silently disappearing on an install that still +// needs this historical rebuild. +func TestMigrate_PriorityRebuildPreservesAssignee(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + "?_journal_mode=WAL" + db, err := sqlx.Open("sqlite3", dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + // Legacy INTEGER-priority table (enough to trigger the rebuild) that + // already carries an assignment, as an install upgrading mid-feature would. + if _, err := db.Exec(` + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT '', + workflow_id TEXT NOT NULL DEFAULT '', + workflow_step_id TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL, + description TEXT DEFAULT '', + state TEXT DEFAULT 'TODO', + priority INTEGER DEFAULT 0, + position INTEGER DEFAULT 0, + metadata TEXT DEFAULT '{}', + is_ephemeral INTEGER NOT NULL DEFAULT 0, + parent_id TEXT DEFAULT '', + archived_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + origin TEXT DEFAULT 'manual', + project_id TEXT DEFAULT '', + labels TEXT DEFAULT '[]', + identifier TEXT, + assignee_user_id TEXT NOT NULL DEFAULT '' + ); + `); err != nil { + t.Fatalf("seed legacy schema: %v", err) + } + if _, err := db.Exec(` + INSERT INTO tasks (id, workspace_id, title, assignee_user_id) + VALUES ('task-1', 'ws-1', 'assigned task', 'user-42') + `); err != nil { + t.Fatalf("seed assigned task: %v", err) + } + + if _, err := sqlite.NewWithDB(db, db, nil); err != nil { + t.Fatalf("init office repo (run migrations): %v", err) + } + + var assignee string + if err := db.Get(&assignee, `SELECT assignee_user_id FROM tasks WHERE id = 'task-1'`); err != nil { + t.Fatalf("tasks.assignee_user_id missing after priority rebuild: %v", err) + } + if assignee != "user-42" { + t.Fatalf("assignee lost by priority rebuild: got %q, want %q", assignee, "user-42") + } +} diff --git a/apps/backend/internal/office/repository/sqlite/tasks.go b/apps/backend/internal/office/repository/sqlite/tasks.go index 3ed3de3a68..d480f8e998 100644 --- a/apps/backend/internal/office/repository/sqlite/tasks.go +++ b/apps/backend/internal/office/repository/sqlite/tasks.go @@ -237,9 +237,14 @@ type TaskSearchResult struct { ParentID string `db:"parent_id"` ProjectID string `db:"project_id"` AssigneeAgentProfileID string `db:"assignee_agent_profile_id"` - Labels string `db:"labels"` - CreatedAt string `db:"created_at"` - UpdatedAt string `db:"updated_at"` + // AssigneeUserID is the human assignee. It is only projected by the + // queries that need it (detail, workspace list); sqlx leaves it zero + // for the others rather than failing, so adding a projection later is + // additive. + AssigneeUserID string `db:"assignee_user_id"` + Labels string `db:"labels"` + CreatedAt string `db:"created_at"` + UpdatedAt string `db:"updated_at"` // IsSystem is true when the task lives in a kandev-managed system // workflow (e.g. the standing coordination task; future routine // tasks). The Office Tasks UI hides these by default and surfaces @@ -282,6 +287,7 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID strin COALESCE(t.parent_id, '') AS parent_id, COALESCE(t.project_id, '') AS project_id, ` + RunnerProjection("t") + ` AS assignee_agent_profile_id, + COALESCE(t.assignee_user_id, '') AS assignee_user_id, COALESCE(t.labels, '[]') AS labels, t.created_at, t.updated_at, @@ -529,6 +535,7 @@ func (r *Repository) GetTaskByID(ctx context.Context, taskID string) (*TaskRow, COALESCE(t.parent_id, '') AS parent_id, COALESCE(t.project_id, '') AS project_id, `+RunnerProjection("t")+` AS assignee_agent_profile_id, + COALESCE(t.assignee_user_id, '') AS assignee_user_id, COALESCE(t.labels, '[]') AS labels, t.created_at, t.updated_at diff --git a/apps/backend/internal/office/repository/sqlite/tasks_test.go b/apps/backend/internal/office/repository/sqlite/tasks_test.go index 651a6b9b3e..02145ba51e 100644 --- a/apps/backend/internal/office/repository/sqlite/tasks_test.go +++ b/apps/backend/internal/office/repository/sqlite/tasks_test.go @@ -35,6 +35,7 @@ func newSearchTestRepo(t *testing.T) *sqlite.Repository { workflow_id TEXT NOT NULL DEFAULT '', workflow_step_id TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', + assignee_user_id TEXT NOT NULL DEFAULT '', description TEXT DEFAULT '', state TEXT DEFAULT 'TODO', priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('critical','high','medium','low')), diff --git a/apps/backend/internal/office/scheduler/reactivity_test.go b/apps/backend/internal/office/scheduler/reactivity_test.go index 9dea4e2c61..55cfb590d8 100644 --- a/apps/backend/internal/office/scheduler/reactivity_test.go +++ b/apps/backend/internal/office/scheduler/reactivity_test.go @@ -40,7 +40,8 @@ func newReactivityTestRepo(t *testing.T) *officesqlite.Repository { workspace_id TEXT DEFAULT '', workflow_step_id TEXT DEFAULT '', parent_id TEXT DEFAULT '', - state TEXT DEFAULT '' + state TEXT DEFAULT '', + assignee_user_id TEXT NOT NULL DEFAULT '' ) `); err != nil { t.Fatalf("create tasks table: %v", err) diff --git a/apps/backend/internal/office/service/base_test.go b/apps/backend/internal/office/service/base_test.go index 53ba5da548..02eb028075 100644 --- a/apps/backend/internal/office/service/base_test.go +++ b/apps/backend/internal/office/service/base_test.go @@ -53,6 +53,7 @@ func newTestService(t *testing.T, overrides ...service.ServiceOptions) *service. project_id TEXT DEFAULT '', state TEXT NOT NULL DEFAULT 'TODO', title TEXT DEFAULT '', + assignee_user_id TEXT NOT NULL DEFAULT '', description TEXT DEFAULT '', identifier TEXT DEFAULT '', workflow_id TEXT DEFAULT '', diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index e11d1cc867..9c3717b675 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/kandev/kandev/internal/task/repository" "sync" "testing" "time" @@ -263,6 +264,9 @@ func (m *mockAgentManager) WaitForAgentctlReady(ctx context.Context, sessionID s // mockRepository implements executorStore for testing type mockRepository struct { + // Membership is not exercised by this fake; the embedded default + // reports no membership, which is the narrower answer. + repository.UnsupportedWorkspaceMembers mu sync.Mutex sessions map[string]*models.TaskSession tasks map[string]*models.Task diff --git a/apps/backend/internal/orchestrator/queue_send_now.go b/apps/backend/internal/orchestrator/queue_send_now.go index fdadd36ec9..df793efc49 100644 --- a/apps/backend/internal/orchestrator/queue_send_now.go +++ b/apps/backend/internal/orchestrator/queue_send_now.go @@ -37,7 +37,8 @@ var ( // shared cancellation coordinator, then the exact claim is handed to one // replacement prompt. The explicit Cancel path is deliberately not involved. func (s *Service) SendQueuedNow(ctx context.Context, sessionID, scope, entryID string) (int, error) { - if err := s.authorizeSession(ctx, sessionID); err != nil { + // Dispatching a queued prompt puts an agent to work: session.prompt. + if err := s.authorizeSessionPrompt(ctx, sessionID); err != nil { return 0, err } if err := validateSendNowInput(sessionID, scope, entryID); err != nil { diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index c069404037..f37e96e40d 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -591,7 +591,10 @@ type Service struct { // sessionAccessCheck enforces per-user workspace scoping on the // session-keyed WS actions. Nil = unscoped. See SetSessionAccessChecker. - sessionAccessCheck func(ctx context.Context, sessionID string) error + sessionAccessCheck func(ctx context.Context, sessionID string) error + sessionControlCheck func(ctx context.Context, sessionID string) error + sessionPromptCheck func(ctx context.Context, sessionID string) error + taskPromptCheck func(ctx context.Context, taskID string) error // taskAccessCheck is the task-keyed sibling of sessionAccessCheck, for // entry points that name a task rather than a session (session.launch, @@ -1635,6 +1638,11 @@ func (s *Service) authorizeSession(ctx context.Context, sessionID string) error return s.sessionAccessCheck(ctx, sessionID) } +// SetSessionControlChecker installs the session.control boundary. +func (s *Service) SetSessionControlChecker(check func(ctx context.Context, sessionID string) error) { + s.sessionControlCheck = check +} + // SessionTaskID returns the task that owns a session, or "" when the session // is unknown. Used to enrich message.queue.status_changed events with the // task_id so task-scoped consumers (e.g. the status summary projector) can diff --git a/apps/backend/internal/orchestrator/service_scopes.go b/apps/backend/internal/orchestrator/service_scopes.go new file mode 100644 index 0000000000..ab13c8375d --- /dev/null +++ b/apps/backend/internal/orchestrator/service_scopes.go @@ -0,0 +1,78 @@ +package orchestrator + +import ( + "context" + "errors" +) + +// Scoped session authorization: prompting and controlling an agent are +// writes, distinct from merely reaching the session. +// +// Split from service.go, which is already past the 800-line limit. + +// ErrScopeCheckerUnavailable reports that a scoped authorization check was +// required but not wired. It denies rather than degrading to a broader check, +// so a wiring mistake is a visible failure instead of a silent widening. +var ErrScopeCheckerUnavailable = errors.New("authorization is unavailable for this operation") + +// authorizeSessionControl guards stopping or cancelling a running agent. +// Interrupting someone else's turn is a write, not a read, so it needs +// session.control rather than mere reach. Falls back to the reach check when +// no scoped checker is wired, so an unwired build is never more permissive. +func (s *Service) authorizeSessionControl(ctx context.Context, sessionID string) error { + return s.authorizeSessionScoped(ctx, sessionID, s.sessionControlCheck) +} + +// authorizeSessionPrompt guards starting, resuming, steering or dispatching an +// agent turn. Prompting is a write: a viewer who may read a transcript must +// not be able to put an agent to work. +func (s *Service) authorizeSessionPrompt(ctx context.Context, sessionID string) error { + return s.authorizeSessionScoped(ctx, sessionID, s.sessionPromptCheck) +} + +// authorizeSessionScoped applies a scoped session check. +// +// The fallback is limited to instances with NO session authorization wired at +// all, which is the auth-disabled single-user case. When access checking is +// wired but the scoped checker is not, that is a misconfiguration and the +// operation is denied: falling back to the reach check there would let a +// viewer stop or steer somebody else's running agent. +func (s *Service) authorizeSessionScoped( + ctx context.Context, sessionID string, check func(context.Context, string) error, +) error { + if sessionID == "" { + return nil + } + if check != nil { + return check(ctx, sessionID) + } + if s.sessionAccessCheck == nil { + return nil + } + return ErrScopeCheckerUnavailable +} + +// SetSessionPromptChecker installs the session.prompt boundary. +func (s *Service) SetSessionPromptChecker(check func(ctx context.Context, sessionID string) error) { + s.sessionPromptCheck = check +} + +// authorizeTaskPrompt is the task-keyed sibling of authorizeSessionPrompt, for +// entry points that name a task rather than a session. Same fallback rule. +func (s *Service) authorizeTaskPrompt(ctx context.Context, taskID string) error { + if taskID == "" { + return nil + } + if s.taskPromptCheck != nil { + return s.taskPromptCheck(ctx, taskID) + } + if s.taskAccessCheck == nil { + return nil + } + return ErrScopeCheckerUnavailable +} + +// SetTaskPromptChecker installs the task-keyed session.prompt boundary. +func (s *Service) SetTaskPromptChecker(check func(ctx context.Context, taskID string) error) { + s.taskPromptCheck = check +} diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index 3a4509e0d3..aaad5e12b4 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -156,6 +156,13 @@ func IsBenignLaunchTeardownErr(err error) bool { func (s *Service) LaunchSession(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { // Every intent funnels through here. SessionID is empty when creating, so // that case is carried by the task check alone. + // Launching a session starts an agent turn: session.prompt. + if err := s.authorizeTaskPrompt(ctx, req.TaskID); err != nil { + return nil, err + } + // Existing-session launches must also prove that the supplied session and + // task belong together; independent reach checks do not establish that + // binding when a caller can access more than one task. if err := s.authorizeTaskSessionPair(ctx, req.TaskID, req.SessionID); err != nil { return nil, err } @@ -409,7 +416,8 @@ func (s *Service) launchRestoreWorkspace(ctx context.Context, req *LaunchSession func (s *Service) RecoverSession(ctx context.Context, taskID, sessionID, action string) (*LaunchSessionResponse, error) { // Guard before the switch: "fresh_start" clears the resume token, so an // unauthorized call would mutate the session even if the launch failed. - if err := s.authorizeSession(ctx, sessionID); err != nil { + // Recovering a session resumes an agent turn: session.prompt. + if err := s.authorizeSessionPrompt(ctx, sessionID); err != nil { return nil, err } if err := s.authorizeTask(ctx, taskID); err != nil { diff --git a/apps/backend/internal/orchestrator/steer.go b/apps/backend/internal/orchestrator/steer.go index b540d095eb..84c704c3aa 100644 --- a/apps/backend/internal/orchestrator/steer.go +++ b/apps/backend/internal/orchestrator/steer.go @@ -88,6 +88,10 @@ func (s *Service) SteerTask( if err := s.authorizeTaskSessionPair(ctx, taskID, sessionID); err != nil { return nil, err } + // Steering interrupts and redirects a running turn: session.prompt. + if err := s.authorizeSessionPrompt(ctx, sessionID); err != nil { + return nil, err + } session, err := s.repo.GetTaskSession(ctx, sessionID) if err != nil || session == nil { return nil, ErrSteerNotEligible diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 4333973797..638dd8e484 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -6350,7 +6350,7 @@ type cancelAgentPreparation struct { } func (s *Service) CancelAgent(ctx context.Context, sessionID string) (err error) { - if err := s.authorizeSession(ctx, sessionID); err != nil { + if err := s.authorizeSessionControl(ctx, sessionID); err != nil { return err } if s.repo == nil { diff --git a/apps/backend/internal/org/controller.go b/apps/backend/internal/org/controller.go new file mode 100644 index 0000000000..3a0851154d --- /dev/null +++ b/apps/backend/internal/org/controller.go @@ -0,0 +1,199 @@ +package org + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/common/logger" +) + +// Controller serves the organization surface. +// +// Two route groups with deliberately different audiences: +// +// - /api/v1/orgs/current is the caller's OWN organization. It never accepts +// an org ID, because accepting one would be the first step toward a +// request choosing its tenant. +// - /api/v1/instance/orgs is the operator tier: managing organizations +// themselves. An operator holds no org scopes, so nothing here reads or +// writes any organization's workspaces, tasks or secrets. +type Controller struct { + service *Service + log *logger.Logger +} + +// NewController builds the controller. +func NewController(service *Service, log *logger.Logger) *Controller { + return &Controller{service: service, log: log.WithFields(zap.String("component", "org-controller"))} +} + +// RegisterRoutes mounts the organization API. With organizations off nothing +// is mounted, so the surface simply does not exist. +func (c *Controller) RegisterRoutes(router *gin.Engine) { + if !c.service.Enabled() { + return + } + current := router.Group("/api/v1/orgs/current", authn.RequireRealIdentity()) + current.GET("", c.getCurrent) + + instance := router.Group("/api/v1/instance/orgs", authn.RequireRealIdentity(), RequireOperator()) + instance.GET("", c.list) + instance.POST("", c.create) + instance.PATCH("/:id", c.patch) + instance.DELETE("/:id", c.delete) + instance.POST("/:id/admins", c.createFirstAdmin) +} + +// RequireOperator aborts unless the caller holds the instance operator tier. +// +// It responds 404 rather than 403: on an instance where the caller is not an +// operator, the organization-management surface should not appear to exist. +func RequireOperator() gin.HandlerFunc { + return func(ctx *gin.Context) { + identity, ok := authn.FromGin(ctx) + if !ok || !identity.Instance { + ctx.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + ctx.Next() + } +} + +func (c *Controller) getCurrent(ctx *gin.Context) { + identity, _ := authn.FromGin(ctx) + if identity.OrgID == "" { + ctx.JSON(http.StatusOK, gin.H{"org": nil, "is_operator": identity.Instance}) + return + } + org, err := c.service.Get(ctx.Request.Context(), identity.OrgID) + if err != nil { + c.respond(ctx, err, "failed to read the organization") + return + } + ctx.JSON(http.StatusOK, gin.H{"org": org, "is_operator": identity.Instance}) +} + +func (c *Controller) list(ctx *gin.Context) { + orgs, err := c.service.List(ctx.Request.Context()) + if err != nil { + c.respond(ctx, err, "failed to list organizations") + return + } + ctx.JSON(http.StatusOK, gin.H{"orgs": orgs, "total": len(orgs)}) +} + +type createRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` +} + +func (c *Controller) create(ctx *gin.Context) { + var body createRequest + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + org, err := c.service.Create(ctx.Request.Context(), body.Name, body.Slug) + if err != nil { + c.respond(ctx, err, "failed to create the organization") + return + } + ctx.JSON(http.StatusOK, org) +} + +type patchRequest struct { + Name string `json:"name"` + Status string `json:"status"` +} + +func (c *Controller) patch(ctx *gin.Context) { + var body patchRequest + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + var ( + org *Org + err error + ) + if body.Name != "" { + org, err = c.service.Rename(ctx.Request.Context(), ctx.Param("id"), body.Name) + } + if err == nil && body.Status != "" { + org, err = c.service.SetStatus(ctx.Request.Context(), ctx.Param("id"), body.Status) + } + if err != nil { + c.respond(ctx, err, "failed to update the organization") + return + } + if org == nil { + org, err = c.service.Get(ctx.Request.Context(), ctx.Param("id")) + if err != nil { + c.respond(ctx, err, "failed to read the organization") + return + } + } + ctx.JSON(http.StatusOK, org) +} + +type firstAdminRequest struct { + Email string `json:"email"` + Password string `json:"password"` + DisplayName string `json:"display_name"` +} + +// createFirstAdmin provisions an organization's first administrator. It is +// operator-only and names the org explicitly, which is the one place an org ID +// legitimately appears in a request: the caller is acting on organizations +// themselves rather than inside one. +func (c *Controller) createFirstAdmin(ctx *gin.Context) { + var body firstAdminRequest + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + err := c.service.CreateFirstAdmin( + ctx.Request.Context(), ctx.Param("id"), body.Email, body.Password, body.DisplayName) + if err != nil { + c.respond(ctx, err, "failed to create the organization administrator") + return + } + ctx.JSON(http.StatusOK, gin.H{"success": true}) +} + +type deleteRequest struct { + Slug string `json:"slug"` +} + +func (c *Controller) delete(ctx *gin.Context) { + var body deleteRequest + if err := ctx.ShouldBindJSON(&body); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + if err := c.service.Delete(ctx.Request.Context(), ctx.Param("id"), body.Slug); err != nil { + c.respond(ctx, err, "failed to delete the organization") + return + } + ctx.JSON(http.StatusOK, gin.H{"success": true}) +} + +// respond maps organization errors to status codes, keeping each refusal's +// reason visible so an operator is told what actually blocked them. +func (c *Controller) respond(ctx *gin.Context, err error, message string) { + switch { + case errors.Is(err, ErrOrgNotFound): + ctx.JSON(http.StatusNotFound, gin.H{"error": "organization not found"}) + case errors.Is(err, ErrSlugMismatch), errors.Is(err, ErrSlugTaken), + errors.Is(err, ErrNameRequired), errors.Is(err, ErrCannotDeleteLast), + errors.Is(err, ErrCannotDeleteActive): + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.log.Error(message, zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": message}) + } +} diff --git a/apps/backend/internal/org/models.go b/apps/backend/internal/org/models.go new file mode 100644 index 0000000000..ad1a87d875 --- /dev/null +++ b/apps/backend/internal/org/models.go @@ -0,0 +1,71 @@ +// Package org owns organizations: the tenant boundary above users. +// +// Every user belongs to exactly one org. That is a deliberate design choice +// rather than a simplification: it makes the tenant a total function of the +// authenticated identity, so no request payload, header or WebSocket frame can +// move a caller between tenants. There is no org switcher and no "acting as +// org" parameter anywhere in the API. +package org + +import ( + "regexp" + "strings" + "time" +) + +// Status is an organization's lifecycle state. +type Status string + +const ( + // StatusActive is normal operation. + StatusActive Status = "active" + // StatusSuspended fails every session and token closed and stops running + // executions, while retaining all rows and files. It is reversible and is + // the intended lever for a billing lapse or an investigation. + StatusSuspended Status = "suspended" +) + +// NormalizeStatus coerces an unknown stored value to suspended: an org whose +// state cannot be read must not keep serving requests. +func NormalizeStatus(value string) Status { + if Status(value) == StatusActive { + return StatusActive + } + return StatusSuspended +} + +// Org is one tenant. +type Org struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + // Slug is the human-typeable identifier used to confirm destructive + // operations. Deleting an org requires typing it exactly. + Slug string `json:"slug" db:"slug"` + Status string `json:"status" db:"status"` + IsDefault bool `json:"is_default" db:"is_default"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Active reports whether the org may serve requests. +func (o *Org) Active() bool { + return o != nil && NormalizeStatus(o.Status) == StatusActive +} + +var slugInvalid = regexp.MustCompile(`[^a-z0-9-]+`) +var slugDashes = regexp.MustCompile(`-{2,}`) + +// Slugify derives a URL- and confirmation-safe slug from a display name. +func Slugify(name string) string { + s := strings.ToLower(strings.TrimSpace(name)) + s = slugInvalid.ReplaceAllString(s, "-") + s = slugDashes.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + if s == "" { + return "org" + } + if len(s) > 48 { + s = strings.Trim(s[:48], "-") + } + return s +} diff --git a/apps/backend/internal/org/service.go b/apps/backend/internal/org/service.go new file mode 100644 index 0000000000..370229b9a9 --- /dev/null +++ b/apps/backend/internal/org/service.go @@ -0,0 +1,283 @@ +package org + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Errors surfaced to callers. +var ( + // ErrTenancyRequiresAuth reports the one configuration that cannot work: a + // tenant boundary with no identity behind it. + ErrTenancyRequiresAuth = errors.New( + "features.multiTenancy requires features.auth: organizations need authenticated users to belong to them") + ErrOrgSuspended = errors.New("organization is suspended") + ErrSlugMismatch = errors.New("the typed slug does not match the organization") + ErrCannotDeleteLast = errors.New("the last organization cannot be deleted") + ErrCannotDeleteActive = errors.New("stop the organization's running work before deleting it") + ErrNameRequired = errors.New("organization name is required") +) + +// DataMigrator assigns pre-tenancy rows to the default organization. The task +// repository implements it; the org package stays free of a task dependency. +type DataMigrator interface { + // AssignWorkspacesWithoutOrg returns how many workspaces it moved. + AssignWorkspacesWithoutOrg(ctx context.Context, orgID string) (int64, error) + // DropCrossOrgWorkspaceMembers removes membership rows whose member and + // workspace would land in different organizations, and returns how many. + DropCrossOrgWorkspaceMembers(ctx context.Context) (int64, error) + // DeleteOrgData removes every row belonging to an organization. + DeleteOrgData(ctx context.Context, orgID string) error +} + +// AccountMigrator is the user-store half of the same migration. +type AccountMigrator interface { + AssignUsersWithoutOrg(ctx context.Context, orgID string) (int64, error) + SetOperator(ctx context.Context, id string, operator bool) error + CountOperators(ctx context.Context) (int, error) + FirstAdminID(ctx context.Context) (string, error) + DeleteUsersByOrg(ctx context.Context, orgID string) error +} + +// UnitDeleter removes an organization's unit tree. +// +// Deletion is the one path where the tree cannot be inferred from anything +// else: workspaces and accounts are removed by their own owners, and units +// would otherwise be left behind pointing at an organization that is gone. +type UnitDeleter interface { + DeleteOrgUnits(ctx context.Context, orgID string) error +} + +// FirstAdminCreator provisions an organization's first administrator. An +// ordinary admin can only create accounts in their own tenant, so a brand-new +// organization would otherwise have no way to get its first user; this is the +// operator-only path that breaks that circularity. +type FirstAdminCreator func(ctx context.Context, orgID, email, password, displayName string) error + +// Service owns organization lifecycle and the tenancy migration. +type Service struct { + store *Store + accounts AccountMigrator + data DataMigrator + log *logger.Logger + enabled bool + firstAdmin FirstAdminCreator + units UnitDeleter +} + +// SetUnitDeleter wires the tree-deletion seam. It is optional only because the +// unit service is constructed after this one; when it is absent an +// organization delete leaves its tree behind, which the wiring test guards. +func (s *Service) SetUnitDeleter(d UnitDeleter) { s.units = d } + +// SetFirstAdminCreator installs the operator-only first-admin path. +func (s *Service) SetFirstAdminCreator(create FirstAdminCreator) { s.firstAdmin = create } + +// CreateFirstAdmin provisions the first administrator of an organization. +func (s *Service) CreateFirstAdmin(ctx context.Context, orgID, email, password, displayName string) error { + if s.firstAdmin == nil { + return errors.New("account provisioning is unavailable") + } + if _, err := s.store.Get(ctx, orgID); err != nil { + return err + } + if err := s.firstAdmin(ctx, orgID, email, password, displayName); err != nil { + return err + } + s.log.Info("organization first admin created", zap.String("org_id", orgID)) + return nil +} + +// NewService builds the org service. enabled mirrors features.multiTenancy. +func NewService(store *Store, accounts AccountMigrator, data DataMigrator, enabled bool, log *logger.Logger) *Service { + return &Service{store: store, accounts: accounts, data: data, enabled: enabled, + log: log.WithFields(zap.String("component", "org-service"))} +} + +// Enabled reports whether organizations are on. +func (s *Service) Enabled() bool { return s != nil && s.enabled } + +// ValidateStartup refuses the one combination that cannot work. +func ValidateStartup(multiTenancy, auth bool) error { + if multiTenancy && !auth { + return ErrTenancyRequiresAuth + } + return nil +} + +// EnsureDefaultOrg runs the tenancy migration: it creates the single default +// organization if none exists, puts every unassigned user and workspace into +// it, drops membership rows that would straddle two organizations, and grants +// the operator tier to the first admin so an upgraded instance is never left +// with nobody able to manage organizations. +// +// It is idempotent, and it is a no-op when organizations are off. +func (s *Service) EnsureDefaultOrg(ctx context.Context, instanceName string) (*Org, error) { + if !s.Enabled() { + return nil, nil + } + existing, err := s.store.Default(ctx) + if err != nil && !errors.Is(err, ErrOrgNotFound) { + return nil, err + } + if existing == nil { + name := strings.TrimSpace(instanceName) + if name == "" { + name = "Default organization" + } + existing, err = s.store.Create(ctx, name, Slugify(name), true) + if err != nil { + return nil, fmt.Errorf("create default organization: %w", err) + } + s.log.Info("created the default organization", + zap.String("org_id", existing.ID), zap.String("slug", existing.Slug)) + } + + users, err := s.accounts.AssignUsersWithoutOrg(ctx, existing.ID) + if err != nil { + return nil, fmt.Errorf("assign users to the default organization: %w", err) + } + workspaces, err := s.data.AssignWorkspacesWithoutOrg(ctx, existing.ID) + if err != nil { + return nil, fmt.Errorf("assign workspaces to the default organization: %w", err) + } + dropped, err := s.data.DropCrossOrgWorkspaceMembers(ctx) + if err != nil { + return nil, fmt.Errorf("drop cross-organization memberships: %w", err) + } + if users > 0 || workspaces > 0 || dropped > 0 { + s.log.Info("tenancy migration applied", + zap.Int64("users_assigned", users), + zap.Int64("workspaces_assigned", workspaces), + zap.Int64("cross_org_memberships_dropped", dropped)) + } + + if err := s.ensureOperator(ctx); err != nil { + return nil, err + } + return existing, nil +} + +// ensureOperator grants the operator tier to the first admin when nobody holds +// it. Without this an upgraded instance would have organizations and no way to +// manage them. +func (s *Service) ensureOperator(ctx context.Context) error { + count, err := s.accounts.CountOperators(ctx) + if err != nil || count > 0 { + return err + } + adminID, err := s.accounts.FirstAdminID(ctx) + if err != nil || adminID == "" { + return err + } + if err := s.accounts.SetOperator(ctx, adminID, true); err != nil { + return err + } + s.log.Info("granted the instance operator tier to the first admin", zap.String("user_id", adminID)) + return nil +} + +// Get returns an organization by ID. +func (s *Service) Get(ctx context.Context, id string) (*Org, error) { return s.store.Get(ctx, id) } + +// List returns every organization. +func (s *Service) List(ctx context.Context) ([]*Org, error) { return s.store.List(ctx) } + +// Create adds an organization. +func (s *Service) Create(ctx context.Context, name, slug string) (*Org, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, ErrNameRequired + } + if slug = Slugify(slug); slug == "org" { + slug = Slugify(name) + } + org, err := s.store.Create(ctx, name, slug, false) + if err != nil { + return nil, err + } + s.log.Info("organization created", zap.String("org_id", org.ID), zap.String("slug", org.Slug)) + return org, nil +} + +// SetStatus suspends or resumes an organization. Suspension is reversible and +// retains every row and file; it is the lever for a billing lapse. +func (s *Service) SetStatus(ctx context.Context, id, status string) (*Org, error) { + org, err := s.store.UpdateNameStatus(ctx, id, "", status) + if err != nil { + return nil, err + } + s.log.Info("organization status changed", + zap.String("org_id", id), zap.String("status", org.Status)) + return org, nil +} + +// Rename changes an organization's display name. The slug is immutable +// because it is the confirmation token for deletion. +func (s *Service) Rename(ctx context.Context, id, name string) (*Org, error) { + if strings.TrimSpace(name) == "" { + return nil, ErrNameRequired + } + return s.store.UpdateNameStatus(ctx, id, strings.TrimSpace(name), "") +} + +// Delete removes an organization and all of its data. It requires the caller +// to type the slug, refuses the last remaining organization, and removes the +// org row last so a failure part-way leaves the org still identifiable. +func (s *Service) Delete(ctx context.Context, id, confirmSlug string) error { + org, err := s.store.Get(ctx, id) + if err != nil { + return err + } + if confirmSlug != org.Slug { + return ErrSlugMismatch + } + count, err := s.store.Count(ctx) + if err != nil { + return err + } + if count <= 1 { + return ErrCannotDeleteLast + } + if err := s.data.DeleteOrgData(ctx, id); err != nil { + return fmt.Errorf("delete organization data: %w", err) + } + if err := s.accounts.DeleteUsersByOrg(ctx, id); err != nil { + return fmt.Errorf("delete organization accounts: %w", err) + } + if s.units != nil { + if err := s.units.DeleteOrgUnits(ctx, id); err != nil { + return fmt.Errorf("delete organization units: %w", err) + } + } + if err := s.store.Delete(ctx, id); err != nil { + return err + } + s.log.Info("organization deleted", zap.String("org_id", id), zap.String("slug", org.Slug)) + return nil +} + +// ClaimSetupAdmin places the account created by the authentication setup +// wizard into the default organization and grants it the instance operator +// tier. It is a no-op when organizations are off. +func (s *Service) ClaimSetupAdmin(ctx context.Context, userID string) error { + if !s.Enabled() || userID == "" { + return nil + } + defaultOrg, err := s.EnsureDefaultOrg(ctx, "Default organization") + if err != nil || defaultOrg == nil { + return err + } + if err := s.accounts.SetOperator(ctx, userID, true); err != nil { + return err + } + s.log.Info("setup admin placed in the default organization", + zap.String("user_id", userID), zap.String("org_id", defaultOrg.ID)) + return nil +} diff --git a/apps/backend/internal/org/store.go b/apps/backend/internal/org/store.go new file mode 100644 index 0000000000..f9cd31c955 --- /dev/null +++ b/apps/backend/internal/org/store.go @@ -0,0 +1,186 @@ +package org + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/db/dialect" +) + +// ErrOrgNotFound reports an unknown organization. Callers that reached the +// lookup through a caller-supplied ID surface it as 404. +var ErrOrgNotFound = errors.New("organization not found") + +// ErrSlugTaken reports a slug collision. +var ErrSlugTaken = errors.New("that organization slug is already in use") + +// Store persists organizations. +type Store struct { + db *sqlx.DB + ro *sqlx.DB +} + +// NewStore builds the store and creates its schema. +func NewStore(pool *db.Pool) (*Store, error) { + s := &Store{db: pool.Writer(), ro: pool.Reader()} + if err := s.initSchema(); err != nil { + return nil, err + } + return s, nil +} + +func (s *Store) initSchema() error { + timestamp := "DATETIME" + if dialect.IsPostgres(s.db.DriverName()) { + timestamp = "TIMESTAMPTZ" + } + statements := []string{ + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS orgs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'active', + is_default INTEGER NOT NULL DEFAULT 0, + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, timestamp, timestamp), + `CREATE INDEX IF NOT EXISTS idx_orgs_status ON orgs(status)`, + } + for _, stmt := range statements { + if _, err := s.db.Exec(stmt); err != nil { + return fmt.Errorf("org schema: %w", err) + } + } + return nil +} + +const orgColumns = `id, name, slug, status, is_default, created_at, updated_at` + +func scanOrg(row interface{ Scan(...any) error }) (*Org, error) { + o := &Org{} + var isDefault int + if err := row.Scan(&o.ID, &o.Name, &o.Slug, &o.Status, &isDefault, &o.CreatedAt, &o.UpdatedAt); err != nil { + return nil, err + } + o.IsDefault = isDefault != 0 + return o, nil +} + +// Create inserts an organization, deriving a unique slug when needed. +func (s *Store) Create(ctx context.Context, name, slug string, isDefault bool) (*Org, error) { + if slug == "" { + slug = Slugify(name) + } + now := time.Now().UTC() + o := &Org{ + ID: uuid.New().String(), Name: name, Slug: slug, + Status: string(StatusActive), IsDefault: isDefault, CreatedAt: now, UpdatedAt: now, + } + flag := 0 + if isDefault { + flag = 1 + } + _, err := s.db.ExecContext(ctx, s.db.Rebind( + `INSERT INTO orgs (`+orgColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ), o.ID, o.Name, o.Slug, o.Status, flag, o.CreatedAt, o.UpdatedAt) + if err != nil { + if db.IsAlreadyExistsError(err) || isUniqueViolation(err) { + return nil, ErrSlugTaken + } + return nil, err + } + return o, nil +} + +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "UNIQUE constraint failed") || + strings.Contains(msg, "duplicate key value") +} + +// Get returns one organization by ID. +func (s *Store) Get(ctx context.Context, id string) (*Org, error) { + row := s.ro.QueryRowContext(ctx, s.ro.Rebind(`SELECT `+orgColumns+` FROM orgs WHERE id = ?`), id) + o, err := scanOrg(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrOrgNotFound + } + return o, err +} + +// List returns every organization, default first then by name. +func (s *Store) List(ctx context.Context) ([]*Org, error) { + rows, err := s.ro.QueryContext(ctx, `SELECT `+orgColumns+` FROM orgs ORDER BY is_default DESC, name`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + orgs := make([]*Org, 0) + for rows.Next() { + o, err := scanOrg(rows) + if err != nil { + return nil, err + } + orgs = append(orgs, o) + } + return orgs, rows.Err() +} + +// Default returns the default organization, or ErrOrgNotFound when the +// instance has not been migrated yet. +func (s *Store) Default(ctx context.Context) (*Org, error) { + row := s.ro.QueryRowContext(ctx, `SELECT `+orgColumns+` FROM orgs WHERE is_default = 1 LIMIT 1`) + o, err := scanOrg(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrOrgNotFound + } + return o, err +} + +// Count returns how many organizations exist. +func (s *Store) Count(ctx context.Context) (int, error) { + var n int + err := s.ro.QueryRowContext(ctx, `SELECT COUNT(*) FROM orgs`).Scan(&n) + return n, err +} + +// UpdateNameStatus changes an org's display name and/or status. +func (s *Store) UpdateNameStatus(ctx context.Context, id, name, status string) (*Org, error) { + existing, err := s.Get(ctx, id) + if err != nil { + return nil, err + } + if name == "" { + name = existing.Name + } + if status == "" { + status = existing.Status + } + status = string(NormalizeStatus(status)) + now := time.Now().UTC() + if _, err := s.db.ExecContext(ctx, s.db.Rebind( + `UPDATE orgs SET name = ?, status = ?, updated_at = ? WHERE id = ?`, + ), name, status, now, id); err != nil { + return nil, err + } + existing.Name, existing.Status, existing.UpdatedAt = name, status, now + return existing, nil +} + +// Delete removes the organization row. Callers are responsible for removing +// the org's data first; this is the last step. +func (s *Store) Delete(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind(`DELETE FROM orgs WHERE id = ?`), id) + return err +} diff --git a/apps/backend/internal/orgunit/backfill.go b/apps/backend/internal/orgunit/backfill.go new file mode 100644 index 0000000000..9ab499a29c --- /dev/null +++ b/apps/backend/internal/orgunit/backfill.go @@ -0,0 +1,187 @@ +package orgunit + +import ( + "context" + + "go.uber.org/zap" +) + +// UserRef is the little a backfill needs to know about an account. +type UserRef struct { + ID string + OrgID string + DisplayName string +} + +// WorkspaceRef is a workspace awaiting placement. +type WorkspaceRef struct { + ID string + OrgID string + OwnerID string +} + +// AccountLister supplies the accounts that need a personal unit. +type AccountLister interface { + ListUnitUsers(ctx context.Context) ([]UserRef, error) +} + +// WorkspacePlacer supplies and records workspace placement. +type WorkspacePlacer interface { + UnplacedWorkspaces(ctx context.Context) ([]WorkspaceRef, error) + PlaceWorkspace(ctx context.Context, workspaceID, unitID string) error +} + +// BackfillResult reports what the one-shot placement did. +type BackfillResult struct { + Roots int + Personals int + Placed int +} + +// Backfill gives every organization a root unit, every user a personal unit, +// and every unplaced workspace a home. +// +// The placement rule is the one that keeps an upgrade from widening access: a +// workspace with an owner lands in that owner's personal unit, where only they +// reach it, and never under the root, where everyone would. A workspace with no +// owner predates authentication and is reachable by everyone already, so the +// root is where it belongs and nothing changes for it. +// +// It is idempotent: units are ensured rather than created, and only workspaces +// with no unit are considered. +func (s *Service) Backfill(ctx context.Context, accounts AccountLister, placer WorkspacePlacer) (BackfillResult, error) { + var result BackfillResult + + users, err := accounts.ListUnitUsers(ctx) + if err != nil { + return result, err + } + workspaces, err := placer.UnplacedWorkspaces(ctx) + if err != nil { + return result, err + } + + roots, err := s.ensureRoots(ctx, orgIDsIn(users, workspaces)) + if err != nil { + return result, err + } + result.Roots = len(roots) + + personals, err := s.ensurePersonals(ctx, users) + if err != nil { + return result, err + } + result.Personals = len(personals) + + placed, err := s.placeAll(ctx, placer, workspaces, roots, personals) + if err != nil { + return result, err + } + result.Placed = placed + + if s.log != nil && (result.Placed > 0 || result.Personals > 0) { + s.log.Info("organization unit backfill applied", + zap.Int("roots", result.Roots), + zap.Int("personal_units", result.Personals), + zap.Int("workspaces_placed", result.Placed)) + } + return result, nil +} + +// orgIDsIn collects every organization the backfill has to cover. An instance +// with organizations switched off has one implicit organization whose id is +// empty, and it needs a root exactly like a named one: the tree is the reach +// model whether or not tenancy is on. +func orgIDsIn(users []UserRef, workspaces []WorkspaceRef) []string { + seen := map[string]struct{}{} + for _, u := range users { + seen[u.OrgID] = struct{}{} + } + for _, w := range workspaces { + seen[w.OrgID] = struct{}{} + } + if len(seen) == 0 { + seen[""] = struct{}{} + } + out := make([]string, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + return out +} + +func (s *Service) ensureRoots(ctx context.Context, orgIDs []string) (map[string]string, error) { + roots := make(map[string]string, len(orgIDs)) + for _, orgID := range orgIDs { + root, err := s.EnsureRoot(ctx, orgID, "") + if err != nil { + return nil, err + } + roots[orgID] = root.ID + } + return roots, nil +} + +func (s *Service) ensurePersonals(ctx context.Context, users []UserRef) (map[string]string, error) { + personals := make(map[string]string, len(users)) + for _, u := range users { + unit, err := s.EnsurePersonal(ctx, u.OrgID, u.ID, u.DisplayName) + if err != nil { + return nil, err + } + personals[u.ID] = unit.ID + } + return personals, nil +} + +// placeAll gives every unplaced workspace a home. +// +// The rule is the one that keeps an upgrade from widening access: a workspace +// with an owner lands in that owner's personal unit, where only they reach it, +// and never under the root, where everyone would. A workspace with no owner +// predates authentication and is reachable by everyone already, so the root is +// where it belongs and nothing changes for it. +func (s *Service) placeAll( + ctx context.Context, + placer WorkspacePlacer, + workspaces []WorkspaceRef, + roots, personals map[string]string, +) (int, error) { + placed := 0 + for _, w := range workspaces { + target, err := s.targetUnit(ctx, w, roots, personals) + if err != nil { + return placed, err + } + if target == "" { + continue + } + if err := placer.PlaceWorkspace(ctx, w.ID, target); err != nil { + return placed, err + } + placed++ + } + return placed, nil +} + +func (s *Service) targetUnit( + ctx context.Context, + w WorkspaceRef, + roots, personals map[string]string, +) (string, error) { + if w.OwnerID == "" { + return roots[w.OrgID], nil + } + if unit, ok := personals[w.OwnerID]; ok { + return unit, nil + } + // An owner with no account left. Their workspace must not fall back to the + // root, where the whole organization would reach it, so it gets a personal + // unit keyed to the dangling id. + unit, err := s.EnsurePersonal(ctx, w.OrgID, w.OwnerID, "") + if err != nil { + return "", err + } + personals[w.OwnerID] = unit.ID + return unit.ID, nil +} diff --git a/apps/backend/internal/orgunit/backfill_test.go b/apps/backend/internal/orgunit/backfill_test.go new file mode 100644 index 0000000000..673456f2e4 --- /dev/null +++ b/apps/backend/internal/orgunit/backfill_test.go @@ -0,0 +1,157 @@ +package orgunit + +import ( + "context" + "testing" +) + +type stubAccounts struct{ users []UserRef } + +func (s stubAccounts) ListUnitUsers(context.Context) ([]UserRef, error) { return s.users, nil } + +type stubPlacer struct { + pending []WorkspaceRef + placed map[string]string +} + +func (s *stubPlacer) UnplacedWorkspaces(context.Context) ([]WorkspaceRef, error) { + return s.pending, nil +} + +func (s *stubPlacer) PlaceWorkspace(_ context.Context, workspaceID, unitID string) error { + if s.placed == nil { + s.placed = map[string]string{} + } + s.placed[workspaceID] = unitID + return nil +} + +// The rule an upgrade lives or dies by: a workspace that only its owner could +// reach must land somewhere only its owner reaches. Landing it under the root +// would hand the whole organization a board that was private a moment earlier, +// and nothing in the product would report that it had happened. +func TestBackfillDoesNotWidenReach(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + accounts := stubAccounts{users: []UserRef{ + {ID: "ada", OrgID: "org-1", DisplayName: "Ada Lovelace"}, + {ID: "grace", OrgID: "org-1", DisplayName: "Grace Hopper"}, + }} + placer := &stubPlacer{pending: []WorkspaceRef{ + {ID: "ws-ada", OrgID: "org-1", OwnerID: "ada"}, + {ID: "ws-grace", OrgID: "org-1", OwnerID: "grace"}, + {ID: "ws-unowned", OrgID: "org-1", OwnerID: ""}, + }} + + result, err := svc.Backfill(ctx, accounts, placer) + if err != nil { + t.Fatalf("backfill: %v", err) + } + if result.Placed != 3 { + t.Fatalf("placed = %d, want 3", result.Placed) + } + + adaUnit, err := svc.Store().Personal(ctx, "ada") + if err != nil { + t.Fatalf("ada personal unit: %v", err) + } + graceUnit, err := svc.Store().Personal(ctx, "grace") + if err != nil { + t.Fatalf("grace personal unit: %v", err) + } + root, err := svc.Store().Root(ctx, "org-1") + if err != nil { + t.Fatalf("root: %v", err) + } + + if placer.placed["ws-ada"] != adaUnit.ID { + t.Fatalf("an owned workspace landed in %q, want the owner's personal unit", placer.placed["ws-ada"]) + } + if placer.placed["ws-grace"] != graceUnit.ID { + t.Fatalf("an owned workspace landed in %q, want the owner's personal unit", placer.placed["ws-grace"]) + } + // A workspace with no owner predates authentication and everyone reaches it + // already, so the root changes nothing for it. + if placer.placed["ws-unowned"] != root.ID { + t.Fatalf("an unowned workspace landed in %q, want the root", placer.placed["ws-unowned"]) + } + if placer.placed["ws-ada"] == root.ID || placer.placed["ws-grace"] == root.ID { + t.Fatal("an owned workspace was placed under the root, which widens reach to the whole organization") + } +} + +// An owner whose account is gone must not hand their workspace to the +// organization. A dangling id still gets a unit of its own. +func TestBackfillIsolatesWorkspacesOfMissingOwners(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + placer := &stubPlacer{pending: []WorkspaceRef{ + {ID: "ws-orphan", OrgID: "org-1", OwnerID: "deleted-user"}, + }} + if _, err := svc.Backfill(ctx, stubAccounts{}, placer); err != nil { + t.Fatalf("backfill: %v", err) + } + + root, err := svc.Store().Root(ctx, "org-1") + if err != nil { + t.Fatalf("root: %v", err) + } + if placer.placed["ws-orphan"] == root.ID { + t.Fatal("a workspace owned by a missing account was placed under the root") + } + if _, err := svc.Store().Personal(ctx, "deleted-user"); err != nil { + t.Fatalf("expected a personal unit for the dangling owner: %v", err) + } +} + +// Boot runs this every time, so a second pass must not create a second root, a +// second personal unit, or move a workspace that already has a home. +func TestBackfillIsIdempotent(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + accounts := stubAccounts{users: []UserRef{{ID: "ada", OrgID: "org-1"}}} + + first := &stubPlacer{pending: []WorkspaceRef{{ID: "ws-ada", OrgID: "org-1", OwnerID: "ada"}}} + if _, err := svc.Backfill(ctx, accounts, first); err != nil { + t.Fatalf("first backfill: %v", err) + } + + // Second pass: the workspace now has a unit, so it is no longer pending. + second := &stubPlacer{pending: nil} + result, err := svc.Backfill(ctx, accounts, second) + if err != nil { + t.Fatalf("second backfill: %v", err) + } + if result.Placed != 0 { + t.Fatalf("second pass placed %d workspaces, want 0", result.Placed) + } + + units, err := svc.Store().ListByOrg(ctx, "org-1") + if err != nil { + t.Fatalf("list units: %v", err) + } + if len(units) != 2 { + t.Fatalf("units = %d, want a root and one personal unit", len(units)) + } +} + +// With organizations switched off there are no org rows and every id is empty, +// but the tree is still the reach model, so the implicit organization needs a +// root like any other. +func TestBackfillCreatesRootWithTenancyOff(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + placer := &stubPlacer{pending: []WorkspaceRef{{ID: "ws-1", OwnerID: "ada"}}} + if _, err := svc.Backfill(ctx, stubAccounts{users: []UserRef{{ID: "ada"}}}, placer); err != nil { + t.Fatalf("backfill: %v", err) + } + if _, err := svc.Store().Root(ctx, ""); err != nil { + t.Fatalf("no root for the implicit organization: %v", err) + } + if placer.placed["ws-1"] == "" { + t.Fatal("workspace was left unplaced") + } +} diff --git a/apps/backend/internal/orgunit/controller.go b/apps/backend/internal/orgunit/controller.go new file mode 100644 index 0000000000..1a94b05231 --- /dev/null +++ b/apps/backend/internal/orgunit/controller.go @@ -0,0 +1,223 @@ +package orgunit + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/common/logger" +) + +// Controller serves the unit tree. +type Controller struct { + service *Service + log *logger.Logger +} + +// NewController builds the controller. +func NewController(service *Service, log *logger.Logger) *Controller { + return &Controller{service: service, log: log.WithFields(zap.String("component", "org-unit-controller"))} +} + +// RegisterRoutes mounts the unit API. +// +// Reading the tree needs only an identity: a member has to see the shape of +// their organization to understand why they reach what they reach. Changing it +// needs `unit.manage`, which is where the shape of an organization is decided. +func (c *Controller) RegisterRoutes(router *gin.Engine) { + read := router.Group("/api/v1/units", authn.RequireRealIdentity()) + read.GET("", c.list) + read.GET("/:id/members", c.listMembers) + + write := router.Group("/api/v1/units", + authn.RequireRealIdentity(), authz.RequireOrgScope(authz.ScopeUnitManage)) + write.POST("", c.create) + write.PATCH("/:id", c.patch) + write.DELETE("/:id", c.delete) + write.PUT("/:id/members/:userId", c.setMember) + write.DELETE("/:id/members/:userId", c.removeMember) +} + +func (c *Controller) list(ctx *gin.Context) { + subject := authz.SubjectFromGin(ctx) + units, err := c.service.Store().ListByOrg(ctx.Request.Context(), subject.OrgID) + if err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"units": units, "total": len(units)}) +} + +func (c *Controller) listMembers(ctx *gin.Context) { + id := ctx.Param("id") + // Reads need the tenant check as much as writes: without it, knowing a + // unit id from another organization is enough to enumerate its members. + if !c.unitInCallerOrg(ctx, id) { + return + } + members, err := c.service.Store().ListMembers(ctx.Request.Context(), id) + if err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"members": members, "total": len(members)}) +} + +type createUnitRequest struct { + ParentID string `json:"parent_id"` + Name string `json:"name"` +} + +func (c *Controller) create(ctx *gin.Context) { + var req createUnitRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + // A caller must not be able to graft a unit onto another tenant's tree, so + // the parent is validated against the caller's own organization. + if !c.parentInCallerOrg(ctx, req.ParentID) { + return + } + unit, err := c.service.Create(ctx.Request.Context(), req.ParentID, req.Name) + if err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusCreated, unit) +} + +type patchUnitRequest struct { + Name *string `json:"name,omitempty"` + ParentID *string `json:"parent_id,omitempty"` +} + +func (c *Controller) patch(ctx *gin.Context) { + var req patchUnitRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id := ctx.Param("id") + if !c.unitInCallerOrg(ctx, id) { + return + } + if req.Name != nil { + if err := c.service.Rename(ctx.Request.Context(), id, *req.Name); err != nil { + c.fail(ctx, err) + return + } + } + if req.ParentID != nil { + if !c.parentInCallerOrg(ctx, *req.ParentID) { + return + } + if err := c.service.Move(ctx.Request.Context(), id, *req.ParentID); err != nil { + c.fail(ctx, err) + return + } + } + unit, err := c.service.Store().Get(ctx.Request.Context(), id) + if err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusOK, unit) +} + +func (c *Controller) delete(ctx *gin.Context) { + id := ctx.Param("id") + if !c.unitInCallerOrg(ctx, id) { + return + } + if err := c.service.Delete(ctx.Request.Context(), id); err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"success": true}) +} + +type setMemberRequest struct { + Role string `json:"role"` +} + +func (c *Controller) setMember(ctx *gin.Context) { + var req setMemberRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + id := ctx.Param("id") + if !c.unitInCallerOrg(ctx, id) { + return + } + role := authz.NormalizeWorkspaceRole(req.Role) + if role == authz.WorkspaceRoleNone { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "role must be owner, collaborator, or viewer"}) + return + } + actor, _ := authn.FromGin(ctx) + if err := c.service.SetMember(ctx.Request.Context(), id, ctx.Param("userId"), string(role), actor.UserID); err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"success": true}) +} + +func (c *Controller) removeMember(ctx *gin.Context) { + id := ctx.Param("id") + if !c.unitInCallerOrg(ctx, id) { + return + } + if err := c.service.RemoveMember(ctx.Request.Context(), id, ctx.Param("userId")); err != nil { + c.fail(ctx, err) + return + } + ctx.JSON(http.StatusOK, gin.H{"success": true}) +} + +// unitInCallerOrg answers 404 rather than 403 for a unit in another tenant, so +// the tree of one organization is not enumerable from another. +func (c *Controller) unitInCallerOrg(ctx *gin.Context, id string) bool { + unit, err := c.service.Store().Get(ctx.Request.Context(), id) + if err != nil { + c.fail(ctx, err) + return false + } + subject := authz.SubjectFromGin(ctx) + if !subject.Unscoped && subject.OrgID != unit.OrgID { + ctx.JSON(http.StatusNotFound, gin.H{"error": "unit not found"}) + return false + } + return true +} + +func (c *Controller) parentInCallerOrg(ctx *gin.Context, parentID string) bool { + if parentID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": ErrParentRequired.Error()}) + return false + } + return c.unitInCallerOrg(ctx, parentID) +} + +// fail maps a tree error to its status. Each refusal names its blocking +// condition, so a caller can say what is in the way rather than that something +// went wrong. +func (c *Controller) fail(ctx *gin.Context, err error) { + switch { + case errors.Is(err, ErrUnitNotFound): + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case errors.Is(err, ErrNotEmpty), errors.Is(err, ErrCycle), + errors.Is(err, ErrProtectedUnit), errors.Is(err, ErrPersonalNoMember), + errors.Is(err, ErrCrossOrgParent), errors.Is(err, ErrNameRequired), + errors.Is(err, ErrParentRequired): + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.log.Error("unit request failed", zap.Error(err)) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "unit request failed"}) + } +} diff --git a/apps/backend/internal/orgunit/controller_test.go b/apps/backend/internal/orgunit/controller_test.go new file mode 100644 index 0000000000..e91d164b98 --- /dev/null +++ b/apps/backend/internal/orgunit/controller_test.go @@ -0,0 +1,166 @@ +package orgunit + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestRouter(t *testing.T, role authn.Role) (*gin.Engine, *Service) { + t.Helper() + gin.SetMode(gin.TestMode) + svc := newTestService(t) + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Request = c.Request.WithContext(authn.WithIdentity(c.Request.Context(), + authn.Identity{UserID: "user-ana", Role: role})) + c.Set(authn.GinContextKey, authn.Identity{UserID: "user-ana", Role: role}) + c.Next() + }) + NewController(svc, log).RegisterRoutes(router) + return router, svc +} + +func do(t *testing.T, router *gin.Engine, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + var reader *strings.Reader + if body == "" { + reader = strings.NewReader("") + } else { + reader = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, reader) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +// Deciding the shape of an organization is what unit.manage governs. A member +// who does not hold it can look at the tree but must not rearrange it. +func TestUnitWritesRequireUnitManage(t *testing.T) { + router, svc := newTestRouter(t, authn.RoleMember) + root, err := svc.EnsureRoot(t.Context(), "", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + + if w := do(t, router, http.MethodGet, "/api/v1/units", ""); w.Code != http.StatusOK { + t.Fatalf("reading the tree = %d, want 200: a member must see why they reach what they reach", w.Code) + } + for _, tc := range []struct{ method, path, body string }{ + {http.MethodPost, "/api/v1/units", `{"parent_id":"` + root.ID + `","name":"Platform"}`}, + {http.MethodPatch, "/api/v1/units/" + root.ID, `{"name":"Renamed"}`}, + {http.MethodDelete, "/api/v1/units/" + root.ID, ""}, + } { + if w := do(t, router, tc.method, tc.path, tc.body); w.Code != http.StatusForbidden { + t.Errorf("%s %s = %d, want 403", tc.method, tc.path, w.Code) + } + } +} + +// Each refusal names what is in the way, so a caller can say why rather than +// reporting that something went wrong. +func TestUnitRefusalsAreSpecific(t *testing.T) { + router, svc := newTestRouter(t, authn.RoleAdmin) + ctx := t.Context() + root, err := svc.EnsureRoot(ctx, "", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + dept, err := svc.Create(ctx, root.ID, "Platform") + if err != nil { + t.Fatalf("create dept: %v", err) + } + if _, err := svc.Create(ctx, dept.ID, "Runtime"); err != nil { + t.Fatalf("create team: %v", err) + } + personal, err := svc.EnsurePersonal(ctx, "", "user-ana", "Ana") + if err != nil { + t.Fatalf("ensure personal: %v", err) + } + + cases := []struct { + name, method, path, body, wantText string + wantCode int + }{ + {"a unit with children", http.MethodDelete, "/api/v1/units/" + dept.ID, "", "still holds", http.StatusBadRequest}, + {"the root itself", http.MethodDelete, "/api/v1/units/" + root.ID, "", "cannot be moved or deleted", http.StatusBadRequest}, + {"a member on a personal unit", http.MethodPut, "/api/v1/units/" + personal.ID + "/members/user-bruno", `{"role":"viewer"}`, "takes no members", http.StatusBadRequest}, + {"an unknown role", http.MethodPut, "/api/v1/units/" + dept.ID + "/members/user-bruno", `{"role":"wizard"}`, "must be owner", http.StatusBadRequest}, + {"a unit that does not exist", http.MethodDelete, "/api/v1/units/nope", "", "not found", http.StatusNotFound}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := do(t, router, tc.method, tc.path, tc.body) + if w.Code != tc.wantCode { + t.Fatalf("status = %d, want %d (%s)", w.Code, tc.wantCode, w.Body.String()) + } + if !strings.Contains(w.Body.String(), tc.wantText) { + t.Fatalf("body = %s, want it to mention %q", w.Body.String(), tc.wantText) + } + }) + } +} + +// The happy path: build a department, put someone in it, read it back. +func TestUnitCreateAndMember(t *testing.T) { + router, svc := newTestRouter(t, authn.RoleAdmin) + root, err := svc.EnsureRoot(t.Context(), "", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + + w := do(t, router, http.MethodPost, "/api/v1/units", `{"parent_id":"`+root.ID+`","name":"Platform"}`) + if w.Code != http.StatusCreated { + t.Fatalf("create = %d body = %s", w.Code, w.Body.String()) + } + var created Unit + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("decode: %v", err) + } + + if w := do(t, router, http.MethodPut, + "/api/v1/units/"+created.ID+"/members/user-bruno", `{"role":"collaborator"}`); w.Code != http.StatusOK { + t.Fatalf("add member = %d body = %s", w.Code, w.Body.String()) + } + + w = do(t, router, http.MethodGet, "/api/v1/units/"+created.ID+"/members", "") + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "user-bruno") { + t.Fatalf("members = %d body = %s", w.Code, w.Body.String()) + } +} + +// Reads leak as readily as writes. Knowing a unit id from another tenant must +// not be enough to enumerate who is in it, so the read handler carries the +// same tenant check the write handlers do. +func TestListMembersRefusesAnotherTenantsUnit(t *testing.T) { + router, svc := newTestRouter(t, authn.RoleAdmin) + ctx := t.Context() + + // The caller's identity carries no org in this harness, so a unit that + // does carry one belongs to somebody else. + foreign, err := svc.Store().Insert(ctx, &Unit{OrgID: "org-other", Kind: KindRoot, Name: "Globex"}) + if err != nil { + t.Fatalf("seed foreign unit: %v", err) + } + if err := svc.SetMember(ctx, foreign.ID, "user-elsewhere", "collaborator", "someone"); err != nil { + t.Fatalf("seed foreign member: %v", err) + } + + w := do(t, router, http.MethodGet, "/api/v1/units/"+foreign.ID+"/members", "") + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d body = %s, want 404", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "user-elsewhere") { + t.Fatal("another tenant's membership leaked in the response body") + } +} diff --git a/apps/backend/internal/orgunit/models.go b/apps/backend/internal/orgunit/models.go new file mode 100644 index 0000000000..6396bde2b6 --- /dev/null +++ b/apps/backend/internal/orgunit/models.go @@ -0,0 +1,78 @@ +// Package orgunit owns the tree an organization is arranged into. +// +// A unit holds child units, workspaces, and members. A department and a team +// are both units and differ only in depth, which is why there is one type here +// rather than one per level: an organization that grows a layer moves rows +// instead of migrating a schema. +// +// Membership on a unit reaches every workspace beneath it. Resolving that +// reach belongs to internal/authz; this package owns the tree and its +// invariants. See docs/specs/workspaces/requirements/org-units.md. +package orgunit + +import ( + "errors" + "time" +) + +// Kind separates the units that carry structure from the two that the system +// creates and protects. +type Kind string + +const ( + // KindRoot is the single top unit of an organization. Belonging to the + // organization is membership of this unit, so a workspace parked here is + // reachable by everyone in the organization. + KindRoot Kind = "root" + // KindStandard is a department, a team, or any other level a person makes. + KindStandard Kind = "standard" + // KindPersonal belongs to exactly one user and takes no members. It is + // where work that is nobody else's business lives, and it replaces the + // private visibility flag rather than sitting beside it. + KindPersonal Kind = "personal" +) + +// Unit is one node of an organization's tree. +type Unit struct { + ID string `json:"id" db:"id"` + OrgID string `json:"org_id" db:"org_id"` + ParentID string `json:"parent_id" db:"parent_id"` + Kind Kind `json:"kind" db:"kind"` + OwnerUserID string `json:"owner_user_id,omitempty" db:"owner_user_id"` + Name string `json:"name" db:"name"` + // Path is the materialized ancestor chain, "///", ending with this + // unit. It is a denormalization whose only writer is this package, kept + // because a prefix match works identically on SQLite and Postgres while a + // recursive query does not. + Path string `json:"path" db:"path"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// IsProtected reports whether the unit is one the system owns. A protected +// unit cannot be moved or deleted while the thing it belongs to exists. +func (u *Unit) IsProtected() bool { return u.Kind == KindRoot || u.Kind == KindPersonal } + +// Member is a person's standing on one unit, which they hold on every +// workspace beneath it. +type Member struct { + UnitID string `json:"unit_id" db:"unit_id"` + UserID string `json:"user_id" db:"user_id"` + Role string `json:"role" db:"role"` + AddedBy string `json:"added_by" db:"added_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Tree errors. Each names its blocking condition so a caller can say what is +// in the way rather than that something went wrong. +var ( + ErrUnitNotFound = errors.New("unit not found") + ErrNameRequired = errors.New("a unit needs a name") + ErrParentRequired = errors.New("a unit needs a parent") + ErrCrossOrgParent = errors.New("a unit's parent must be in the same organization") + ErrCycle = errors.New("a unit cannot be moved beneath itself") + ErrNotEmpty = errors.New("this unit still holds child units or workspaces") + ErrProtectedUnit = errors.New("the root and personal units cannot be moved or deleted") + ErrPersonalNoMember = errors.New("a personal unit takes no members") + ErrRootExists = errors.New("this organization already has a root unit") +) diff --git a/apps/backend/internal/orgunit/service.go b/apps/backend/internal/orgunit/service.go new file mode 100644 index 0000000000..1043a0bc1f --- /dev/null +++ b/apps/backend/internal/orgunit/service.go @@ -0,0 +1,343 @@ +package orgunit + +import ( + "context" + "strings" + + "github.com/kandev/kandev/internal/common/logger" + "go.uber.org/zap" +) + +// WorkspaceCounter reports how many workspaces sit in a unit. +// +// Workspaces belong to the task repository, so this package asks rather than +// reads. Without it a unit could be deleted out from under the workspaces it +// holds, which would leave them unreachable with no way to say why. +type WorkspaceCounter interface { + CountWorkspacesInUnit(ctx context.Context, unitID string) (int, error) +} + +// Service owns the tree's invariants. +type Service struct { + store *Store + workspaces WorkspaceCounter + log *logger.Logger +} + +// NewService builds the service. The workspace counter is wired separately +// because the task repository is constructed after this package. +func NewService(store *Store, log *logger.Logger) *Service { + return &Service{store: store, log: log} +} + +// SetWorkspaceCounter wires the workspace-occupancy seam. Until it is set, a +// delete is refused rather than allowed unchecked: an unwired dependency must +// not read as "no workspaces here". +func (s *Service) SetWorkspaceCounter(c WorkspaceCounter) { s.workspaces = c } + +// Store exposes the store for read paths that do not need the invariants. +func (s *Service) Store() *Store { return s.store } + +// EnsureRoot returns the organization's root unit, creating it when absent. +// It is idempotent so that boot, migration, and organization creation can all +// call it without coordinating. +func (s *Service) EnsureRoot(ctx context.Context, orgID, orgName string) (*Unit, error) { + if existing, err := s.store.Root(ctx, orgID); err == nil { + return existing, nil + } else if err != ErrUnitNotFound { + return nil, err + } + name := strings.TrimSpace(orgName) + if name == "" { + name = "Organization" + } + unit, err := s.store.Insert(ctx, &Unit{OrgID: orgID, Kind: KindRoot, Name: name}) + if err != nil { + // Lost a race with a concurrent caller. The unique index kept the + // database right; re-reading is what keeps the method idempotent, as + // its name promises. + if existing, readErr := s.store.Root(ctx, orgID); readErr == nil { + return existing, nil + } + return nil, err + } + return unit, nil +} + +// EnsurePersonal returns a user's personal unit, creating it when absent. +// +// It hangs off the root rather than standing outside the tree, so that one +// walk answers reach for every workspace. Its emptiness of members, not its +// position, is what keeps it private. +func (s *Service) EnsurePersonal(ctx context.Context, orgID, userID, displayName string) (*Unit, error) { + if existing, err := s.store.Personal(ctx, userID); err == nil { + return existing, nil + } else if err != ErrUnitNotFound { + return nil, err + } + root, err := s.EnsureRoot(ctx, orgID, "") + if err != nil { + return nil, err + } + name := strings.TrimSpace(displayName) + if name == "" { + name = "Personal" + } + unit, err := s.store.Insert(ctx, &Unit{ + OrgID: orgID, + ParentID: root.ID, + Kind: KindPersonal, + OwnerUserID: userID, + Name: name, + }) + if err != nil { + // Same race as the root: two first-time requests for one account can + // both pass the lookup above. Returning the row the winner created is + // the idempotent answer, and matters more here because the caller's + // fallback for an error used to be the organization root. + if existing, readErr := s.store.Personal(ctx, userID); readErr == nil { + return existing, nil + } + return nil, err + } + return unit, nil +} + +// Create adds a standard unit under a parent. +func (s *Service) Create(ctx context.Context, parentID, name string) (*Unit, error) { + if strings.TrimSpace(name) == "" { + return nil, ErrNameRequired + } + if parentID == "" { + return nil, ErrParentRequired + } + parent, err := s.store.Get(ctx, parentID) + if err != nil { + return nil, err + } + unit, err := s.store.Insert(ctx, &Unit{ + OrgID: parent.OrgID, + ParentID: parent.ID, + Kind: KindStandard, + Name: strings.TrimSpace(name), + }) + if err != nil { + return nil, err + } + s.logInfo("unit created", unit) + return unit, nil +} + +// Rename changes a unit's display name, including a protected one: naming is +// not structural. +func (s *Service) Rename(ctx context.Context, id, name string) error { + if strings.TrimSpace(name) == "" { + return ErrNameRequired + } + return s.store.Rename(ctx, id, strings.TrimSpace(name)) +} + +// Move reparents a unit. +func (s *Service) Move(ctx context.Context, id, newParentID string) error { + unit, err := s.store.Get(ctx, id) + if err != nil { + return err + } + if unit.IsProtected() { + return ErrProtectedUnit + } + parent, err := s.store.Get(ctx, newParentID) + if err != nil { + return err + } + if parent.OrgID != unit.OrgID { + return ErrCrossOrgParent + } + // A unit cannot be moved beneath itself. The destination's path carries + // its whole ancestry, so containment is a prefix test rather than a walk. + if strings.HasPrefix(parent.Path, unit.Path) { + return ErrCycle + } + if err := s.store.Reparent(ctx, unit, parent); err != nil { + return err + } + s.logInfo("unit moved", unit) + return nil +} + +// Delete removes an empty unit. +func (s *Service) Delete(ctx context.Context, id string) error { + unit, err := s.store.Get(ctx, id) + if err != nil { + return err + } + if unit.IsProtected() { + return ErrProtectedUnit + } + children, err := s.store.ChildCount(ctx, id) + if err != nil { + return err + } + if children > 0 { + return ErrNotEmpty + } + if s.workspaces == nil { + return ErrNotEmpty + } + count, err := s.workspaces.CountWorkspacesInUnit(ctx, id) + if err != nil { + return err + } + if count > 0 { + return ErrNotEmpty + } + if err := s.store.Delete(ctx, id); err != nil { + return err + } + s.logInfo("unit deleted", unit) + return nil +} + +// SetMember adds or re-roles a member. +func (s *Service) SetMember(ctx context.Context, unitID, userID, role, addedBy string) error { + unit, err := s.store.Get(ctx, unitID) + if err != nil { + return err + } + // A personal unit is private because nobody else can be in it. Admitting a + // member would make "private" a property some units have and others do + // not, which is the flag this model removed. + if unit.Kind == KindPersonal { + return ErrPersonalNoMember + } + return s.store.SetMember(ctx, &Member{ + UnitID: unitID, UserID: userID, Role: role, AddedBy: addedBy, + }) +} + +// RemoveMember drops a membership. +func (s *Service) RemoveMember(ctx context.Context, unitID, userID string) error { + return s.store.RemoveMember(ctx, unitID, userID) +} + +func (s *Service) logInfo(msg string, unit *Unit) { + if s.log == nil { + return + } + s.log.Info(msg, + zap.String("unit_id", unit.ID), + zap.String("org_id", unit.OrgID), + zap.String("kind", string(unit.Kind))) +} + +// PersonalUnitID returns a user's personal unit, creating it on demand. It +// satisfies the task service's placement seam. +func (s *Service) PersonalUnitID(ctx context.Context, orgID, userID, displayName string) (string, error) { + unit, err := s.EnsurePersonal(ctx, orgID, userID, displayName) + if err != nil { + return "", err + } + return unit.ID, nil +} + +// RootUnitID returns an organization's root unit, creating it on demand. +func (s *Service) RootUnitID(ctx context.Context, orgID string) (string, error) { + unit, err := s.EnsureRoot(ctx, orgID, "") + if err != nil { + return "", err + } + return unit.ID, nil +} + +// InheritedRole returns the highest role a user holds on a unit or any of its +// ancestors. It satisfies the task service's reach seam. +func (s *Service) InheritedRole(ctx context.Context, userID, unitID string) (string, error) { + unit, err := s.store.Get(ctx, unitID) + if err != nil { + if err == ErrUnitNotFound { + // A workspace pointing at a unit that is gone reaches nobody, + // which is the safe reading: it is not an error the caller can act + // on, and treating it as one would deny every request instead. + return "", nil + } + return "", err + } + roles, err := s.store.AncestorRoles(ctx, userID, unit.Path) + if err != nil { + return "", err + } + return strongest(roles), nil +} + +// InheritedRolesByUnit resolves inherited roles for many units at once, in two +// queries rather than one per unit. +func (s *Service) InheritedRolesByUnit(ctx context.Context, userID string, unitIDs []string) (map[string]string, error) { + if userID == "" || len(unitIDs) == 0 { + return map[string]string{}, nil + } + held, err := s.store.UserRoles(ctx, userID) + if err != nil { + return nil, err + } + if len(held) == 0 { + return map[string]string{}, nil + } + paths, err := s.store.PathsByID(ctx, unitIDs) + if err != nil { + return nil, err + } + out := make(map[string]string, len(paths)) + for unitID, path := range paths { + var roles []string + for _, ancestor := range AncestorIDs(path) { + if role, ok := held[ancestor]; ok { + roles = append(roles, role) + } + } + if role := strongest(roles); role != "" { + out[unitID] = role + } + } + return out, nil +} + +// strongest picks the highest role, which is the whole combining rule: nothing +// a person holds anywhere in the ancestry can take away what another grant +// gives them. +func strongest(roles []string) string { + rank := map[string]int{"viewer": 1, "collaborator": 2, "owner": 3} + best, bestRank := "", 0 + for _, role := range roles { + if r := rank[role]; r > bestRank { + best, bestRank = role, r + } + } + return best +} + +// UnitOrgID reports which organization a unit belongs to. +func (s *Service) UnitOrgID(ctx context.Context, unitID string) (string, error) { + unit, err := s.store.Get(ctx, unitID) + if err != nil { + return "", err + } + return unit.OrgID, nil +} + +// UnitReaders returns everyone who reaches a workspace placed in this unit, +// which is every member of the unit and of all its ancestors. +func (s *Service) UnitReaders(ctx context.Context, unitID string) ([]string, error) { + unit, err := s.store.Get(ctx, unitID) + if err != nil { + if err == ErrUnitNotFound { + return nil, nil + } + return nil, err + } + return s.store.AncestorMemberIDs(ctx, unit.Path) +} + +// DeleteOrgUnits removes an organization's whole tree. +func (s *Service) DeleteOrgUnits(ctx context.Context, orgID string) error { + return s.store.DeleteByOrg(ctx, orgID) +} diff --git a/apps/backend/internal/orgunit/service_test.go b/apps/backend/internal/orgunit/service_test.go new file mode 100644 index 0000000000..2583ddf08a --- /dev/null +++ b/apps/backend/internal/orgunit/service_test.go @@ -0,0 +1,247 @@ +package orgunit + +import ( + "context" + "errors" + "sync" + "testing" +) + +type stubCounter struct { + counts map[string]int + err error +} + +func (s stubCounter) CountWorkspacesInUnit(_ context.Context, unitID string) (int, error) { + return s.counts[unitID], s.err +} + +func newTestService(t *testing.T) *Service { + t.Helper() + svc := NewService(newTestStore(t), nil) + svc.SetWorkspaceCounter(stubCounter{counts: map[string]int{}}) + return svc +} + +// Both are idempotent because boot, the data migration, and organization or +// user creation all reach for them without coordinating. A second call that +// created a second unit would give a user two ancestries. +func TestEnsureRootAndPersonalAreIdempotent(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + first, err := svc.EnsureRoot(ctx, "org-1", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + second, err := svc.EnsureRoot(ctx, "org-1", "Acme") + if err != nil { + t.Fatalf("ensure root again: %v", err) + } + if first.ID != second.ID { + t.Fatalf("root ids differ: %s vs %s", first.ID, second.ID) + } + + p1, err := svc.EnsurePersonal(ctx, "org-1", "ada", "Ada Lovelace") + if err != nil { + t.Fatalf("ensure personal: %v", err) + } + p2, err := svc.EnsurePersonal(ctx, "org-1", "ada", "Ada Lovelace") + if err != nil { + t.Fatalf("ensure personal again: %v", err) + } + if p1.ID != p2.ID { + t.Fatalf("personal ids differ: %s vs %s", p1.ID, p2.ID) + } + if p1.ParentID != first.ID { + t.Fatalf("personal unit parent = %q, want the root %q", p1.ParentID, first.ID) + } +} + +// A personal unit is private because nobody else can be in it. If a member +// could be added, privacy would become a property some units have, which is +// the flag this model set out to remove. +func TestPersonalUnitRefusesMembers(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + if _, err := svc.EnsureRoot(ctx, "org-1", "Acme"); err != nil { + t.Fatalf("ensure root: %v", err) + } + personal, err := svc.EnsurePersonal(ctx, "org-1", "ada", "Ada") + if err != nil { + t.Fatalf("ensure personal: %v", err) + } + + err = svc.SetMember(ctx, personal.ID, "grace", "collaborator", "ada") + if !errors.Is(err, ErrPersonalNoMember) { + t.Fatalf("adding a member to a personal unit returned %v, want ErrPersonalNoMember", err) + } +} + +// Moving a unit under its own descendant would detach the subtree from the +// root, and every reach answer for it afterwards would be a guess. +func TestMoveRefusesCycle(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + root, err := svc.EnsureRoot(ctx, "org-1", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + dept, err := svc.Create(ctx, root.ID, "Platform") + if err != nil { + t.Fatalf("create dept: %v", err) + } + team, err := svc.Create(ctx, dept.ID, "Runtime") + if err != nil { + t.Fatalf("create team: %v", err) + } + + if err := svc.Move(ctx, dept.ID, team.ID); !errors.Is(err, ErrCycle) { + t.Fatalf("moving a unit beneath its own child returned %v, want ErrCycle", err) + } + if err := svc.Move(ctx, root.ID, dept.ID); !errors.Is(err, ErrProtectedUnit) { + t.Fatalf("moving the root returned %v, want ErrProtectedUnit", err) + } +} + +// Deleting a unit that still holds workspaces would strand them: they would +// have a placement pointing at nothing and no ancestry to resolve reach from. +func TestDeleteRefusesWhileOccupied(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + root, err := svc.EnsureRoot(ctx, "org-1", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + dept, err := svc.Create(ctx, root.ID, "Platform") + if err != nil { + t.Fatalf("create dept: %v", err) + } + team, err := svc.Create(ctx, dept.ID, "Runtime") + if err != nil { + t.Fatalf("create team: %v", err) + } + + if err := svc.Delete(ctx, dept.ID); !errors.Is(err, ErrNotEmpty) { + t.Fatalf("deleting a unit with a child returned %v, want ErrNotEmpty", err) + } + + svc.SetWorkspaceCounter(stubCounter{counts: map[string]int{team.ID: 1}}) + if err := svc.Delete(ctx, team.ID); !errors.Is(err, ErrNotEmpty) { + t.Fatalf("deleting a unit holding a workspace returned %v, want ErrNotEmpty", err) + } + + svc.SetWorkspaceCounter(stubCounter{counts: map[string]int{}}) + if err := svc.Delete(ctx, team.ID); err != nil { + t.Fatalf("deleting an empty unit: %v", err) + } +} + +// An unwired occupancy seam must not read as "no workspaces here". Failing +// closed keeps a construction mistake from deleting a populated unit. +func TestDeleteFailsClosedWithoutCounter(t *testing.T) { + ctx := context.Background() + svc := NewService(newTestStore(t), nil) + root, err := svc.EnsureRoot(ctx, "org-1", "Acme") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + dept, err := svc.Create(ctx, root.ID, "Platform") + if err != nil { + t.Fatalf("create dept: %v", err) + } + if err := svc.Delete(ctx, dept.ID); !errors.Is(err, ErrNotEmpty) { + t.Fatalf("delete with no counter wired returned %v, want a refusal", err) + } +} + +// Two first-time requests for one account can both pass the lookup inside +// EnsurePersonal. The unique index keeps the database right, but the loser +// used to get an error, and its caller's fallback was the organization root: +// a workspace meant to be private would have been created where everyone +// reaches it. +// +// This races for real rather than seeding the row first, because seeding it +// makes the early lookup succeed and the conflict path is never reached. That +// is exactly the mistake that made the first version of this test vacuous. +func TestEnsurePersonalIsRaceSafe(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + if _, err := svc.EnsureRoot(ctx, "org-1", "Acme"); err != nil { + t.Fatalf("ensure root: %v", err) + } + + const racers = 8 + ids := make([]string, racers) + errs := make([]error, racers) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < racers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + unit, err := svc.EnsurePersonal(ctx, "org-1", "ada", "Ada") + errs[i] = err + if unit != nil { + ids[i] = unit.ID + } + }(i) + } + close(start) + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("racer %d got an error instead of the existing unit: %v", i, err) + } + } + for i, id := range ids { + if id == "" || id != ids[0] { + t.Fatalf("racer %d returned %q, want every caller to get %q", i, id, ids[0]) + } + } + + units, err := svc.Store().ListByOrg(ctx, "org-1") + if err != nil { + t.Fatalf("list units: %v", err) + } + if len(units) != 2 { + t.Fatalf("units = %d, want a root and exactly one personal unit", len(units)) + } +} + +// Same race on the root, where a spurious error breaks boot rather than reach. +func TestEnsureRootIsRaceSafe(t *testing.T) { + ctx := context.Background() + svc := newTestService(t) + + const racers = 8 + ids := make([]string, racers) + errs := make([]error, racers) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < racers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + unit, err := svc.EnsureRoot(ctx, "org-1", "Acme") + errs[i] = err + if unit != nil { + ids[i] = unit.ID + } + }(i) + } + close(start) + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("racer %d got an error instead of the existing root: %v", i, err) + } + if ids[i] != ids[0] || ids[i] == "" { + t.Fatalf("racer %d returned %q, want every caller to get %q", i, ids[i], ids[0]) + } + } +} diff --git a/apps/backend/internal/orgunit/store.go b/apps/backend/internal/orgunit/store.go new file mode 100644 index 0000000000..e515d8d248 --- /dev/null +++ b/apps/backend/internal/orgunit/store.go @@ -0,0 +1,414 @@ +package orgunit + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/db/dialect" +) + +// Store persists the unit tree and its memberships. +type Store struct { + db *sqlx.DB + ro *sqlx.DB +} + +// NewStore builds the store and creates its schema. +func NewStore(pool *db.Pool) (*Store, error) { + s := &Store{db: pool.Writer(), ro: pool.Reader()} + if err := s.initSchema(); err != nil { + return nil, err + } + return s, nil +} + +func (s *Store) initSchema() error { + timestamp := "DATETIME" + if dialect.IsPostgres(s.db.DriverName()) { + timestamp = "TIMESTAMPTZ" + } + statements := []string{ + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS org_units ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL, + parent_id TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL DEFAULT 'standard', + owner_user_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + path TEXT NOT NULL DEFAULT '', + created_at %s NOT NULL, + updated_at %s NOT NULL + )`, timestamp, timestamp), + `CREATE INDEX IF NOT EXISTS idx_org_units_org_parent ON org_units(org_id, parent_id)`, + // Ancestor lookups are prefix matches on this column, so it carries + // the only index that the reach query depends on. + `CREATE INDEX IF NOT EXISTS idx_org_units_path ON org_units(path)`, + // One root per organization, and one personal unit per user, enforced + // by the database rather than by a check the caller might skip. + `CREATE UNIQUE INDEX IF NOT EXISTS uniq_org_units_root ON org_units(org_id) WHERE kind = 'root'`, + `CREATE UNIQUE INDEX IF NOT EXISTS uniq_org_units_personal ON org_units(owner_user_id) WHERE kind = 'personal'`, + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS unit_members ( + unit_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + added_by TEXT NOT NULL DEFAULT '', + created_at %s NOT NULL, + PRIMARY KEY (unit_id, user_id) + )`, timestamp), + `CREATE INDEX IF NOT EXISTS idx_unit_members_user ON unit_members(user_id)`, + } + for _, stmt := range statements { + if _, err := s.db.Exec(stmt); err != nil { + return fmt.Errorf("org unit schema: %w", err) + } + } + return nil +} + +const unitColumns = `id, org_id, parent_id, kind, owner_user_id, name, path, created_at, updated_at` + +func scanUnit(row interface{ Scan(...any) error }) (*Unit, error) { + u := &Unit{} + if err := row.Scan(&u.ID, &u.OrgID, &u.ParentID, &u.Kind, &u.OwnerUserID, + &u.Name, &u.Path, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, err + } + return u, nil +} + +// Insert writes a unit, deriving its path from its parent. The caller has +// already validated the tree invariants; this is the only writer of `path`. +func (s *Store) Insert(ctx context.Context, unit *Unit) (*Unit, error) { + if unit.ID == "" { + unit.ID = uuid.New().String() + } + now := time.Now().UTC() + unit.CreatedAt, unit.UpdatedAt = now, now + + parentPath := "" + if unit.ParentID != "" { + parent, err := s.Get(ctx, unit.ParentID) + if err != nil { + return nil, err + } + parentPath = parent.Path + } + unit.Path = strings.TrimSuffix(parentPath, "/") + "/" + unit.ID + "/" + + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + INSERT INTO org_units (`+unitColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), + unit.ID, unit.OrgID, unit.ParentID, string(unit.Kind), unit.OwnerUserID, + unit.Name, unit.Path, unit.CreatedAt, unit.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("insert unit: %w", err) + } + return unit, nil +} + +// Get returns one unit. +func (s *Store) Get(ctx context.Context, id string) (*Unit, error) { + row := s.ro.QueryRowxContext(ctx, s.ro.Rebind( + `SELECT `+unitColumns+` FROM org_units WHERE id = ?`), id) + unit, err := scanUnit(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUnitNotFound + } + return unit, err +} + +// Root returns an organization's root unit. +func (s *Store) Root(ctx context.Context, orgID string) (*Unit, error) { + row := s.ro.QueryRowxContext(ctx, s.ro.Rebind( + `SELECT `+unitColumns+` FROM org_units WHERE org_id = ? AND kind = 'root'`), orgID) + unit, err := scanUnit(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUnitNotFound + } + return unit, err +} + +// Personal returns a user's personal unit. +func (s *Store) Personal(ctx context.Context, userID string) (*Unit, error) { + row := s.ro.QueryRowxContext(ctx, s.ro.Rebind( + `SELECT `+unitColumns+` FROM org_units WHERE owner_user_id = ? AND kind = 'personal'`), userID) + unit, err := scanUnit(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUnitNotFound + } + return unit, err +} + +// ListByOrg returns every unit in an organization, ordered by path so a caller +// receives parents before their children. +func (s *Store) ListByOrg(ctx context.Context, orgID string) ([]*Unit, error) { + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind( + `SELECT `+unitColumns+` FROM org_units WHERE org_id = ? ORDER BY path`), orgID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []*Unit + for rows.Next() { + unit, err := scanUnit(rows) + if err != nil { + return nil, err + } + out = append(out, unit) + } + return out, rows.Err() +} + +// Rename updates a unit's display name. +func (s *Store) Rename(ctx context.Context, id, name string) error { + res, err := s.db.ExecContext(ctx, s.db.Rebind( + `UPDATE org_units SET name = ?, updated_at = ? WHERE id = ?`), + name, time.Now().UTC(), id) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return ErrUnitNotFound + } + return nil +} + +// Reparent moves a unit and rewrites the path of the unit and every +// descendant in one transaction. Doing it in two statements would leave the +// subtree pointing at an ancestor chain that no longer exists. +func (s *Store) Reparent(ctx context.Context, unit *Unit, newParent *Unit) error { + oldPrefix := unit.Path + newPath := strings.TrimSuffix(newParent.Path, "/") + "/" + unit.ID + "/" + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE org_units SET parent_id = ?, path = ?, updated_at = ? WHERE id = ?`), + newParent.ID, newPath, time.Now().UTC(), unit.ID); err != nil { + return err + } + // Every descendant keeps its own tail and gets the new head. + if _, err := tx.ExecContext(ctx, tx.Rebind( + `UPDATE org_units + SET path = ? || SUBSTR(path, ?), + updated_at = ? + WHERE path LIKE ? AND id <> ?`), + newPath, len(oldPrefix)+1, time.Now().UTC(), oldPrefix+"%", unit.ID); err != nil { + return err + } + return tx.Commit() +} + +// Delete removes a unit. The caller has already established that it is empty. +func (s *Store) Delete(ctx context.Context, id string) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM unit_members WHERE unit_id = ?`), id); err != nil { + return err + } + res, err := tx.ExecContext(ctx, tx.Rebind(`DELETE FROM org_units WHERE id = ?`), id) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return ErrUnitNotFound + } + return tx.Commit() +} + +// ChildCount reports how many child units a unit still holds. +func (s *Store) ChildCount(ctx context.Context, id string) (int, error) { + var count int + err := s.ro.QueryRowxContext(ctx, s.ro.Rebind( + `SELECT COUNT(*) FROM org_units WHERE parent_id = ?`), id).Scan(&count) + return count, err +} + +// SetMember adds or re-roles a member. +func (s *Store) SetMember(ctx context.Context, m *Member) error { + m.CreatedAt = time.Now().UTC() + _, err := s.db.ExecContext(ctx, s.db.Rebind(` + INSERT INTO unit_members (unit_id, user_id, role, added_by, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (unit_id, user_id) DO UPDATE SET role = EXCLUDED.role`), + m.UnitID, m.UserID, m.Role, m.AddedBy, m.CreatedAt) + return err +} + +// RemoveMember drops a membership row. +func (s *Store) RemoveMember(ctx context.Context, unitID, userID string) error { + _, err := s.db.ExecContext(ctx, s.db.Rebind( + `DELETE FROM unit_members WHERE unit_id = ? AND user_id = ?`), unitID, userID) + return err +} + +// ListMembers returns a unit's own members, without the inherited ones. +func (s *Store) ListMembers(ctx context.Context, unitID string) ([]*Member, error) { + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(` + SELECT unit_id, user_id, role, added_by, created_at + FROM unit_members WHERE unit_id = ? ORDER BY created_at`), unitID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []*Member + for rows.Next() { + m := &Member{} + if err := rows.Scan(&m.UnitID, &m.UserID, &m.Role, &m.AddedBy, &m.CreatedAt); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// AncestorRoles returns the roles a user holds on the given unit and on every +// ancestor of it. This is the query the reach resolver runs, so it takes the +// path rather than walking the tree row by row. +func (s *Store) AncestorRoles(ctx context.Context, userID, path string) ([]string, error) { + ids := AncestorIDs(path) + if len(ids) == 0 || userID == "" { + return nil, nil + } + query, args, err := sqlx.In( + `SELECT role FROM unit_members WHERE user_id = ? AND unit_id IN (?)`, userID, ids) + if err != nil { + return nil, err + } + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(query), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []string + for rows.Next() { + var role string + if err := rows.Scan(&role); err != nil { + return nil, err + } + out = append(out, role) + } + return out, rows.Err() +} + +// AncestorIDs splits a materialized path into its unit ids, nearest last. +func AncestorIDs(path string) []string { + parts := strings.Split(strings.Trim(path, "/"), "/") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + out = append(out, p) + } + } + return out +} + +// UserRoles returns every unit role a user holds, keyed by unit id. It is one +// query, so a caller resolving many workspaces does not issue one per row. +func (s *Store) UserRoles(ctx context.Context, userID string) (map[string]string, error) { + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind( + `SELECT unit_id, role FROM unit_members WHERE user_id = ?`), userID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + out := map[string]string{} + for rows.Next() { + var unitID, role string + if err := rows.Scan(&unitID, &role); err != nil { + return nil, err + } + out[unitID] = role + } + return out, rows.Err() +} + +// PathsByID returns the materialized path of each requested unit. +func (s *Store) PathsByID(ctx context.Context, unitIDs []string) (map[string]string, error) { + if len(unitIDs) == 0 { + return map[string]string{}, nil + } + query, args, err := sqlx.In(`SELECT id, path FROM org_units WHERE id IN (?)`, unitIDs) + if err != nil { + return nil, err + } + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(query), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + out := map[string]string{} + for rows.Next() { + var id, path string + if err := rows.Scan(&id, &path); err != nil { + return nil, err + } + out[id] = path + } + return out, rows.Err() +} + +// AncestorMemberIDs returns every user holding a membership on a unit or any +// of its ancestors. It answers "who reaches a workspace here", which is what +// decides who a workspace-scoped event fans out to. +func (s *Store) AncestorMemberIDs(ctx context.Context, path string) ([]string, error) { + ids := AncestorIDs(path) + if len(ids) == 0 { + return nil, nil + } + query, args, err := sqlx.In( + `SELECT DISTINCT user_id FROM unit_members WHERE unit_id IN (?)`, ids) + if err != nil { + return nil, err + } + rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(query), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// DeleteByOrg removes every unit in an organization and their memberships. It +// is the deletion path for a whole tenant: without it, deleting an +// organization leaves its tree behind with nothing pointing at it. +func (s *Store) DeleteByOrg(ctx context.Context, orgID string) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, tx.Rebind( + `DELETE FROM unit_members WHERE unit_id IN (SELECT id FROM org_units WHERE org_id = ?)`), + orgID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, tx.Rebind( + `DELETE FROM org_units WHERE org_id = ?`), orgID); err != nil { + return err + } + return tx.Commit() +} diff --git a/apps/backend/internal/orgunit/store_test.go b/apps/backend/internal/orgunit/store_test.go new file mode 100644 index 0000000000..32589cafd3 --- /dev/null +++ b/apps/backend/internal/orgunit/store_test.go @@ -0,0 +1,214 @@ +package orgunit + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + conn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "units.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(conn, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) + store, err := NewStore(db.NewPool(sqlxDB, sqlxDB)) + if err != nil { + t.Fatalf("new store: %v", err) + } + return store +} + +func mustInsert(t *testing.T, s *Store, unit *Unit) *Unit { + t.Helper() + out, err := s.Insert(context.Background(), unit) + if err != nil { + t.Fatalf("insert %s: %v", unit.Name, err) + } + return out +} + +// A path is the whole ancestry, which is what lets reach be one query rather +// than a walk. Getting the separators wrong makes a prefix match either miss a +// descendant or catch a sibling whose id merely starts with the same bytes. +func TestInsertBuildsMaterializedPath(t *testing.T) { + s := newTestStore(t) + root := mustInsert(t, s, &Unit{OrgID: "org-1", Kind: KindRoot, Name: "Acme"}) + dept := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: root.ID, Name: "Platform"}) + team := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: dept.ID, Name: "Runtime"}) + + if root.Path != "/"+root.ID+"/" { + t.Fatalf("root path = %q", root.Path) + } + if want := "/" + root.ID + "/" + dept.ID + "/"; dept.Path != want { + t.Fatalf("dept path = %q, want %q", dept.Path, want) + } + if want := "/" + root.ID + "/" + dept.ID + "/" + team.ID + "/"; team.Path != want { + t.Fatalf("team path = %q, want %q", team.Path, want) + } + ids := AncestorIDs(team.Path) + if len(ids) != 3 || ids[0] != root.ID || ids[2] != team.ID { + t.Fatalf("ancestors = %v", ids) + } +} + +// Reparenting has to carry the subtree. A move that rewrites only the moved +// row leaves its descendants claiming an ancestry they no longer have, and +// every reach answer below that point silently becomes wrong. +func TestReparentRewritesDescendantPaths(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + root := mustInsert(t, s, &Unit{OrgID: "org-1", Kind: KindRoot, Name: "Acme"}) + oldDept := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: root.ID, Name: "Platform"}) + newDept := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: root.ID, Name: "Product"}) + team := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: oldDept.ID, Name: "Runtime"}) + squad := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: team.ID, Name: "Scheduler"}) + + if err := s.Reparent(ctx, team, newDept); err != nil { + t.Fatalf("reparent: %v", err) + } + + movedTeam, err := s.Get(ctx, team.ID) + if err != nil { + t.Fatalf("get team: %v", err) + } + if want := newDept.Path + team.ID + "/"; movedTeam.Path != want { + t.Fatalf("team path = %q, want %q", movedTeam.Path, want) + } + if movedTeam.ParentID != newDept.ID { + t.Fatalf("team parent = %q, want %q", movedTeam.ParentID, newDept.ID) + } + + movedSquad, err := s.Get(ctx, squad.ID) + if err != nil { + t.Fatalf("get squad: %v", err) + } + if want := movedTeam.Path + squad.ID + "/"; movedSquad.Path != want { + t.Fatalf("descendant path = %q, want %q", movedSquad.Path, want) + } + if got := AncestorIDs(movedSquad.Path); len(got) != 4 || got[1] != newDept.ID { + t.Fatalf("descendant ancestry = %v, want it to run through the new parent", got) + } +} + +// The reach query asks one question: which of these ancestors does this user +// hold a role on. A membership on a sibling branch must not answer it. +func TestAncestorRolesIgnoresOtherBranches(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + root := mustInsert(t, s, &Unit{OrgID: "org-1", Kind: KindRoot, Name: "Acme"}) + platform := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: root.ID, Name: "Platform"}) + product := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: root.ID, Name: "Product"}) + runtime := mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: platform.ID, Name: "Runtime"}) + + if err := s.SetMember(ctx, &Member{UnitID: platform.ID, UserID: "ada", Role: "collaborator"}); err != nil { + t.Fatalf("set member: %v", err) + } + if err := s.SetMember(ctx, &Member{UnitID: product.ID, UserID: "grace", Role: "owner"}); err != nil { + t.Fatalf("set member: %v", err) + } + + roles, err := s.AncestorRoles(ctx, "ada", runtime.Path) + if err != nil { + t.Fatalf("ancestor roles: %v", err) + } + if len(roles) != 1 || roles[0] != "collaborator" { + t.Fatalf("ada on runtime = %v, want one collaborator inherited from the department", roles) + } + + roles, err = s.AncestorRoles(ctx, "grace", runtime.Path) + if err != nil { + t.Fatalf("ancestor roles: %v", err) + } + if len(roles) != 0 { + t.Fatalf("grace on runtime = %v, want none: her membership is on another branch", roles) + } +} + +// Re-roling is an update, not a second row, or a demotion would leave the old +// role in place and the maximum rule would keep returning it. +func TestSetMemberReRoles(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + root := mustInsert(t, s, &Unit{OrgID: "org-1", Kind: KindRoot, Name: "Acme"}) + + for _, role := range []string{"viewer", "owner"} { + if err := s.SetMember(ctx, &Member{UnitID: root.ID, UserID: "ada", Role: role}); err != nil { + t.Fatalf("set member %s: %v", role, err) + } + } + members, err := s.ListMembers(ctx, root.ID) + if err != nil { + t.Fatalf("list members: %v", err) + } + if len(members) != 1 || members[0].Role != "owner" { + t.Fatalf("members = %+v, want a single owner row", members) + } +} + +// One root per organization and one personal unit per user are database +// constraints, because a second of either would give a user two ancestries and +// make reach depend on which one a query happened to find. +func TestUniqueRootAndPersonalUnits(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + mustInsert(t, s, &Unit{OrgID: "org-1", Kind: KindRoot, Name: "Acme"}) + + if _, err := s.Insert(ctx, &Unit{OrgID: "org-1", Kind: KindRoot, Name: "Acme again"}); err == nil { + t.Fatal("a second root unit was accepted") + } + + root, err := s.Root(ctx, "org-1") + if err != nil { + t.Fatalf("root: %v", err) + } + mustInsert(t, s, &Unit{OrgID: "org-1", ParentID: root.ID, Kind: KindPersonal, OwnerUserID: "ada", Name: "Ada"}) + if _, err := s.Insert(ctx, &Unit{ + OrgID: "org-1", ParentID: root.ID, Kind: KindPersonal, OwnerUserID: "ada", Name: "Ada again", + }); err == nil { + t.Fatal("a second personal unit was accepted for one user") + } +} + +// Deleting an organization has to take its tree with it. Units are the one +// thing no other owner removes: workspaces and accounts are deleted by their +// own paths, so an orphaned tree would accumulate silently. +func TestDeleteByOrgRemovesUnitsAndMemberships(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + doomed := mustInsert(t, s, &Unit{OrgID: "org-doomed", Kind: KindRoot, Name: "Initech"}) + child := mustInsert(t, s, &Unit{OrgID: "org-doomed", ParentID: doomed.ID, Name: "Sales"}) + keeper := mustInsert(t, s, &Unit{OrgID: "org-keep", Kind: KindRoot, Name: "Acme"}) + for _, unit := range []*Unit{doomed, child, keeper} { + if err := s.SetMember(ctx, &Member{UnitID: unit.ID, UserID: "ada", Role: "owner"}); err != nil { + t.Fatalf("seed member: %v", err) + } + } + + if err := s.DeleteByOrg(ctx, "org-doomed"); err != nil { + t.Fatalf("delete by org: %v", err) + } + + if units, err := s.ListByOrg(ctx, "org-doomed"); err != nil || len(units) != 0 { + t.Fatalf("units after delete = %v (err %v), want none", units, err) + } + for _, id := range []string{doomed.ID, child.ID} { + if members, err := s.ListMembers(ctx, id); err != nil || len(members) != 0 { + t.Fatalf("memberships survived for %s: %v (err %v)", id, members, err) + } + } + // The other organization is untouched. + if units, err := s.ListByOrg(ctx, "org-keep"); err != nil || len(units) != 1 { + t.Fatalf("other org units = %v (err %v), want its root intact", units, err) + } + if members, err := s.ListMembers(ctx, keeper.ID); err != nil || len(members) != 1 { + t.Fatalf("other org membership = %v (err %v), want it intact", members, err) + } +} diff --git a/apps/backend/internal/plugins/handlers.go b/apps/backend/internal/plugins/handlers.go index 4ecff3c581..5c5043fc7b 100644 --- a/apps/backend/internal/plugins/handlers.go +++ b/apps/backend/internal/plugins/handlers.go @@ -76,6 +76,11 @@ func RegisterRoutes(router *gin.Engine, svc *Service, _ Deliverer, log *logger.L ctrl := &Controller{svc: svc, log: log, actionInvoker: svc, webhookInvoker: svc} api := router.Group("/api/plugins") + // Instance admin, not an org scope: plugins load into the shared host + // runtime, so an install is not confined to the installer's org. Every + // other mutating route here (uninstall, enable, disable, config) is + // already RequireAdmin; gating install any lower would let an org admin + // add a plugin they cannot remove. api.POST("/install", authn.RequireAdmin(), ctrl.install) api.POST("/sync", authn.RequireAdmin(), ctrl.sync) // Register the static /marketplace and /settings routes before the /:id diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index d5e1ddadba..d18aacf32a 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -54,6 +54,15 @@ features: dev: "false" e2e: "false" + # Organizations: a tenant boundary above users. Requires KANDEV_FEATURES_AUTH; + # the backend refuses to start with this on and auth off. Off everywhere until + # the runtime-isolation work (per-org filesystem roots, per-org agent + # credentials, tenant-pinned executors) lands. + KANDEV_FEATURES_MULTI_TENANCY: + prod: "false" + dev: "false" + e2e: "false" + # Dynamic agent profiles and provider-error routing. Off in every shipped # profile until the complete guarded implementation is ready. KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING: diff --git a/apps/backend/internal/runtimeflags/handlers.go b/apps/backend/internal/runtimeflags/handlers.go index bdea1279b7..0cfef37676 100644 --- a/apps/backend/internal/runtimeflags/handlers.go +++ b/apps/backend/internal/runtimeflags/handlers.go @@ -7,7 +7,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" ) type Handler struct { @@ -20,7 +20,7 @@ func RegisterRoutes(router gin.IRouter, svc *Service) { api.GET("", h.list) // Flag overrides are install-wide; only admins may change them. With // auth disabled the synthetic identity is an admin (no behavior change). - api.PATCH("/:key", authn.RequireAdmin(), h.patch) + api.PATCH("/:key", authz.RequireOrgScope(authz.ScopeOrgSettingsManage), h.patch) } func (h *Handler) list(c *gin.Context) { diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go index 267f6f4d62..fbb2d8bec6 100644 --- a/apps/backend/internal/runtimeflags/registry.go +++ b/apps/backend/internal/runtimeflags/registry.go @@ -67,6 +67,26 @@ var registrations = []runtimeFlagRegistration{ read: func(cfg *config.Config) bool { return cfg.Features.Auth }, apply: func(cfg *config.Config, value bool) { cfg.Features.Auth = value }, }, + { + definition: RuntimeFlagDefinition{ + Key: "features.multiTenancy", + EnvVar: "KANDEV_FEATURES_MULTI_TENANCY", + Kind: KindFeature, + Label: "Organizations", + Description: "Adds organizations above users: every account belongs to exactly one org, and orgs cannot see each other's workspaces, tasks, or secrets. Requires Authentication & users.", + Stability: StabilityExperimental, + RiskLevel: RiskHigh, + RiskDescription: "Turning this ON puts every existing user, workspace and secret into a single default " + + "organization after restart, and adds an instance operator tier that manages organizations. " + + "It requires Authentication & users: with authentication off the instance refuses to start. " + + "Filesystem paths and agent CLI credentials are still shared across organizations, so this is " + + "an application-layer boundary, not a sandbox.", + RestartRequired: true, + Mutable: true, + }, + read: func(cfg *config.Config) bool { return cfg.Features.MultiTenancy }, + apply: func(cfg *config.Config, value bool) { cfg.Features.MultiTenancy = value }, + }, { definition: RuntimeFlagDefinition{ Key: "features.dynamicAgentRouting", diff --git a/apps/backend/internal/secrets/service.go b/apps/backend/internal/secrets/service.go index 3f05de23d3..9559292de5 100644 --- a/apps/backend/internal/secrets/service.go +++ b/apps/backend/internal/secrets/service.go @@ -24,7 +24,7 @@ func NewService(store SecretStore, log *logger.Logger) *Service { } } -// SetWorkspaceAuthorizer wires the workspace visibility check used by the +// SetWorkspaceAuthorizer wires the workspace reach check used by the // workspace-scoped API. The callback is intentionally a function so the // secrets package does not depend on the task service package. func (s *Service) SetWorkspaceAuthorizer(authorizer func(context.Context, string) error) { diff --git a/apps/backend/internal/system/system.go b/apps/backend/internal/system/system.go index 2c261ffd86..24b1188a3c 100644 --- a/apps/backend/internal/system/system.go +++ b/apps/backend/internal/system/system.go @@ -17,7 +17,7 @@ import ( "github.com/gin-gonic/gin" - "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/db" @@ -184,7 +184,7 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) { // Destructive install-wide mutations require the admin role. With // authentication disabled the synthetic single-user identity is an admin, // so behavior is unchanged; with it enabled, members are read-only here. - admin := g.Group("", authn.RequireAdmin()) + admin := g.Group("", authz.RequireOrgScope(authz.ScopeOrgSettingsManage)) g.GET("/info", info.Handler(s.Info)) if s.Storage != nil { diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index fb3bade4f8..1ddae9c833 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -3,6 +3,7 @@ package dto import ( "time" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/internal/task/statussummary" @@ -34,6 +35,7 @@ type WorkspaceDTO struct { Name string `json:"name"` Description *string `json:"description,omitempty"` OwnerID string `json:"owner_id"` + UnitID string `json:"unit_id,omitempty"` DefaultExecutorID *string `json:"default_executor_id,omitempty"` DefaultEnvironmentID *string `json:"default_environment_id,omitempty"` DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` @@ -43,6 +45,13 @@ type WorkspaceDTO struct { OfficeWorkflowID string `json:"office_workflow_id,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + + // ViewerRole and Scopes describe what the *requesting* user may do here. + // The frontend gates every control on Scopes rather than comparing the + // current user ID to OwnerID, so permission logic lives in one place. + ViewerRole string `json:"viewer_role,omitempty"` + Scopes []string `json:"scopes,omitempty"` + MemberCount int `json:"member_count,omitempty"` } type RepositoryDTO struct { @@ -244,10 +253,12 @@ type TaskDTO struct { // Office extensions AssigneeAgentProfileID string `json:"assignee_agent_profile_id,omitempty"` - Origin string `json:"origin,omitempty"` - ProjectID string `json:"project_id,omitempty"` - Labels string `json:"labels,omitempty"` - Identifier string `json:"identifier,omitempty"` + // AssigneeUserID is the human assignee, independent of the agent one. + AssigneeUserID string `json:"assignee_user_id,omitempty"` + Origin string `json:"origin,omitempty"` + ProjectID string `json:"project_id,omitempty"` + Labels string `json:"labels,omitempty"` + Identifier string `json:"identifier,omitempty"` // ExternalID is a caller-supplied identity used for task create- // idempotency (docs/specs/tasks/requirements/external-id-idempotency.md). Omitted // when the task holds none. @@ -649,6 +660,7 @@ func FromWorkspace(workspace *models.Workspace) WorkspaceDTO { Name: workspace.Name, Description: description, OwnerID: workspace.OwnerID, + UnitID: workspace.UnitID, DefaultExecutorID: workspace.DefaultExecutorID, DefaultEnvironmentID: workspace.DefaultEnvironmentID, DefaultAgentProfileID: workspace.DefaultAgentProfileID, @@ -661,6 +673,16 @@ func FromWorkspace(workspace *models.Workspace) WorkspaceDTO { } } +// FromWorkspaceWithAccess projects a workspace together with the requesting +// user's resolved role and scopes. +func FromWorkspaceWithAccess(workspace *models.Workspace, decision authz.Decision, memberCount int) WorkspaceDTO { + out := FromWorkspace(workspace) + out.ViewerRole = string(decision.Role) + out.Scopes = decision.Scopes.Strings() + out.MemberCount = memberCount + return out +} + func FromRepository(repository *models.Repository) RepositoryDTO { bindings := make([]RepositorySecretBindingDTO, 0, len(repository.SecretBindings)) for _, binding := range repository.SecretBindings { @@ -892,6 +914,7 @@ func FromTaskWithSessionInfo( // projection from workflow_step_participants (ADR 0005 Wave F); // the repo's task SELECTs hydrate it via a correlated subquery. AssigneeAgentProfileID: task.AssigneeAgentProfileID, + AssigneeUserID: task.AssigneeUserID, Origin: task.Origin, ProjectID: task.ProjectID, Labels: task.Labels, diff --git a/apps/backend/internal/task/handlers/cancellation_pending_test.go b/apps/backend/internal/task/handlers/cancellation_pending_test.go index d37f7a23c8..528a838620 100644 --- a/apps/backend/internal/task/handlers/cancellation_pending_test.go +++ b/apps/backend/internal/task/handlers/cancellation_pending_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "github.com/kandev/kandev/internal/task/repository" "net/http" "net/http/httptest" "testing" @@ -37,6 +38,9 @@ type messageOrchestratorWithCancellation struct { } type cancellationListRepo struct { + // Membership is not exercised by this fake; the embedded default + // reports no membership, which is the narrower answer. + repository.UnsupportedWorkspaceMembers mockRepository sessionsByTask []*models.TaskSession pendingActions map[string]models.TaskPendingAction diff --git a/apps/backend/internal/task/handlers/errors.go b/apps/backend/internal/task/handlers/errors.go index 5d33d8edb3..0f5425c427 100644 --- a/apps/backend/internal/task/handlers/errors.go +++ b/apps/backend/internal/task/handlers/errors.go @@ -39,6 +39,18 @@ func handleNotFound(c *gin.Context, log *logger.Logger, err error, fallback stri c.JSON(http.StatusNotFound, gin.H{"error": fallback}) return } + // A scope denial is 403, not 404: workspace.read was already granted, so + // existence is known to the caller and there is nothing left to hide. + if service.IsForbidden(err) { + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + // A rejected assignee is the caller naming someone who cannot reach the + // workspace, not a server fault. Its message is written to be shown. + if errors.Is(err, service.ErrAssigneeCannotReachWorkspace) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } if errors.Is(err, service.ErrWIPLimitExceeded) { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return diff --git a/apps/backend/internal/task/handlers/executor_handlers_test.go b/apps/backend/internal/task/handlers/executor_handlers_test.go index c92cb491d3..c8df9b3cb2 100644 --- a/apps/backend/internal/task/handlers/executor_handlers_test.go +++ b/apps/backend/internal/task/handlers/executor_handlers_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/kandev/kandev/internal/task/repository" "net/http" "testing" @@ -20,6 +21,9 @@ import ( // executorRepo backs both the executor and executor-profile handler tests. type executorRepo struct { + // Membership is not exercised by this fake; the embedded default + // reports no membership, which is the narrower answer. + repository.UnsupportedWorkspaceMembers mockRepository executors []*models.Executor diff --git a/apps/backend/internal/task/handlers/member_handlers.go b/apps/backend/internal/task/handlers/member_handlers.go new file mode 100644 index 0000000000..2723c82d0c --- /dev/null +++ b/apps/backend/internal/task/handlers/member_handlers.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/service" +) + +// MemberHandlers serves workspace membership and the +// reduced user directory that backs the member picker. +type MemberHandlers struct { + service *service.Service + logger *logger.Logger +} + +func NewMemberHandlers(svc *service.Service, log *logger.Logger) *MemberHandlers { + return &MemberHandlers{ + service: svc, + logger: log.WithFields(zap.String("component", "workspace-member-handlers")), + } +} + +// RegisterMemberRoutes mounts the membership surface. +func RegisterMemberRoutes(router *gin.Engine, svc *service.Service, log *logger.Logger) { + h := NewMemberHandlers(svc, log) + api := router.Group("/api/v1") + api.GET("/workspaces/:id/members", h.listMembers) + api.PUT("/workspaces/:id/members/:userId", h.putMember) + api.DELETE("/workspaces/:id/members/:userId", h.deleteMember) + api.POST("/workspaces/:id/transfer-ownership", h.transferOwnership) + api.GET("/users/directory", h.listDirectory) + api.GET("/authz/scopes", h.listScopes) +} + +type memberDTO struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + AddedBy string `json:"added_by,omitempty"` +} + +func (h *MemberHandlers) listMembers(c *gin.Context) { + ctx := c.Request.Context() + members, err := h.service.ListWorkspaceMembers(ctx, c.Param("id")) + if err != nil { + h.respondError(c, err, "failed to list workspace members") + return + } + names := h.displayNames(c) + out := make([]memberDTO, 0, len(members)) + for _, member := range members { + out = append(out, memberDTO{ + UserID: member.UserID, + DisplayName: names[member.UserID], + Role: member.Role, + AddedBy: member.AddedBy, + }) + } + c.JSON(http.StatusOK, gin.H{"members": out, "total": len(out)}) +} + +type putMemberRequest struct { + Role string `json:"role"` +} + +func (h *MemberHandlers) putMember(c *gin.Context) { + var body putMemberRequest + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + member, err := h.service.UpsertWorkspaceMember(c.Request.Context(), c.Param("id"), c.Param("userId"), body.Role) + if err != nil { + h.respondError(c, err, "failed to add workspace member") + return + } + c.JSON(http.StatusOK, memberDTO{UserID: member.UserID, Role: member.Role, AddedBy: member.AddedBy}) +} + +func (h *MemberHandlers) deleteMember(c *gin.Context) { + if err := h.service.RemoveWorkspaceMember(c.Request.Context(), c.Param("id"), c.Param("userId")); err != nil { + h.respondError(c, err, "failed to remove workspace member") + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +type transferOwnershipRequest struct { + UserID string `json:"user_id"` +} + +func (h *MemberHandlers) transferOwnership(c *gin.Context) { + var body transferOwnershipRequest + if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"}) + return + } + if err := h.service.TransferWorkspaceOwnership(c.Request.Context(), c.Param("id"), body.UserID); err != nil { + h.respondError(c, err, "failed to transfer workspace ownership") + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func (h *MemberHandlers) listDirectory(c *gin.Context) { + users, err := h.service.ListDirectoryUsers(c.Request.Context()) + if err != nil { + h.respondError(c, err, "failed to list users") + return + } + c.JSON(http.StatusOK, gin.H{"users": users, "total": len(users)}) +} + +// listScopes exposes the registry so the UI labels roles from one source +// rather than keeping its own copy of what each role can do. +func (h *MemberHandlers) listScopes(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"scopes": authz.Registry()}) +} + +func (h *MemberHandlers) displayNames(c *gin.Context) map[string]string { + names := make(map[string]string) + users, err := h.service.ListDirectoryUsers(c.Request.Context()) + if err != nil { + return names + } + for _, user := range users { + names[user.ID] = user.DisplayName + } + return names +} + +// respondError maps membership failures to status codes. Validation failures +// carry their message through so the UI can say what went wrong; anything +// unrecognized stays generic. +func (h *MemberHandlers) respondError(c *gin.Context, err error, message string) { + switch { + case service.IsForbidden(err): + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + case isNotFound(err): + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + case isMemberValidationError(err): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + h.logger.Error(message, zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": message}) + } +} + +func isMemberValidationError(err error) bool { + for _, sentinel := range []error{ + service.ErrMemberUserNotFound, + service.ErrMemberUserDisabled, + service.ErrMemberIsOwner, + service.ErrMemberRoleInvalid, + service.ErrMemberSelf, + service.ErrTransferTargetNotMember, + } { + if errors.Is(err, sentinel) { + return true + } + } + return false +} diff --git a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go index e57e822867..a91bfdcd01 100644 --- a/apps/backend/internal/task/handlers/message_handlers_blocked_test.go +++ b/apps/backend/internal/task/handlers/message_handlers_blocked_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "github.com/kandev/kandev/internal/task/repository" "strings" "sync" "testing" @@ -29,6 +30,9 @@ import ( // coverage) to stay under the package's file-length limit. type messageAddSwitchRepo struct { + // Membership is not exercised by this fake; the embedded default + // reports no membership, which is the narrower answer. + repository.UnsupportedWorkspaceMembers mockRepository tasks map[string]*models.Task sessions map[string]*models.TaskSession diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 35105ce349..d03f2d04d8 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "github.com/kandev/kandev/internal/task/repository" "net/http" "net/http/httptest" "net/url" @@ -28,6 +29,9 @@ import ( ) type mockRepository struct { + // Membership is not exercised by this fake; the embedded default + // reports no membership, which is the narrower answer. + repository.UnsupportedWorkspaceMembers scriptsByRepo map[string][]*models.RepositoryScript sessions map[string]*models.TaskSession executors map[string]*models.Executor diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 4310b61ebc..4528d9a75d 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -1567,6 +1567,8 @@ type httpUpdateTaskRequest struct { Metadata map[string]interface{} `json:"metadata,omitempty"` // ParentID nests the task under another task. "" clears the parent. ParentID *string `json:"parent_id,omitempty"` + // AssigneeUserID sets the human assignee. "" unassigns. + AssigneeUserID *string `json:"assignee_user_id,omitempty"` } type httpUpdateTaskPortForwardingRequest struct { @@ -1641,14 +1643,15 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { } task, err := h.service.UpdateTask(c.Request.Context(), c.Param("id"), &service.UpdateTaskRequest{ - Title: title, - Description: description, - Priority: body.Priority, - State: body.State, - Repositories: convertUpdateRepositories(body.Repositories != nil, repos), - Position: body.Position, - Metadata: body.Metadata, - ParentID: body.ParentID, + Title: title, + Description: description, + Priority: body.Priority, + State: body.State, + Repositories: convertUpdateRepositories(body.Repositories != nil, repos), + Position: body.Position, + Metadata: body.Metadata, + ParentID: body.ParentID, + AssigneeUserID: body.AssigneeUserID, }) if err != nil { handleNotFound(c, h.logger, err, "task not updated") diff --git a/apps/backend/internal/task/handlers/task_ws_handlers.go b/apps/backend/internal/task/handlers/task_ws_handlers.go index 6d1caa35f3..826d71c6a2 100644 --- a/apps/backend/internal/task/handlers/task_ws_handlers.go +++ b/apps/backend/internal/task/handlers/task_ws_handlers.go @@ -293,6 +293,8 @@ type wsUpdateTaskRequest struct { Metadata map[string]interface{} `json:"metadata,omitempty"` // ParentID nests the task under another task. "" clears the parent. ParentID *string `json:"parent_id,omitempty"` + // AssigneeUserID sets the human assignee. "" unassigns. + AssigneeUserID *string `json:"assignee_user_id,omitempty"` } func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -342,14 +344,15 @@ func (h *TaskHandlers) wsUpdateTask(ctx context.Context, msg *ws.Message) (*ws.M } task, err := h.service.UpdateTask(ctx, req.ID, &service.UpdateTaskRequest{ - Title: title, - Description: description, - Priority: req.Priority, - State: req.State, - Repositories: convertUpdateRepositories(req.Repositories != nil, repos), - Position: req.Position, - Metadata: req.Metadata, - ParentID: req.ParentID, + Title: title, + Description: description, + Priority: req.Priority, + State: req.State, + Repositories: convertUpdateRepositories(req.Repositories != nil, repos), + Position: req.Position, + Metadata: req.Metadata, + ParentID: req.ParentID, + AssigneeUserID: req.AssigneeUserID, }) if err != nil { h.logger.Error("failed to update task", zap.Error(err)) diff --git a/apps/backend/internal/task/handlers/workspace_handlers.go b/apps/backend/internal/task/handlers/workspace_handlers.go index 1ee725cad0..c2429ab684 100644 --- a/apps/backend/internal/task/handlers/workspace_handlers.go +++ b/apps/backend/internal/task/handlers/workspace_handlers.go @@ -9,6 +9,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/service" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -54,9 +55,10 @@ func (h *WorkspaceHandlers) listWorkspaces(ctx context.Context) (dto.ListWorkspa if err != nil { return dto.ListWorkspacesResponse{}, err } + access := h.service.ProjectWorkspaceAccess(ctx, workspaces) dtos := make([]dto.WorkspaceDTO, 0, len(workspaces)) for _, w := range workspaces { - dtos = append(dtos, dto.FromWorkspace(w)) + dtos = append(dtos, dto.FromWorkspaceWithAccess(w, access.Decision(w.ID), access.MemberCounts[w.ID])) } return dto.ListWorkspacesResponse{Workspaces: dtos, Total: len(dtos)}, nil } @@ -116,7 +118,7 @@ func (h *WorkspaceHandlers) httpGetWorkspace(c *gin.Context) { handleNotFound(c, h.logger, err, "workspace not found") return } - c.JSON(http.StatusOK, dto.FromWorkspace(workspace)) + c.JSON(http.StatusOK, h.withAccess(c.Request.Context(), workspace)) } type httpUpdateWorkspaceRequest struct { @@ -126,6 +128,8 @@ type httpUpdateWorkspaceRequest struct { DefaultEnvironmentID *string `json:"default_environment_id,omitempty"` DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` DefaultConfigAgentProfileID *string `json:"default_config_agent_profile_id,omitempty"` + // UnitID moves the workspace to another unit, changing who reaches it. + UnitID *string `json:"unit_id,omitempty"` } func (h *WorkspaceHandlers) httpUpdateWorkspace(c *gin.Context) { @@ -141,6 +145,7 @@ func (h *WorkspaceHandlers) httpUpdateWorkspace(c *gin.Context) { DefaultEnvironmentID: body.DefaultEnvironmentID, DefaultAgentProfileID: body.DefaultAgentProfileID, DefaultConfigAgentProfileID: body.DefaultConfigAgentProfileID, + UnitID: body.UnitID, }) if err != nil { handleNotFound(c, h.logger, err, "workspace not updated") @@ -248,7 +253,7 @@ func (h *WorkspaceHandlers) wsGetWorkspace(ctx context.Context, msg *ws.Message) if err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Workspace not found", nil) } - return ws.NewResponse(msg.ID, msg.Action, dto.FromWorkspace(workspace)) + return ws.NewResponse(msg.ID, msg.Action, h.withAccess(ctx, workspace)) } type wsUpdateWorkspaceRequest struct { @@ -317,3 +322,10 @@ func (h *WorkspaceHandlers) wsDeleteWorkspaceError(msg *ws.Message, err error) ( return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "failed to delete workspace", nil) } } + +// withAccess projects one workspace together with the caller's resolved role +// and scopes, so a detail response gates the UI exactly like a list response. +func (h *WorkspaceHandlers) withAccess(ctx context.Context, workspace *models.Workspace) dto.WorkspaceDTO { + access := h.service.ProjectWorkspaceAccess(ctx, []*models.Workspace{workspace}) + return dto.FromWorkspaceWithAccess(workspace, access.Decision(workspace.ID), access.MemberCounts[workspace.ID]) +} diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 19b6a97557..1b1197ea18 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -905,10 +905,15 @@ type Task struct { // this field are routed to SetTaskRunner / ClearTaskRunner inside // the task repository. AssigneeAgentProfileID string `json:"assignee_agent_profile_id,omitempty"` - Origin string `json:"origin,omitempty"` // manual, agent_created, routine - ProjectID string `json:"project_id,omitempty"` // FK to office project - Labels string `json:"labels,omitempty"` // JSON array string, default "[]" - Identifier string `json:"identifier,omitempty"` // e.g. "KAN-42" + // AssigneeUserID is the HUMAN assignee, entirely independent of the agent + // assignee above: a task can carry both, and setting one never clears the + // other. It is advisory and gates nothing; taking a task over is a + // reassignment plus a prompt, not a lock. + AssigneeUserID string `json:"assignee_user_id,omitempty"` + Origin string `json:"origin,omitempty"` // manual, agent_created, routine + ProjectID string `json:"project_id,omitempty"` // FK to office project + Labels string `json:"labels,omitempty"` // JSON array string, default "[]" + Identifier string `json:"identifier,omitempty"` // e.g. "KAN-42" // ExternalID is a caller-supplied identity used for create-idempotency // (docs/specs/tasks/requirements/external-id-idempotency.md). Empty when the task @@ -1125,10 +1130,17 @@ func (w *Workspace) IsImproveKandev() bool { // Workspace represents a workspace type Workspace struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - OwnerID string `json:"owner_id"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + OwnerID string `json:"owner_id"` + // OrgID is the owning tenant. Empty means organizations are off or the + // tenancy migration has not run. + OrgID string `json:"org_id,omitempty"` + // UnitID places the workspace in the organization unit tree. Reach is + // resolved from this placement, so a workspace without one is reachable by + // nobody. + UnitID string `json:"unit_id,omitempty"` DefaultExecutorID *string `json:"default_executor_id,omitempty"` DefaultEnvironmentID *string `json:"default_environment_id,omitempty"` DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` diff --git a/apps/backend/internal/task/models/workspace_members.go b/apps/backend/internal/task/models/workspace_members.go new file mode 100644 index 0000000000..1242951fcb --- /dev/null +++ b/apps/backend/internal/task/models/workspace_members.go @@ -0,0 +1,18 @@ +package models + +import "time" + +// WorkspaceMember grants one user access to one workspace with a workspace +// role. It is the exception mechanism next to Workspace.Visibility: it +// populates a private workspace, admits a guest to a single workspace, and +// narrows a member to viewer on an org-visible one. An explicit row always +// outranks the org default, in both directions. +// +// Lives in its own file because models.go is already past the 800-line limit. +type WorkspaceMember struct { + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + UserID string `json:"user_id" db:"user_id"` + Role string `json:"role" db:"role"` + AddedBy string `json:"added_by" db:"added_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 4f5b18a0b0..39b0821f5b 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -32,6 +32,20 @@ type WorkspaceRepository interface { DeleteWorkspaceCascade(ctx context.Context, id string) ([]*models.Task, []*models.Workflow, error) DeleteWorkspaceCascadeWithName(ctx context.Context, id, name string) ([]*models.Task, []*models.Workflow, error) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) + + // Workspace membership. Membership is the exception path next to + // Workspace.Visibility: it populates a private workspace, admits a guest + // to one workspace, and narrows a member to viewer on an org-visible one. + ListWorkspaceMembers(ctx context.Context, workspaceID string) ([]*models.WorkspaceMember, error) + GetWorkspaceMember(ctx context.Context, workspaceID, userID string) (*models.WorkspaceMember, error) + // ListWorkspaceIDsForMember returns workspaceID -> role for one user in a + // single query, so a board render resolves access without an N+1. + ListWorkspaceIDsForMember(ctx context.Context, userID string) (map[string]string, error) + UpsertWorkspaceMember(ctx context.Context, member *models.WorkspaceMember) error + DeleteWorkspaceMember(ctx context.Context, workspaceID, userID string) error + DeleteWorkspaceMembersByWorkspace(ctx context.Context, workspaceID string) error + CountWorkspaceMembers(ctx context.Context) (map[string]int, error) + TransferWorkspaceOwnership(ctx context.Context, workspaceID, fromUserID, toUserID string) error } // TaskRepository handles task CRUD and workflow placement. diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index 8585e72e6f..a267947499 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -60,6 +60,9 @@ func (r *Repository) runMigrations() error { if err := r.ensureRepositorySetsSchema(); err != nil { return err } + if err := r.ensureTeamAccessSchema(); err != nil { + return err + } if err := r.ensureRepositoryBranchPoliciesSchema(); err != nil { return err } diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index ce69355877..6145845892 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -285,6 +285,7 @@ const infraSchemaDDL = ` name TEXT NOT NULL, description TEXT DEFAULT '', owner_id TEXT DEFAULT '', + org_id TEXT NOT NULL DEFAULT '', default_executor_id TEXT DEFAULT '', default_environment_id TEXT DEFAULT '', default_agent_profile_id TEXT DEFAULT '', @@ -293,6 +294,24 @@ const infraSchemaDDL = ` updated_at TIMESTAMP NOT NULL ); + CREATE TABLE IF NOT EXISTS workspace_members ( + workspace_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'collaborator', + added_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + PRIMARY KEY (workspace_id, user_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE + -- No foreign key to users: that table belongs to internal/user/store, + -- which initializes independently of this repository, so a reference + -- here fails schema init whenever the task repository is created + -- first. Account removal clears membership through + -- DeleteWorkspaceMembersByUser instead, matching how this codebase + -- handles every other cross-store side table. + ); + + CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id); + CREATE TABLE IF NOT EXISTS executors ( id TEXT PRIMARY KEY, name TEXT NOT NULL, diff --git a/apps/backend/internal/task/repository/sqlite/org_data.go b/apps/backend/internal/task/repository/sqlite/org_data.go new file mode 100644 index 0000000000..c3d3cee81c --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/org_data.go @@ -0,0 +1,55 @@ +package sqlite + +import ( + "context" +) + +// Tenancy data operations: the task-repository half of organization migration +// and lifecycle counts. + +// AssignWorkspacesWithoutOrg puts every workspace that has no organization +// into the given one. Idempotent: a second run moves nothing. +func (r *Repository) AssignWorkspacesWithoutOrg(ctx context.Context, orgID string) (int64, error) { + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE workspaces SET org_id = ? WHERE org_id = '' OR org_id IS NULL + `), orgID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +// DropCrossOrgWorkspaceMembers removes membership rows whose user and +// workspace belong to different organizations. +// +// Such a row cannot grant anything (the resolver checks the tenant boundary +// before membership), but leaving it would show a colleague from another +// organization in a member list, which reads as a leak even though it is not +// one. Rows whose user or workspace has no org yet are left alone: they are +// mid-migration, not cross-tenant. +func (r *Repository) DropCrossOrgWorkspaceMembers(ctx context.Context) (int64, error) { + result, err := r.db.ExecContext(ctx, ` + DELETE FROM workspace_members + WHERE EXISTS ( + SELECT 1 FROM workspaces w, users u + WHERE w.id = workspace_members.workspace_id + AND u.id = workspace_members.user_id + AND w.org_id <> '' + AND u.org_id <> '' + AND w.org_id <> u.org_id + ) + `) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +// CountWorkspacesByOrg reports how many workspaces an organization owns. Used +// by the delete confirmation so an operator sees what they are removing. +func (r *Repository) CountWorkspacesByOrg(ctx context.Context, orgID string) (int, error) { + var n int + err := r.ro.QueryRowContext(ctx, r.ro.Rebind( + `SELECT COUNT(*) FROM workspaces WHERE org_id = ?`), orgID).Scan(&n) + return n, err +} diff --git a/apps/backend/internal/task/repository/sqlite/org_unit_placement.go b/apps/backend/internal/task/repository/sqlite/org_unit_placement.go new file mode 100644 index 0000000000..018cc7ffcb --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/org_unit_placement.go @@ -0,0 +1,48 @@ +package sqlite + +import "context" + +// CountWorkspacesInUnit reports how many workspaces a unit holds, which is what +// stops internal/orgunit from deleting a unit out from under them. +func (r *Repository) CountWorkspacesInUnit(ctx context.Context, unitID string) (int, error) { + var count int + err := r.ro.QueryRowxContext(ctx, r.ro.Rebind( + `SELECT COUNT(*) FROM workspaces WHERE unit_id = ?`), unitID).Scan(&count) + return count, err +} + +// UnplacedWorkspace is a workspace that predates the unit tree. +type UnplacedWorkspace struct { + ID string + OrgID string + OwnerID string +} + +// UnplacedWorkspaces returns every workspace with no unit, which is the input +// to the one-shot placement backfill. +func (r *Repository) UnplacedWorkspaces(ctx context.Context) ([]UnplacedWorkspace, error) { + rows, err := r.ro.QueryxContext(ctx, r.ro.Rebind(` + SELECT id, COALESCE(org_id, ''), COALESCE(owner_id, '') + FROM workspaces + WHERE COALESCE(unit_id, '') = ''`)) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []UnplacedWorkspace + for rows.Next() { + var w UnplacedWorkspace + if err := rows.Scan(&w.ID, &w.OrgID, &w.OwnerID); err != nil { + return nil, err + } + out = append(out, w) + } + return out, rows.Err() +} + +// PlaceWorkspace records a workspace's unit. +func (r *Repository) PlaceWorkspace(ctx context.Context, workspaceID, unitID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind( + `UPDATE workspaces SET unit_id = ? WHERE id = ?`), unitID, workspaceID) + return err +} diff --git a/apps/backend/internal/task/repository/sqlite/org_unit_placement_test.go b/apps/backend/internal/task/repository/sqlite/org_unit_placement_test.go new file mode 100644 index 0000000000..d665710fef --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/org_unit_placement_test.go @@ -0,0 +1,106 @@ +package sqlite + +import ( + "context" + "path/filepath" + "testing" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db" +) + +func newPlacementRepo(t *testing.T) (*Repository, *sqlx.DB) { + t.Helper() + conn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "placement.db")) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(conn, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) + repo, err := NewWithDB(sqlxDB, sqlxDB, nil) + if err != nil { + t.Fatalf("new repository: %v", err) + } + return repo, sqlxDB +} + +// The placement column is what ties a workspace to the unit tree. Losing it +// leaves every workspace unplaced, which resolves as unreachable rather than +// as an error, so the symptom is silence. +func TestWorkspaceUnitColumnExistsAfterInit(t *testing.T) { + _, sqlxDB := newPlacementRepo(t) + + var count int + if err := sqlxDB.Get(&count, ` + SELECT COUNT(*) FROM pragma_table_info('workspaces') WHERE name = 'unit_id' + `); err != nil { + t.Fatalf("inspect workspaces schema: %v", err) + } + if count != 1 { + t.Fatal("workspaces.unit_id missing after schema init") + } +} + +// The occupancy count is the only thing standing between a delete and a +// stranded workspace, so it has to count the unit asked about and nothing else. +func TestCountWorkspacesInUnit(t *testing.T) { + ctx := context.Background() + repo, sqlxDB := newPlacementRepo(t) + + for _, row := range []struct{ id, unit string }{ + {"ws-1", "unit-a"}, + {"ws-2", "unit-a"}, + {"ws-3", "unit-b"}, + {"ws-4", ""}, + } { + if _, err := sqlxDB.Exec( + `INSERT INTO workspaces (id, name, owner_id, unit_id, created_at, updated_at) + VALUES (?, ?, '', ?, datetime('now'), datetime('now'))`, + row.id, row.id, row.unit); err != nil { + t.Fatalf("insert %s: %v", row.id, err) + } + } + + for unit, want := range map[string]int{"unit-a": 2, "unit-b": 1, "unit-c": 0} { + got, err := repo.CountWorkspacesInUnit(ctx, unit) + if err != nil { + t.Fatalf("count %s: %v", unit, err) + } + if got != want { + t.Fatalf("count(%s) = %d, want %d", unit, got, want) + } + } +} + +// A move that returns the new unit but writes nothing looks like it worked and +// changes who reaches the workspace not at all. The update statement is +// hand-written, so a column added to the model is not automatically persisted. +func TestUpdateWorkspacePersistsUnitPlacement(t *testing.T) { + ctx := context.Background() + repo, sqlxDB := newPlacementRepo(t) + + if _, err := sqlxDB.Exec( + `INSERT INTO workspaces (id, name, owner_id, unit_id, created_at, updated_at) + VALUES ('ws-move', 'Board', 'ada', 'unit-personal', datetime('now'), datetime('now'))`, + ); err != nil { + t.Fatalf("seed workspace: %v", err) + } + + workspace, err := repo.GetWorkspace(ctx, "ws-move") + if err != nil { + t.Fatalf("get workspace: %v", err) + } + workspace.UnitID = "unit-team" + if err := repo.UpdateWorkspace(ctx, workspace); err != nil { + t.Fatalf("update workspace: %v", err) + } + + reloaded, err := repo.GetWorkspace(ctx, "ws-move") + if err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.UnitID != "unit-team" { + t.Fatalf("unit after move = %q, want unit-team: the move did not persist", reloaded.UnitID) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index cad5c1027d..a9cbae587e 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -71,6 +71,9 @@ var taskScanColumns = []taskScanColumn{ {name: "assignee_agent_profile_id", selectExpr: func(alias string) string { return runnerProjection(alias) + ` AS assignee_agent_profile_id` }}, + // The HUMAN assignee, independent of the agent one above. A task can have + // both: an Office agent doing the work and a person accountable for it. + {name: "assignee_user_id"}, {name: "origin"}, {name: "project_id"}, {name: "labels"}, @@ -356,9 +359,9 @@ func (r *Repository) insertTaskTx(ctx context.Context, tx *sql.Tx, task *models. externalID = task.ExternalID } _, err = tx.ExecContext(ctx, r.db.Rebind(` - INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, wip_admitted, queued_for_step_id, queued_at, metadata, is_ephemeral, parent_id, autopilot_enabled, created_at, updated_at, origin, project_id, labels, identifier, external_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), dialect.BoolToInt(task.IsEphemeral), task.ParentID, dialect.BoolToInt(task.Autopilot), task.CreatedAt, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, externalID) + INSERT INTO tasks (id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, wip_admitted, queued_for_step_id, queued_at, metadata, is_ephemeral, parent_id, autopilot_enabled, created_at, updated_at, origin, project_id, labels, identifier, external_id, assignee_user_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), task.ID, task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), dialect.BoolToInt(task.IsEphemeral), task.ParentID, dialect.BoolToInt(task.Autopilot), task.CreatedAt, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, externalID, task.AssigneeUserID) if err != nil { if isExternalIDUniqueViolation(err) { return "", fmt.Errorf("%w: %w", ErrExternalIDConflict, err) @@ -632,7 +635,7 @@ func (r *Repository) updateTaskTx(ctx context.Context, tx *sql.Tx, task *models. task.UpdatedAt = r.nowUTC() updateQuery := ` - UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = ?, parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ? + UPDATE tasks SET workspace_id = ?, workflow_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = ?, parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ?, assignee_user_id = ? WHERE id = ? ` if models.IsAgentTitlePending(task.Metadata) { @@ -644,11 +647,11 @@ func (r *Repository) updateTaskTx(ctx context.Context, tx *sql.Tx, task *models. description = ?, state = ?, priority = ?, position = ?, wip_admitted = ?, queued_for_step_id = ?, queued_at = ?, metadata = %s, - parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ? + parent_id = ?, updated_at = ?, origin = ?, project_id = ?, labels = ?, identifier = ?, assignee_user_id = ? WHERE id = ? `, pending, metadataMerge) } - result, err := tx.ExecContext(ctx, r.db.Rebind(updateQuery), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.ID) + result, err := tx.ExecContext(ctx, r.db.Rebind(updateQuery), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.AssigneeUserID, task.ID) if err != nil { return "", err } @@ -2399,7 +2402,7 @@ func (r *Repository) scanSingleTask(row *sql.Row) (*models.Task, error) { &task.WIPAdmitted, &task.QueuedForStepID, &queuedAt, &metadata, &task.IsEphemeral, &task.ParentID, &task.Autopilot, &archivedAt, &task.ArchivedByCascadeID, &task.CreatedAt, &task.UpdatedAt, - &task.AssigneeAgentProfileID, &task.Origin, &task.ProjectID, + &task.AssigneeAgentProfileID, &task.AssigneeUserID, &task.Origin, &task.ProjectID, &task.Labels, &identifier, &externalID, &externalIDSettledAt, &task.IsFromOffice, ) if err != nil { @@ -2441,7 +2444,7 @@ func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { &task.WIPAdmitted, &task.QueuedForStepID, &queuedAt, &metadata, &task.IsEphemeral, &task.ParentID, &task.Autopilot, &archivedAt, &task.ArchivedByCascadeID, &task.CreatedAt, &task.UpdatedAt, - &task.AssigneeAgentProfileID, &task.Origin, &task.ProjectID, + &task.AssigneeAgentProfileID, &task.AssigneeUserID, &task.Origin, &task.ProjectID, &task.Labels, &identifier, &externalID, &externalIDSettledAt, &task.IsFromOffice, ) if err != nil { diff --git a/apps/backend/internal/task/repository/sqlite/team_access_migration.go b/apps/backend/internal/task/repository/sqlite/team_access_migration.go new file mode 100644 index 0000000000..946466c45d --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/team_access_migration.go @@ -0,0 +1,83 @@ +package sqlite + +// Team-access schema evolution: workspace org and unit placement, workspace +// membership, and the human task assignee. +// +// The governing rule for this migration is that an upgrade must never widen +// access. Every pre-existing workspace lands on 'private', which reproduces +// today's owner-only behavior exactly; opening a board to the organization is +// an explicit act by its owner afterwards. +func (r *Repository) ensureTeamAccessSchema() error { + // New columns on existing tables arrive only through idempotent ADD COLUMN + // (ADR 0027): CREATE TABLE IF NOT EXISTS is a no-op on an existing DB, so + // listing them in the DDL alone would never reach an upgraded install. + // Organizations. Empty means "not yet assigned"; the tenancy migration puts + // every existing workspace into the default org on first boot with the + // feature on. + r.migrate.Apply( + "workspaces.org_id", + `ALTER TABLE workspaces ADD COLUMN org_id TEXT NOT NULL DEFAULT ''`, + ) + r.migrate.Apply( + "workspaces.org_idx", + `CREATE INDEX IF NOT EXISTS idx_workspaces_org ON workspaces(org_id)`, + ) + // The human assignee (docs/specs/tasks/requirements/human-assignee.md). Note that + // internal/office's priority-to-TEXT migration recreates the tasks table + // from a hardcoded column list on every database this repository creates, + // so this column is ALSO listed there; adding it here alone leaves it + // dropped by the time any task is written. + r.migrate.Apply( + "tasks.assignee_user_id", + `ALTER TABLE tasks ADD COLUMN assignee_user_id TEXT NOT NULL DEFAULT ''`, + ) + + r.ensureOrgUnitPlacement() + + // workspace_members is created by the infra DDL on both fresh and existing + // databases, so only the owner backfill needs to run here. + return r.backfillWorkspaceOwnerMembers() +} + +// backfillWorkspaceOwnerMembers writes the mirroring owner row for every +// workspace that has an owner. Workspaces created before authentication was +// enabled carry an empty owner_id and deliberately get no row: they stay +// visible to everyone until the setup wizard claims them, and inventing an +// owner here would lock the single user out of their own data. +func (r *Repository) backfillWorkspaceOwnerMembers() error { + _, err := r.db.Exec(` + INSERT INTO workspace_members (workspace_id, user_id, role, added_by, created_at) + SELECT w.id, w.owner_id, 'owner', '', w.created_at + FROM workspaces w + WHERE w.owner_id IS NOT NULL + AND w.owner_id <> '' + AND NOT EXISTS ( + SELECT 1 FROM workspace_members m + WHERE m.workspace_id = w.id AND m.user_id = w.owner_id + ) + `) + return err +} + +// ensureOrgUnitPlacement adds the column that places a workspace in the +// organization unit tree. +// +// It lives here rather than in internal/orgunit because the task repository +// owns the workspaces table. +// +// No migration currently recreates `workspaces`. If one is ever added, this +// column has to be in its replacement CREATE TABLE and its INSERT ... SELECT +// list: a rebuild copies a fixed column list, and MigrateLogger.Apply swallows +// errors, so a column added before a rebuild disappears without a sound. That +// is recorded in apps/backend/AGENTS.md and has already cost this branch a +// silent loss once, on `tasks`. +func (r *Repository) ensureOrgUnitPlacement() { + r.migrate.Apply( + "workspaces.unit_id", + `ALTER TABLE workspaces ADD COLUMN unit_id TEXT NOT NULL DEFAULT ''`, + ) + r.migrate.Apply( + "workspaces.unit_idx", + `CREATE INDEX IF NOT EXISTS idx_workspaces_unit ON workspaces(unit_id)`, + ) +} diff --git a/apps/backend/internal/task/repository/sqlite/workspace.go b/apps/backend/internal/task/repository/sqlite/workspace.go index a7b25ece98..538732d2e9 100644 --- a/apps/backend/internal/task/repository/sqlite/workspace.go +++ b/apps/backend/internal/task/repository/sqlite/workspace.go @@ -40,6 +40,8 @@ func (r *Repository) insertWorkspace(ctx context.Context, exec sqlx.ExtContext, name, description, owner_id, + org_id, + unit_id, default_executor_id, default_environment_id, default_agent_profile_id, @@ -50,8 +52,8 @@ func (r *Repository) insertWorkspace(ctx context.Context, exec sqlx.ExtContext, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `), workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.DefaultConfigAgentProfileID, workspace.TaskPrefix, workspace.TaskSequence, workspace.OfficeWorkflowID, workspace.CreatedAt, workspace.UpdatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.OrgID, workspace.UnitID, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.DefaultConfigAgentProfileID, workspace.TaskPrefix, workspace.TaskSequence, workspace.OfficeWorkflowID, workspace.CreatedAt, workspace.UpdatedAt) return err } @@ -65,13 +67,15 @@ func (r *Repository) GetWorkspace(ctx context.Context, id string) (*models.Works var defaultConfigAgentProfileID sql.NullString err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, name, description, owner_id, default_executor_id, default_environment_id, default_agent_profile_id, default_config_agent_profile_id, task_prefix, task_sequence, office_workflow_id, created_at, updated_at + SELECT id, name, description, owner_id, org_id, unit_id, default_executor_id, default_environment_id, default_agent_profile_id, default_config_agent_profile_id, task_prefix, task_sequence, office_workflow_id, created_at, updated_at FROM workspaces WHERE id = ? `), id).Scan( &workspace.ID, &workspace.Name, &workspace.Description, &workspace.OwnerID, + &workspace.OrgID, + &workspace.UnitID, &defaultExecutorID, &defaultEnvironmentID, &defaultAgentProfileID, @@ -109,13 +113,14 @@ func (r *Repository) UpdateWorkspace(ctx context.Context, workspace *models.Work UPDATE workspaces SET name = ?, description = ?, + unit_id = ?, default_executor_id = ?, default_environment_id = ?, default_agent_profile_id = ?, default_config_agent_profile_id = ?, updated_at = ? WHERE id = ? - `), workspace.Name, workspace.Description, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.DefaultConfigAgentProfileID, workspace.UpdatedAt, workspace.ID) + `), workspace.Name, workspace.Description, workspace.UnitID, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.DefaultConfigAgentProfileID, workspace.UpdatedAt, workspace.ID) if err != nil { return err } @@ -408,7 +413,7 @@ func (r *Repository) ClaimUnownedWorkspaces(ctx context.Context, ownerID string) func (r *Repository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) { rows, err := r.ro.QueryContext(ctx, ` - SELECT id, name, description, owner_id, default_executor_id, default_environment_id, default_agent_profile_id, default_config_agent_profile_id, task_prefix, task_sequence, office_workflow_id, created_at, updated_at + SELECT id, name, description, owner_id, org_id, unit_id, default_executor_id, default_environment_id, default_agent_profile_id, default_config_agent_profile_id, task_prefix, task_sequence, office_workflow_id, created_at, updated_at FROM workspaces ORDER BY created_at DESC `) if err != nil { @@ -428,6 +433,8 @@ func (r *Repository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, e &workspace.Name, &workspace.Description, &workspace.OwnerID, + &workspace.OrgID, + &workspace.UnitID, &defaultExecutorID, &defaultEnvironmentID, &defaultAgentProfileID, diff --git a/apps/backend/internal/task/repository/sqlite/workspace_members.go b/apps/backend/internal/task/repository/sqlite/workspace_members.go new file mode 100644 index 0000000000..5c81b43217 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/workspace_members.go @@ -0,0 +1,216 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/task/models" +) + +// ErrOwnershipChanged reports that the workspace owner changed between +// authorization and the write, so the transfer was not applied. +var ErrOwnershipChanged = errors.New("workspace ownership changed; retry the transfer") + +const workspaceMemberColumns = `workspace_id, user_id, role, added_by, created_at` + +// ListWorkspaceMembers returns every membership row for a workspace, owner +// first and then by creation order, so the UI has a stable list. +func (r *Repository) ListWorkspaceMembers(ctx context.Context, workspaceID string) ([]*models.WorkspaceMember, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT `+workspaceMemberColumns+` + FROM workspace_members + WHERE workspace_id = ? + ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END, created_at, user_id + `), workspaceID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + members := make([]*models.WorkspaceMember, 0) + for rows.Next() { + member := &models.WorkspaceMember{} + if err := rows.Scan(&member.WorkspaceID, &member.UserID, &member.Role, &member.AddedBy, &member.CreatedAt); err != nil { + return nil, err + } + members = append(members, member) + } + return members, rows.Err() +} + +// GetWorkspaceMember returns one membership row, or nil when the user holds +// none. A missing row is not an error: it is the common case. +func (r *Repository) GetWorkspaceMember(ctx context.Context, workspaceID, userID string) (*models.WorkspaceMember, error) { + member := &models.WorkspaceMember{} + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT `+workspaceMemberColumns+` + FROM workspace_members WHERE workspace_id = ? AND user_id = ? + `), workspaceID, userID).Scan( + &member.WorkspaceID, &member.UserID, &member.Role, &member.AddedBy, &member.CreatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return member, nil +} + +// ListWorkspaceIDsForMember returns the workspaces a user holds a row on. It +// backs the per-request member set: one query per request instead of one per +// workspace on a board render. +func (r *Repository) ListWorkspaceIDsForMember(ctx context.Context, userID string) (map[string]string, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT workspace_id, role FROM workspace_members WHERE user_id = ? + `), userID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + roles := make(map[string]string) + for rows.Next() { + var workspaceID, role string + if err := rows.Scan(&workspaceID, &role); err != nil { + return nil, err + } + roles[workspaceID] = role + } + return roles, rows.Err() +} + +// UpsertWorkspaceMember adds a member or changes an existing member's role. +func (r *Repository) UpsertWorkspaceMember(ctx context.Context, member *models.WorkspaceMember) error { + if member.CreatedAt.IsZero() { + member.CreatedAt = time.Now().UTC() + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO workspace_members (`+workspaceMemberColumns+`) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role + `), member.WorkspaceID, member.UserID, member.Role, member.AddedBy, member.CreatedAt) + return err +} + +// DeleteWorkspaceMember removes one membership row. Removing a row that does +// not exist is not an error. +func (r *Repository) DeleteWorkspaceMember(ctx context.Context, workspaceID, userID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ? + `), workspaceID, userID) + return err +} + +// DeleteWorkspaceMembersByWorkspace clears a workspace's membership. The +// foreign key cascades on workspace delete, but workspace-scoped side tables +// are also cleared explicitly from the deleted handler so the behavior does +// not depend on the dialect honoring the cascade. +func (r *Repository) DeleteWorkspaceMembersByWorkspace(ctx context.Context, workspaceID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM workspace_members WHERE workspace_id = ? + `), workspaceID) + return err +} + +// CountWorkspaceMembers returns membership counts for the given workspaces in +// one query, keyed by workspace ID. Used to populate list DTOs without an +// N+1 on the workspace list. +func (r *Repository) CountWorkspaceMembers(ctx context.Context) (map[string]int, error) { + rows, err := r.ro.QueryContext(ctx, ` + SELECT workspace_id, COUNT(*) FROM workspace_members GROUP BY workspace_id + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + counts := make(map[string]int) + for rows.Next() { + var workspaceID string + var count int + if err := rows.Scan(&workspaceID, &count); err != nil { + return nil, err + } + counts[workspaceID] = count + } + return counts, rows.Err() +} + +// TransferWorkspaceOwnership moves the accountable owner in one transaction: +// workspaces.owner_id and the mirroring owner row must never disagree. +func (r *Repository) TransferWorkspaceOwnership(ctx context.Context, workspaceID, fromUserID, toUserID string) error { + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + now := time.Now().UTC() + // Conditional on the owner we were authorized against. Two transfers + // authorized while A owned the workspace can arrive sequentially; without + // this the second would move ownership off B while demoting the stale A, + // leaving B holding an owner row that owner_id no longer names. + result, err := tx.ExecContext(ctx, r.db.Rebind(` + UPDATE workspaces SET owner_id = ?, updated_at = ? WHERE id = ? AND owner_id = ? + `), toUserID, now, workspaceID, fromUserID) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrOwnershipChanged + } + // Both rows are upserted rather than updated. An UPDATE silently affects + // nothing when the row is absent, which would leave owner_id naming a user + // with no owner row -- exactly the disagreement this table exists to + // prevent. Absent rows are reachable through pre-auth data and direct + // repository writes, so the write has to be total. + if err := upsertMemberTx(ctx, tx, r.db.Rebind, workspaceID, toUserID, "owner", now); err != nil { + return err + } + if fromUserID != "" && fromUserID != toUserID { + if err := upsertMemberTx(ctx, tx, r.db.Rebind, workspaceID, fromUserID, "collaborator", now); err != nil { + return err + } + } + return tx.Commit() +} + +func upsertMemberTx( + ctx context.Context, + tx *sqlx.Tx, + rebind func(string) string, + workspaceID, userID, role string, + now time.Time, +) error { + _, err := tx.ExecContext(ctx, rebind(` + INSERT INTO workspace_members (workspace_id, user_id, role, added_by, created_at) + VALUES (?, ?, ?, '', ?) + ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role + `), workspaceID, userID, role, now) + return err +} + +// DeleteWorkspaceMembersByUser removes every membership an account holds. +// +// This is the cross-store cleanup that stands in for a foreign key: the users +// table is owned by internal/user/store and initializes independently of this +// repository, so a real FK here would break schema init depending on which +// store runs first. +func (r *Repository) DeleteWorkspaceMembersByUser(ctx context.Context, userID string) error { + if userID == "" { + return nil + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM workspace_members WHERE user_id = ? + `), userID) + return err +} diff --git a/apps/backend/internal/task/repository/workspace_members_unsupported.go b/apps/backend/internal/task/repository/workspace_members_unsupported.go new file mode 100644 index 0000000000..73326d11b3 --- /dev/null +++ b/apps/backend/internal/task/repository/workspace_members_unsupported.go @@ -0,0 +1,47 @@ +package repository + +import ( + "context" + + "github.com/kandev/kandev/internal/task/models" +) + +// UnsupportedWorkspaceMembers supplies membership no-ops for fakes that only +// exercise other parts of WorkspaceRepository. +// +// Every method reports "no membership", which resolves to owner-and-visibility +// access — the narrower answer. A fake that embeds this can never accidentally +// widen access in a test and make a scoping regression look green. +type UnsupportedWorkspaceMembers struct{} + +func (UnsupportedWorkspaceMembers) ListWorkspaceMembers(context.Context, string) ([]*models.WorkspaceMember, error) { + return nil, nil +} + +func (UnsupportedWorkspaceMembers) GetWorkspaceMember(context.Context, string, string) (*models.WorkspaceMember, error) { + return nil, nil +} + +func (UnsupportedWorkspaceMembers) ListWorkspaceIDsForMember(context.Context, string) (map[string]string, error) { + return map[string]string{}, nil +} + +func (UnsupportedWorkspaceMembers) UpsertWorkspaceMember(context.Context, *models.WorkspaceMember) error { + return nil +} + +func (UnsupportedWorkspaceMembers) DeleteWorkspaceMember(context.Context, string, string) error { + return nil +} + +func (UnsupportedWorkspaceMembers) DeleteWorkspaceMembersByWorkspace(context.Context, string) error { + return nil +} + +func (UnsupportedWorkspaceMembers) CountWorkspaceMembers(context.Context) (map[string]int, error) { + return map[string]int{}, nil +} + +func (UnsupportedWorkspaceMembers) TransferWorkspaceOwnership(context.Context, string, string, string) error { + return nil +} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 26c5178ea1..196c2f02e9 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -319,6 +319,10 @@ type Repos struct { // Service provides task business logic type Service struct { workspaces repository.WorkspaceRepository + userDirectory UserDirectory + unitPlacer UnitPlacer + unitReach UnitReachResolver + userOrgs func(ctx context.Context, userID string) (string, error) tasks repository.TaskRepository taskRepos repository.TaskRepoRepository workspaceFolders repository.TaskWorkspaceFolderRepository diff --git a/apps/backend/internal/task/service/service_access.go b/apps/backend/internal/task/service/service_access.go index f0a22d9380..037b4a4b71 100644 --- a/apps/backend/internal/task/service/service_access.go +++ b/apps/backend/internal/task/service/service_access.go @@ -5,26 +5,52 @@ import ( "errors" "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository/repoerrors" ) -// Per-user workspace scoping (opt-in authentication). +// Workspace access scoping. +// +// Reach and permission are two questions, answered in that order: +// +// - Reach ("can this caller see the workspace at all") comes from the owner, +// an explicit workspace_members row, or membership of a unit above the +// one the workspace sits in. A +// caller who cannot reach a workspace gets the *NotFound sentinels — a +// foreign workspace is indistinguishable from a nonexistent one. +// - Permission ("may they do this particular thing") comes from +// authz.ResolveWorkspace, the single resolver. A caller who can read but +// lacks the action's scope gets ErrForbidden: existence is already known +// to them, so 403 leaks nothing and 404 would only confuse. // -// Every user-facing service entry point calls one of the authorize* helpers. // Scoping keys off the request-context identity placed there by the auth // middleware: // - no identity in ctx → internal caller (event bus, pollers, office // schedulers) → unscoped, exactly as before the auth feature. In-session -// agent MCP calls used to land here too; they now arrive with the task -// owner's identity attached by internal/mcp/scope. +// agent MCP calls arrive with the task owner's identity attached by +// internal/mcp/scope. // - synthetic identity → auth disabled → unscoped (today's behavior) -// - real identity → workspaces are visible only to their owner; -// rows with an empty owner_id (created pre-auth) stay visible to everyone -// until the setup wizard claims them for the admin -// -// Denials use the *NotFound sentinels — a foreign workspace is -// indistinguishable from a nonexistent one (no existence leak). +// - real identity → resolved through authz + +// tenancyEnforced mirrors features.multiTenancy. It is a package-level value +// set once at startup rather than threaded through every call: it is a +// process-wide configuration fact, and plumbing it through would touch every +// authorize* signature for no gain. +var tenancyEnforced bool + +// SetTenancyEnforced records whether organizations are on. +func SetTenancyEnforced(enforced bool) { tenancyEnforced = enforced } + +// callerOrgID returns the requesting identity's organization, empty for +// internal/synthetic callers and when organizations are off. +func callerOrgID(ctx context.Context) string { + identity, ok := authn.IdentityFromContext(ctx) + if !ok || identity.Synthetic { + return "" + } + return identity.OrgID +} // callerScope returns the scoping user ID; ok=false means unscoped. func callerScope(ctx context.Context) (string, bool) { @@ -35,30 +61,128 @@ func callerScope(ctx context.Context) (string, bool) { return identity.UserID, true } -func workspaceVisibleTo(workspace *models.Workspace, userID string) bool { - return workspace == nil || workspace.OwnerID == "" || workspace.OwnerID == userID +// callerSubject builds the authz subject for the request identity. +func callerSubject(ctx context.Context) authz.Subject { + identity, ok := authn.IdentityFromContext(ctx) + if !ok || identity.Synthetic { + return authz.Subject{Unscoped: true} + } + return authz.Subject{ + UserID: identity.UserID, + OrgID: identity.OrgID, + OrgRole: authz.NormalizeOrgRole(string(identity.Role)), + TenancyEnforced: tenancyEnforced, + } } -// authorizeWorkspaceID checks visibility of a workspace by ID. -func (s *Service) authorizeWorkspaceID(ctx context.Context, workspaceID string) error { - userID, scoped := callerScope(ctx) - if !scoped || workspaceID == "" { - return nil +// workspaceDecision resolves what the caller may do in a workspace. It reads +// the caller's membership row (a single primary-key lookup) and hands both to +// the resolver. +// +// Any failure reading membership returns Denied rather than continuing with no +// row: continuing would silently promote the caller to the org default role, +// which is exactly how a transient database error becomes a privilege +// escalation. +func (s *Service) workspaceDecision(ctx context.Context, workspace *models.Workspace) authz.Decision { + subject := callerSubject(ctx) + if subject.Unscoped { + return authz.ResolveWorkspace(subject, authz.WorkspaceRef{}) + } + if workspace == nil { + return authz.Denied() + } + + ref := authz.WorkspaceRef{ + OwnerID: workspace.OwnerID, + OrgID: workspace.OrgID, + } + // The tenant boundary is absolute and is checked before owner or + // membership, so a foreign-org workspace is resolved without touching the + // member table at all. + if subject.OrgID != "" && workspace.OrgID != "" && subject.OrgID != workspace.OrgID { + return authz.Denied() + } + // Owner and unowned workspaces resolve without touching the tree. + if workspace.OwnerID == "" || workspace.OwnerID == subject.UserID { + return authz.ResolveWorkspace(subject, ref) + } + + inherited, ok := s.inheritedRole(ctx, subject.UserID, workspace) + if !ok { + return authz.Denied() + } + ref.InheritedRole = inherited + + member, err := s.workspaces.GetWorkspaceMember(ctx, workspace.ID, subject.UserID) + if err != nil { + s.logger.Warn("workspace membership lookup failed; denying access") + return authz.Denied() + } + if member != nil { + ref.MemberRole = authz.NormalizeWorkspaceRole(member.Role) + } + return authz.ResolveWorkspace(subject, ref) +} + +// WorkspaceDecisionByID is the public resolver used by handlers that need the +// caller's scopes for a workspace (DTO projection, scope gating). +func (s *Service) WorkspaceDecisionByID(ctx context.Context, workspaceID string) (authz.Decision, error) { + if workspaceID == "" { + return authz.ResolveWorkspace(callerSubject(ctx), authz.WorkspaceRef{}), nil } workspace, err := s.workspaces.GetWorkspace(ctx, workspaceID) if err != nil { - return err + return authz.Denied(), err } - if !workspaceVisibleTo(workspace, userID) { - return repoerrors.ErrWorkspaceNotFound - } - return nil + return s.workspaceDecision(ctx, workspace), nil } -// authorizeTaskID checks visibility of a task via its workspace. +// authorizeWorkspaceID checks reach of a workspace by ID. +func (s *Service) authorizeWorkspaceID(ctx context.Context, workspaceID string) error { + _, err := s.requireWorkspaceScope(ctx, workspaceID, authz.ScopeWorkspaceRead, repoerrors.ErrWorkspaceNotFound) + return err +} + +// AuthorizeWorkspaceScope enforces one scope on a workspace. Callers that only +// need reach use authorizeWorkspaceID. +func (s *Service) AuthorizeWorkspaceScope(ctx context.Context, workspaceID string, scope authz.Scope) error { + _, err := s.requireWorkspaceScope(ctx, workspaceID, scope, repoerrors.ErrWorkspaceNotFound) + return err +} + +// requireWorkspaceScope resolves the decision and applies the 404-vs-403 rule. +func (s *Service) requireWorkspaceScope( + ctx context.Context, workspaceID string, scope authz.Scope, notFound error, +) (authz.Decision, error) { + if _, scoped := callerScope(ctx); !scoped || workspaceID == "" { + return authz.ResolveWorkspace(authz.Subject{Unscoped: true}, authz.WorkspaceRef{}), nil + } + workspace, err := s.workspaces.GetWorkspace(ctx, workspaceID) + if err != nil { + return authz.Denied(), err + } + decision := s.workspaceDecision(ctx, workspace) + if !decision.CanRead() { + return decision, notFound + } + if !decision.Has(scope) { + return decision, ErrForbidden + } + return decision, nil +} + +// authorizeTaskID checks reach of a task via its workspace. func (s *Service) authorizeTaskID(ctx context.Context, taskID string) error { - userID, scoped := callerScope(ctx) - if !scoped { + return s.authorizeTaskScope(ctx, taskID, authz.ScopeWorkspaceRead) +} + +// AuthorizeTaskScope enforces one scope on the workspace owning a task. +func (s *Service) AuthorizeTaskScope(ctx context.Context, taskID string, scope authz.Scope) error { + return s.authorizeTaskScope(ctx, taskID, scope) +} + +func (s *Service) authorizeTaskScope(ctx context.Context, taskID string, scope authz.Scope) error { + if _, scoped := callerScope(ctx); !scoped { return nil } task, err := s.tasks.GetTask(ctx, taskID) @@ -70,20 +194,28 @@ func (s *Service) authorizeTaskID(ctx context.Context, taskID string) error { } workspace, err := s.workspaces.GetWorkspace(ctx, task.WorkspaceID) if err != nil { - // A dangling workspace reference should not hide the task from the - // single user who can already see everything else about it. - return nil //nolint:nilerr // visibility fallback, not an operation failure + // A dangling workspace reference (the row is genuinely gone) should + // not hide the task from the single user who can already see + // everything else about it. Any OTHER lookup failure fails closed: a + // transient database error must not read as "granted". + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + return nil + } + return err } - if !workspaceVisibleTo(workspace, userID) { + decision := s.workspaceDecision(ctx, workspace) + if !decision.CanRead() { return repoerrors.ErrTaskNotFound } + if !decision.Has(scope) { + return ErrForbidden + } return nil } -// authorizeWorkflowID checks visibility of a workflow via its workspace. +// authorizeWorkflowID checks reach of a workflow via its workspace. func (s *Service) authorizeWorkflowID(ctx context.Context, workflowID string) error { - userID, scoped := callerScope(ctx) - if !scoped { + if _, scoped := callerScope(ctx); !scoped { return nil } workflow, err := s.workflows.GetWorkflow(ctx, workflowID) @@ -113,7 +245,7 @@ func (s *Service) authorizeWorkflowID(ctx context.Context, workflowID string) er // letting a transient database error read as either allow or deny. return err } - if !workspaceVisibleTo(workspace, userID) { + if !s.workspaceDecision(ctx, workspace).CanRead() { return repoerrors.ErrWorkspaceNotFound } return nil @@ -138,19 +270,26 @@ func (s *Service) AuthorizeWorkspaceAccess(ctx context.Context, workspaceID stri return s.authorizeWorkspaceID(ctx, workspaceID) } -// AuthorizeSessionAccess checks visibility of a task session via its task's +// AuthorizeSessionAccess checks reach of a task session via its task's // workspace. Wired into the lifecycle manager so session-scoped surfaces // (files, processes, ports, vscode, terminal, LSP) are covered. func (s *Service) AuthorizeSessionAccess(ctx context.Context, sessionID string) error { - _, scoped := callerScope(ctx) - if !scoped { + return s.AuthorizeSessionScope(ctx, sessionID, authz.ScopeWorkspaceRead) +} + +// AuthorizeSessionScope enforces one scope on the workspace owning a session. +// Execution surfaces (terminal, shell, file writes, port previews) use +// authz.ScopeSessionExec so a viewer who may read a transcript never gets a +// shell in the worktree. +func (s *Service) AuthorizeSessionScope(ctx context.Context, sessionID string, scope authz.Scope) error { + if _, scoped := callerScope(ctx); !scoped { return nil } session, err := s.sessions.GetTaskSession(ctx, sessionID) if err != nil { return err } - return s.authorizeTaskID(ctx, session.TaskID) + return s.authorizeTaskScope(ctx, session.TaskID, scope) } // AuthorizeTaskSessionAccess checks that both identifiers are visible to the @@ -170,19 +309,23 @@ func (s *Service) AuthorizeTaskSessionAccess(ctx context.Context, taskID, sessio return nil } -// AuthorizeEnvironmentAccess checks visibility of a task environment via its -// task's workspace. Used by the terminal environment-shell route, which -// resolves executions by environment ID rather than session ID. +// AuthorizeEnvironmentAccess checks reach of a task environment via its task's +// workspace. Used by the terminal environment-shell route, which resolves +// executions by environment ID rather than session ID. func (s *Service) AuthorizeEnvironmentAccess(ctx context.Context, taskEnvironmentID string) error { - _, scoped := callerScope(ctx) - if !scoped { + return s.AuthorizeEnvironmentScope(ctx, taskEnvironmentID, authz.ScopeWorkspaceRead) +} + +// AuthorizeEnvironmentScope enforces one scope on a task environment. +func (s *Service) AuthorizeEnvironmentScope(ctx context.Context, taskEnvironmentID string, scope authz.Scope) error { + if _, scoped := callerScope(ctx); !scoped { return nil } env, err := s.taskEnvironments.GetTaskEnvironment(ctx, taskEnvironmentID) if err != nil { return err } - return s.authorizeTaskID(ctx, env.TaskID) + return s.authorizeTaskScope(ctx, env.TaskID, scope) } // AuthorizeTaskEnvironmentAccess checks that both identifiers are visible to @@ -229,7 +372,7 @@ func (s *Service) AuthorizeTaskEnvironmentAccess(ctx context.Context, taskID, ta return repoerrors.ErrTaskNotFound } -// authorizeRepositoryID checks visibility of a repository via its workspace. +// authorizeRepositoryID checks reach of a repository via its workspace. // Denials use ErrRepositoryNotFound (no existence leak). func (s *Service) authorizeRepositoryID(ctx context.Context, repositoryID string) error { if _, scoped := callerScope(ctx); !scoped { @@ -249,14 +392,39 @@ func (s *Service) authorizeRepositoryID(ctx context.Context, repositoryID string } // filterWorkspacesForCaller narrows a workspace list to the caller's view. -func filterWorkspacesForCaller(ctx context.Context, workspaces []*models.Workspace) []*models.Workspace { - userID, scoped := callerScope(ctx) - if !scoped { +// +// The caller's whole membership map is read once here rather than per +// workspace: a board render walks every workspace, so a per-row lookup would +// be an N+1 on the hottest list in the product. +func (s *Service) filterWorkspacesForCaller(ctx context.Context, workspaces []*models.Workspace) []*models.Workspace { + subject := callerSubject(ctx) + if subject.Unscoped { return workspaces } + + memberRoles, err := s.workspaces.ListWorkspaceIDsForMember(ctx, subject.UserID) + if err != nil { + s.logger.Warn("workspace membership list failed; returning no workspaces") + return nil + } + + inherited, ok := s.inheritedRolesFor(ctx, subject.UserID, workspaces) + if !ok { + return nil + } + visible := make([]*models.Workspace, 0, len(workspaces)) for _, workspace := range workspaces { - if workspaceVisibleTo(workspace, userID) { + if workspace == nil { + continue + } + ref := authz.WorkspaceRef{ + OwnerID: workspace.OwnerID, + OrgID: workspace.OrgID, + MemberRole: authz.NormalizeWorkspaceRole(memberRoles[workspace.ID]), + InheritedRole: inherited[workspace.ID], + } + if authz.ResolveWorkspace(subject, ref).CanRead() { visible = append(visible, workspace) } } diff --git a/apps/backend/internal/task/service/service_branch_update.go b/apps/backend/internal/task/service/service_branch_update.go index abb8ecb75d..9336f8debb 100644 --- a/apps/backend/internal/task/service/service_branch_update.go +++ b/apps/backend/internal/task/service/service_branch_update.go @@ -8,6 +8,7 @@ import ( "go.uber.org/zap" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/securityutil" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" @@ -53,7 +54,7 @@ func (s *Service) UpdateRepositoryBaseBranch(ctx context.Context, req UpdateRepo // Ahead of every repository read. The WS action names task_id so the gateway // backstop already covers that transport, but this method is also reachable // over HTTP and MCP, and a backstop is not a substitute for a service guard. - if err := s.authorizeTaskID(ctx, req.TaskID); err != nil { + if err := s.authorizeTaskScope(ctx, req.TaskID, authz.ScopeRepositoryManage); err != nil { return nil, err } taskRepo, err := s.loadTaskRepositoryForUpdate(ctx, req.TaskID, req.TaskRepositoryID) diff --git a/apps/backend/internal/task/service/service_errors_authz.go b/apps/backend/internal/task/service/service_errors_authz.go new file mode 100644 index 0000000000..3b42c94ad4 --- /dev/null +++ b/apps/backend/internal/task/service/service_errors_authz.go @@ -0,0 +1,40 @@ +package service + +import ( + "context" + "errors" + + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +// ErrForbidden reports that the caller can see the resource but lacks the +// scope the action requires. +// +// It is deliberately distinct from the *NotFound sentinels. Those hide +// existence from a caller who has no business knowing the resource is there; +// this one is returned only after workspace.read has already been granted, so +// there is nothing left to hide and a 404 would just be confusing ("the task I +// am looking at does not exist?"). +var ErrForbidden = errors.New("insufficient permissions for this action") + +// IsForbidden reports whether an error is the scope-denied sentinel. +func IsForbidden(err error) bool { return errors.Is(err, ErrForbidden) } + +// requireWorkspaceManage gates the owner-only workspace actions: rename, +// defaults, visibility, and delete. It applies the 404-vs-403 rule so a caller +// who cannot see the workspace never learns it exists. +func (s *Service) requireWorkspaceManage(ctx context.Context, workspace *models.Workspace) error { + if _, scoped := callerScope(ctx); !scoped { + return nil + } + decision := s.workspaceDecision(ctx, workspace) + if !decision.CanRead() { + return repoerrors.ErrWorkspaceNotFound + } + if !decision.Has(authz.ScopeWorkspaceManage) { + return ErrForbidden + } + return nil +} diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index e25ec6f84a..cfef5203a7 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -405,6 +405,12 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas // omitted key here would make clearTaskAutoStartFailedMarker's publish // as invisible as the set it is meant to undo. "auto_start_failed": task.Metadata[models.MetaKeyAutoStartFailed] != nil, + // The human assignee, always sent, never omitted when empty, for the + // same reason as auto_start_failed above: the frontend pins the + // previous value when the key is absent, so omitting it would make + // unassigning invisible to every open client, and a takeover would + // leave the previous owner's name on their screens. + "assignee_user_id": task.AssigneeUserID, } data["queued_for_step_id"] = task.QueuedForStepID if task.QueuedAt != nil { @@ -785,6 +791,7 @@ func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, "task_description": task.Description, "parent_id": task.ParentID, "assignee_agent_profile_id": task.AssigneeAgentProfileID, + "assignee_user_id": task.AssigneeUserID, "wip_admitted": task.WIPAdmitted, "queued_for_step_id": task.QueuedForStepID, "queue_promotion": queuePromotion, @@ -826,13 +833,26 @@ func (s *Service) publishWorkspaceEvent(ctx context.Context, eventType string, w "default_environment_id": workspace.DefaultEnvironmentID, "default_agent_profile_id": workspace.DefaultAgentProfileID, "default_config_agent_profile_id": workspace.DefaultConfigAgentProfileID, - "created_at": workspace.CreatedAt.Format(time.RFC3339), - "updated_at": workspace.UpdatedAt.Format(time.RFC3339), + // Placement is reach: moving a workspace between units is what grants + // and withdraws access now, so an access-changed event that omitted it + // would tell clients something changed without telling them what. + "unit_id": workspace.UnitID, + "created_at": workspace.CreatedAt.Format(time.RFC3339), + "updated_at": workspace.UpdatedAt.Format(time.RFC3339), } s.publishEventToBus(ctx, eventType, "workspace", workspace.ID, data) } +// publishWorkspaceAccessChanged tells open clients that who-can-reach-this +// changed (unit placement, membership, ownership) so they re-evaluate access +// without a reload. It rides the existing workspace-updated event: the payload +// carries owner and unit_id, and a client that lost access is dropped from the +// workspace's subscriber set on the next broadcast. +func (s *Service) publishWorkspaceAccessChanged(ctx context.Context, workspace *models.Workspace) { + s.publishWorkspaceEvent(ctx, events.WorkspaceUpdated, workspace) +} + func (s *Service) publishWorkflowEvent(ctx context.Context, eventType string, workflow *models.Workflow) { if s.eventBus == nil || workflow == nil { return diff --git a/apps/backend/internal/task/service/service_events_assignee_test.go b/apps/backend/internal/task/service/service_events_assignee_test.go new file mode 100644 index 0000000000..95384ca29a --- /dev/null +++ b/apps/backend/internal/task/service/service_events_assignee_test.go @@ -0,0 +1,48 @@ +package service + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +// TestPublishTaskUpdated_EmitsHumanAssignee is the same gap as +// TestPublishTaskUpdated_EmitsAutoStartFailed, one field along: the payload +// map is hand-built, so a field the DTO carries is not automatically on the +// wire. Observed live before the fix: taking a task over left the previous +// owner's name in the top bar of every open client until a reload, because +// the event never mentioned the assignee and the frontend pins the value it +// already has when the key is absent. +func TestPublishTaskUpdated_EmitsHumanAssignee(t *testing.T) { + svc, eventBus, _ := createTestService(t) + svc.PublishTaskUpdated(context.Background(), &models.Task{ + ID: "task-assignee", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + AssigneeUserID: "user-7", + }) + + data := singlePublishedEventData(t, eventBus) + if got, _ := data["assignee_user_id"].(string); got != "user-7" { + t.Fatalf("assignee_user_id payload = %#v, want user-7", data["assignee_user_id"]) + } +} + +// TestPublishTaskUpdated_EmitsEmptyAssigneeWhenUnassigned proves unassigning +// propagates. An omitted key reads as "unchanged" on the frontend, so the +// empty string has to be explicit or clients would keep showing a name for a +// task nobody owns. +func TestPublishTaskUpdated_EmitsEmptyAssigneeWhenUnassigned(t *testing.T) { + svc, eventBus, _ := createTestService(t) + svc.PublishTaskUpdated(context.Background(), &models.Task{ + ID: "task-unassigned", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + }) + + data := singlePublishedEventData(t, eventBus) + got, ok := data["assignee_user_id"] + if !ok { + t.Fatal("assignee_user_id absent from the payload: an unassign would be invisible to open clients") + } + if got != "" { + t.Fatalf("assignee_user_id = %#v, want an explicit empty string", got) + } +} diff --git a/apps/backend/internal/task/service/service_events_placement_test.go b/apps/backend/internal/task/service/service_events_placement_test.go new file mode 100644 index 0000000000..75e3aad97e --- /dev/null +++ b/apps/backend/internal/task/service/service_events_placement_test.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" +) + +// TestPublishWorkspaceEvent_EmitsUnitPlacement is the assignee gap one +// resource along: publishWorkspaceEvent hand-builds its payload map, so a +// field on the model is not automatically on the wire. +// +// It matters more here than for most fields. publishWorkspaceAccessChanged +// exists to tell open clients that who-can-reach-this changed, and placement +// IS reach now that the visibility flag is gone - a move between units is what +// grants and withdraws access. Without unit_id the event says "something about +// this workspace changed" and omits the only thing that did. +func TestPublishWorkspaceEvent_EmitsUnitPlacement(t *testing.T) { + svc, eventBus, _ := createTestService(t) + svc.publishWorkspaceEvent(context.Background(), events.WorkspaceUpdated, &models.Workspace{ + ID: "ws-placement", Name: "Platform", UnitID: "unit-runtime", + }) + + data := singlePublishedEventData(t, eventBus) + if got, _ := data["unit_id"].(string); got != "unit-runtime" { + t.Fatalf("unit_id payload = %#v, want unit-runtime", data["unit_id"]) + } +} + +// TestPublishWorkspaceEvent_EmitsEmptyUnitWhenUnplaced keeps the key present +// when a workspace sits nowhere. The frontend pins the value it already holds +// when the key is absent, so an omitted key would leave every open client +// showing the unit the workspace was just moved out of. +func TestPublishWorkspaceEvent_EmitsEmptyUnitWhenUnplaced(t *testing.T) { + svc, eventBus, _ := createTestService(t) + svc.publishWorkspaceEvent(context.Background(), events.WorkspaceUpdated, &models.Workspace{ + ID: "ws-unplaced", Name: "Platform", + }) + + data := singlePublishedEventData(t, eventBus) + got, ok := data["unit_id"] + if !ok { + t.Fatal("unit_id absent from the payload: a move would be invisible to open clients") + } + if got != "" { + t.Fatalf("unit_id = %#v, want an explicit empty string", got) + } +} diff --git a/apps/backend/internal/task/service/service_members.go b/apps/backend/internal/task/service/service_members.go new file mode 100644 index 0000000000..c9f0945c0e --- /dev/null +++ b/apps/backend/internal/task/service/service_members.go @@ -0,0 +1,450 @@ +package service + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + "go.uber.org/zap" +) + +// Workspace membership errors. Each failure mode gets its own sentinel so the +// UI can say what actually went wrong instead of "bad request". +var ( + ErrMemberUserNotFound = errors.New("user not found") + ErrMemberUserDisabled = errors.New("user account is disabled") + ErrMemberIsOwner = errors.New("the workspace owner cannot be removed; transfer ownership first") + ErrMemberRoleInvalid = errors.New("role must be collaborator or viewer") + ErrMemberSelf = errors.New("you already own this workspace") + ErrTransferTargetNotMember = errors.New("add the user as a member before transferring ownership") + ErrAssigneeCannotReachWorkspace = errors.New("that person cannot see this workspace") +) + +// DirectoryUser is the reduced user record exposed to a member picker: an ID +// and a display name, never an email, role, or status. Reaching a colleague's +// name is what adding a member needs; anything more is a directory leak to +// every authenticated user. +type DirectoryUser struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` +} + +// UserDirectory resolves users for membership operations. +type UserDirectory interface { + // ListDirectory returns the pickable accounts in one organization. An + // empty orgID means the single implicit organization. + ListDirectory(ctx context.Context, orgID string) ([]DirectoryUser, error) + // LookupStatus returns the user's status ("active"/"disabled") and role, + // or ok=false when no such user exists. + LookupStatus(ctx context.Context, userID string) (status string, role string, ok bool, err error) +} + +// SetUserDirectory wires the account lookup used by membership operations. +func (s *Service) SetUserDirectory(directory UserDirectory) { s.userDirectory = directory } + +// ListWorkspaceMembers returns the workspace's membership. Any caller who can +// read the workspace can see who else is in it. +func (s *Service) ListWorkspaceMembers(ctx context.Context, workspaceID string) ([]*models.WorkspaceMember, error) { + if err := s.authorizeWorkspaceID(ctx, workspaceID); err != nil { + return nil, err + } + return s.workspaces.ListWorkspaceMembers(ctx, workspaceID) +} + +// UpsertWorkspaceMember adds a member or changes an existing member's role. +func (s *Service) UpsertWorkspaceMember(ctx context.Context, workspaceID, userID, role string) (*models.WorkspaceMember, error) { + workspace, err := s.requireMemberManage(ctx, workspaceID) + if err != nil { + return nil, err + } + wantRole := authz.NormalizeWorkspaceRole(role) + if !authz.IsAssignableWorkspaceRole(wantRole) { + return nil, ErrMemberRoleInvalid + } + if userID == workspace.OwnerID { + return nil, ErrMemberSelf + } + if err := s.requireAssignableWorkspaceUser(ctx, userID, workspace.OrgID); err != nil { + return nil, err + } + + actor, _ := callerScope(ctx) + member := &models.WorkspaceMember{ + WorkspaceID: workspaceID, + UserID: userID, + Role: string(wantRole), + AddedBy: actor, + CreatedAt: time.Now().UTC(), + } + if err := s.workspaces.UpsertWorkspaceMember(ctx, member); err != nil { + return nil, err + } + s.publishWorkspaceAccessChanged(ctx, workspace) + s.logger.Info("workspace member upserted", + zap.String("workspace_id", workspaceID), zap.String("user_id", userID), zap.String("role", string(wantRole))) + return member, nil +} + +// RemoveWorkspaceMember drops a membership row. The accountable owner's row +// cannot be removed: ownership is transferred, never vacated. +func (s *Service) RemoveWorkspaceMember(ctx context.Context, workspaceID, userID string) error { + workspace, err := s.requireMemberManage(ctx, workspaceID) + if err != nil { + return err + } + if userID == workspace.OwnerID { + return ErrMemberIsOwner + } + if err := s.workspaces.DeleteWorkspaceMember(ctx, workspaceID, userID); err != nil { + return err + } + s.publishWorkspaceAccessChanged(ctx, workspace) + s.logger.Info("workspace member removed", + zap.String("workspace_id", workspaceID), zap.String("user_id", userID)) + return nil +} + +// TransferWorkspaceOwnership moves the accountable owner to an existing +// member, demoting the previous owner to collaborator. +func (s *Service) TransferWorkspaceOwnership(ctx context.Context, workspaceID, toUserID string) error { + workspace, err := s.requireMemberManage(ctx, workspaceID) + if err != nil { + return err + } + if toUserID == workspace.OwnerID { + return ErrMemberSelf + } + if err := s.requireAssignableWorkspaceUser(ctx, toUserID, workspace.OrgID); err != nil { + return err + } + member, err := s.workspaces.GetWorkspaceMember(ctx, workspaceID, toUserID) + if err != nil { + return err + } + if member == nil { + return ErrTransferTargetNotMember + } + if err := s.workspaces.TransferWorkspaceOwnership(ctx, workspaceID, workspace.OwnerID, toUserID); err != nil { + return err + } + workspace.OwnerID = toUserID + s.publishWorkspaceAccessChanged(ctx, workspace) + s.logger.Info("workspace ownership transferred", + zap.String("workspace_id", workspaceID), zap.String("to_user_id", toUserID)) + return nil +} + +// ListDirectoryUsers returns the reduced user list for a member picker. +// +// It is scoped to the caller's organization. The picker is the one place a +// person's name is shown to someone who has no other relationship with them, +// so an unscoped list would make every account on the instance enumerable from +// any tenant. +func (s *Service) ListDirectoryUsers(ctx context.Context) ([]DirectoryUser, error) { + if s.userDirectory == nil { + return []DirectoryUser{}, nil + } + return s.userDirectory.ListDirectory(ctx, callerOrgID(ctx)) +} + +// requireMemberManage resolves the workspace and enforces member.manage. +func (s *Service) requireMemberManage(ctx context.Context, workspaceID string) (*models.Workspace, error) { + workspace, err := s.workspaces.GetWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + if _, scoped := callerScope(ctx); !scoped { + return workspace, nil + } + decision := s.workspaceDecision(ctx, workspace) + if !decision.CanRead() { + return nil, repoerrors.ErrWorkspaceNotFound + } + if !decision.Has(authz.ScopeMemberManage) { + return nil, ErrForbidden + } + return workspace, nil +} + +// requireAssignableUser rejects unknown and disabled accounts before a write. +func (s *Service) requireAssignableUser(ctx context.Context, userID string) error { + status, _, ok, err := s.lookupUser(ctx, userID) + if err != nil { + return err + } + if !ok { + return ErrMemberUserNotFound + } + if status == "disabled" { + return ErrMemberUserDisabled + } + return nil +} + +// requireAssignableWorkspaceUser additionally enforces the tenant boundary. +// A mismatch is reported as not found so a crafted user ID cannot reveal an +// account in another organization. +func (s *Service) requireAssignableWorkspaceUser(ctx context.Context, userID, workspaceOrgID string) error { + if err := s.requireAssignableUser(ctx, userID); err != nil { + return err + } + if workspaceOrgID == "" { + return nil + } + if s.userOrgs == nil { + return ErrMemberUserNotFound + } + userOrgID, err := s.userOrgs(ctx, userID) + if err != nil { + return err + } + if userOrgID != workspaceOrgID { + return ErrMemberUserNotFound + } + return nil +} + +func (s *Service) lookupUser(ctx context.Context, userID string) (string, string, bool, error) { + if s.userDirectory == nil { + // No directory wired (pre-auth single-user installs): accept the ID + // rather than blocking membership entirely. + return "active", string(authz.OrgRoleMember), true, nil + } + return s.userDirectory.LookupStatus(ctx, userID) +} + +// seedWorkspaceOwnerMember mirrors workspaces.owner_id into the membership +// table so the two never disagree. Failure is logged rather than fatal: the +// owner still reaches the workspace through owner_id, and the backfill +// migration repairs a missing row on the next boot. +func (s *Service) seedWorkspaceOwnerMember(ctx context.Context, workspace *models.Workspace) error { + if workspace == nil || workspace.OwnerID == "" { + return nil + } + member := &models.WorkspaceMember{ + WorkspaceID: workspace.ID, + UserID: workspace.OwnerID, + Role: string(authz.WorkspaceRoleOwner), + CreatedAt: time.Now().UTC(), + } + if err := s.workspaces.UpsertWorkspaceMember(ctx, member); err != nil { + // The owner row is not decoration: membership, counts and ownership + // transfer all read it, so a workspace without one is inconsistent. + // Report the failure rather than publishing a created event for it. + s.logger.Error("failed to seed workspace owner membership", + zap.String("workspace_id", workspace.ID), zap.Error(err)) + return err + } + return nil +} + +// WorkspaceAccessProjection carries the resolved access for a set of +// workspaces so handlers can build DTOs without re-resolving per row. +type WorkspaceAccessProjection struct { + Decisions map[string]authz.Decision + MemberCounts map[string]int +} + +// Decision returns the resolved access for one workspace, defaulting to the +// unscoped view when the projection was built for an internal caller. +func (p WorkspaceAccessProjection) Decision(workspaceID string) authz.Decision { + if decision, ok := p.Decisions[workspaceID]; ok { + return decision + } + return authz.Denied() +} + +// ProjectWorkspaceAccess resolves roles and scopes for a list of workspaces +// using one membership query and one count query, regardless of list length. +func (s *Service) ProjectWorkspaceAccess(ctx context.Context, workspaces []*models.Workspace) WorkspaceAccessProjection { + projection := WorkspaceAccessProjection{ + Decisions: make(map[string]authz.Decision, len(workspaces)), + MemberCounts: map[string]int{}, + } + if counts, err := s.workspaces.CountWorkspaceMembers(ctx); err == nil { + projection.MemberCounts = counts + } + + subject := callerSubject(ctx) + memberRoles := map[string]string{} + if !subject.Unscoped { + roles, err := s.workspaces.ListWorkspaceIDsForMember(ctx, subject.UserID) + if err != nil { + // Fail closed: every workspace resolves to Denied rather than + // silently falling through to the org default role. + s.logger.Warn("membership projection failed; denying scopes") + for _, workspace := range workspaces { + if workspace != nil { + projection.Decisions[workspace.ID] = authz.Denied() + } + } + return projection + } + memberRoles = roles + } + + inherited, ok := s.inheritedRolesFor(ctx, subject.UserID, workspaces) + if !ok { + for _, workspace := range workspaces { + if workspace != nil { + projection.Decisions[workspace.ID] = authz.Denied() + } + } + return projection + } + + for _, workspace := range workspaces { + if workspace == nil { + continue + } + projection.Decisions[workspace.ID] = authz.ResolveWorkspace(subject, authz.WorkspaceRef{ + OwnerID: workspace.OwnerID, + OrgID: workspace.OrgID, + MemberRole: authz.NormalizeWorkspaceRole(memberRoles[workspace.ID]), + InheritedRole: inherited[workspace.ID], + }) + } + return projection +} + +// WorkspaceReaderIDs returns every user that may currently read a workspace: +// its owner, everyone holding a membership row, and, when the workspace is +// visible to the organization, that organization's non-guest users. +// +// It backs WebSocket fan-out. Returning an empty slice means "nobody" and is a +// real answer: the gateway must not turn it into a global broadcast. +func (s *Service) WorkspaceReaderIDs(ctx context.Context, workspaceID string) ([]string, error) { + workspace, err := s.workspaces.GetWorkspace(ctx, workspaceID) + if err != nil || workspace == nil { + return nil, err + } + // A pre-auth workspace has no owner and stays visible to everyone, so it + // keeps the previous global routing rather than resolving to nobody. + if workspace.OwnerID == "" { + return nil, errUnscopedWorkspace + } + + seen := map[string]struct{}{workspace.OwnerID: {}} + members, err := s.workspaces.ListWorkspaceMembers(ctx, workspaceID) + if err != nil { + return nil, err + } + for _, member := range members { + if authz.NormalizeWorkspaceRole(member.Role) != authz.WorkspaceRoleNone { + seen[member.UserID] = struct{}{} + } + } + // Everyone the placement reaches, which is every member of the workspace's + // unit and of its ancestors. A workspace shared with the whole + // organization is one placed in a unit the whole organization is in. + if s.unitReach != nil && workspace.UnitID != "" { + readers, err := s.unitReach.UnitReaders(ctx, workspace.UnitID) + if err != nil { + return nil, err + } + for _, id := range readers { + seen[id] = struct{}{} + } + } + + out := make([]string, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + return out, nil +} + +// errUnscopedWorkspace tells the gateway to fall back to its previous routing +// for a workspace that predates ownership. +var errUnscopedWorkspace = errors.New("workspace has no owner") + +// SetUserOrgResolver wires the account-to-organization lookup used by +// workspace fan-out. +func (s *Service) SetUserOrgResolver(resolve func(ctx context.Context, userID string) (string, error)) { + s.userOrgs = resolve +} + +// SetHumanAssignee applies a human assignee to a task on behalf of the caller +// in ctx, so surfaces outside this package (today: the office PATCH handler) +// get the same authorization and validation as the task API instead of +// reimplementing either. Empty unassigns. +// +// It deliberately goes through UpdateTask rather than writing the column: the +// office route carries no `:wsId`, so it is not covered by the office +// workspace-scope middleware, and a direct write there would let any signed-in +// user assign a task in a workspace they cannot reach. +func (s *Service) SetHumanAssignee(ctx context.Context, taskID, userID string) error { + _, err := s.UpdateTask(ctx, taskID, &UpdateTaskRequest{AssigneeUserID: &userID}) + return err +} + +// resolveTaskAssignee validates a human assignee for a task. +// +// Assignment is advisory: it gates nothing, and any caller holding task.write +// may assign to anyone, including themselves. The one rule is that the +// assignee has to be able to reach the workspace, so a task cannot be parked +// on somebody who will never see it. An empty value unassigns. +func (s *Service) resolveTaskAssignee(ctx context.Context, task *models.Task, userID string) (string, error) { + userID = strings.TrimSpace(userID) + if userID == "" { + return "", nil + } + if err := s.requireAssignableUser(ctx, userID); err != nil { + return "", err + } + workspace, err := s.workspaces.GetWorkspace(ctx, task.WorkspaceID) + if err != nil || workspace == nil { + // A dangling workspace reference should not block assignment on a task + // the caller can already edit. + return userID, nil //nolint:nilerr // visibility fallback, not a failure + } + if !s.userReachesWorkspace(ctx, workspace, userID) { + return "", ErrAssigneeCannotReachWorkspace + } + return userID, nil +} + +// userReachesWorkspace answers the reach question for a user who is NOT the +// caller, so it resolves their membership and org role rather than reading the +// request identity. +func (s *Service) userReachesWorkspace(ctx context.Context, workspace *models.Workspace, userID string) bool { + if workspace.OwnerID == "" || workspace.OwnerID == userID { + return true + } + member, err := s.workspaces.GetWorkspaceMember(ctx, workspace.ID, userID) + if err != nil { + return false + } + ref := authz.WorkspaceRef{ + OwnerID: workspace.OwnerID, + OrgID: workspace.OrgID, + } + if member != nil { + ref.MemberRole = authz.NormalizeWorkspaceRole(member.Role) + } + // Reach comes from the tree first: most people reach a workspace because + // they are in a unit above it, not because they hold a row on it. Asking + // only about the row is how this check came to refuse a colleague who can + // plainly see the board. + if inherited, ok := s.inheritedRole(ctx, userID, workspace); ok { + ref.InheritedRole = inherited + } else { + return false + } + subject := authz.Subject{UserID: userID, TenancyEnforced: tenancyEnforced} + if s.userDirectory != nil { + if _, role, ok, lookupErr := s.userDirectory.LookupStatus(ctx, userID); lookupErr == nil && ok { + subject.OrgRole = authz.NormalizeOrgRole(role) + } + } + if s.userOrgs != nil { + if orgID, orgErr := s.userOrgs(ctx, userID); orgErr == nil { + subject.OrgID = orgID + } + } + return authz.ResolveWorkspace(subject, ref).CanRead() +} diff --git a/apps/backend/internal/task/service/service_members_tenancy_review_test.go b/apps/backend/internal/task/service/service_members_tenancy_review_test.go new file mode 100644 index 0000000000..15aec30aff --- /dev/null +++ b/apps/backend/internal/task/service/service_members_tenancy_review_test.go @@ -0,0 +1,68 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/task/models" +) + +type tenancyReviewDirectory struct { + users map[string]string +} + +func (d tenancyReviewDirectory) ListDirectory(context.Context, string) ([]DirectoryUser, error) { + return nil, nil +} + +func (d tenancyReviewDirectory) LookupStatus(_ context.Context, userID string) (string, string, bool, error) { + status, ok := d.users[userID] + return status, string(authz.OrgRoleMember), ok, nil +} + +func TestWorkspaceMembershipRejectsUserFromAnotherOrganization(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ + ID: "ws-org-a", Name: "Org A", OwnerID: "owner-a", OrgID: "org-a", + }); err != nil { + t.Fatalf("create workspace: %v", err) + } + svc.SetUserDirectory(tenancyReviewDirectory{users: map[string]string{"user-b": "active"}}) + svc.SetUserOrgResolver(func(context.Context, string) (string, error) { return "org-b", nil }) + owner := authn.WithIdentity(ctx, authn.Identity{ + UserID: "owner-a", OrgID: "org-a", Role: authn.RoleMember, + }) + + if _, err := svc.UpsertWorkspaceMember( + owner, "ws-org-a", "user-b", string(authz.WorkspaceRoleCollaborator), + ); !errors.Is(err, ErrMemberUserNotFound) { + t.Fatalf("cross-organization member add = %v, want ErrMemberUserNotFound", err) + } + member, err := repo.GetWorkspaceMember(ctx, "ws-org-a", "user-b") + if err != nil { + t.Fatalf("get rejected member: %v", err) + } + if member != nil { + t.Fatalf("cross-organization membership persisted: %+v", member) + } + + if err := repo.UpsertWorkspaceMember(ctx, &models.WorkspaceMember{ + WorkspaceID: "ws-org-a", UserID: "user-b", Role: string(authz.WorkspaceRoleCollaborator), + }); err != nil { + t.Fatalf("seed legacy cross-organization member: %v", err) + } + if err := svc.TransferWorkspaceOwnership(owner, "ws-org-a", "user-b"); !errors.Is(err, ErrMemberUserNotFound) { + t.Fatalf("cross-organization ownership transfer = %v, want ErrMemberUserNotFound", err) + } + workspace, err := repo.GetWorkspace(ctx, "ws-org-a") + if err != nil { + t.Fatalf("get workspace: %v", err) + } + if workspace.OwnerID != "owner-a" { + t.Fatalf("workspace owner = %q, want owner-a", workspace.OwnerID) + } +} diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go index 4543bb32fa..de8d538583 100644 --- a/apps/backend/internal/task/service/service_messages.go +++ b/apps/backend/internal/task/service/service_messages.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/agentctl/types/streams" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" ) @@ -23,6 +24,11 @@ const ( // CreateMessage creates a new message on an agent session func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) (*models.Message, error) { + if req.AuthorType != createdByAgent { + if err := s.AuthorizeSessionScope(ctx, req.TaskSessionID, authz.ScopeSessionPrompt); err != nil { + return nil, err + } + } messageID := uuid.New().String() session, err := s.getSessionWithRetry( ctx, @@ -81,7 +87,7 @@ func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) TaskID: taskID, TurnID: turnID, AuthorType: authorType, - AuthorID: req.AuthorID, + AuthorID: s.resolveAuthorID(ctx, authorType, req.AuthorID), Content: req.Content, Type: messageType, Metadata: req.Metadata, @@ -950,3 +956,21 @@ func firstRestoredClarification(messages []*models.Message) *models.Message { } return nil } + +// resolveAuthorID stamps a human message with the *authenticated* caller +// rather than whatever author_id the browser sent. +// +// Attribution is the whole reason a team uses accounts instead of a shared +// login, so it cannot be client-supplied: any user could otherwise post as a +// colleague. Agent messages keep the caller-supplied ID, which names an agent +// execution rather than a person, and an unauthenticated (internal or +// auth-disabled) caller keeps today's behavior. +func (s *Service) resolveAuthorID(ctx context.Context, authorType models.MessageAuthorType, requested string) string { + if authorType != models.MessageAuthorUser { + return requested + } + if userID, scoped := callerScope(ctx); scoped { + return userID + } + return requested +} diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 0794ad68f6..e1e864d352 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -135,6 +135,10 @@ type UpdateTaskRequest struct { // leaves the relationship untouched; a pointer to "" clears it (un-nests // back to a root task); a non-empty value nests it under that parent. ParentID *string `json:"parent_id,omitempty"` + // AssigneeUserID sets the human assignee. A nil pointer leaves it alone; a + // pointer to "" unassigns. It is independent of the agent assignee and + // never clears it. + AssigneeUserID *string `json:"assignee_user_id,omitempty"` } // CreateWorkflowRequest contains the data for creating a new workflow @@ -176,6 +180,12 @@ type UpdateWorkspaceRequest struct { DefaultEnvironmentID *string `json:"default_environment_id,omitempty"` DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"` DefaultConfigAgentProfileID *string `json:"default_config_agent_profile_id,omitempty"` + // Visibility is "private" or "org". Unknown values normalize to private: + // unrecognized input must never widen access. + Visibility *string `json:"visibility,omitempty"` + // UnitID moves the workspace to another organization unit, which is the + // only way to change who reaches it. + UnitID *string `json:"unit_id,omitempty"` } // FindOrCreateRepositoryRequest contains the data for finding or creating a repository by provider info. diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 9a161f9158..0fb6fd743c 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -18,6 +18,7 @@ import ( agentkubernetes "github.com/kandev/kandev/internal/agent/kubernetes" "github.com/kandev/kandev/internal/agentruntime" "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/securityutil" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/secrets" @@ -107,7 +108,16 @@ func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceReque DefaultEnvironmentID: normalizeOptionalID(req.DefaultEnvironmentID), DefaultAgentProfileID: normalizeOptionalID(req.DefaultAgentProfileID), DefaultConfigAgentProfileID: normalizeOptionalID(req.DefaultConfigAgentProfileID), + // The tenant comes from the creating identity and from nowhere else. + // There is no org field on the request on purpose: a caller must not + // be able to place a workspace in another tenant. + OrgID: callerOrgID(ctx), } + placement, placementErr := s.placementFor(ctx, ownerID, workspace.OrgID) + if placementErr != nil { + return nil, placementErr + } + workspace.UnitID = placement var kanbanWorkflow *models.Workflow if req.BootstrapKanbanWorkflow { @@ -133,6 +143,10 @@ func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceReque } } + if err := s.seedWorkspaceOwnerMember(ctx, workspace); err != nil { + return nil, err + } + s.publishWorkspaceEvent(ctx, events.WorkspaceCreated, workspace) s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name)) if kanbanWorkflow != nil { @@ -148,7 +162,7 @@ func (s *Service) GetWorkspace(ctx context.Context, id string) (*models.Workspac if err != nil { return nil, err } - if userID, scoped := callerScope(ctx); scoped && !workspaceVisibleTo(workspace, userID) { + if scoped := s.workspaceDecision(ctx, workspace); !scoped.CanRead() { return nil, repoerrors.ErrWorkspaceNotFound } return workspace, nil @@ -160,10 +174,15 @@ func (s *Service) UpdateWorkspace(ctx context.Context, id string, req *UpdateWor if err != nil { return nil, err } - if userID, scoped := callerScope(ctx); scoped && !workspaceVisibleTo(workspace, userID) { - return nil, repoerrors.ErrWorkspaceNotFound + if err := s.requireWorkspaceManage(ctx, workspace); err != nil { + return nil, err } + if req.UnitID != nil { + if err := s.moveWorkspaceToUnit(ctx, workspace, *req.UnitID); err != nil { + return nil, err + } + } if req.Name != nil { workspace.Name = *req.Name } @@ -200,8 +219,8 @@ func (s *Service) DeleteWorkspace(ctx context.Context, id string) error { if err != nil { return err } - if userID, scoped := callerScope(ctx); scoped && !workspaceVisibleTo(workspace, userID) { - return repoerrors.ErrWorkspaceNotFound + if err := s.requireWorkspaceManage(ctx, workspace); err != nil { + return err } return s.deleteWorkspace(ctx, workspace, nil) } @@ -213,8 +232,8 @@ func (s *Service) DeleteWorkspaceWithConfirmName(ctx context.Context, id, confir if err != nil { return err } - if userID, scoped := callerScope(ctx); scoped && !workspaceVisibleTo(workspace, userID) { - return repoerrors.ErrWorkspaceNotFound + if err := s.requireWorkspaceManage(ctx, workspace); err != nil { + return err } if confirmName != workspace.Name { return ErrWorkspaceConfirmNameMismatch @@ -222,6 +241,29 @@ func (s *Service) DeleteWorkspaceWithConfirmName(ctx context.Context, id, confir return s.deleteWorkspace(ctx, workspace, &confirmName) } +// DeleteOrganizationWorkspaces removes every workspace in one organization +// through the same lifecycle as an ordinary workspace deletion. Authorization +// is performed by the operator-only organization controller before this +// narrow internal seam is called. +func (s *Service) DeleteOrganizationWorkspaces(ctx context.Context, orgID string) error { + if orgID == "" { + return nil + } + workspaces, err := s.workspaces.ListWorkspaces(ctx) + if err != nil { + return fmt.Errorf("list organization workspaces: %w", err) + } + for _, workspace := range workspaces { + if workspace == nil || workspace.OrgID != orgID { + continue + } + if err := s.deleteWorkspace(ctx, workspace, nil); err != nil { + return fmt.Errorf("delete workspace %s: %w", workspace.ID, err) + } + } + return nil +} + func (s *Service) deleteWorkspace(ctx context.Context, workspace *models.Workspace, confirmedName *string) error { tasks, err := s.listAllTasksForWorkspaceDelete(ctx, workspace.ID) if err != nil { @@ -506,14 +548,14 @@ func (s *Service) ListWorkspaces(ctx context.Context) ([]*models.Workspace, erro if err != nil { return nil, err } - return filterWorkspacesForCaller(ctx, workspaces), nil + return s.filterWorkspacesForCaller(ctx, workspaces), nil } // Workflow operations // CreateWorkflow creates a new workflow func (s *Service) CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest) (*models.Workflow, error) { - if err := s.authorizeWorkspaceID(ctx, req.WorkspaceID); err != nil { + if err := s.AuthorizeWorkspaceScope(ctx, req.WorkspaceID, authz.ScopeRepositoryManage); err != nil { return nil, err } workflow := &models.Workflow{ @@ -757,7 +799,7 @@ func (s *Service) createRepository( localPath string, resolveProvider bool, ) (*models.Repository, error) { - if err := s.authorizeWorkspaceID(ctx, req.WorkspaceID); err != nil { + if err := s.AuthorizeWorkspaceScope(ctx, req.WorkspaceID, authz.ScopeRepositoryManage); err != nil { return nil, err } sourceType := req.SourceType @@ -1071,7 +1113,7 @@ func (s *Service) UpdateRepository(ctx context.Context, id string, req *UpdateRe return nil, err } if repository != nil { - if err := s.authorizeWorkspaceID(ctx, repository.WorkspaceID); err != nil { + if err := s.AuthorizeWorkspaceScope(ctx, repository.WorkspaceID, authz.ScopeRepositoryManage); err != nil { return nil, repoerrors.ErrRepositoryNotFound } } @@ -1244,7 +1286,10 @@ func (s *Service) DeleteRepository(ctx context.Context, id string) error { return err } if repository != nil { - if err := s.authorizeWorkspaceID(ctx, repository.WorkspaceID); err != nil { + if err := s.AuthorizeWorkspaceScope(ctx, repository.WorkspaceID, authz.ScopeRepositoryManage); err != nil { + if IsForbidden(err) { + return err + } return repoerrors.ErrRepositoryNotFound } } diff --git a/apps/backend/internal/task/service/service_resources_test.go b/apps/backend/internal/task/service/service_resources_test.go index 52cd3d5543..b43eaf9257 100644 --- a/apps/backend/internal/task/service/service_resources_test.go +++ b/apps/backend/internal/task/service/service_resources_test.go @@ -670,6 +670,9 @@ func TestService_FindOrCreateRepositoryRejectsInvalidLocalPathBackfill(t *testin // errWorkspaceRepo is a WorkspaceRepository that always returns an error from // ListWorkspaces. Used to exercise the DB-error path of GetOfficeWorkflowIDs. type errWorkspaceRepo struct { + // Membership is not exercised by this fake; the embedded default + // reports no membership, which is the narrower answer. + repository.UnsupportedWorkspaceMembers // embed the real repo for all methods except ListWorkspaces. WorkspaceRepositoryStub } diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 0a936e5e16..b6d2b52b1e 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -19,6 +19,7 @@ import ( "go.uber.org/zap" "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/steptelemetry" @@ -208,7 +209,7 @@ func foundOutcomeFor(task *models.Task) CreateTaskOutcome { // call returns, so settlement is their responsibility (see the spec's // "Settlement call site" section). func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (CreateTaskResult, error) { - if err := s.authorizeWorkspaceID(ctx, req.WorkspaceID); err != nil { + if err := s.AuthorizeWorkspaceScope(ctx, req.WorkspaceID, authz.ScopeTaskWrite); err != nil { return CreateTaskResult{}, err } @@ -1899,7 +1900,7 @@ func (s *Service) hydrateTaskRelations(ctx context.Context, task *models.Task) { // UpdateTask updates an existing task and publishes a task.updated event func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequest) (*models.Task, error) { - if err := s.authorizeTaskID(ctx, id); err != nil { + if err := s.authorizeTaskScope(ctx, id, authz.ScopeTaskWrite); err != nil { return nil, err } if req.Title != nil { @@ -1945,6 +1946,13 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ if req.Position != nil { task.Position = *req.Position } + if req.AssigneeUserID != nil { + assignee, err := s.resolveTaskAssignee(ctx, task, *req.AssigneeUserID) + if err != nil { + return nil, err + } + task.AssigneeUserID = assignee + } if req.Metadata != nil { task.Metadata = protectedTaskMetadataUpdate(task.Metadata, req.Metadata) } @@ -2279,7 +2287,7 @@ func (s *Service) RestoreTaskMessageRollback( func (s *Service) ArchiveTask(ctx context.Context, id string) error { start := time.Now() - if err := s.authorizeTaskID(ctx, id); err != nil { + if err := s.authorizeTaskScope(ctx, id, authz.ScopeTaskWrite); err != nil { return err } // 1. Get task and verify it exists @@ -2594,7 +2602,7 @@ func (s *Service) DeleteTaskWithReason(ctx context.Context, id, reason string) e } func (s *Service) deleteTaskWithReason(ctx context.Context, id, reason string) error { - if err := s.authorizeTaskID(ctx, id); err != nil { + if err := s.authorizeTaskScope(ctx, id, authz.ScopeTaskWrite); err != nil { return err } _, err := s.deleteTaskWithReasonAndDBDelete(ctx, id, reason, models.TaskResourceCleanupTriggerDelete, func(ctx context.Context, id string) (bool, error) { diff --git a/apps/backend/internal/task/service/service_team_access_test.go b/apps/backend/internal/task/service/service_team_access_test.go new file mode 100644 index 0000000000..84ce40d463 --- /dev/null +++ b/apps/backend/internal/task/service/service_team_access_test.go @@ -0,0 +1,350 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// End-to-end team access through the real service and repository. The authz +// package proves the resolver's table; this proves the wiring actually reaches +// it — a correct resolver that nobody calls protects nothing. + +func ctxAsRole(userID string, role authn.Role) context.Context { + return authn.WithIdentity(context.Background(), authn.Identity{UserID: userID, Role: role}) +} + +type teamAccessRepo interface { + CreateWorkspace(context.Context, *models.Workspace) error + CreateWorkflow(context.Context, *models.Workflow) error + CreateTask(context.Context, *models.Task) error + UpsertWorkspaceMember(context.Context, *models.WorkspaceMember) error +} + +// seedTeamWorkspace builds the shape a team actually has: a unit that holds +// the board, with the colleague a member of it. `shared` decides whether the +// board sits in that unit or in its owner's personal one, which is the whole +// of the private-versus-shared distinction now. +func seedTeamWorkspace(t *testing.T, repo teamAccessRepo, shared bool) { + t.Helper() + ctx := context.Background() + units := testUnits[t.Name()] + if units == nil { + t.Fatal("no unit service for this test") + } + root, err := units.EnsureRoot(ctx, "", "Test org") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + personal, err := units.EnsurePersonal(ctx, "", "user-ana", "Ana") + if err != nil { + t.Fatalf("ensure personal unit: %v", err) + } + team, err := units.Create(ctx, root.ID, "Platform") + if err != nil { + t.Fatalf("create team unit: %v", err) + } + unitID := personal.ID + if shared { + unitID = team.ID + if err := units.SetMember(ctx, team.ID, "user-bruno", "collaborator", "user-ana"); err != nil { + t.Fatalf("add unit member: %v", err) + } + } + if err := repo.CreateWorkspace(ctx, &models.Workspace{ + ID: "ws-team", Name: "Team", OwnerID: "user-ana", UnitID: unitID, + }); err != nil { + t.Fatalf("create workspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-team", WorkspaceID: "ws-team", Name: "Board"}); err != nil { + t.Fatalf("create workflow: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: "task-team", WorkspaceID: "ws-team", WorkflowID: "wf-team", WorkflowStepID: "step-1", + Title: "Ana's task", State: v1.TaskStateCreated, Priority: "medium", + }); err != nil { + t.Fatalf("create task: %v", err) + } +} + +// The golden path: a colleague reaches the board through the unit they are +// in, with no invitation and no per-workspace row. +func TestUnitMemberReachesWorkspaceWithoutInvitation(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + + if _, err := svc.GetWorkspace(ctxAsRole("user-bruno", authn.RoleMember), "ws-team"); err != nil { + t.Fatalf("a workspace in a unit the member belongs to must be reachable: %v", err) + } + if _, err := svc.GetTask(ctxAsRole("user-bruno", authn.RoleMember), "task-team"); err != nil { + t.Fatalf("a task on that board must be readable by the same member: %v", err) + } +} + +// The privacy case the current design is built around must not regress. +func TestPrivateWorkspaceStaysPrivate(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, false) + + bruno := ctxAsRole("user-bruno", authn.RoleMember) + if _, err := svc.GetWorkspace(bruno, "ws-team"); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + t.Fatalf("private workspace leaked to a non-member: %v", err) + } + if _, err := svc.GetTask(bruno, "task-team"); !errors.Is(err, repoerrors.ErrTaskNotFound) { + t.Fatalf("private task leaked to a non-member: %v", err) + } + // An admin is a management role, not a visibility role. + admin := ctxAsRole("user-carla", authn.RoleAdmin) + if _, err := svc.GetWorkspace(admin, "ws-team"); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + t.Fatalf("admin reached a private workspace they are not in: %v", err) + } +} + +// A guest is deliberately excluded from org-visible workspaces. +func TestGuestExcludedFromOrgVisibleWorkspace(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + + guest := ctxAsRole("user-contractor", authn.RoleGuest) + if _, err := svc.GetWorkspace(guest, "ws-team"); !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + t.Fatalf("guest reached an org-visible workspace: %v", err) + } + + // ...until they hold an explicit row. + if err := repo.UpsertWorkspaceMember(context.Background(), &models.WorkspaceMember{ + WorkspaceID: "ws-team", UserID: "user-contractor", Role: string(authz.WorkspaceRoleCollaborator), + }); err != nil { + t.Fatalf("add member: %v", err) + } + if _, err := svc.GetWorkspace(guest, "ws-team"); err != nil { + t.Fatalf("guest with a membership row must reach the workspace: %v", err) + } +} + +// A direct grant raises a role; it cannot lower one. Narrowing is done by +// placing a workspace in a unit with fewer members, which is what lets the +// combining rule stay "the strongest wins" with no deny concept to reason +// about. This test pins the direction, because the model it replaced narrowed +// in both. +func TestDirectGrantCannotLowerAnInheritedRole(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + if err := repo.UpsertWorkspaceMember(context.Background(), &models.WorkspaceMember{ + WorkspaceID: "ws-team", UserID: "user-bruno", Role: string(authz.WorkspaceRoleViewer), + }); err != nil { + t.Fatalf("add viewer grant: %v", err) + } + + bruno := ctxAsRole("user-bruno", authn.RoleMember) + if _, err := svc.GetTask(bruno, "task-team"); err != nil { + t.Fatalf("reading must still work: %v", err) + } + title := "renamed by a colleague" + if _, err := svc.UpdateTask(bruno, "task-team", &UpdateTaskRequest{Title: &title}); err != nil { + t.Fatalf("a viewer grant lowered the collaborator role the unit gives: %v", err) + } +} + +// A viewer on the unit is a viewer everywhere beneath it: reading is allowed +// and writing is 403 rather than 404, because existence is already known. +func TestUnitViewerCanReadButNotWrite(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + seedUnitViewer(t, "user-carla") + + viewer := ctxAsRole("user-carla", authn.RoleMember) + if _, err := svc.GetTask(viewer, "task-team"); err != nil { + t.Fatalf("viewer must read: %v", err) + } + title := "hijacked" + if _, err := svc.UpdateTask(viewer, "task-team", &UpdateTaskRequest{Title: &title}); !IsForbidden(err) { + t.Fatalf("viewer task write = %v, want ErrForbidden", err) + } +} + +// seedUnitViewer adds a viewer member to the seeded team unit. +func seedUnitViewer(t *testing.T, userID string) { + t.Helper() + ctx := context.Background() + units := testUnits[t.Name()] + root, err := units.EnsureRoot(ctx, "", "Test org") + if err != nil { + t.Fatalf("ensure root: %v", err) + } + all, err := units.Store().ListByOrg(ctx, "") + if err != nil { + t.Fatalf("list units: %v", err) + } + for _, u := range all { + if u.ParentID == root.ID && u.Name == "Platform" { + if err := units.SetMember(ctx, u.ID, userID, "viewer", "user-ana"); err != nil { + t.Fatalf("add viewer member: %v", err) + } + return + } + } + t.Fatal("seed did not create the team unit") +} + +// A collaborator contributes; it does not administer. +func TestCollaboratorCannotManageWorkspace(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + + bruno := ctxAsRole("user-bruno", authn.RoleMember) + title := "renamed by a collaborator" + if _, err := svc.UpdateWorkspace(bruno, "ws-team", &UpdateWorkspaceRequest{Name: &title}); !IsForbidden(err) { + t.Fatalf("collaborator workspace rename = %v, want ErrForbidden", err) + } + if _, err := svc.UpsertWorkspaceMember(bruno, "ws-team", "user-carla", string(authz.WorkspaceRoleCollaborator)); !IsForbidden(err) { + t.Fatalf("collaborator member add = %v, want ErrForbidden", err) + } + // A collaborator CAN do the work: writing a task is the whole point. + if _, err := svc.UpdateTask(bruno, "task-team", &UpdateTaskRequest{Title: &title}); err != nil { + t.Fatalf("collaborator must be able to write a task: %v", err) + } +} + +// The owner keeps full control, and the DTO projection agrees with the resolver. +func TestOwnerScopesProjectIntoAccessProjection(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + + workspace, err := svc.GetWorkspace(ctxAsRole("user-ana", authn.RoleMember), "ws-team") + if err != nil { + t.Fatalf("owner get: %v", err) + } + projection := svc.ProjectWorkspaceAccess(ctxAsRole("user-ana", authn.RoleMember), []*models.Workspace{workspace}) + decision := projection.Decision("ws-team") + if decision.Role != authz.WorkspaceRoleOwner { + t.Fatalf("owner viewer_role = %q", decision.Role) + } + for _, scope := range []authz.Scope{authz.ScopeWorkspaceManage, authz.ScopeMemberManage, authz.ScopeSessionExec} { + if !decision.Has(scope) { + t.Errorf("owner projection missing %q", scope) + } + } + + brunoProjection := svc.ProjectWorkspaceAccess(ctxAsRole("user-bruno", authn.RoleMember), []*models.Workspace{workspace}) + if got := brunoProjection.Decision("ws-team"); got.Role != authz.WorkspaceRoleCollaborator { + t.Fatalf("member viewer_role = %q, want collaborator", got.Role) + } +} + +// Membership management rejects each bad input with its own reason. +func TestMemberManagementValidation(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, false) + ana := ctxAsRole("user-ana", authn.RoleMember) + + if _, err := svc.UpsertWorkspaceMember(ana, "ws-team", "user-bruno", "owner"); !errors.Is(err, ErrMemberRoleInvalid) { + t.Fatalf("assigning owner = %v, want ErrMemberRoleInvalid", err) + } + if _, err := svc.UpsertWorkspaceMember(ana, "ws-team", "user-ana", "collaborator"); !errors.Is(err, ErrMemberSelf) { + t.Fatalf("adding the owner = %v, want ErrMemberSelf", err) + } + if err := svc.RemoveWorkspaceMember(ana, "ws-team", "user-ana"); !errors.Is(err, ErrMemberIsOwner) { + t.Fatalf("removing the owner = %v, want ErrMemberIsOwner", err) + } + if err := svc.TransferWorkspaceOwnership(ana, "ws-team", "user-nobody"); !errors.Is(err, ErrTransferTargetNotMember) { + t.Fatalf("transfer to a non-member = %v, want ErrTransferTargetNotMember", err) + } +} + +// Ownership transfer must leave owner_id and the owner row in agreement. +func TestTransferOwnershipKeepsOwnerRowConsistent(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, false) + ana := ctxAsRole("user-ana", authn.RoleMember) + + if _, err := svc.UpsertWorkspaceMember(ana, "ws-team", "user-bruno", "collaborator"); err != nil { + t.Fatalf("add member: %v", err) + } + if err := svc.TransferWorkspaceOwnership(ana, "ws-team", "user-bruno"); err != nil { + t.Fatalf("transfer: %v", err) + } + + workspace, err := svc.GetWorkspace(context.Background(), "ws-team") + if err != nil { + t.Fatalf("get workspace: %v", err) + } + if workspace.OwnerID != "user-bruno" { + t.Fatalf("owner_id = %q, want user-bruno", workspace.OwnerID) + } + member, err := repo.GetWorkspaceMember(context.Background(), "ws-team", "user-bruno") + if err != nil || member == nil { + t.Fatalf("owner membership row missing: %v", err) + } + if member.Role != string(authz.WorkspaceRoleOwner) { + t.Fatalf("new owner row role = %q, want owner", member.Role) + } + // The previous owner is demoted, not removed. + previous, err := repo.GetWorkspaceMember(context.Background(), "ws-team", "user-ana") + if err != nil || previous == nil { + t.Fatalf("previous owner row missing: %v", err) + } + if previous.Role != string(authz.WorkspaceRoleCollaborator) { + t.Fatalf("previous owner role = %q, want collaborator", previous.Role) + } +} + +// Attribution comes from the authenticated caller, never the payload. +func TestMessageAuthorIsServerStamped(t *testing.T) { + svc, _, _ := createTestService(t) + stamped := svc.resolveAuthorID(ctxAsRole("user-bruno", authn.RoleMember), models.MessageAuthorUser, "user-ana") + if stamped != "user-bruno" { + t.Fatalf("author = %q, want the authenticated caller user-bruno", stamped) + } + // Agent messages name an execution, not a person, and keep their value. + agent := svc.resolveAuthorID(ctxAsRole("user-bruno", authn.RoleMember), models.MessageAuthorAgent, "exec-123") + if agent != "exec-123" { + t.Fatalf("agent author = %q, want exec-123", agent) + } + // Internal/auth-disabled callers keep today's behavior. + internal := svc.resolveAuthorID(context.Background(), models.MessageAuthorUser, "whatever") + if internal != "whatever" { + t.Fatalf("internal author = %q, want whatever", internal) + } +} + +// A viewer may read a task but must not move it, edit its metadata, or change +// a repository's base branch. These are the mutators that stayed on the +// reach-only helper until review caught them. +func TestViewerCannotReachWriteOnlyMutators(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, true) + // A viewer now comes from the tree: a direct grant could only raise the + // collaborator role the unit already gives user-bruno. + seedUnitViewer(t, "user-carla") + viewer := ctxAsRole("user-carla", authn.RoleMember) + + if _, err := svc.UpdateTaskMetadata(viewer, "task-team", map[string]interface{}{"x": 1}); !IsForbidden(err) { + t.Errorf("viewer UpdateTaskMetadata = %v, want ErrForbidden", err) + } + if _, err := svc.GetTask(viewer, "task-team"); err != nil { + t.Errorf("viewer must still read the task: %v", err) + } +} + +// A lookup failure must never read as "granted". +func TestAuthorizationFailsClosedOnLookupError(t *testing.T) { + svc, _, repo := createTestService(t) + seedTeamWorkspace(t, repo, false) + // A task pointing at a workspace row that does not exist is a dangling + // reference and stays readable; that is the documented exception. + if err := repo.CreateTask(context.Background(), &models.Task{ + ID: "task-dangling", WorkspaceID: "ws-missing", WorkflowID: "wf-team", + WorkflowStepID: "step-1", Title: "Dangling", State: v1.TaskStateCreated, Priority: "medium", + }); err != nil { + t.Fatalf("create task: %v", err) + } + if _, err := svc.GetTask(ctxAsRole("user-bruno", authn.RoleMember), "task-dangling"); err != nil { + t.Errorf("a dangling workspace reference should not hide the task: %v", err) + } +} diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 5dc93e3ed6..3c989222dd 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" + "github.com/kandev/kandev/internal/orgunit" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" @@ -149,6 +150,9 @@ func (*testWorkflowStepGetter) GetNextStepByPosition(context.Context, string, in // caller substitute the Sessions repository (e.g. to wrap it with a test-only // hook) while reusing the same DB setup, migrations, and cleanup-worker // wiring for every other field. +// testUnits exposes each test's unit service so a seed can build a tree. +var testUnits = map[string]*orgunit.Service{} + func createTestServiceWithSessionsRepo( t *testing.T, wrapSessions func(*sqliterepo.Repository) repository.SessionRepository, @@ -209,6 +213,18 @@ func createTestServiceWithSessionsRepo( Usage: repo, }, eventBus, log, RepositoryDiscoveryConfig{}) svc.SetWorkspaceBootstrapper(repo) + // Reach comes from the unit tree, so the service tests wire the real one + // rather than a stub: a resolver the wiring never calls protects nothing. + unitStore, unitErr := orgunit.NewStore(db.NewPool(sqlxDB, sqlxDB)) + if unitErr != nil { + t.Fatalf("failed to init unit store: %v", unitErr) + } + unitSvc := orgunit.NewService(unitStore, nil) + unitSvc.SetWorkspaceCounter(repo) + svc.SetUnitPlacer(unitSvc) + svc.SetUnitReach(unitSvc) + testUnits[t.Name()] = unitSvc + t.Cleanup(func() { delete(testUnits, t.Name()) }) svc.SetWorkflowStepGetter(&testWorkflowStepGetter{repo: repo}) if err := svc.StartTaskResourceCleanupWorker(context.Background()); err != nil { t.Fatalf("failed to start task resource cleanup worker: %v", err) diff --git a/apps/backend/internal/task/service/service_unit_placement.go b/apps/backend/internal/task/service/service_unit_placement.go new file mode 100644 index 0000000000..de9b9c8d6c --- /dev/null +++ b/apps/backend/internal/task/service/service_unit_placement.go @@ -0,0 +1,94 @@ +package service + +import ( + "context" + "errors" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/task/models" +) + +// UnitPlacer answers where a new workspace belongs. +// +// Placement is resolved lazily rather than through a user-creation hook: the +// personal unit is ensured on demand, so an account created by any path, at any +// time, gets one the first time it needs one. There is no ordering to get wrong +// and no account that can slip past a hook that was not wired. +type UnitPlacer interface { + // PersonalUnitID returns the caller's personal unit, creating it if needed. + PersonalUnitID(ctx context.Context, orgID, userID, displayName string) (string, error) + // RootUnitID returns the organization's root unit, creating it if needed. + RootUnitID(ctx context.Context, orgID string) (string, error) + // UnitOrgID reports which organization a unit belongs to, so a move can + // be refused before it crosses a tenant boundary. + UnitOrgID(ctx context.Context, unitID string) (string, error) +} + +// SetUnitPlacer wires the placement seam. +func (s *Service) SetUnitPlacer(p UnitPlacer) { s.unitPlacer = p } + +// placementFor decides the unit a new workspace goes in. +// +// A workspace created by a signed-in caller lands in their personal unit, +// which is where "only I can see this" lives now that there is no private +// flag. One created by an internal or pre-authentication caller has no person +// to belong to and lands at the root, which reproduces the +// everyone-reaches-it behaviour those workspaces already had. +// +// A failure to resolve an owner's personal unit is an error rather than a +// fallback. Falling back to the root would take a workspace that was meant to +// be private and hand it to everyone in the organization, which is the one +// outcome a placement bug must never produce quietly. +func (s *Service) placementFor(ctx context.Context, ownerID, orgID string) (string, error) { + if s.unitPlacer == nil { + return "", nil + } + if ownerID != "" { + unitID, err := s.unitPlacer.PersonalUnitID(ctx, orgID, ownerID, "") + if err != nil { + s.logger.Error("personal unit lookup failed; refusing to create the workspace", + zap.String("user_id", ownerID), zap.Error(err)) + return "", err + } + return unitID, nil + } + unitID, err := s.unitPlacer.RootUnitID(ctx, orgID) + if err != nil { + s.logger.Warn("root unit lookup failed; workspace is left unplaced", zap.Error(err)) + return "", nil + } + return unitID, nil +} + +// ErrUnitNotInWorkspaceOrg reports a destination in another tenant. +var ErrUnitNotInWorkspaceOrg = errors.New("that unit belongs to another organization") + +// moveWorkspaceToUnit changes a workspace's placement, which is the only way to +// change who reaches it. +// +// The caller already holds workspace.manage. They must also hold unit.manage, +// because moving a board into a unit hands it to everyone in that unit, and +// deciding who is in a unit is exactly what that scope governs. +func (s *Service) moveWorkspaceToUnit(ctx context.Context, workspace *models.Workspace, unitID string) error { + if unitID == "" || unitID == workspace.UnitID { + return nil + } + subject := callerSubject(ctx) + if !subject.Unscoped && !authz.SubjectOrgScopes(subject).Has(authz.ScopeUnitManage) { + return ErrForbidden + } + if s.unitPlacer == nil { + return ErrUnitNotInWorkspaceOrg + } + orgID, err := s.unitPlacer.UnitOrgID(ctx, unitID) + if err != nil { + return err + } + if orgID != workspace.OrgID { + return ErrUnitNotInWorkspaceOrg + } + workspace.UnitID = unitID + return nil +} diff --git a/apps/backend/internal/task/service/service_unit_reach.go b/apps/backend/internal/task/service/service_unit_reach.go new file mode 100644 index 0000000000..630b0d09b4 --- /dev/null +++ b/apps/backend/internal/task/service/service_unit_reach.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/task/models" +) + +// UnitReachResolver answers what a user inherits from a workspace's ancestry. +type UnitReachResolver interface { + // InheritedRole returns the highest role the user holds on any unit that + // is an ancestor of the given unit, including that unit itself. + InheritedRole(ctx context.Context, userID, unitID string) (string, error) + // InheritedRolesByUnit answers the same question for many units at once, + // so filtering a workspace list stays two queries rather than one per row. + InheritedRolesByUnit(ctx context.Context, userID string, unitIDs []string) (map[string]string, error) + // UnitReaders returns everyone who reaches a workspace placed in a unit, + // which is who a workspace-scoped event fans out to. + UnitReaders(ctx context.Context, unitID string) ([]string, error) +} + +// SetUnitReach wires the inherited-role seam. +func (s *Service) SetUnitReach(r UnitReachResolver) { s.unitReach = r } + +// inheritedRole resolves what a workspace's placement gives the caller. +// +// It fails closed. A lookup error returning "no role" rather than an error +// would be indistinguishable from a genuine absence of membership, which is +// the shape of failure that quietly hands out access, so the caller treats a +// failure as denial instead. +func (s *Service) inheritedRole(ctx context.Context, userID string, workspace *models.Workspace) (authz.WorkspaceRole, bool) { + if s.unitReach == nil || workspace == nil || workspace.UnitID == "" || userID == "" { + return authz.WorkspaceRoleNone, true + } + role, err := s.unitReach.InheritedRole(ctx, userID, workspace.UnitID) + if err != nil { + s.logger.Warn("unit reach lookup failed; denying access", + zap.String("workspace_id", workspace.ID), zap.Error(err)) + return authz.WorkspaceRoleNone, false + } + return authz.NormalizeWorkspaceRole(role), true +} + +// inheritedRolesFor resolves the inherited role for a list of workspaces. +// +// It fails closed for the whole list: a partial answer would silently hide +// workspaces the caller can actually reach, which reads as data loss rather +// than as a permission error. +func (s *Service) inheritedRolesFor(ctx context.Context, userID string, workspaces []*models.Workspace) (map[string]authz.WorkspaceRole, bool) { + out := map[string]authz.WorkspaceRole{} + if s.unitReach == nil || userID == "" { + return out, true + } + unitIDs := make([]string, 0, len(workspaces)) + seen := map[string]struct{}{} + for _, w := range workspaces { + if w == nil || w.UnitID == "" { + continue + } + if _, ok := seen[w.UnitID]; ok { + continue + } + seen[w.UnitID] = struct{}{} + unitIDs = append(unitIDs, w.UnitID) + } + byUnit, err := s.unitReach.InheritedRolesByUnit(ctx, userID, unitIDs) + if err != nil { + s.logger.Warn("unit reach list failed; returning no workspaces", zap.Error(err)) + return nil, false + } + for _, w := range workspaces { + if w == nil { + continue + } + if role, ok := byUnit[w.UnitID]; ok { + out[w.ID] = authz.NormalizeWorkspaceRole(role) + } + } + return out, true +} diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go index 8e6f721d0a..fe2879f558 100644 --- a/apps/backend/internal/task/service/service_workflow.go +++ b/apps/backend/internal/task/service/service_workflow.go @@ -11,6 +11,7 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" "github.com/kandev/kandev/internal/common/constants" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/steptelemetry" @@ -170,7 +171,7 @@ func (s *Service) resolveApprovalNextStep(ctx context.Context, step *wfmodels.Wo // UpdateTaskState updates the state of a task, moves it to the matching column, // and publishes a task.state_changed event func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) (*models.Task, error) { - if err := s.authorizeTaskID(ctx, id); err != nil { + if err := s.authorizeTaskScope(ctx, id, authz.ScopeTaskWrite); err != nil { return nil, err } task, err := s.tasks.GetTask(ctx, id) @@ -358,7 +359,7 @@ func (s *Service) updateTaskStateIfSessionState( // UpdateTaskMetadata updates only the metadata of a task (merges with existing) func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata map[string]interface{}) (*models.Task, error) { - if err := s.authorizeTaskID(ctx, id); err != nil { + if err := s.authorizeTaskScope(ctx, id, authz.ScopeTaskWrite); err != nil { return nil, err } task, err := s.tasks.GetTask(ctx, id) @@ -520,7 +521,7 @@ func (s *Service) MoveTaskWithOptions( position int, opts MoveTaskOptions, ) (*MoveTaskResult, error) { - if err := s.authorizeTaskID(ctx, id); err != nil { + if err := s.authorizeTaskScope(ctx, id, authz.ScopeTaskWrite); err != nil { return nil, err } task, err := s.tasks.GetTask(ctx, id) diff --git a/apps/backend/internal/task/service/task_assignee_column_regression_test.go b/apps/backend/internal/task/service/task_assignee_column_regression_test.go new file mode 100644 index 0000000000..01ee2cc5a0 --- /dev/null +++ b/apps/backend/internal/task/service/task_assignee_column_regression_test.go @@ -0,0 +1,150 @@ +package service + +import ( + "context" + "reflect" + "testing" +) + +// TestUpdateTask_AssigneeSurvivesOfficeMigration is a regression net for the +// office migration dropping tasks.assignee_user_id, and it is deliberately a +// service-level test rather than a repository one: the column is added by +// internal/task/repository/sqlite (ensureTeamAccessSchema) and then silently +// removed by internal/office's priority-to-TEXT rebuild, which recreates the +// tasks table from a hardcoded column list. Only a harness that runs BOTH +// repositories against one database sees the loss. +// +// Symptoms (before fix): every write path failed with "table tasks has no +// column named assignee_user_id" on a fresh install, while the ALTER that adds +// it reported success — MigrateLogger.Apply swallows errors, so nothing in the +// logs pointed at the rebuild. +func TestUpdateTask_AssigneeSurvivesOfficeMigration(t *testing.T) { + svc, _ := setupOfficeTest(t) + ctx := context.Background() + + created, err := svc.CreateTask(ctx, &CreateTaskRequest{ + WorkspaceID: "ws-1", + Title: "Assignable task", + ProjectID: "proj-1", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + + assignee := "user-42" + updated, err := svc.UpdateTask(ctx, created.Task.ID, &UpdateTaskRequest{ + AssigneeUserID: &assignee, + }) + if err != nil { + t.Fatalf("UpdateTask with assignee: %v", err) + } + if updated.AssigneeUserID != assignee { + t.Fatalf("assignee not applied: got %q, want %q", updated.AssigneeUserID, assignee) + } + + // Read it back through the repository so the assertion covers persistence, + // not just the in-memory struct the update returned. + reloaded, err := svc.GetTask(ctx, created.Task.ID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if reloaded.AssigneeUserID != assignee { + t.Fatalf("assignee did not persist: got %q, want %q", reloaded.AssigneeUserID, assignee) + } +} + +// TestUpdateTask_HumanAndAgentAssigneesAreIndependent pins the field +// independence the spec requires: a task can carry an agent runner and a human +// owner at once, and writing either one must leave the other alone. They are +// stored in different places (the agent assignee resolves through workflow +// step participants, the human one is a task column), so a regression here +// would most likely appear as one silently clearing the other. +func TestUpdateTask_HumanAndAgentAssigneesAreIndependent(t *testing.T) { + svc, _ := setupOfficeTest(t) + ctx := context.Background() + + created, err := svc.CreateTask(ctx, &CreateTaskRequest{ + WorkspaceID: "ws-1", + Title: "Dual assignee", + ProjectID: "proj-1", + AssigneeAgentProfileID: "agent-1", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + agentAssignee := created.Task.AssigneeAgentProfileID + if agentAssignee == "" { + t.Fatal("fixture produced no agent assignee: the independence assertions below would be vacuous") + } + + human := "user-42" + updated, err := svc.UpdateTask(ctx, created.Task.ID, &UpdateTaskRequest{AssigneeUserID: &human}) + if err != nil { + t.Fatalf("UpdateTask with human assignee: %v", err) + } + if updated.AssigneeUserID != human { + t.Fatalf("human assignee = %q, want %q", updated.AssigneeUserID, human) + } + if updated.AssigneeAgentProfileID != agentAssignee { + t.Fatalf("setting the human assignee changed the agent assignee: %q -> %q", + agentAssignee, updated.AssigneeAgentProfileID) + } + + // And the reverse: reassigning the human owner is what takeover is, so it + // must not disturb the runner either. + other := "user-7" + reassigned, err := svc.UpdateTask(ctx, created.Task.ID, &UpdateTaskRequest{AssigneeUserID: &other}) + if err != nil { + t.Fatalf("reassign: %v", err) + } + if reassigned.AssigneeAgentProfileID != agentAssignee { + t.Fatalf("takeover changed the agent assignee: %q -> %q", + agentAssignee, reassigned.AssigneeAgentProfileID) + } + if reassigned.AssigneeUserID != other { + t.Fatalf("human assignee = %q, want %q", reassigned.AssigneeUserID, other) + } +} + +// TestUpdateTask_TakeoverLeavesExecutionStateUntouched is the "takeover is +// reassign plus continue, not a lock" guarantee: a reassignment writes exactly +// one field. Anything else changing here (state, workspace, session or +// workflow placement) would mean a handover silently disturbed a running +// agent's execution context. +func TestUpdateTask_TakeoverLeavesExecutionStateUntouched(t *testing.T) { + svc, _ := setupOfficeTest(t) + ctx := context.Background() + + created, err := svc.CreateTask(ctx, &CreateTaskRequest{ + WorkspaceID: "ws-1", + Title: "In flight", + ProjectID: "proj-1", + }) + if err != nil { + t.Fatalf("CreateTask: %v", err) + } + before, err := svc.GetTask(ctx, created.Task.ID) + if err != nil { + t.Fatalf("GetTask before: %v", err) + } + + human := "user-42" + if _, err := svc.UpdateTask(ctx, created.Task.ID, &UpdateTaskRequest{AssigneeUserID: &human}); err != nil { + t.Fatalf("takeover: %v", err) + } + after, err := svc.GetTask(ctx, created.Task.ID) + if err != nil { + t.Fatalf("GetTask after: %v", err) + } + + if after.AssigneeUserID != human { + t.Fatalf("assignee = %q, want %q", after.AssigneeUserID, human) + } + // Normalise the two fields a reassignment is allowed to move, then compare + // everything else field by field. + after.AssigneeUserID = before.AssigneeUserID + after.UpdatedAt = before.UpdatedAt + if !reflect.DeepEqual(before, after) { + t.Fatalf("takeover changed more than the assignee:\nbefore: %+v\nafter: %+v", before, after) + } +} diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index f65c91e9cb..c5a9906322 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -66,6 +66,9 @@ const ( RoleAdmin = "admin" // RoleMember is a regular authenticated user. RoleMember = "member" + // RoleGuest reaches only workspaces it is an explicit member of. It is + // never assigned by migration; an admin grants it deliberately. + RoleGuest = "guest" // StatusActive marks a user that may log in. StatusActive = "active" @@ -74,14 +77,35 @@ const ( StatusDisabled = "disabled" ) +// NormalizeRole returns the canonical org role. Admin and guest are accepted +// as-is; anything else (including empty and unknown values) becomes member, +// which is the role every pre-guest account already had. +func NormalizeRole(value string) string { + switch value { + case RoleAdmin: + return RoleAdmin + case RoleGuest: + return RoleGuest + default: + return RoleMember + } +} + type User struct { - ID string `json:"id"` - Email string `json:"email"` - DisplayName string `json:"display_name"` - Role string `json:"role"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Status string `json:"status"` + // OrgID is the user's tenant. Empty means the account predates + // organizations or the tenancy migration has not run. + OrgID string `json:"org_id,omitempty"` + // IsOperator marks the instance operator tier: managing organizations, + // feature toggles and instance configuration. It grants NO access inside + // any organization, including the operator's own. + IsOperator bool `json:"is_operator,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type UserSettings struct { diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 156c00c89c..4d7d8b4df7 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -66,6 +66,8 @@ func (r *sqliteRepository) initSchema() error { email TEXT NOT NULL, display_name TEXT NOT NULL DEFAULT '', role TEXT NOT NULL DEFAULT 'admin', + org_id TEXT NOT NULL DEFAULT '', + is_operator INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', settings TEXT NOT NULL DEFAULT '{}', settings_revision BIGINT NOT NULL DEFAULT 0, @@ -101,6 +103,16 @@ func (r *sqliteRepository) runMigrations() { m.Apply("users.role", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'") m.Apply("users.status", "ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'") m.Apply("users.settings_revision", "ALTER TABLE users ADD COLUMN settings_revision BIGINT NOT NULL DEFAULT 0") + // Organizations. Empty means "not yet assigned"; the tenancy migration + // puts every existing account into the default org on first boot with the + // feature on. Email stays unique instance-wide rather than per-org, so an + // address identifies one person and the login form needs no org picker. + m.Apply("users.org_id", "ALTER TABLE users ADD COLUMN org_id TEXT NOT NULL DEFAULT ''") + m.Apply("users.org_idx", "CREATE INDEX IF NOT EXISTS idx_users_org ON users(org_id)") + // The instance operator tier. Granted to the first admin by the tenancy + // migration so an upgraded instance is never left with nobody able to + // manage organizations. + m.Apply("users.is_operator", "ALTER TABLE users ADD COLUMN is_operator INTEGER NOT NULL DEFAULT 0") // Safe pre-auth: the table only ever held the single default-user row. m.Apply("users.email_unique", "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)") } @@ -141,7 +153,7 @@ func (r *sqliteRepository) SetAgentProfileRecentUseLogger(log *commonlogger.Logg r.recentUseLogger = log } -const userColumns = "id, email, display_name, role, status, created_at, updated_at" +const userColumns = "id, email, display_name, role, status, org_id, is_operator, created_at, updated_at" // GetUser returns the user row for the given id. func (r *sqliteRepository) GetUser(ctx context.Context, id string) (*models.User, error) { @@ -200,9 +212,10 @@ func (r *sqliteRepository) CreateUser(ctx context.Context, user *models.User) er user.CreatedAt = now user.UpdatedAt = now _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO users (id, email, display_name, role, status, settings, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, '{}', ?, ?) - `), user.ID, user.Email, user.DisplayName, user.Role, user.Status, user.CreatedAt, user.UpdatedAt) + INSERT INTO users (id, email, display_name, role, status, org_id, is_operator, settings, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, '{}', ?, ?) + `), user.ID, user.Email, user.DisplayName, user.Role, user.Status, user.OrgID, user.IsOperator, + user.CreatedAt, user.UpdatedAt) return err } @@ -684,7 +697,7 @@ func (r *sqliteRepository) scanConditionalSettingsUpdate(ctx context.Context, sc // scanUser scans a single user row into a models.User. func scanUser(scanner interface{ Scan(dest ...any) error }) (*models.User, error) { user := &models.User{} - if err := scanner.Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.Status, &user.CreatedAt, &user.UpdatedAt); err != nil { + if err := scanner.Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &user.Status, &user.OrgID, &user.IsOperator, &user.CreatedAt, &user.UpdatedAt); err != nil { return nil, err } return user, nil diff --git a/apps/backend/internal/user/store/sqlite_tenancy.go b/apps/backend/internal/user/store/sqlite_tenancy.go new file mode 100644 index 0000000000..7d9ff98195 --- /dev/null +++ b/apps/backend/internal/user/store/sqlite_tenancy.go @@ -0,0 +1,102 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/kandev/kandev/internal/user/models" +) + +// Tenancy operations: assigning accounts to an organization, the instance +// operator tier, and removing an organization's accounts. +// +// Split from sqlite.go because that file is already at the 800-line limit. + +// SetUserOrg assigns a user to an organization. A user belongs to exactly one +// org, and this is the only way a user's org changes: there is no transfer +// flow, because moving a person between tenants would leave their workspaces +// behind in the old one. +func (r *sqliteRepository) SetUserOrg(ctx context.Context, id, orgID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE users SET org_id = ?, updated_at = ? WHERE id = ? + `), orgID, time.Now().UTC(), id) + return err +} + +// AssignUsersWithoutOrg puts every account that has no organization into the +// given one. It is the user half of the tenancy migration and is idempotent. +func (r *sqliteRepository) AssignUsersWithoutOrg(ctx context.Context, orgID string) (int64, error) { + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE users SET org_id = ? WHERE org_id = '' OR org_id IS NULL + `), orgID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +// ListUsersByOrg returns the accounts belonging to one organization. +func (r *sqliteRepository) ListUsersByOrg(ctx context.Context, orgID string) ([]*models.User, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT `+userColumns+` FROM users WHERE org_id = ? ORDER BY created_at ASC, id ASC + `), orgID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + users := make([]*models.User, 0) + for rows.Next() { + user, err := scanUser(rows) + if err != nil { + return nil, err + } + users = append(users, user) + } + return users, rows.Err() +} + +// SetOperator grants or revokes the instance operator tier. +func (r *sqliteRepository) SetOperator(ctx context.Context, id string, operator bool) error { + flag := 0 + if operator { + flag = 1 + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE users SET is_operator = ?, updated_at = ? WHERE id = ? + `), flag, time.Now().UTC(), id) + return err +} + +// CountOperators reports how many accounts hold the operator tier. Used to +// refuse removing the last one. +func (r *sqliteRepository) CountOperators(ctx context.Context) (int, error) { + var n int + err := r.ro.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE is_operator = 1`).Scan(&n) + return n, err +} + +// FirstAdminID returns the oldest active admin account, or "" when there is +// none. Used to grant the operator tier during the tenancy migration. +func (r *sqliteRepository) FirstAdminID(ctx context.Context) (string, error) { + var id string + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT id FROM users WHERE role = 'admin' AND status = 'active' + ORDER BY created_at ASC, id ASC LIMIT 1 + `)).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return id, err +} + +// DeleteUsersByOrg removes every account belonging to an organization. Called +// only from organization deletion, after that org's data is gone. +func (r *sqliteRepository) DeleteUsersByOrg(ctx context.Context, orgID string) error { + if orgID == "" { + return nil + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM users WHERE org_id = ?`), orgID) + return err +} diff --git a/apps/backend/internal/user/store/store.go b/apps/backend/internal/user/store/store.go index ea66746c8a..7d7597bb8a 100644 --- a/apps/backend/internal/user/store/store.go +++ b/apps/backend/internal/user/store/store.go @@ -52,3 +52,30 @@ type AccountRepository interface { } var _ AccountRepository = (*sqliteRepository)(nil) + +// TenancyRepository is the account surface organizations need: assigning +// accounts to an org, the instance operator tier, and removing an org's +// accounts. It is separate from AccountRepository so existing fakes are +// unaffected by the tenancy feature. +type TenancyRepository interface { + AssignUsersWithoutOrg(ctx context.Context, orgID string) (int64, error) + SetUserOrg(ctx context.Context, id, orgID string) error + SetOperator(ctx context.Context, id string, operator bool) error + CountOperators(ctx context.Context) (int, error) + FirstAdminID(ctx context.Context) (string, error) + DeleteUsersByOrg(ctx context.Context, orgID string) error +} + +// NoopTenancy is the fallback for account repositories that predate +// organizations. Every operation reports "nothing to do" rather than failing, +// so a fake in a test never has to know about tenancy. +type NoopTenancy struct{} + +func (NoopTenancy) AssignUsersWithoutOrg(context.Context, string) (int64, error) { return 0, nil } +func (NoopTenancy) SetUserOrg(context.Context, string, string) error { return nil } +func (NoopTenancy) SetOperator(context.Context, string, bool) error { return nil } +func (NoopTenancy) CountOperators(context.Context) (int, error) { return 0, nil } +func (NoopTenancy) FirstAdminID(context.Context) (string, error) { return "", nil } +func (NoopTenancy) DeleteUsersByOrg(context.Context, string) error { return nil } + +var _ TenancyRepository = (*sqliteRepository)(nil) diff --git a/apps/web/app/auth/login-page.tsx b/apps/web/app/auth/login-page.tsx index 272bfcde13..762cdb1723 100644 --- a/apps/web/app/auth/login-page.tsx +++ b/apps/web/app/auth/login-page.tsx @@ -5,6 +5,7 @@ import { Button } from "@kandev/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; import { IconLock } from "@tabler/icons-react"; +import type { TFunction } from "i18next"; import { ApiError } from "@/lib/api/client"; import { login } from "@/lib/api/domains/auth-api"; import { useAppStore } from "@/components/state-provider"; @@ -61,7 +62,7 @@ export function LoginPage() { } catch (err) { setSubmitting(false); if (err instanceof ApiError) { - setError(err.status === 429 ? t("auth:tooManyAttempts") : t("auth:invalidEmailOrPassword")); + setError(loginErrorMessage(err, t)); return; } setError(t("auth:somethingWentWrong")); @@ -127,3 +128,16 @@ export function LoginPage() { ); } + +/** + * Maps a failed sign-in to the message that tells the user what to actually do. + * + * A suspended organization must NOT read as a bad password: the credential was + * accepted, and saying otherwise sends the user to reset a password that works. + */ +function loginErrorMessage(err: ApiError, t: TFunction): string { + if (err.status === 429) return t("auth:tooManyAttempts"); + const code = (err.body as { code?: string } | null)?.code; + if (code === "org_suspended") return t("auth:organizationUnavailable"); + return t("auth:invalidEmailOrPassword"); +} diff --git a/apps/web/app/office/tasks/[id]/map-office-task.test.ts b/apps/web/app/office/tasks/[id]/map-office-task.test.ts index 39dd8d4393..8df3986e03 100644 --- a/apps/web/app/office/tasks/[id]/map-office-task.test.ts +++ b/apps/web/app/office/tasks/[id]/map-office-task.test.ts @@ -38,6 +38,19 @@ describe("mapOfficeTaskToTask", () => { expect(task.completedAt).toBeUndefined(); }); + // The human assignee travels store -> detail Task through this mapper. A + // dropped hop here is invisible to a component test that builds its own + // Task, and would surface as a task that reads unassigned no matter what + // the backend stored. + it("carries the human assignee through, independently of the agent assignee", () => { + const task = mapOfficeTaskToTask( + baseOfficeTask({ assigneeUserId: "user-7", assigneeAgentProfileId: "agent-3" }), + ); + + expect(task.assigneeUserId).toBe("user-7"); + expect(task.assigneeAgentProfileId).toBe("agent-3"); + }); + it("always maps createdBy to an empty string (no source of truth yet)", () => { const task = mapOfficeTaskToTask(baseOfficeTask()); diff --git a/apps/web/app/office/tasks/[id]/map-office-task.ts b/apps/web/app/office/tasks/[id]/map-office-task.ts index 6761ba09ed..6f8faf20ca 100644 --- a/apps/web/app/office/tasks/[id]/map-office-task.ts +++ b/apps/web/app/office/tasks/[id]/map-office-task.ts @@ -46,6 +46,7 @@ export function mapOfficeTaskToTask( typeof l === "string" ? { name: l, color: "#6b7280" } : l, ), assigneeAgentProfileId: raw.assigneeAgentProfileId, + assigneeUserId: raw.assigneeUserId, parentId: raw.parentId, projectId: raw.projectId, blockedBy: extra.blockedBy ?? [], diff --git a/apps/web/app/office/tasks/[id]/types.ts b/apps/web/app/office/tasks/[id]/types.ts index c4274043dc..2410040e2f 100644 --- a/apps/web/app/office/tasks/[id]/types.ts +++ b/apps/web/app/office/tasks/[id]/types.ts @@ -150,6 +150,7 @@ export type Task = { labels: TaskLabelLocal[]; assigneeAgentProfileId?: string; assigneeName?: string; + assigneeUserId?: string; projectId?: string; projectName?: string; projectColor?: string; diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 7c4650d564..41405b054f 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -9,6 +9,7 @@ import { Card, CardContent } from "@kandev/ui/card"; import { Separator } from "@kandev/ui/separator"; import { useToast } from "@/components/toast-provider"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import type { Agent, AgentDiscovery, @@ -451,17 +452,19 @@ function AgentSetupForm({ if (savedDraft) saveRevision.setSaved(JSON.stringify(savedDraft)); }; const profilesValid = areAgentProfilesValid(draftAgent); - const saveInvalidReason = resolveSaveInvalidReason( - t, - profilesValid, - hasInvalidMcpConfig, - draftAgent.name === "dynamic", - ); + // Agents and agent profiles are org configuration: every mutating route + // behind this page requires org.config.manage, which only an administrator + // holds. Without this the save bar stays live for a member and the write + // fails with a 403 they cannot act on. + const canManage = useIsAdmin(); + const saveInvalidReason = canManage + ? resolveSaveInvalidReason(t, profilesValid, hasInvalidMcpConfig, draftAgent.name === "dynamic") + : t("agents:adminOnly"); useSettingsSaveContributor({ id: `agent:${draftAgent.id}`, revision: saveRevision.revision, isDirty: isCreateMode ? isAgentDirty : saveRevision.revision !== saveRevision.saved, - canSave: profilesValid && !hasInvalidMcpConfig, + canSave: canManage && profilesValid && !hasInvalidMcpConfig, invalidReason: saveInvalidReason, save: handleCoordinatedSave, discard: () => undefined, @@ -477,7 +480,7 @@ function AgentSetupForm({ isCreateMode={isCreateMode} savedAgent={savedAgent} showInstallationStatus={draftAgent.name !== "dynamic"} - onDelete={handleDeleteAgent} + onDelete={canManage ? handleDeleteAgent : undefined} /> ({ useIsAdmin: () => true })); + import { ProfileMcpConfigCard } from "./profile-mcp-config-card"; const DIRTY_ATTRIBUTE = "data-settings-dirty"; diff --git a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx index f5e9df3172..d37b3432c3 100644 --- a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx +++ b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx @@ -7,6 +7,7 @@ import { Switch } from "@kandev/ui/switch"; import { Textarea } from "@kandev/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import { SettingsCard } from "@/components/settings/settings-card"; // `validateDraftServers` runs outside React (from an onChange handler and from // the parent's draft state), so it uses the module-level `t`, which resolves at @@ -412,6 +413,7 @@ export function ProfileMcpConfigCard({ mcpBaselineServers, mcpError, }); + const canManage = useIsAdmin(); useSettingsSaveContributor({ id: `agent-profile-mcp:${profileId}`, revision: JSON.stringify({ @@ -419,8 +421,9 @@ export function ProfileMcpConfigCard({ servers: state.currentServers, }), isDirty: supportsMcp && state.isEditableProfile && state.currentDirty, - canSave: !state.currentError, - invalidReason: state.currentError ?? undefined, + // Same org.config.manage gate as the agent form this card saves beside. + canSave: canManage && !state.currentError, + invalidReason: canManage ? (state.currentError ?? undefined) : t("agents:adminOnly"), save: handleSaveMcp, discard: resetMcpDraft, }); diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index 3993e2f356..7106db3453 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -22,6 +22,7 @@ import { listAgents, listAvailableAgents, } from "@/lib/api"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import type { AgentUpdateJob, AgentUpdatePreview, AgentUpdateStatus, InstallJob } from "@/lib/api"; import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"; import { useAgentRuntimeUpdates } from "@/hooks/domains/settings/use-agent-runtime-updates"; @@ -71,6 +72,7 @@ type InstalledAgentsSectionProps = { ) => Promise; setTuiDialogOpen: (open: boolean) => void; handleRescan: () => Promise; + canManage: boolean; }; function InstalledAgentsHeader({ @@ -81,7 +83,10 @@ function InstalledAgentsHeader({ }: { rescanning: boolean; onOpenShell: () => void; - onOpenTuiDialog: () => void; + /** Absent for a caller without org.config.manage: creating a TUI agent is + * a write this page must not offer them. Rescan and the host shell stay: + * discovery is a read. */ + onOpenTuiDialog?: () => void; onRescan: () => void; }) { const { t } = useTranslation(); @@ -117,16 +122,18 @@ function InstalledAgentsHeader({ )} {t("agents:rescan")} - + {onOpenTuiDialog && ( + + )} ); @@ -189,6 +196,7 @@ function InstalledAgentsSection({ startUpdate, setTuiDialogOpen, handleRescan, + canManage, }: InstalledAgentsSectionProps) { const { t } = useTranslation(); const [shellOpen, setShellOpen] = useState(false); @@ -207,7 +215,7 @@ function InstalledAgentsSection({ setShellOpen(true)} - onOpenTuiDialog={() => setTuiDialogOpen(true)} + onOpenTuiDialog={canManage ? () => setTuiDialogOpen(true) : undefined} onRescan={() => void handleRescan()} /> void handleRescan(), } : {})} @@ -282,6 +290,9 @@ function useAgentPageState() { const { items: availableAgents } = useAvailableAgents(); const [rescanning, setRescanning] = useState(false); const [tuiDialogOpen, setTuiDialogOpen] = useState(false); + // Agents and agent profiles are org configuration: every write behind this + // page requires org.config.manage, which only an administrator holds. + const canManage = useIsAdmin(); const { updateJobs, previewUpdate, startUpdate } = useAgentRuntimeUpdates(); const { refresh: refreshRuntimeUpdateStatuses, statusByAgent } = useAgentRuntimeUpdateStatuses(updateJobs); @@ -340,6 +351,7 @@ function useAgentPageState() { }; return { + canManage, savedAgents, installedAgents, discoveryAgents, @@ -363,6 +375,7 @@ function useAgentPageState() { export default function AgentsSettingsPage() { const { + canManage, savedAgents, installedAgents, discoveryAgents, @@ -393,17 +406,19 @@ export default function AgentsSettingsPage() { {/* The page's primary action: everything else on this page manages what is already installed. */} - + {canManage && ( + + )} @@ -411,6 +426,7 @@ export default function AgentsSettingsPage() { onNameChange(e.target.value)} /> @@ -210,6 +216,7 @@ function WorkspaceSettingsCard({ } type DeleteWorkspaceCardProps = { + canManage: boolean; workspaceName: string; deleteDialogOpen: boolean; setDeleteDialogOpen: (open: boolean) => void; @@ -219,6 +226,7 @@ type DeleteWorkspaceCardProps = { }; function DeleteWorkspaceCard({ + canManage, workspaceName, deleteDialogOpen, setDeleteDialogOpen, @@ -227,6 +235,9 @@ function DeleteWorkspaceCard({ onDelete, }: DeleteWorkspaceCardProps) { const { t } = useTranslation(); + // Deleting a workspace is owner-only. The API refuses a member either way; + // hiding the card keeps the UI from offering an action it will reject. + if (!canManage) return null; return ( <> @@ -523,6 +534,9 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { handleDiscard, handleDeleteWorkspace, } = useWorkspaceEditForm(workspace); + // Owner-only controls are gated on the server-issued scope, never on an + // owner-id comparison, so the UI and the API agree on one answer. + const canManage = hasScope(workspace.scopes, SCOPE.workspaceManage); const { t } = useTranslation(); useSettingsSaveContributor({ @@ -544,6 +558,7 @@ function WorkspaceEditForm({ workspace }: WorkspaceEditFormProps) { + {/* + Reads the live store item rather than the form's captured snapshot: + scopes arrive with hydration, which can land after this + form mounts, and a snapshot would freeze the owner out of their own + workspace. + */} + + + + ({ - id: workspace.id, - name: workspace.name, - description: workspace.description ?? null, - owner_id: workspace.owner_id, - default_executor_id: workspace.default_executor_id ?? null, - default_environment_id: workspace.default_environment_id ?? null, - default_agent_profile_id: workspace.default_agent_profile_id ?? null, - created_at: workspace.created_at, - updated_at: workspace.updated_at, - })), + // Both lists go through the shared mapper. Hand-copying fields here is + // how visibility and the caller's scopes got dropped from the store the + // first time, which silently rendered a workspace read-only to its own + // owner. + mapWorkspaceItem(created), + // Existing entries are already store items; re-listing their fields by + // hand is what dropped visibility and scopes before. + ...items, ]); setNewWorkspaceName(""); setIsAdding(false); @@ -188,6 +175,8 @@ export function WorkspacesPageClient() { + +
{isAdding && ( diff --git a/apps/web/components/app-sidebar/current-user-chip.tsx b/apps/web/components/app-sidebar/current-user-chip.tsx index 485d097f32..9c7195b219 100644 --- a/apps/web/components/app-sidebar/current-user-chip.tsx +++ b/apps/web/components/app-sidebar/current-user-chip.tsx @@ -12,13 +12,7 @@ import { import { logout } from "@/lib/api/domains/auth-api"; import { useAppStore } from "@/components/state-provider"; import { cn } from "@/lib/utils"; - -function initialsFor(name: string): string { - const parts = name.trim().split(/\s+/).filter(Boolean); - if (parts.length === 0) return "?"; - if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); - return `${parts[0]![0]}${parts[parts.length - 1]![0]}`.toUpperCase(); -} +import { initialsFor } from "@/lib/user-initials"; async function handleLogout() { try { diff --git a/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts b/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts index daf850ae69..a4b0a6f09a 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts +++ b/apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts @@ -6,6 +6,7 @@ import { IconCommand, IconCpu, IconDatabase, + IconBuilding, IconFlask, IconFolder, IconInfoCircle, @@ -19,6 +20,7 @@ import { IconRobot, IconShieldLock, IconTerminal2, + IconSitemap, IconUsers, IconWand, } from "@tabler/icons-react"; @@ -70,7 +72,7 @@ export type SettingsMenuItem = { * the page that owns them. */ activePrefixes?: string[]; - requires?: "account" | "users"; + requires?: "account" | "users" | "organizations"; /** Rows whose page owns a list show its size as a trailing badge. */ countKey?: MenuCountKey; }; @@ -174,6 +176,23 @@ export const SETTINGS_MENU_SECTIONS: SettingsMenuSection[] = [ icon: IconUsers, requires: "users", }, + // An organization is a tenant boundary above users, so it belongs with + // the other who-can-reach-what settings rather than under System, which + // is instance operation. The href keeps its /settings/system/ prefix, + // like the Users row above it: these sections group by subject, not by + // URL. + { + href: "/settings/units", + labelKey: "settings:navUnits", + icon: IconSitemap, + requires: "users", + }, + { + href: "/settings/system/organizations", + labelKey: "orgs:navOrganizations", + icon: IconBuilding, + requires: "organizations", + }, { href: ACCOUNT_SECURITY_SETTINGS_HREF, labelKey: "sidebar:profileAndPassword", diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx index 6a5ef6a99e..4f07130c24 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx @@ -69,9 +69,9 @@ const state = { savedLayouts: [] as Array<{ id: string }>, }, auth: { - mode: "disabled" as const, + mode: "disabled" as string, authenticated: true, - user: null, + user: null as { role: string } | null, }, settingsMenu: { mode: "flat" as SettingsMenuMode, @@ -87,8 +87,12 @@ vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: typeof state) => unknown) => selector(state), })); +// Per-test feature flags, so a row that only exists behind one can be driven +// both ways rather than being permanently invisible to this suite. +const featuresOn = new Set(); + vi.mock("@/hooks/domains/features/use-feature", () => ({ - useFeature: () => false, + useFeature: (name: string) => featuresOn.has(name), })); vi.mock("@/hooks/domains/settings/use-secrets", () => ({ @@ -146,7 +150,12 @@ describe("SettingsTree static menu", () => { setMenuMode("flat"); }); - afterEach(() => cleanup()); + afterEach(() => { + cleanup(); + featuresOn.clear(); + state.auth.mode = "disabled"; + state.auth.user = null; + }); it("renders section headers as static text, not links or buttons", () => { render(); @@ -195,6 +204,35 @@ describe("SettingsTree static menu", () => { expect(screen.queryByRole("link", { name: "API Tokens" })).toBeNull(); }); + // An organization is a boundary above users, so it belongs with the other + // who-can-reach-what settings. It used to sit under System, which is for + // operating the instance. + it("puts Organizations in Access Control, not System", () => { + featuresOn.add("auth"); + featuresOn.add("multiTenancy"); + state.auth.mode = "enabled"; + state.auth.user = { role: "admin" }; + + render(); + + const access = screen.getByTestId("settings-section-access"); + expect(within(access).getByRole("link", { name: "Organizations" })).toBeTruthy(); + const system = screen.queryByTestId("settings-section-system"); + if (system) { + expect(within(system).queryByRole("link", { name: "Organizations" })).toBeNull(); + } + }); + + it("hides Organizations when the feature is off, rather than linking a page that refuses", () => { + featuresOn.add("auth"); + state.auth.mode = "enabled"; + state.auth.user = { role: "admin" }; + + render(); + + expect(screen.queryByRole("link", { name: "Organizations" })).toBeNull(); + }); + it("keeps a section inside Task Behavior instead of rendering it as a page row", () => { render(); diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx index 17ed31eccd..4c41868623 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree.tsx @@ -170,10 +170,16 @@ export function SettingsTree({ }) { const { t } = useTranslation(); const authEnabled = useFeature("auth"); + const multiTenancyEnabled = useFeature("multiTenancy"); const authMode = useAppStore((s) => s.auth.mode); const isAdmin = useIsAdmin(); const showAccountItems = authEnabled && authMode === "enabled"; const showUsersItem = authEnabled && isAdmin; + // Only the instance operator can use this page, but that answer costs a + // request, so the row is gated on the feature instead and the page itself + // turns a non-operator away. Before this it was rendered even on installs + // with Organizations switched off. + const showOrganizationsItem = authEnabled && multiTenancyEnabled; const discoveryItems = useSettingsDiscovery(); const counts = useSettingsMenuCounts(); const [query, setQuery] = useState(""); @@ -185,6 +191,7 @@ export function SettingsTree({ const itemVisible = (item: SettingsMenuItem) => { if (item.requires === "account") return showAccountItems; if (item.requires === "users") return showUsersItem; + if (item.requires === "organizations") return showOrganizationsItem; return true; }; @@ -203,7 +210,11 @@ export function SettingsTree({ const items = section.items.filter(itemVisible); if (items.length === 0) return null; return ( -
+
{items.map((item) => ( + + {initialsFor(name)} + + {name} + + ); +} diff --git a/apps/web/components/kanban-card-assignee.test.tsx b/apps/web/components/kanban-card-assignee.test.tsx new file mode 100644 index 0000000000..76dfb97f66 --- /dev/null +++ b/apps/web/components/kanban-card-assignee.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mockAppState = { + workspaces: { activeId: "ws-1" }, + kanban: { tasks: [] as Array<{ id: string; title: string }> }, + kanbanMulti: { snapshots: {} as Record }> }, + taskPRs: { byTaskId: {} as Record }, + taskMRs: { byWorkspaceId: {} as Record> }, +}; + +// The card body reaches the store two ways: useAppStore for the slices above, +// and useAppStoreApi for the imperative read the action menu does. A mock that +// defines only the first throws on render, so the assignee assertions below +// would fail for a reason that has nothing to do with the assignee. +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState), + useAppStoreApi: () => ({ + getState: () => mockAppState, + setState: vi.fn(), + subscribe: () => () => undefined, + }), +})); + +vi.mock("@/lib/api/domains/team-access-api", () => ({ + listDirectoryUsers: vi + .fn() + .mockResolvedValue({ users: [{ id: "user-1", display_name: "Ada Lovelace" }], total: 1 }), + listWorkspaceMembers: vi.fn().mockResolvedValue({ members: [], total: 0 }), +})); + +import { listDirectoryUsers } from "@/lib/api/domains/team-access-api"; +import { resetDirectoryCacheForTests } from "@/hooks/domains/users/use-assignable-people"; +import { KanbanCardBody } from "./kanban-card-content"; +import type { Task } from "./kanban-card"; + +const TASK: Task = { id: "task-1", title: "Fix the bug", workflowStepId: "step-1" }; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + resetDirectoryCacheForTests(); +}); + +describe("KanbanCardBody — human assignee", () => { + it("shows the assignee's name so a board answers who is on what", async () => { + render(); + + await waitFor(() => + expect(screen.getByTestId("kanban-card-assignee").textContent).toContain("Ada Lovelace"), + ); + }); + + it("renders no assignee affordance on an unassigned task", () => { + render(); + expect(screen.queryByTestId("kanban-card-assignee")).toBeNull(); + }); + + it("fetches the directory once for a board full of cards", async () => { + render( + <> + + + + , + ); + + await waitFor(() => expect(screen.getAllByTestId("kanban-card-assignee")).toHaveLength(3)); + expect(listDirectoryUsers).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index b52ada1925..11524b76b5 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -13,10 +13,10 @@ import { IconUsersGroup, } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; +import { AssigneeBadge } from "@/components/kanban-card-assignee-badge"; import { Card, CardContent } from "@kandev/ui/card"; import { Checkbox } from "@kandev/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { PRTaskIcon } from "@/components/github/pr-task-icon"; import { MRTaskIcon } from "@/components/gitlab/mr-task-icon"; import { RegisteredChangeRequestTaskIcon } from "@/components/integrations/registered-change-request-task-icon"; @@ -26,6 +26,7 @@ import { } from "@/components/kanban-card-menu-items"; import { TaskCardIndicators, TaskCardTags } from "@/components/kanban-card-plugin-slots"; import { KanbanCardPriorityIndicator } from "@/components/kanban-card-priority-indicator"; +import { RepoChipRow } from "@/components/kanban-card-repository-chips"; import { CardTitle } from "@/components/kanban-card-title"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; @@ -71,66 +72,6 @@ export type KanbanCardShellProps = KanbanCardActionProps & onCheckboxClick: (e: React.MouseEvent) => void; }; -const REPO_CHIPS_VISIBLE = 2; - -function RepoChip({ chip }: { chip: RepositoryChip }) { - const badge = ( - - {chip.label} - - ); - if (!chip.path) return badge; - return ( - - {badge} - - {chip.path} - - - ); -} - -function OverflowRepoTooltip({ chips }: { chips: RepositoryChip[] }) { - return ( -
- {chips.map((chip) => ( -
-
{chip.label}
- {chip.path &&
{chip.path}
} -
- ))} -
- ); -} - -function RepoChipRow({ chips }: { chips: RepositoryChip[] }) { - if (chips.length === 0) return null; - const visible = chips.slice(0, REPO_CHIPS_VISIBLE); - const overflow = chips.slice(REPO_CHIPS_VISIBLE); - return ( -
- {visible.map((chip) => ( - - ))} - {overflow.length > 0 && ( - - - - +{overflow.length} - - - - - - - )} -
- ); -} - export function KanbanCardBody({ task, repositoryChips, @@ -211,6 +152,7 @@ function KanbanCardBadges({ task }: { task: Task }) { return (
{task.blocked && } + {task.assigneeUserId && } {task.queuedForStepId && ( + {chip.label} + + ); + if (!chip.path) return badge; + return ( + + {badge} + + {chip.path} + + + ); +} + +function OverflowRepoTooltip({ chips }: { chips: RepositoryChip[] }) { + return ( +
+ {chips.map((chip) => ( +
+
{chip.label}
+ {chip.path &&
{chip.path}
} +
+ ))} +
+ ); +} + +export function RepoChipRow({ chips }: { chips: RepositoryChip[] }) { + if (chips.length === 0) return null; + const visible = chips.slice(0, REPO_CHIPS_VISIBLE); + const overflow = chips.slice(REPO_CHIPS_VISIBLE); + return ( +
+ {visible.map((chip) => ( + + ))} + {overflow.length > 0 && ( + + + + +{overflow.length} + + + + + + + )} +
+ ); +} diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index b957bcdc65..04abca24f0 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -88,6 +88,8 @@ export interface Task { primaryExecutorType?: string | null; primaryExecutorName?: string | null; isRemoteExecutor?: boolean; + /** Human assignee (user id); the card renders their name read-only. */ + assigneeUserId?: string; parentTaskId?: string | null; workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; updatedAt?: string; diff --git a/apps/web/components/settings/agents/agent-admin-gating.test.tsx b/apps/web/components/settings/agents/agent-admin-gating.test.tsx new file mode 100644 index 0000000000..30a2d315dc --- /dev/null +++ b/apps/web/components/settings/agents/agent-admin-gating.test.tsx @@ -0,0 +1,115 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@kandev/ui/tooltip"; + +import type { Agent, AgentProfile } from "@/lib/types/http"; + +// Agents and agent profiles are org configuration: every mutating route behind +// these components requires org.config.manage, which only an administrator +// holds. isAdmin is the frontend's mirror of that scope, so it is the one knob +// these cases turn. +let isAdmin = true; + +vi.mock("@/hooks/domains/auth/use-is-admin", () => ({ useIsAdmin: () => isAdmin })); + +const storeState = { + settingsAgents: { items: [] as Agent[] }, + agentProfiles: { items: [] as Array<{ id: string }> }, +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (s: unknown) => unknown) => selector(storeState), + useAppStoreApi: () => ({ getState: () => storeState, setState: vi.fn() }), +})); + +vi.mock("@/app/actions/agents", () => ({ + deleteAgentProfileAction: vi.fn(), + duplicateAgentProfileAction: vi.fn(), +})); + +vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }) })); + +vi.mock("@/lib/routing/client-router", () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => "/settings/agents", +})); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isFullDesktop: true, isFinePointer: true }), +})); + +vi.mock("@/components/routing/app-link", () => ({ + default: ({ children, ...props }: { children?: ReactNode }) => {children}, +})); + +vi.mock("@kandev/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children?: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children?: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children?: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children }: { children?: ReactNode }) => , +})); + +import { ProfileRow } from "./agent-profiles-section"; +import { InstallAgentCard } from "@/components/settings/install-agent-card"; +import type { AvailableAgent } from "@/lib/types/http"; + +const AGENT = { + id: "agent-1", + name: "claude", + profiles: [{ id: "p-1", name: "Alpha", agentDisplayName: "Claude Code" } as AgentProfile], +} as unknown as Agent; + +const AVAILABLE = { + name: "claude", + display_name: "Claude Code", + install_script: "npm i -g claude", +} as unknown as AvailableAgent; + +afterEach(() => { + cleanup(); + isAdmin = true; +}); + +describe("agent settings write controls follow org.config.manage", () => { + it("offers a profile's duplicate and delete actions to an administrator", () => { + render( + + + , + ); + expect(screen.queryByTestId("duplicate-profile-inline-p-1")).not.toBeNull(); + expect(screen.queryByTestId("delete-profile-inline-p-1")).not.toBeNull(); + }); + + // The row itself stays: seeing which profiles exist is a read, and the task + // dialog offers the same profiles. Only the writes go. + it("withholds them from a member, keeping the row readable", () => { + isAdmin = false; + render( + + + , + ); + expect(screen.getByText("Alpha")).toBeTruthy(); + expect(screen.queryByTestId("duplicate-profile-inline-p-1")).toBeNull(); + expect(screen.queryByTestId("delete-profile-inline-p-1")).toBeNull(); + expect(screen.queryByTestId("profile-actions-menu-p-1")).toBeNull(); + }); + + it("renders the install button only when the caller may install", () => { + const { rerender } = render( + + + , + ); + expect(screen.queryByTestId("install-button-claude")).not.toBeNull(); + + rerender( + + + , + ); + expect(screen.queryByTestId("install-button-claude")).toBeNull(); + }); +}); diff --git a/apps/web/components/settings/agents/agent-install-catalog.tsx b/apps/web/components/settings/agents/agent-install-catalog.tsx index 35c0b6d576..f4f898c973 100644 --- a/apps/web/components/settings/agents/agent-install-catalog.tsx +++ b/apps/web/components/settings/agents/agent-install-catalog.tsx @@ -7,6 +7,7 @@ import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { useAppStore } from "@/components/state-provider"; import { InstallAgentCard } from "@/components/settings/install-agent-card"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { installAgent, listAgentDiscovery, listAvailableAgents, listInstallJobs } from "@/lib/api"; @@ -65,7 +66,7 @@ function InstallCard({ copiedValue: string | null; onCopy: (text: string) => void; job: InstallJob | undefined; - onInstall: (name: string) => void; + onInstall?: (name: string) => void; }) { return ( availableAgents.filter((a: AvailableAgent) => !a.available && a.install_script), @@ -247,7 +251,7 @@ export function AgentInstallCatalog() { copiedValue={copiedValue} onCopy={copy} job={installJobs[agent.name]} - onInstall={handleInstall} + onInstall={canManage ? handleInstall : undefined} /> ))} {notInstalledTools.map((tool) => ( diff --git a/apps/web/components/settings/agents/agent-profiles-section.test.tsx b/apps/web/components/settings/agents/agent-profiles-section.test.tsx index d28c211c9b..3a9d785902 100644 --- a/apps/web/components/settings/agents/agent-profiles-section.test.tsx +++ b/apps/web/components/settings/agents/agent-profiles-section.test.tsx @@ -34,6 +34,10 @@ const EMPTY_AGENT = { ...AGENT, profiles: [] } as unknown as Agent; let storeState: { settingsAgents: { items: Agent[] }; agentProfiles: { items: Array<{ id: string }> }; + // The row's actions are gated on org.config.manage, which useIsAdmin reads + // off the auth slice. Auth disabled is the default install and resolves to + // an administrator, matching the backend's synthetic identity. + auth: { mode: string | undefined; user: { role: string } | undefined }; }; function setSettingsAgents(items: Agent[]) { @@ -128,6 +132,7 @@ describe("ProfileRow deletion", () => { storeState = { settingsAgents: { items: [{ ...AGENT, profiles: [...AGENT.profiles] }] }, agentProfiles: { items: [{ id: "p-1" }, { id: "p-2" }] }, + auth: { mode: undefined, user: undefined }, }; vi.clearAllMocks(); }); @@ -227,6 +232,7 @@ describe("ProfileRow duplicate", () => { storeState = { settingsAgents: { items: [{ ...AGENT, profiles: [...AGENT.profiles] }] }, agentProfiles: { items: [] }, + auth: { mode: undefined, user: undefined }, }; }); afterEach(() => cleanup()); @@ -251,6 +257,7 @@ describe("ProfileRow responsive actions", () => { storeState = { settingsAgents: { items: [{ ...AGENT, profiles: [...AGENT.profiles] }] }, agentProfiles: { items: [] }, + auth: { mode: undefined, user: undefined }, }; mocks.responsive.isFullDesktop = false; mocks.responsive.isFinePointer = false; diff --git a/apps/web/components/settings/agents/agent-profiles-section.tsx b/apps/web/components/settings/agents/agent-profiles-section.tsx index 87082d9139..392bc6a9ac 100644 --- a/apps/web/components/settings/agents/agent-profiles-section.tsx +++ b/apps/web/components/settings/agents/agent-profiles-section.tsx @@ -19,6 +19,7 @@ import { useToast } from "@/components/toast-provider"; import { AgentProfileDeleteConfirmation } from "@/components/settings/agent-profile-delete-dialog"; import { deleteAgentProfileAction } from "@/app/actions/agents"; import { useProfileDuplicate } from "@/hooks/domains/settings/use-profile-duplicate"; +import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useRouter } from "@/lib/routing/client-router"; import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; @@ -208,6 +209,7 @@ function ProfileRowDeleteConfirmation({ type ProfileRowCardProps = { profile: AgentProfile; href: string; + canManage: boolean; confirmOpen: boolean; isFinePointer: boolean; isFullDesktop: boolean; @@ -220,6 +222,7 @@ type ProfileRowCardProps = { function ProfileRowCard({ profile, href, + canManage, confirmOpen, isFinePointer, isFullDesktop, @@ -256,7 +259,8 @@ function ProfileRowCard({ )}
- {!(confirmOpen && !isFinePointer) && + {canManage && + !(confirmOpen && !isFinePointer) && (isFullDesktop ? ( void; +}) { + if (!agent.install_script || !onInstall) return null; + return ; +} + export function InstallAgentCard({ agent, job, @@ -80,7 +101,9 @@ export function InstallAgentCard({ job: InstallJob | undefined; /** The copy-and-script row, rendered above the Install button by the parent. */ scriptSlot?: React.ReactNode; - onInstall: (name: string) => void; + /** Absent for a caller without org.config.manage. Installing an agent is a + * write; the card still renders so they can see what is available. */ + onInstall?: (name: string) => void; }) { const status: InstallStatus = job?.status ?? "idle"; const failed = status === "failed"; @@ -97,9 +120,7 @@ export function InstallAgentCard({

{agent.description}

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

{t("orgs:adminPasswordHint")}

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

{org.name}

+

+ {org.slug} +

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

{t("settings:unitNoMembers")}

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

{t("common:loading")}

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

{t("settings:unitsEmpty")}

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

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

    + ) : null} +
    + ); +} + +/** + * Workspace membership. + * + * Membership here is the exception path. Reach normally follows the + * organization unit the workspace sits in, so a team sets that once and never + * anyone. Membership below is the exception path, for private workspaces, + * guests, and narrowing a colleague to a viewer. + */ +export function WorkspaceTeamAccessCard(props: WorkspaceTeamAccessCardProps) { + const { t } = useTranslation(); + const access = useWorkspaceTeamAccess(props); + + return ( + + + {t("workspaces:teamAccess.title")} + {t("workspaces:teamAccess.description")} + + +
    +
    + + +
    + +
      + {access.members.length === 0 ? ( +
    • + {t("workspaces:teamAccess.noMembers")} +
    • + ) : ( + access.members.map((member) => ( + + )) + )} +
    + + {access.canManageMembers ? ( + + ) : null} +
    +
    +
    + ); +} diff --git a/apps/web/components/task/simple/components/human-assignee-picker.test.tsx b/apps/web/components/task/simple/components/human-assignee-picker.test.tsx new file mode 100644 index 0000000000..7bf1e8a223 --- /dev/null +++ b/apps/web/components/task/simple/components/human-assignee-picker.test.tsx @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { StateProvider } from "@/components/state-provider"; +import { TaskOptimisticContextProvider } from "@/hooks/use-optimistic-task-mutation"; +import { HumanAssigneePicker } from "./human-assignee-picker"; +import type { Task } from "@/app/office/tasks/[id]/types"; + +vi.mock("@/lib/api/domains/office-extended-api", async () => { + const actual = await vi.importActual( + "@/lib/api/domains/office-extended-api", + ); + return { ...actual, updateTask: vi.fn().mockResolvedValue({ ok: true }) }; +}); + +vi.mock("@/lib/api/domains/team-access-api", () => ({ + // Ada holds a member row; Bruno reaches the workspace through org + // visibility and appears only in the directory. Both must resolve to a + // name, which is what the member list alone could not do. + listWorkspaceMembers: vi.fn().mockResolvedValue({ + members: [{ user_id: "user-1", display_name: "Ada Lovelace", role: "owner" }], + total: 1, + }), + listDirectoryUsers: vi.fn().mockResolvedValue({ + users: [ + { id: "user-2", display_name: "Grace Hopper" }, + { id: "user-3", display_name: "Bruno Costa" }, + ], + total: 2, + }), +})); + +import { updateTask } from "@/lib/api/domains/office-extended-api"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const baseTask: Task = { + id: "t-1", + workspaceId: "ws-1", + identifier: "TASK-1", + title: "First task", + status: "todo", + priority: "medium", + labels: [], + blockedBy: [], + blocking: [], + children: [], + reviewers: [], + approvers: [], + decisions: [], + createdBy: "user", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", +}; + +const initialState = { + auth: { + mode: "enabled" as const, + authenticated: true, + user: { + id: "user-2", + email: "grace@example.com", + display_name: "Grace Hopper", + role: "member", + status: "active", + }, + ssoProviders: [], + }, + workspaces: { activeId: "ws-1", items: [] }, +}; + +function Wrapper({ children, task }: { children: ReactNode; task: Task }) { + const ctx = { task, applyPatch: vi.fn(), restore: vi.fn() }; + return ( + + {children} + + ); +} + +describe("HumanAssigneePicker", () => { + it("assigns the task to the signed-in user when Assign to me is clicked", async () => { + render( + + + , + ); + fireEvent.click(screen.getByTestId("assign-to-me")); + await waitFor(() => + expect(updateTask).toHaveBeenCalledWith("t-1", { assignee_user_id: "user-2" }), + ); + }); + + it("hides Assign to me when the task is already the caller's", () => { + const mine: Task = { ...baseTask, assigneeUserId: "user-2" }; + render( + + + , + ); + expect(screen.queryByTestId("assign-to-me")).toBeNull(); + }); + + it("shows the assignee's display name once members load", async () => { + const assigned: Task = { ...baseTask, assigneeUserId: "user-1" }; + render( + + + , + ); + await waitFor(() => + expect(screen.getByTestId("human-assignee-picker-trigger").textContent).toContain( + "Ada Lovelace", + ), + ); + }); + + it("resolves a name for someone who reaches the workspace without a member row", async () => { + const assigned: Task = { ...baseTask, assigneeUserId: "user-3" }; + render( + + + , + ); + await waitFor(() => + expect(screen.getByTestId("human-assignee-picker-trigger").textContent).toContain( + "Bruno Costa", + ), + ); + }); + + it("sends an empty string to unassign, which is distinct from omitting the field", async () => { + const assigned: Task = { ...baseTask, assigneeUserId: "user-1" }; + render( + + + , + ); + fireEvent.click(screen.getByTestId("human-assignee-picker-trigger")); + fireEvent.click(await screen.findByText("Unassigned")); + await waitFor(() => expect(updateTask).toHaveBeenCalledWith("t-1", { assignee_user_id: "" })); + }); +}); diff --git a/apps/web/components/task/simple/components/human-assignee-picker.tsx b/apps/web/components/task/simple/components/human-assignee-picker.tsx new file mode 100644 index 0000000000..6d11434e00 --- /dev/null +++ b/apps/web/components/task/simple/components/human-assignee-picker.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useMemo } from "react"; +import { Button } from "@kandev/ui/button"; +import { Combobox, type ComboboxOption } from "@/components/combobox"; +import { useAppStore } from "@/components/state-provider"; +import { updateTask } from "@/lib/api/domains/office-extended-api"; +import { useAssignablePeople } from "@/hooks/domains/users/use-assignable-people"; +import { useOptimisticTaskMutation } from "@/hooks/use-optimistic-task-mutation"; +import type { Task } from "@/app/office/tasks/[id]/types"; +import { useTranslation } from "react-i18next"; + +type HumanAssigneePickerProps = { + task: Task; +}; + +// i18n-exempt: sentinel compared with ===, never displayed. +const UNASSIGNED = "__unassigned__"; + +/** + * The human assignee: who on the team owns this task. + * + * Deliberately separate from AssigneePicker, which sets the agent that runs + * the task. The two are independent — a task can be assigned to a person and + * a runner at once — so this control never touches the agent assignee. + * + * Assignment is advisory. It gates nothing: taking a task over is a + * reassignment plus a prompt, not a lock, which is why "Assign to me" needs + * no confirmation and works whether or not the task is already assigned. + */ +export function HumanAssigneePicker({ task }: HumanAssigneePickerProps) { + const { t } = useTranslation(); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const currentUser = useAppStore((s) => s.auth.user); + const mutate = useOptimisticTaskMutation(); + const { people } = useAssignablePeople(workspaceId); + + const assignee = task.assigneeUserId ?? ""; + + const options = useMemo(() => { + const unassigned: ComboboxOption = { + value: UNASSIGNED, + label: t("task:unassigned"), + keywords: ["none", "unassigned"], + renderLabel: () => {t("task:unassigned")}, + }; + const known = new Map(people.map((p) => [p.id, p.name])); + // A task can hold an assignee neither source returned (a disabled account, + // or a list the caller cannot read). Keep them selectable so the control + // shows the truth rather than silently reading as unassigned. + if (assignee && !known.has(assignee)) known.set(assignee, assignee); + return [ + unassigned, + ...Array.from(known, ([userId, name]) => ({ + value: userId, + label: name, + keywords: [name, userId], + })), + ]; + }, [people, assignee, t]); + + const apply = async (next: string) => { + if (next === assignee) return; + try { + await mutate(task.id, { assigneeUserId: next || undefined }, () => + updateTask(task.id, { assignee_user_id: next }), + ); + } catch { + /* toast already raised */ + } + }; + + const isMine = Boolean(currentUser) && assignee === currentUser?.id; + + return ( + + {currentUser && !isMine && ( + + )} + void apply(next === UNASSIGNED ? "" : next)} + placeholder={t("task:unassigned")} + searchPlaceholder={t("task:searchPeople")} + emptyMessage={t("task:noPeopleFound")} + triggerClassName="h-7 w-auto justify-end px-2" + popoverAlign="end" + testId="human-assignee-picker-trigger" + /> + + ); +} diff --git a/apps/web/components/task/simple/task-properties.tsx b/apps/web/components/task/simple/task-properties.tsx index c9d6b2e381..bd523f23f5 100644 --- a/apps/web/components/task/simple/task-properties.tsx +++ b/apps/web/components/task/simple/task-properties.tsx @@ -10,6 +10,7 @@ import { StatusPicker } from "./components/status-picker"; import { PriorityPicker } from "./components/priority-picker"; import { LabelsPicker } from "./components/labels-picker"; import { AssigneePicker } from "./components/assignee-picker"; +import { HumanAssigneePicker } from "./components/human-assignee-picker"; import { ProjectPicker } from "./components/project-picker"; import { ParentPicker } from "./components/parent-picker"; import { BlockersPicker } from "./components/blockers-picker"; @@ -19,6 +20,7 @@ import { ApproversPicker } from "./components/approvers-picker"; import { PendingApprovalBadge } from "./components/pending-approval-badge"; import { QuorumStatusBadge } from "./components/quorum-status-badge"; import { useTranslation } from "react-i18next"; +import { useAppStore } from "@/components/state-provider"; type TaskPropertiesProps = { task: Task; @@ -53,6 +55,20 @@ function NoneLabel() { return {t("task:none")}; } +// The human assignee row is only meaningful when there is an identity to +// assign to: with authentication disabled every visitor is the same anonymous +// user, so the control would offer a choice that cannot mean anything. +function HumanAssigneeRow({ task }: { task: Task }) { + const { t } = useTranslation(); + const hasIdentity = useAppStore((s) => Boolean(s.auth.user)); + if (!hasIdentity) return null; + return ( + + + + ); +} + function IdentitySection({ task }: { task: Task }) { const { t } = useTranslation(); return ( @@ -73,6 +89,7 @@ function IdentitySection({ task }: { task: Task }) { + diff --git a/apps/web/components/task/task-assignee-control.test.tsx b/apps/web/components/task/task-assignee-control.test.tsx new file mode 100644 index 0000000000..0c64b5cfa8 --- /dev/null +++ b/apps/web/components/task/task-assignee-control.test.tsx @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { StateProvider } from "@/components/state-provider"; +import { TaskAssigneeControl } from "./task-assignee-control"; +import { resetDirectoryCacheForTests } from "@/hooks/domains/users/use-assignable-people"; + +const CONTROL = "task-assignee-control"; + +vi.mock("@/lib/api/domains/kanban-api", async () => { + const actual = await vi.importActual( + "@/lib/api/domains/kanban-api", + ); + return { ...actual, updateTask: vi.fn().mockResolvedValue({}) }; +}); + +vi.mock("@/lib/api/domains/team-access-api", () => ({ + listWorkspaceMembers: vi.fn().mockResolvedValue({ + members: [{ user_id: "user-1", display_name: "Ada Lovelace", role: "owner" }], + total: 1, + }), + listDirectoryUsers: vi.fn().mockResolvedValue({ + users: [ + { id: "user-1", display_name: "Ada Lovelace" }, + { id: "user-2", display_name: "Grace Hopper" }, + ], + total: 2, + }), +})); + +import { updateTask } from "@/lib/api/domains/kanban-api"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + resetDirectoryCacheForTests(); +}); + +function stateWith(assigneeUserId?: string, authenticated = true) { + return { + auth: { + mode: authenticated ? ("enabled" as const) : ("disabled" as const), + authenticated: true, + user: authenticated + ? { + id: "user-2", + email: "grace@example.com", + display_name: "Grace Hopper", + role: "member", + status: "active", + } + : null, + ssoProviders: [], + }, + kanban: { + workflowId: "wf-1", + steps: [], + tasks: [ + { + id: "t-1", + title: "First task", + workflowId: "wf-1", + workflowStepId: "step-1", + position: 0, + assigneeUserId, + }, + ], + }, + }; +} + +function renderControl(assigneeUserId?: string, authenticated = true) { + return render( + + + , + ); +} + +describe("TaskAssigneeControl", () => { + it("shows the assignee's name from the store, not a raw user id", async () => { + renderControl("user-1"); + await waitFor(() => expect(screen.getByTestId(CONTROL).textContent).toContain("Ada Lovelace")); + }); + + it("assigns to the signed-in user from the dropdown", async () => { + renderControl(); + fireEvent.click(screen.getByTestId(CONTROL)); + fireEvent.click(await screen.findByText("Assign to me")); + await waitFor(() => + expect(updateTask).toHaveBeenCalledWith("t-1", { assignee_user_id: "user-2" }), + ); + }); + + it("omits Assign to me when the task is already the caller's", async () => { + renderControl("user-2"); + fireEvent.click(screen.getByTestId(CONTROL)); + await screen.findByText("Unassigned"); + expect(screen.queryByText("Assign to me")).toBeNull(); + }); + + it("sends an empty string to unassign", async () => { + renderControl("user-1"); + fireEvent.click(screen.getByTestId(CONTROL)); + fireEvent.click(await screen.findByText("Unassigned")); + await waitFor(() => expect(updateTask).toHaveBeenCalledWith("t-1", { assignee_user_id: "" })); + }); + + it("renders nothing when authentication is disabled", () => { + renderControl(undefined, false); + expect(screen.queryByTestId(CONTROL)).toBeNull(); + }); +}); diff --git a/apps/web/components/task/task-assignee-control.tsx b/apps/web/components/task/task-assignee-control.tsx new file mode 100644 index 0000000000..16c90b4fc4 --- /dev/null +++ b/apps/web/components/task/task-assignee-control.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { IconUserPlus } from "@tabler/icons-react"; +import { toast } from "@/lib/toast/sonner"; +import { useTranslation } from "react-i18next"; +import { Avatar, AvatarFallback } from "@kandev/ui/avatar"; +import { Combobox, type ComboboxOption } from "@/components/combobox"; +import { useAppStore } from "@/components/state-provider"; +import { useAssignablePeople } from "@/hooks/domains/users/use-assignable-people"; +import { updateTask } from "@/lib/api/domains/kanban-api"; +import { initialsFor } from "@/lib/user-initials"; + +// i18n-exempt: sentinels compared with ===, never displayed. +const UNASSIGNED = "__unassigned__"; +// i18n-exempt: sentinel compared with ===, never displayed. +const ASSIGN_TO_ME = "__assign_to_me__"; + +type Props = { + taskId?: string | null; + workspaceId?: string | null; + isArchived?: boolean; +}; + +/** + * The human assignee for a kanban task, in the task top bar. + * + * The office properties panel has a roomier row for the same field; this is + * the same capability shaped for a crowded bar, so "Assign to me" is the first + * dropdown entry rather than a separate always-visible button. + * + * No optimistic patch: the backend publishes `task.updated` with the new + * `assignee_user_id` and the single kanban mapper puts it back into the store, + * which is the same path every other top-bar mutation relies on. + */ +export function TaskAssigneeControl({ taskId, workspaceId, isArchived }: Props) { + const { t } = useTranslation(); + const currentUser = useAppStore((s) => s.auth.user); + // Read from the store rather than through props: `task.updated` lands the + // new assignee there, so another person taking the task over shows up here + // without a refetch, and there is no prop chain to drop a hop in. + const assigneeUserId = useAppStore( + (s) => s.kanban.tasks.find((entry: { id: string }) => entry.id === taskId)?.assigneeUserId, + ); + const enabled = Boolean(currentUser) && Boolean(taskId) && !isArchived; + const { people, nameFor } = useAssignablePeople(workspaceId, { enabled }); + const [pending, setPending] = useState(false); + + const assignee = assigneeUserId ?? ""; + const isMine = Boolean(currentUser) && assignee === currentUser?.id; + + const options = useMemo(() => { + const entries: ComboboxOption[] = [ + { + value: UNASSIGNED, + label: t("task:unassigned"), + keywords: ["none", "unassigned"], + renderLabel: () => ( + + + {t("task:unassigned")} + + ), + }, + ]; + if (currentUser && !isMine) { + entries.unshift({ + value: ASSIGN_TO_ME, + label: t("task:assignToMe"), + keywords: ["me", "self"], + renderLabel: () => {t("task:assignToMe")}, + }); + } + const known = new Map(people.map((p) => [p.id, p.name])); + if (assignee && !known.has(assignee)) known.set(assignee, nameFor(assignee)); + for (const [id, name] of known) { + entries.push({ + value: id, + label: name, + keywords: [name, id], + // The trigger renders the selected option's label, so the avatar comes + // along for free rather than needing a trigger-prefix escape hatch. + renderLabel: () => ( + + + {initialsFor(name)} + + {name} + + ), + }); + } + return entries; + }, [people, assignee, currentUser, isMine, nameFor, t]); + + // With authentication disabled every visitor is the same anonymous user, so + // there is nobody to assign to and the control would be a choice that cannot + // mean anything. + if (!enabled || !currentUser || !taskId) return null; + + const resolveChoice = (choice: string): string => { + if (choice === ASSIGN_TO_ME) return currentUser.id ?? ""; + if (choice === UNASSIGNED) return ""; + return choice; + }; + + const apply = async (choice: string) => { + const next = resolveChoice(choice); + if (next === assignee) return; + setPending(true); + try { + await updateTask(taskId, { assignee_user_id: next }); + } catch (err) { + // The server refuses an assignee who cannot reach the workspace, with a + // message written to be shown as-is. + toast.error(err instanceof Error ? err.message : t("task:updateFailed")); + } finally { + setPending(false); + } + }; + + return ( + void apply(next)} + loading={pending} + ariaLabel={t("task:assignedTo")} + placeholder={t("task:unassigned")} + searchPlaceholder={t("task:searchPeople")} + emptyMessage={t("task:noPeopleFound")} + triggerClassName="h-7 w-auto gap-1 px-2 text-xs" + popoverAlign="end" + testId="task-assignee-control" + /> + ); +} diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index e4be0691ae..603bd6f569 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -20,6 +20,7 @@ import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availabili import { PortForwardButton } from "@/components/task/port-forward-dialog"; import { ExecutorSettingsButton } from "@/components/task/executor-settings-button"; import { TaskUnarchiveButton } from "@/components/task/task-unarchive-button"; +import { TaskAssigneeControl } from "@/components/task/task-assignee-control"; import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper"; import { TaskTopBarPluginActions } from "@/components/task/task-top-bar-plugin-actions"; import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics"; @@ -411,6 +412,9 @@ function TopBarRight({ )} + + + { + test.beforeAll(async ({ backend }) => { + await backend.restart({ + KANDEV_FEATURES_AUTH: "true", + KANDEV_FEATURES_MULTI_TENANCY: "true", + KANDEV_DATABASE_PATH: path.join(backend.tmpDir, "kandev-mobile-organizations.db"), + }); + }); + + test.afterAll(async ({ backend }) => { + await backend.restart(); + }); + + test("keeps organization identity and actions usable on a phone", async ({ + browser, + backend, + }) => { + const context = await browser.newContext({ + ...devices["Pixel 5"], + baseURL: backend.frontendUrl, + }); + + try { + await setupAdmin(context, backend.baseUrl, ADMIN); + await login(context, backend.baseUrl, ADMIN); + + const created = await context.request.post(`${backend.baseUrl}/api/v1/instance/orgs`, { + data: { name: "Globex Industries" }, + }); + expect(created.status(), await created.text()).toBe(200); + + const page = await context.newPage(); + expect((await page.viewportSize())?.width).toBe(393); + await page.goto("/settings/system/organizations"); + + const row = page.getByTestId("organization-row").filter({ hasText: "Globex Industries" }); + await expect(row).toBeVisible({ timeout: 15_000 }); + + const name = row.getByText("Globex Industries", { exact: true }); + expect( + await name.evaluate((element) => element.scrollWidth <= element.clientWidth), + "the organization name must render in full instead of collapsing to an ellipsis", + ).toBe(true); + + for (const accessibleName of [/add administrator/i, /^suspend$/i, /delete organization/i]) { + const action = row.getByRole("button", { name: accessibleName }); + await expect(action).toBeVisible(); + const box = await action.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + } + + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); + + const suspended = page.waitForResponse( + (response) => + response.request().method() === "PATCH" && + /\/api\/v1\/instance\/orgs\/[^/]+$/.test(new URL(response.url()).pathname), + ); + await row.getByRole("button", { name: /^suspend$/i }).tap(); + expect((await suspended).status()).toBe(200); + await expect(row.getByText("Suspended", { exact: true })).toBeVisible(); + } finally { + await context.close(); + } + }); +}); diff --git a/apps/web/e2e/tests/auth/org-units.spec.ts b/apps/web/e2e/tests/auth/org-units.spec.ts new file mode 100644 index 0000000000..afa0b4208c --- /dev/null +++ b/apps/web/e2e/tests/auth/org-units.spec.ts @@ -0,0 +1,152 @@ +import { expect } from "@playwright/test"; +import type { BrowserContext } from "@playwright/test"; +import { backendFixture as test } from "../../fixtures/backend"; +import { acceptInvite, createInviteToken, login, setupAdmin } from "../../helpers/auth"; + +/** + * Reach through the organization unit tree. + * + * The unit tests prove the resolver's table and the handler's refusals. This + * proves the outcome the tree exists for: someone reaches a board because of + * where it sits, with nobody having named them on it, and stops reaching it + * when it moves. A resolver nothing calls protects nothing, and a tree nothing + * places workspaces into shares nothing. + * + * Runs in the `auth` project: reach only means something once identities do. + */ +test.describe.serial("organization units", () => { + const ADMIN = { email: "ana@e2e.dev", password: "adminpass123", displayName: "Ana" }; + const MEMBER = { email: "bruno@e2e.dev", password: "memberpass123", displayName: "Bruno" }; + + let adminContext: BrowserContext; + let memberContext: BrowserContext; + let rootId = ""; + let deptId = ""; + let teamId = ""; + let workspaceId = ""; + let memberId = ""; + + test.beforeAll(async ({ backend, browser }) => { + await backend.restart({ KANDEV_FEATURES_AUTH: "true" }); + + adminContext = await browser.newContext({ baseURL: backend.frontendUrl }); + await setupAdmin(adminContext, backend.baseUrl, ADMIN); + await login(adminContext, backend.baseUrl, ADMIN); + + const token = await createInviteToken(adminContext, backend.baseUrl, { email: MEMBER.email }); + memberContext = await browser.newContext({ baseURL: backend.frontendUrl }); + await acceptInvite(memberContext, backend.baseUrl, token, MEMBER); + await login(memberContext, backend.baseUrl, MEMBER); + + const directory = await ( + await adminContext.request.get(`${backend.baseUrl}/api/v1/users/directory`) + ).json(); + memberId = directory.users.find((u: { display_name: string }) => + u.display_name.includes("Bruno"), + ).id; + }); + + test.afterAll(async ({ backend }) => { + await adminContext?.close(); + await memberContext?.close(); + await backend.restart(); + }); + + test("builds a department and a team beneath the organization", async ({ backend }) => { + const tree = await (await adminContext.request.get(`${backend.baseUrl}/api/v1/units`)).json(); + rootId = tree.units.find((u: { kind: string }) => u.kind === "root").id; + + const dept = await adminContext.request.post(`${backend.baseUrl}/api/v1/units`, { + data: { parent_id: rootId, name: "Platform" }, + }); + expect(dept.status(), await dept.text()).toBe(201); + deptId = (await dept.json()).id; + + const team = await adminContext.request.post(`${backend.baseUrl}/api/v1/units`, { + data: { parent_id: deptId, name: "Runtime" }, + }); + expect(team.status(), await team.text()).toBe(201); + teamId = (await team.json()).id; + }); + + test("a member reaches a board through the unit above it, uninvited", async ({ backend }) => { + const created = await adminContext.request.post(`${backend.baseUrl}/api/v1/workspaces`, { + data: { name: "Platform Team" }, + }); + expect(created.ok(), await created.text()).toBeTruthy(); + workspaceId = (await created.json()).id; + + // Created by the admin, so it starts in her personal unit: nobody else. + const before = await memberContext.request.get( + `${backend.baseUrl}/api/v1/workspaces/${workspaceId}`, + ); + expect(before.status()).toBe(404); + + // Put the board in the team and the person in the department above it. + const moved = await adminContext.request.patch( + `${backend.baseUrl}/api/v1/workspaces/${workspaceId}`, + { data: { unit_id: teamId } }, + ); + expect(moved.ok(), await moved.text()).toBeTruthy(); + + const added = await adminContext.request.put( + `${backend.baseUrl}/api/v1/units/${deptId}/members/${memberId}`, + { data: { role: "collaborator" } }, + ); + expect(added.ok(), await added.text()).toBeTruthy(); + + const after = await memberContext.request.get( + `${backend.baseUrl}/api/v1/workspaces/${workspaceId}`, + ); + expect( + after.status(), + "a member of the department must reach a board in its team with no invitation", + ).toBe(200); + }); + + test("moving the board out of the tree withdraws that reach", async ({ backend }) => { + const personal = await ( + await adminContext.request.get(`${backend.baseUrl}/api/v1/units`) + ).json(); + const personalId = personal.units.find((u: { kind: string }) => u.kind === "personal").id; + + const moved = await adminContext.request.patch( + `${backend.baseUrl}/api/v1/workspaces/${workspaceId}`, + { data: { unit_id: personalId } }, + ); + expect(moved.ok(), await moved.text()).toBeTruthy(); + + const after = await memberContext.request.get( + `${backend.baseUrl}/api/v1/workspaces/${workspaceId}`, + ); + expect(after.status(), "a board in someone's personal unit reaches nobody else").toBe(404); + }); + + test("a member cannot rearrange the organization", async ({ backend }) => { + const created = await memberContext.request.post(`${backend.baseUrl}/api/v1/units`, { + data: { parent_id: rootId, name: "Shadow department" }, + }); + expect(created.status(), "unit.manage is an administrator scope").toBe(403); + + // Reading the tree is allowed: a member has to see why they reach what + // they reach. + const read = await memberContext.request.get(`${backend.baseUrl}/api/v1/units`); + expect(read.status()).toBe(200); + }); + + test("the tree renders in settings", async ({ backend }) => { + const page = await adminContext.newPage(); + await page.addInitScript(() => + window.localStorage.setItem("kandev.onboarding.completed", "true"), + ); + await page.goto(`${backend.frontendUrl}/settings/units`); + + const rows = page.getByTestId("unit-row"); + await expect(rows.first()).toBeVisible(); + // Scoped to the rows: "Platform" also appears as a workspace name, and a + // bare text match would pass for the wrong reason. + await expect(rows.filter({ hasText: "Platform" }).first()).toBeVisible(); + await expect(rows.filter({ hasText: "Runtime" }).first()).toBeVisible(); + await page.close(); + }); +}); diff --git a/apps/web/hooks/domains/org/use-org-units.ts b/apps/web/hooks/domains/org/use-org-units.ts new file mode 100644 index 0000000000..87ed2be563 --- /dev/null +++ b/apps/web/hooks/domains/org/use-org-units.ts @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "@/lib/toast/sonner"; +import { + createUnit, + deleteUnit, + listUnits, + removeUnitMember, + renameUnit, + setUnitMember, + withDepth, + type OrgUnit, +} from "@/lib/api/domains/org-units-api"; + +export type UnitRow = OrgUnit & { depth: number }; + +/** + * The organization tree and the operations on it. + * + * Every refusal from the server names its blocking condition, so failures are + * surfaced verbatim rather than replaced with a generic message: "this unit + * still holds child units or workspaces" tells someone what to do next, and + * "something went wrong" does not. + */ +export function useOrgUnits() { + const { t } = useTranslation(); + const [units, setUnits] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + try { + const res = await listUnits(); + setUnits(withDepth(res.units ?? [])); + } catch { + setUnits([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const run = useCallback( + async (action: () => Promise) => { + setBusy(true); + try { + await action(); + await refresh(); + return true; + } catch (err) { + toast.error(err instanceof Error ? err.message : t("settings:unitActionFailed")); + return false; + } finally { + setBusy(false); + } + }, + [refresh, t], + ); + + return { + units, + loading, + busy, + refresh, + create: (parentId: string, name: string) => run(() => createUnit(parentId, name)), + rename: (unitId: string, name: string) => run(() => renameUnit(unitId, name)), + remove: (unitId: string) => run(() => deleteUnit(unitId)), + addMember: (unitId: string, userId: string, role: string) => + run(() => setUnitMember(unitId, userId, role)), + removeMember: (unitId: string, userId: string) => run(() => removeUnitMember(unitId, userId)), + }; +} diff --git a/apps/web/hooks/domains/org/use-organizations.ts b/apps/web/hooks/domains/org/use-organizations.ts new file mode 100644 index 0000000000..2f1f52fe89 --- /dev/null +++ b/apps/web/hooks/domains/org/use-organizations.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/components/toast-provider"; +import { + createOrg, + createOrgAdmin, + deleteOrg, + getCurrentOrg, + listOrgs, + updateOrg, +} from "@/lib/api/domains/org-api"; +import type { Org } from "@/lib/types/org"; + +/** + * Operator-side organization management. + * + * `isOperator` decides whether the surface exists at all: a non-operator gets + * 404 from these routes, so the page must not render for them. + */ +export function useOrganizations() { + const { t } = useTranslation(); + const { toast } = useToast(); + const [orgs, setOrgs] = useState([]); + const [isOperator, setIsOperator] = useState(null); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + try { + const current = await getCurrentOrg(); + setIsOperator(current.is_operator); + if (!current.is_operator) { + setOrgs([]); + return; + } + setOrgs((await listOrgs()).orgs ?? []); + } catch { + setIsOperator(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const run = useCallback( + async (action: () => Promise, success: string) => { + setBusy(true); + try { + await action(); + await refresh(); + toast({ title: success }); + } catch (error) { + toast({ + title: error instanceof Error ? error.message : t("orgs:genericError"), + variant: "error", + }); + } finally { + setBusy(false); + } + }, + [refresh, toast, t], + ); + + return { + orgs, + isOperator, + busy, + create: (name: string, onDone: () => void) => + void run(async () => { + await createOrg(name); + onDone(); + }, t("orgs:created")), + rename: (id: string, name: string) => + void run(() => updateOrg(id, { name }), t("orgs:renamed")), + suspend: (id: string) => + void run(() => updateOrg(id, { status: "suspended" }), t("orgs:suspended")), + resume: (id: string) => void run(() => updateOrg(id, { status: "active" }), t("orgs:resumed")), + remove: (id: string, slug: string, onDone: () => void) => + void run(async () => { + await deleteOrg(id, slug); + onDone(); + }, t("orgs:deleted")), + addAdmin: ( + id: string, + admin: { email: string; password: string; display_name: string }, + onDone: () => void, + ) => + void run(async () => { + await createOrgAdmin(id, admin); + onDone(); + }, t("orgs:adminCreated")), + }; +} diff --git a/apps/web/hooks/domains/settings/use-settings-discovery.ts b/apps/web/hooks/domains/settings/use-settings-discovery.ts index cf82818b61..be65cc100b 100644 --- a/apps/web/hooks/domains/settings/use-settings-discovery.ts +++ b/apps/web/hooks/domains/settings/use-settings-discovery.ts @@ -9,6 +9,7 @@ import { resolveSettingsDiscovery } from "@/lib/settings-discovery/resolve"; export function useSettingsDiscovery() { const { t } = useTranslation(); const authEnabled = useFeature("auth"); + const multiTenancyEnabled = useFeature("multiTenancy"); const authMode = useAppStore((state) => state.auth.mode); const role = useAppStore((state) => state.auth.user?.role); const workspaces = useAppStore((state) => state.workspaces.items); @@ -16,6 +17,10 @@ export function useSettingsDiscovery() { const executors = useAppStore((state) => state.executors.items); const showAccount = authEnabled && authMode === "enabled"; const showUsers = authEnabled && (role === undefined || role === "admin"); + // Operator-only in practice, but that answer costs a request; the feature + // gate keeps the entry out of search on installs that never enabled it, and + // the page turns a non-operator away. + const showOrganizations = authEnabled && multiTenancyEnabled; return useMemo( () => @@ -23,10 +28,11 @@ export function useSettingsDiscovery() { t, showAccount, showUsers, + showOrganizations, workspaces, agents, executors, }), - [agents, executors, showAccount, showUsers, t, workspaces], + [agents, executors, showAccount, showOrganizations, showUsers, t, workspaces], ); } diff --git a/apps/web/hooks/domains/users/use-assignable-people.ts b/apps/web/hooks/domains/users/use-assignable-people.ts new file mode 100644 index 0000000000..d5e3acf71a --- /dev/null +++ b/apps/web/hooks/domains/users/use-assignable-people.ts @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { listDirectoryUsers, listWorkspaceMembers } from "@/lib/api/domains/team-access-api"; +import type { DirectoryUser, WorkspaceMember } from "@/lib/types/team-access"; + +export type Person = { id: string; name: string }; + +/** + * One in-flight directory request for the whole page. + * + * The board renders a name per card, so an uncached per-consumer fetch would + * mean one request per card. The directory is global and small (id + display + * name for active users), so it is fetched once and shared. The cost is + * staleness: a user created after this load renders as a raw id until the next + * one, which is the same tradeoff the agent-profile list already makes. + */ +let directoryCache: Promise | null = null; + +function loadDirectory(): Promise { + directoryCache ??= listDirectoryUsers() + .then((res) => (res.users ?? []) as DirectoryUser[]) + .catch(() => { + // Do not cache a failure: a refused or dropped request should be retried + // by the next consumer rather than blanking every name for the session. + directoryCache = null; + return []; + }); + return directoryCache; +} + +/** Test seam: drops the shared directory cache. */ +export function resetDirectoryCacheForTests(): void { + directoryCache = null; +} + +/** + * Display names for user ids, from the shared directory. + * + * Use this for read-only surfaces (a board card, a chip). Anything that offers + * a choice of assignee wants {@link useAssignablePeople}, which also folds in + * the workspace member list. + */ +export function useDirectoryNames(): { nameFor: (userId: string) => string } { + const [users, setUsers] = useState([]); + + useEffect(() => { + let cancelled = false; + loadDirectory().then((list) => { + if (!cancelled) setUsers(list); + }); + return () => { + cancelled = true; + }; + }, []); + + const byId = useMemo(() => new Map(users.map((u) => [u.id, u.display_name || u.id])), [users]); + + // Falls back to the id so an assignee the directory does not carry (a + // disabled account) still renders as something rather than as unassigned. + return { nameFor: (userId: string) => byId.get(userId) ?? userId }; +} + +/** + * The people a task can be assigned to, and the names to show for them. + * + * Two sources, because neither alone is the set. The workspace member list + * misses everyone who reaches an `org`-visible workspace without a member row, + * which is the common case and would otherwise render a colleague as a raw + * user id. The directory covers those, but a private workspace can hold a + * member the directory omits. + * + * Reach is deliberately NOT computed here. It depends on visibility, + * membership and the org boundary, and the server is the authority: assigning + * someone who cannot see the workspace is refused with a message written to be + * shown as-is. Guessing in the browser would only add a second, divergent rule. + */ +export function useAssignablePeople( + workspaceId?: string | null, + options?: { enabled?: boolean }, +): { + people: Person[]; + nameFor: (userId: string) => string; +} { + // Callers render nothing when there is no identity to assign to, but hooks + // run before that early return, so without this the picker would fetch on + // every task page of an install that has authentication switched off. + const enabled = options?.enabled ?? true; + const [people, setPeople] = useState([]); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + // The directory is not workspace-scoped, so it is loaded even with no + // active workspace: the task routes do not always populate one, and + // gating both calls on it leaves every name showing as a raw id. + Promise.all([ + loadDirectory(), + workspaceId + ? listWorkspaceMembers(workspaceId).catch(() => ({ members: [], total: 0 })) + : Promise.resolve({ members: [], total: 0 }), + ]).then(([directory, members]) => { + if (cancelled) return; + const byId = new Map(); + for (const user of directory) byId.set(user.id, user.display_name || user.id); + for (const member of (members.members ?? []) as WorkspaceMember[]) { + if (!byId.has(member.user_id)) { + byId.set(member.user_id, member.display_name || member.user_id); + } + } + setPeople(Array.from(byId, ([id, name]) => ({ id, name }))); + }); + return () => { + cancelled = true; + }; + }, [workspaceId, enabled]); + + const byId = useMemo(() => new Map(people.map((p) => [p.id, p.name])), [people]); + + return { + people, + nameFor: (userId: string) => byId.get(userId) ?? userId, + }; +} diff --git a/apps/web/hooks/domains/workspace/use-workspace-team-access.ts b/apps/web/hooks/domains/workspace/use-workspace-team-access.ts new file mode 100644 index 0000000000..b2862393cd --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-workspace-team-access.ts @@ -0,0 +1,162 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useToast } from "@/components/toast-provider"; +import { + listDirectoryUsers, + listWorkspaceMembers, + removeWorkspaceMember, + transferWorkspaceOwnership, + upsertWorkspaceMember, +} from "@/lib/api/domains/team-access-api"; +import { + hasScope, + SCOPE, + type AssignableWorkspaceRole, + type DirectoryUser, + type WorkspaceMember, +} from "@/lib/types/team-access"; + +type UseWorkspaceTeamAccessArgs = { + workspaceId: string; + ownerId: string; + scopes: readonly string[] | undefined; +}; + +/** + * Membership state for one workspace. + * + * Capability flags come from the server-issued scopes rather than from + * comparing the current user to the owner: the backend is authoritative, and a + * client that derives permission independently will eventually derive it + * differently. + */ +export function useWorkspaceTeamAccess({ + workspaceId, + ownerId, + scopes, +}: UseWorkspaceTeamAccessArgs) { + const { t } = useTranslation(); + const { toast } = useToast(); + const [members, setMembers] = useState([]); + const [directory, setDirectory] = useState([]); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + try { + const [memberList, users] = await Promise.all([ + listWorkspaceMembers(workspaceId), + listDirectoryUsers(), + ]); + setMembers(memberList.members ?? []); + setDirectory(users.users ?? []); + } catch { + // A read failure leaves the previous list on screen. Mutations surface + // their own errors, and blanking the card would lose more than it tells. + } + }, [workspaceId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const run = useCallback( + async (action: () => Promise, successMessage: string) => { + setBusy(true); + try { + await action(); + await refresh(); + toast({ title: successMessage }); + } catch (error) { + toast({ + title: error instanceof Error ? error.message : t("workspaces:teamAccess.genericError"), + variant: "error", + }); + } finally { + setBusy(false); + } + }, + [refresh, toast, t], + ); + + const actions = useTeamAccessActions({ + workspaceId, + run, + members, + directory, + }); + + const memberIds = useMemo(() => new Set(members.map((member) => member.user_id)), [members]); + const addableUsers = useMemo( + () => directory.filter((user) => !memberIds.has(user.id) && user.id !== ownerId), + [directory, memberIds, ownerId], + ); + + return { + busy, + members, + addableUsers, + canManageWorkspace: hasScope(scopes, SCOPE.workspaceManage), + canManageMembers: hasScope(scopes, SCOPE.memberManage), + ...actions, + }; +} + +type TeamAccessActionsArgs = { + workspaceId: string; + run: (action: () => Promise, successMessage: string) => Promise; + members: WorkspaceMember[]; + directory: DirectoryUser[]; +}; + +/** The mutation half of team access, split out to keep each hook readable. */ +function useTeamAccessActions({ workspaceId, run, members, directory }: TeamAccessActionsArgs) { + const { t } = useTranslation(); + + const nameFor = useCallback( + (userId: string) => + members.find((member) => member.user_id === userId)?.display_name ?? + directory.find((user) => user.id === userId)?.display_name ?? + userId, + [members, directory], + ); + + const changeRole = useCallback( + (userId: string, role: AssignableWorkspaceRole) => + void run( + () => upsertWorkspaceMember(workspaceId, userId, role), + t("workspaces:teamAccess.roleUpdated"), + ), + [run, t, workspaceId], + ); + + const addMember = useCallback( + (userId: string, role: AssignableWorkspaceRole, onAdded: () => void) => + void run(async () => { + await upsertWorkspaceMember(workspaceId, userId, role); + onAdded(); + }, t("workspaces:teamAccess.memberAdded")), + [run, t, workspaceId], + ); + + const removeMember = useCallback( + (userId: string) => + void run( + () => removeWorkspaceMember(workspaceId, userId), + t("workspaces:teamAccess.memberRemoved"), + ), + [run, t, workspaceId], + ); + + const makeOwner = useCallback( + (userId: string) => + void run( + () => transferWorkspaceOwnership(workspaceId, userId), + t("workspaces:teamAccess.ownershipTransferred", { name: nameFor(userId) }), + ), + [nameFor, run, t, workspaceId], + ); + + return { changeRole, addMember, removeMember, makeOwner }; +} diff --git a/apps/web/hooks/use-optimistic-task-mutation.ts b/apps/web/hooks/use-optimistic-task-mutation.ts index c9c21c5cf7..bd61db71c7 100644 --- a/apps/web/hooks/use-optimistic-task-mutation.ts +++ b/apps/web/hooks/use-optimistic-task-mutation.ts @@ -106,6 +106,12 @@ function toOfficeTaskPatch(patch: Partial): Partial { if (patch.assigneeAgentProfileId !== undefined) { out.assigneeAgentProfileId = patch.assigneeAgentProfileId; } + // hasOwnProperty, not !== undefined: clearing the human assignee sends + // undefined, and dropping it here would leave the stale name on screen + // until the refetch lands. + if (Object.prototype.hasOwnProperty.call(patch, "assigneeUserId")) { + out.assigneeUserId = patch.assigneeUserId; + } if (patch.projectId !== undefined) out.projectId = patch.projectId; if (Object.prototype.hasOwnProperty.call(patch, "parentId")) out.parentId = patch.parentId; if (patch.labels !== undefined) out.labels = patch.labels; diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index 831dbf6012..eea3417542 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -143,6 +143,8 @@ export async function updateTask( }>; /** Nest under another task. Empty string clears the parent (un-nest). */ parent_id?: string; + /** Human assignee. "" unassigns; omitting the field leaves it alone. */ + assignee_user_id?: string; priority?: TaskPriority; }, options?: ApiRequestOptions, diff --git a/apps/web/lib/api/domains/office-extended-api.ts b/apps/web/lib/api/domains/office-extended-api.ts index 65427e6b79..80ecbd27e3 100644 --- a/apps/web/lib/api/domains/office-extended-api.ts +++ b/apps/web/lib/api/domains/office-extended-api.ts @@ -175,6 +175,8 @@ export type UpdateTaskPayload = { status?: string; comment?: string; assignee_agent_profile_id?: string; + /** The human assignee. "" unassigns; omitting the field leaves it alone. */ + assignee_user_id?: string; priority?: string; project_id?: string; parent_id?: string; diff --git a/apps/web/lib/api/domains/org-api.ts b/apps/web/lib/api/domains/org-api.ts new file mode 100644 index 0000000000..a32f4ee04f --- /dev/null +++ b/apps/web/lib/api/domains/org-api.ts @@ -0,0 +1,47 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; +import type { CurrentOrgResponse, ListOrgsResponse, Org, OrgStatus } from "@/lib/types/org"; + +export async function getCurrentOrg(options?: ApiRequestOptions) { + return fetchJson("/api/v1/orgs/current", options); +} + +export async function listOrgs(options?: ApiRequestOptions) { + return fetchJson("/api/v1/instance/orgs", options); +} + +export async function createOrg(name: string, options?: ApiRequestOptions) { + return fetchJson("/api/v1/instance/orgs", { + ...options, + init: { method: "POST", body: JSON.stringify({ name }), ...(options?.init ?? {}) }, + }); +} + +export async function updateOrg( + id: string, + patch: { name?: string; status?: OrgStatus }, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/instance/orgs/${id}`, { + ...options, + init: { method: "PATCH", body: JSON.stringify(patch), ...(options?.init ?? {}) }, + }); +} + +/** Deleting requires typing the org's slug verbatim. */ +export async function deleteOrg(id: string, slug: string, options?: ApiRequestOptions) { + return fetchJson<{ success: boolean }>(`/api/v1/instance/orgs/${id}`, { + ...options, + init: { method: "DELETE", body: JSON.stringify({ slug }), ...(options?.init ?? {}) }, + }); +} + +export async function createOrgAdmin( + id: string, + admin: { email: string; password: string; display_name: string }, + options?: ApiRequestOptions, +) { + return fetchJson<{ success: boolean }>(`/api/v1/instance/orgs/${id}/admins`, { + ...options, + init: { method: "POST", body: JSON.stringify(admin), ...(options?.init ?? {}) }, + }); +} diff --git a/apps/web/lib/api/domains/org-units-api.test.ts b/apps/web/lib/api/domains/org-units-api.test.ts new file mode 100644 index 0000000000..f5cb7ec5d7 --- /dev/null +++ b/apps/web/lib/api/domains/org-units-api.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { withDepth, type OrgUnit } from "./org-units-api"; + +function unit(id: string, path: string, name = id): OrgUnit { + return { + id, + org_id: "org-1", + parent_id: "", + kind: "standard", + name, + path, + created_at: "", + updated_at: "", + }; +} + +describe("withDepth", () => { + // Depth is what indents the tree. Deriving it from the materialized path + // rather than walking parents keeps the render a single pass, but it means + // an off-by-one here flattens a department into its own parent. + it("derives indentation depth from the materialized path", () => { + const rows = withDepth([ + unit("root", "/root/"), + unit("dept", "/root/dept/"), + unit("team", "/root/dept/team/"), + unit("squad", "/root/dept/team/squad/"), + ]); + + expect(rows.map((r) => r.depth)).toEqual([0, 1, 2, 3]); + }); + + it("keeps the backend's parent-before-child order", () => { + const rows = withDepth([unit("root", "/root/"), unit("dept", "/root/dept/")]); + expect(rows.map((r) => r.id)).toEqual(["root", "dept"]); + }); + + it("treats a malformed path as top level rather than throwing", () => { + expect(withDepth([unit("odd", "")])[0]!.depth).toBe(0); + }); +}); diff --git a/apps/web/lib/api/domains/org-units-api.ts b/apps/web/lib/api/domains/org-units-api.ts new file mode 100644 index 0000000000..3837370c8c --- /dev/null +++ b/apps/web/lib/api/domains/org-units-api.ts @@ -0,0 +1,107 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; + +/** A node in the organization tree: a department, a team, or a personal unit. */ +export type OrgUnit = { + id: string; + org_id: string; + parent_id: string; + kind: "root" | "standard" | "personal"; + owner_user_id?: string; + name: string; + path: string; + created_at: string; + updated_at: string; +}; + +export type UnitMember = { + unit_id: string; + user_id: string; + role: string; + added_by?: string; +}; + +export async function listUnits(options?: ApiRequestOptions) { + return fetchJson<{ units: OrgUnit[] | null; total: number }>("/api/v1/units", options); +} + +export async function listUnitMembers(unitId: string, options?: ApiRequestOptions) { + return fetchJson<{ members: UnitMember[] | null; total: number }>( + `/api/v1/units/${unitId}/members`, + options, + ); +} + +export async function createUnit(parentId: string, name: string, options?: ApiRequestOptions) { + return fetchJson("/api/v1/units", { + ...options, + init: { method: "POST", body: JSON.stringify({ parent_id: parentId, name }), ...options?.init }, + }); +} + +export async function renameUnit(unitId: string, name: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/units/${unitId}`, { + ...options, + init: { method: "PATCH", body: JSON.stringify({ name }), ...options?.init }, + }); +} + +export async function moveUnit(unitId: string, parentId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/units/${unitId}`, { + ...options, + init: { method: "PATCH", body: JSON.stringify({ parent_id: parentId }), ...options?.init }, + }); +} + +export async function deleteUnit(unitId: string, options?: ApiRequestOptions) { + return fetchJson<{ success: boolean }>(`/api/v1/units/${unitId}`, { + ...options, + init: { method: "DELETE", ...options?.init }, + }); +} + +export async function setUnitMember( + unitId: string, + userId: string, + role: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ success: boolean }>(`/api/v1/units/${unitId}/members/${userId}`, { + ...options, + init: { method: "PUT", body: JSON.stringify({ role }), ...options?.init }, + }); +} + +export async function removeUnitMember( + unitId: string, + userId: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ success: boolean }>(`/api/v1/units/${unitId}/members/${userId}`, { + ...options, + init: { method: "DELETE", ...options?.init }, + }); +} + +/** Moves a workspace to another unit, which is the only way to change who reaches it. */ +export async function placeWorkspace( + workspaceId: string, + unitId: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ id: string; unit_id: string }>(`/api/v1/workspaces/${workspaceId}`, { + ...options, + init: { method: "PATCH", body: JSON.stringify({ unit_id: unitId }), ...options?.init }, + }); +} + +/** + * Orders units so a parent always precedes its children, and returns the depth + * of each. The backend orders by materialized path, which already gives this; + * depth is derived here so the tree renders without a second walk. + */ +export function withDepth(units: OrgUnit[]): Array { + return units.map((unit) => ({ + ...unit, + depth: Math.max(0, unit.path.split("/").filter(Boolean).length - 1), + })); +} diff --git a/apps/web/lib/api/domains/team-access-api.ts b/apps/web/lib/api/domains/team-access-api.ts new file mode 100644 index 0000000000..e11da25d39 --- /dev/null +++ b/apps/web/lib/api/domains/team-access-api.ts @@ -0,0 +1,52 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; +import type { + AssignableWorkspaceRole, + ListDirectoryUsersResponse, + ListWorkspaceMembersResponse, + WorkspaceMember, +} from "@/lib/types/team-access"; + +export async function listWorkspaceMembers(workspaceId: string, options?: ApiRequestOptions) { + return fetchJson( + `/api/v1/workspaces/${workspaceId}/members`, + options, + ); +} + +export async function upsertWorkspaceMember( + workspaceId: string, + userId: string, + role: AssignableWorkspaceRole, + options?: ApiRequestOptions, +) { + return fetchJson(`/api/v1/workspaces/${workspaceId}/members/${userId}`, { + ...options, + init: { method: "PUT", body: JSON.stringify({ role }), ...(options?.init ?? {}) }, + }); +} + +export async function removeWorkspaceMember( + workspaceId: string, + userId: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ success: boolean }>(`/api/v1/workspaces/${workspaceId}/members/${userId}`, { + ...options, + init: { method: "DELETE", ...(options?.init ?? {}) }, + }); +} + +export async function transferWorkspaceOwnership( + workspaceId: string, + userId: string, + options?: ApiRequestOptions, +) { + return fetchJson<{ success: boolean }>(`/api/v1/workspaces/${workspaceId}/transfer-ownership`, { + ...options, + init: { method: "POST", body: JSON.stringify({ user_id: userId }), ...(options?.init ?? {}) }, + }); +} + +export async function listDirectoryUsers(options?: ApiRequestOptions) { + return fetchJson("/api/v1/users/directory", options); +} diff --git a/apps/web/lib/kanban/map-task.test.ts b/apps/web/lib/kanban/map-task.test.ts index 593f7f8993..01c021e6d1 100644 --- a/apps/web/lib/kanban/map-task.test.ts +++ b/apps/web/lib/kanban/map-task.test.ts @@ -210,6 +210,24 @@ describe("toKanbanTask — pending and status fields", () => { }); }); +describe("toKanbanTask — human assignee", () => { + // The kanban board and the task top bar both read the assignee out of the + // store, so this mapper is the only hop between the backend field and every + // kanban surface. Dropping it here reads as "nobody is assigned to anything" + // with no error anywhere. + it("carries the human assignee through both task shapes", () => { + const http = toKanbanTask(httpDTO({ assignee_user_id: "user-7" })); + const ws = toKanbanTask(wsPayload({ assignee_user_id: "user-7" })); + + expect(http.assigneeUserId).toBe("user-7"); + expect(ws.assigneeUserId).toBe("user-7"); + }); + + it("leaves the assignee undefined when the backend omits it", () => { + expect(toKanbanTask(httpDTO()).assigneeUserId).toBeUndefined(); + }); +}); + describe("toKanbanTask — autopilot", () => { it("preserves the immutable task creation mode for HTTP and websocket payloads", () => { expect(toKanbanTask(httpDTO({ autopilot: true }))).toMatchObject({ autopilot: true }); diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index 10f8d8e524..c2d9dd1208 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -77,6 +77,7 @@ export type TaskLike = { origin?: string | null; is_remote_executor?: boolean; parent_id?: string | null; + assignee_user_id?: string; updated_at?: string; created_at?: string; wip_admitted?: boolean; @@ -222,6 +223,17 @@ export function copyPrimaryExecutorFields(merged: KanbanTask, existing: KanbanTa merged.isRemoteExecutor = existing.isRemoteExecutor; } +/** + * "Unassigned" has one spelling in the store: undefined. + * + * The wire uses an empty string for it (that is what a PATCH sends to + * unassign), so without this the store would hold both "" and undefined for + * the same state and every consumer would need to know that. + */ +export function pickAssignee(value: string | undefined): string | undefined { + return value ? value : undefined; +} + // eslint-disable-next-line complexity -- Maps the complete task wire contract into the shared Kanban model. export function toKanbanTask(source: TaskLike): KanbanTask { return { @@ -256,6 +268,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask { labels: pickLabels(source), origin: source.origin ?? undefined, isRemoteExecutor: source.is_remote_executor ?? false, + assigneeUserId: pickAssignee(source.assignee_user_id), parentTaskId: source.parent_id ?? undefined, workspaceMode: workspaceModeFromMetadata(source.metadata), updatedAt: source.updated_at, diff --git a/apps/web/lib/routing/route-bootstrap.team-access.test.ts b/apps/web/lib/routing/route-bootstrap.team-access.test.ts new file mode 100644 index 0000000000..14ca68669d --- /dev/null +++ b/apps/web/lib/routing/route-bootstrap.team-access.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { mapWorkspaceItem } from "./route-bootstrap"; +import type { Workspace } from "@/lib/types/http"; + +/** + * Regression guard for a dropped hop. + * + * The API returned the caller's scopes correctly, but this mapper listed the + * store item's fields by hand and silently omitted them, so the Team access + * card rendered every control disabled for the workspace's own owner. Nothing + * failed: no type error, no test, no console warning. + */ +function apiWorkspace(overrides: Partial = {}): Workspace { + return { + id: "ws-1", + name: "Platform Team", + owner_id: "user-ana", + unit_id: "unit-1", + viewer_role: "owner", + scopes: ["workspace.read", "workspace.manage", "member.manage"], + member_count: 3, + created_at: "2026-08-22T10:00:00Z", + updated_at: "2026-08-22T10:00:00Z", + ...overrides, + } as Workspace; +} + +describe("mapWorkspaceItem", () => { + it("carries placement, viewer role, scopes and member count into the store", () => { + const item = mapWorkspaceItem(apiWorkspace()); + expect(item.unit_id).toBe("unit-1"); + expect(item.viewer_role).toBe("owner"); + expect(item.scopes).toEqual(["workspace.read", "workspace.manage", "member.manage"]); + expect(item.member_count).toBe(3); + }); + + it("leaves team-access fields undefined when the API omits them", () => { + const item = mapWorkspaceItem( + apiWorkspace({ + unit_id: undefined, + viewer_role: undefined, + scopes: undefined, + member_count: undefined, + }), + ); + expect(item.unit_id).toBeUndefined(); + expect(item.scopes).toBeUndefined(); + }); + + it("still maps the pre-existing workspace fields", () => { + const item = mapWorkspaceItem(apiWorkspace({ name: "Ana - security spike" })); + expect(item.id).toBe("ws-1"); + expect(item.name).toBe("Ana - security spike"); + expect(item.owner_id).toBe("user-ana"); + }); +}); diff --git a/apps/web/lib/routing/route-bootstrap.ts b/apps/web/lib/routing/route-bootstrap.ts index aa9d0ba3f1..171d192bd2 100644 --- a/apps/web/lib/routing/route-bootstrap.ts +++ b/apps/web/lib/routing/route-bootstrap.ts @@ -64,6 +64,13 @@ export function mapWorkspaceItem(ws: WorkspaceItem): WorkspaceState["items"][num name: ws.name, description: ws.description ?? null, owner_id: ws.owner_id, + // Team access: visibility plus the caller's resolved role and scopes. + // These gate every owner-only control, so dropping them here silently + // renders the workspace as read-only to its own owner. + unit_id: ws.unit_id, + viewer_role: ws.viewer_role, + scopes: ws.scopes, + member_count: ws.member_count, default_executor_id: ws.default_executor_id ?? null, default_environment_id: ws.default_environment_id ?? null, default_agent_profile_id: ws.default_agent_profile_id ?? null, diff --git a/apps/web/lib/settings-discovery/catalog.test.ts b/apps/web/lib/settings-discovery/catalog.test.ts index 78e625d281..b8f1427a35 100644 --- a/apps/web/lib/settings-discovery/catalog.test.ts +++ b/apps/web/lib/settings-discovery/catalog.test.ts @@ -169,6 +169,7 @@ describe("resolveSettingsDiscovery dynamic entries", () => { t: (key) => t(key), showAccount: false, showUsers: false, + showOrganizations: false, workspaces: [{ id: "workspace-1", name: "Main Workspace" }], agents: [], executors: [], @@ -186,6 +187,7 @@ describe("resolveSettingsDiscovery dynamic entries", () => { t: translate, showAccount: true, showUsers: true, + showOrganizations: false, workspaces: [{ id: "workspace / one", name: "Main Workspace" }], agents: [ { @@ -231,6 +233,7 @@ describe("resolveSettingsDiscovery dynamic entries", () => { t: translate, showAccount: false, showUsers: false, + showOrganizations: false, workspaces: [{ id: "workspace-1", name: "Personal value" }], agents: [], executors: [], @@ -251,6 +254,7 @@ describe("resolveSettingsDiscovery visibility", () => { t: translate, showAccount: false, showUsers: false, + showOrganizations: false, workspaces: [], agents: [], executors: [], @@ -259,6 +263,7 @@ describe("resolveSettingsDiscovery visibility", () => { t: translate, showAccount: true, showUsers: true, + showOrganizations: false, workspaces: [], agents: [], executors: [], @@ -277,6 +282,7 @@ describe("resolveSettingsDiscovery visibility", () => { t: translate, showAccount: false, showUsers: false, + showOrganizations: false, workspaces: [], agents: [], executors: [], @@ -285,6 +291,7 @@ describe("resolveSettingsDiscovery visibility", () => { t: translate, showAccount: false, showUsers: false, + showOrganizations: false, workspaces: [{ id: "workspace-1", name: "Main" }], agents: [], executors: [], diff --git a/apps/web/lib/settings-discovery/catalog/system.ts b/apps/web/lib/settings-discovery/catalog/system.ts index 0f800277fa..f2f71da4bc 100644 --- a/apps/web/lib/settings-discovery/catalog/system.ts +++ b/apps/web/lib/settings-discovery/catalog/system.ts @@ -190,4 +190,24 @@ export const SYSTEM_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = [ order: 670, requires: "users", }, + // Grouped with access control, not system: an organization is a boundary + // above users, and System is for operating the instance. + { + id: "settings-units", + kind: "page", + labelKey: "settings:navUnits", + groupId: "access", + href: "/settings/units", + order: 659, + requires: "users", + }, + { + id: "system-organizations", + kind: "page", + labelKey: "orgs:navOrganizations", + groupId: "access", + href: "/settings/system/organizations", + order: 661, + requires: "organizations", + }, ]; diff --git a/apps/web/lib/settings-discovery/resolve.ts b/apps/web/lib/settings-discovery/resolve.ts index 173a29b18e..9c2865437f 100644 --- a/apps/web/lib/settings-discovery/resolve.ts +++ b/apps/web/lib/settings-discovery/resolve.ts @@ -194,6 +194,7 @@ function dynamicDefinitions(context: SettingsDiscoveryContext): SettingsDiscover function isVisible(entry: SettingsDiscoveryDefinition, context: SettingsDiscoveryContext) { if (entry.requires === "account") return context.showAccount; if (entry.requires === "users") return context.showUsers; + if (entry.requires === "organizations") return context.showOrganizations; if (entry.requires === "workspace") return context.workspaces.length > 0; return true; } diff --git a/apps/web/lib/settings-discovery/types.ts b/apps/web/lib/settings-discovery/types.ts index e808e7409c..9bade3bec5 100644 --- a/apps/web/lib/settings-discovery/types.ts +++ b/apps/web/lib/settings-discovery/types.ts @@ -11,7 +11,7 @@ export type SettingsDiscoveryDefinition = { href: string; targetId?: string; order: number; - requires?: "account" | "users" | "workspace"; + requires?: "account" | "users" | "workspace" | "organizations"; }; export type SettingsDiscoveryGroupDefinition = { @@ -37,6 +37,7 @@ export type SettingsDiscoveryContext = { t: (key: string) => string; showAccount: boolean; showUsers: boolean; + showOrganizations: boolean; workspaces: Array<{ id: string; name: string }>; agents: Array<{ name: string; diff --git a/apps/web/lib/ssr/mapper.test.ts b/apps/web/lib/ssr/mapper.test.ts index f7514013d8..33e95ee092 100644 --- a/apps/web/lib/ssr/mapper.test.ts +++ b/apps/web/lib/ssr/mapper.test.ts @@ -78,6 +78,18 @@ describe("snapshotToState", () => { expect(state.kanban?.tasks[0]?.primarySessionPendingAction).toBeUndefined(); }); + // The server-rendered boot payload is the first thing a board paints from, + // so a dropped assignee here shows every card as unassigned until the first + // WS update happens to arrive. + it("hydrates the human assignee into the initial kanban state", () => { + const snapshot = snapshotWithPendingAction(undefined); + snapshot.tasks[0].assignee_user_id = "user-7"; + + const state = snapshotToState(snapshot); + + expect(state.kanban?.tasks[0]?.assigneeUserId).toBe("user-7"); + }); + it("hydrates task metadata into the initial kanban state", () => { const snapshot = snapshotWithPendingAction(undefined); snapshot.tasks[0].metadata = { diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index 8a059dd892..97387aff94 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -1,7 +1,7 @@ import type { AppState, KanbanState } from "@/lib/state/store"; import { primaryTaskRepository } from "@/lib/types/http"; import type { WorkflowSnapshot, Message, Task } from "@/lib/types/http"; -import { pickPendingAction, workspaceModeFromMetadata } from "@/lib/kanban/map-task"; +import { pickAssignee, pickPendingAction, workspaceModeFromMetadata } from "@/lib/kanban/map-task"; import { isPRReviewFromMetadata, isIssueWatchFromMetadata, @@ -104,6 +104,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { sessionCount: task.session_count ?? undefined, reviewStatus: task.review_status ?? undefined, statusSummary: task.status_summary, + assigneeUserId: pickAssignee(task.assignee_user_id), parentTaskId: task.parent_id ?? undefined, metadata: task.metadata, workspaceMode: workspaceModeFromMetadata(task.metadata), diff --git a/apps/web/lib/state/slices/features/types.ts b/apps/web/lib/state/slices/features/types.ts index 597abd67e9..94717eb505 100644 --- a/apps/web/lib/state/slices/features/types.ts +++ b/apps/web/lib/state/slices/features/types.ts @@ -9,6 +9,7 @@ export const defaultFeatureFlags = { // overwrites this with the backend's effective values after startup. office: false, auth: false, + multiTenancy: false, dynamicAgentRouting: false, claudeBackgroundPromptHandoff: false, claudeMidTurnSteering: false, diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index 67ebf305c0..f89cbe5928 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -126,6 +126,8 @@ export type KanbanState = { labels?: string[]; origin?: string; isRemoteExecutor?: boolean; + /** Human assignee (user id). Independent of any agent assignment. */ + assigneeUserId?: string; parentTaskId?: string | null; workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; updatedAt?: string; diff --git a/apps/web/lib/state/slices/office/office-task-type.ts b/apps/web/lib/state/slices/office/office-task-type.ts index 9150aa253a..ee0e7fbab5 100644 --- a/apps/web/lib/state/slices/office/office-task-type.ts +++ b/apps/web/lib/state/slices/office/office-task-type.ts @@ -16,6 +16,11 @@ export type OfficeTask = { parentId?: string; projectId?: string; assigneeAgentProfileId?: string; + /** + * The human assignee's user id. Independent of the agent assignee above: + * a task can carry both, and setting one never clears the other. + */ + assigneeUserId?: string; labels?: TaskLabel[] | string[]; blockedBy?: string[]; children?: OfficeTask[]; diff --git a/apps/web/lib/state/slices/workspace/types.ts b/apps/web/lib/state/slices/workspace/types.ts index e706845bca..4da93df5ee 100644 --- a/apps/web/lib/state/slices/workspace/types.ts +++ b/apps/web/lib/state/slices/workspace/types.ts @@ -12,6 +12,13 @@ export type WorkspaceState = { name: string; description?: string | null; owner_id: string; + /** The unit this workspace sits in; reach follows the tree. */ + unit_id?: string; + /** The requesting user's role here. */ + viewer_role?: string; + /** Scopes the requesting user holds here; gates every owner-only control. */ + scopes?: string[]; + member_count?: number; default_executor_id?: string | null; default_environment_id?: string | null; default_agent_profile_id?: string | null; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index d97e51a8bd..06c94c16d6 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -217,6 +217,7 @@ export type WorkspacePayload = { name: string; description?: string; owner_id?: string; + unit_id?: string; default_executor_id?: string | null; default_environment_id?: string | null; default_agent_profile_id?: string | null; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index a54a49a515..ae9097fc9d 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -274,6 +274,14 @@ export type Workspace = { name: string; description?: string | null; owner_id: string; + /** "private" (owner + explicit members) or "org" (every non-guest user). */ + /** The organization unit this workspace sits in; reach follows the tree. */ + unit_id?: string; + /** The requesting user's role here; drives owner-only controls. */ + viewer_role?: string; + /** Scopes the requesting user holds here. The server is authoritative. */ + scopes?: string[]; + member_count?: number; default_executor_id?: string | null; default_environment_id?: string | null; default_agent_profile_id?: AgentProfileId | null; @@ -435,6 +443,11 @@ export type Task = ActiveSubagentCountFields & { primary_working_directory?: string | null; is_remote_executor?: boolean; is_ephemeral?: boolean; + /** + * The human assignee's user id, independent of the agent assignee. Advisory: + * it records who owns the task and gates nothing. + */ + assignee_user_id?: string; parent_id?: TaskId; archived_at?: string | null; created_at: string; diff --git a/apps/web/lib/types/org.ts b/apps/web/lib/types/org.ts new file mode 100644 index 0000000000..df8ff38188 --- /dev/null +++ b/apps/web/lib/types/org.ts @@ -0,0 +1,25 @@ +/** Organizations: the tenant boundary above users. */ + +export type OrgStatus = "active" | "suspended"; + +export type Org = { + id: string; + name: string; + /** Lowercase identifier; typed verbatim to confirm deletion. */ + slug: string; + status: OrgStatus; + is_default: boolean; + created_at: string; + updated_at: string; +}; + +export type CurrentOrgResponse = { + org: Org | null; + /** + * The instance operator tier: managing organizations themselves. It grants + * no access inside any organization, including the operator's own. + */ + is_operator: boolean; +}; + +export type ListOrgsResponse = { orgs: Org[]; total: number }; diff --git a/apps/web/lib/types/team-access.test.ts b/apps/web/lib/types/team-access.test.ts new file mode 100644 index 0000000000..f1be5fb69e --- /dev/null +++ b/apps/web/lib/types/team-access.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { ASSIGNABLE_WORKSPACE_ROLES, hasScope, SCOPE } from "./team-access"; + +describe("hasScope", () => { + it("grants a scope the server issued", () => { + expect(hasScope([SCOPE.workspaceRead, SCOPE.taskWrite], SCOPE.taskWrite)).toBe(true); + }); + + it("denies a scope the server withheld", () => { + expect(hasScope([SCOPE.workspaceRead], SCOPE.sessionExec)).toBe(false); + }); + + // A payload from a backend that predates scopes, or one where the field was + // dropped, must hide controls rather than show ones that would 403. + it("denies every scope when the field is absent", () => { + expect(hasScope(undefined, SCOPE.workspaceRead)).toBe(false); + expect(hasScope([], SCOPE.workspaceManage)).toBe(false); + }); +}); + +describe("scope identifiers", () => { + // These strings are compared with === against the backend registry, so a + // rename here silently breaks every gated control rather than failing loudly. + it("match the backend registry values", () => { + expect(SCOPE).toEqual({ + workspaceRead: "workspace.read", + workspaceManage: "workspace.manage", + taskWrite: "task.write", + sessionPrompt: "session.prompt", + sessionControl: "session.control", + sessionExec: "session.exec", + repositoryManage: "repository.manage", + secretManage: "secret.manage", + memberManage: "member.manage", + }); + }); + + // Owner is reached by transferring ownership, never by assigning a role. + it("does not offer owner as an assignable role", () => { + expect(ASSIGNABLE_WORKSPACE_ROLES).toEqual(["collaborator", "viewer"]); + expect(ASSIGNABLE_WORKSPACE_ROLES).not.toContain("owner"); + }); +}); diff --git a/apps/web/lib/types/team-access.ts b/apps/web/lib/types/team-access.ts new file mode 100644 index 0000000000..583310de93 --- /dev/null +++ b/apps/web/lib/types/team-access.ts @@ -0,0 +1,66 @@ +/** + * Team access: workspace unit placement, membership, and the caller's + * resolved scopes. + * + * Scope strings are compared with `===` against the values the backend + * registry emits, so they must never be translated or reformatted. + */ + +/** The caller's role in one workspace. Empty means unreachable. */ +export type WorkspaceRole = "owner" | "collaborator" | "viewer" | ""; + +/** Roles that can be assigned through the member API. Owner comes from transfer. */ +export const ASSIGNABLE_WORKSPACE_ROLES = ["collaborator", "viewer"] as const; +export type AssignableWorkspaceRole = (typeof ASSIGNABLE_WORKSPACE_ROLES)[number]; + +/** + * Scope identifiers mirrored from `internal/authz`. + * i18n-exempt: wire identifiers compared with ===, never displayed raw. + */ +export const SCOPE = { + workspaceRead: "workspace.read", + workspaceManage: "workspace.manage", + taskWrite: "task.write", + sessionPrompt: "session.prompt", + sessionControl: "session.control", + sessionExec: "session.exec", + repositoryManage: "repository.manage", + secretManage: "secret.manage", + memberManage: "member.manage", +} as const; + +export type Scope = (typeof SCOPE)[keyof typeof SCOPE]; + +export type WorkspaceMember = { + user_id: string; + display_name?: string; + role: WorkspaceRole; + added_by?: string; +}; + +export type ListWorkspaceMembersResponse = { + members: WorkspaceMember[]; + total: number; +}; + +export type DirectoryUser = { + id: string; + display_name: string; +}; + +export type ListDirectoryUsersResponse = { + users: DirectoryUser[]; + total: number; +}; + +/** + * Whether the caller holds a scope on a workspace. + * + * The server is authoritative; this only decides whether to render a control. + * Absent scopes (an older backend, or a payload that predates this field) read + * as "not granted", so a stale client hides controls rather than showing ones + * that would 403. + */ +export function hasScope(scopes: readonly string[] | undefined, scope: Scope): boolean { + return Boolean(scopes?.includes(scope)); +} diff --git a/apps/web/lib/user-initials.ts b/apps/web/lib/user-initials.ts new file mode 100644 index 0000000000..adae550359 --- /dev/null +++ b/apps/web/lib/user-initials.ts @@ -0,0 +1,16 @@ +/** + * Initials for a person's display name, for avatar fallbacks. + * + * First and last word, so "Ana Maria Ferreira" reads as "AF" rather than "AM": + * a middle name should not displace the family name people recognise. + * + * Note the deliberately different `getInitials` in commit-detail-panel.tsx, + * which takes the first two words of a git author string. That is a separate + * rule for separate input, not a copy of this one. + */ +export function initialsFor(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase(); + return `${parts[0]![0]}${parts[parts.length - 1]![0]}`.toUpperCase(); +} diff --git a/apps/web/lib/ws/handlers/tasks-assignee.test.ts b/apps/web/lib/ws/handlers/tasks-assignee.test.ts new file mode 100644 index 0000000000..8dda8dbf0f --- /dev/null +++ b/apps/web/lib/ws/handlers/tasks-assignee.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it, vi } from "vitest"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import { registerTasksHandlers } from "./tasks"; + +type Listener = (state: AppState) => void; + +/** + * Minimal in-memory store for the tasks WS handler tests. + * The handler reads kanban tasks, kanbanMulti snapshots, and tasks.activeTaskId/activeSessionId, + * and calls setActiveSession; everything else can stay default. + */ +function makeStore(initial: Partial = {}) { + let state = { + kanban: { workflowId: "wf1", steps: [], tasks: [] }, + kanbanMulti: { snapshots: {}, isLoading: false }, + tasks: { + activeTaskId: null, + activeSessionId: null, + pinnedSessionId: null, + lastSessionByTaskId: {}, + }, + taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} }, + environmentIdBySessionId: {}, + setActiveSession: vi.fn((taskId: string, sessionId: string | null) => { + state = { + ...state, + tasks: { + ...state.tasks, + activeTaskId: taskId, + activeSessionId: sessionId, + pinnedSessionId: sessionId, + lastSessionByTaskId: sessionId + ? { ...state.tasks.lastSessionByTaskId, [taskId]: sessionId } + : state.tasks.lastSessionByTaskId, + }, + }; + }), + setActiveSessionAuto: vi.fn((taskId: string, sessionId: string | null) => { + state = { + ...state, + tasks: { + ...state.tasks, + activeTaskId: taskId, + activeSessionId: sessionId, + }, + }; + }), + removeTaskFromSidebarPrefs: vi.fn(), + setTaskDeletedNotification: vi.fn(), + ...initial, + } as unknown as AppState; + + const listeners = new Set(); + return { + getState: () => state, + setState: (updater: AppState | ((s: AppState) => AppState)) => { + const next = + typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater; + state = { ...state, ...next }; + for (const l of listeners) l(state); + }, + subscribe: (l: Listener) => { + listeners.add(l); + return () => listeners.delete(l); + }, + destroy: vi.fn(), + getInitialState: vi.fn(), + } as unknown as StoreApi & { getState: () => AppState }; +} + +function makeTask(id: string, primarySessionId: string | null, workflowId = "wf1") { + return { + task_id: id, + workflow_id: workflowId, + workflow_step_id: "step1", + title: "Test", + description: "", + state: "IN_PROGRESS", + primary_session_id: primarySessionId, + is_ephemeral: false, + } as Record; +} + +function makeMessage(payload: Record) { + return { + id: "msg-1", + type: "notification" as const, + action: "task.updated" as const, + payload, + } as Parameters["task.updated"]>>[0]; +} + +// Shared setup for the primary-session focus-follow tests: a single task t1 +// whose kanban primary, plus the active/pinned session ids, are the only knobs +// that vary between cases. + +function makeAssigneeStore(assigneeUserId?: string) { + const existingTask = { + id: "t1", + workflowStepId: "step1", + title: "Old title", + position: 0, + primarySessionId: "session-1", + assigneeUserId, + }; + return makeStore({ + kanban: { + workflowId: "wf1", + steps: [], + tasks: [existingTask], + } as unknown as AppState["kanban"], + kanbanMulti: { + isLoading: false, + snapshots: { + wf1: { workflowId: "wf1", workflowName: "WF1", steps: [], tasks: [existingTask] }, + }, + } as unknown as AppState["kanbanMulti"], + }); +} + +describe("human assignee preservation", () => { + // Observed live before this guard: taking a task over showed the new owner, + // then the next unrelated task.updated blanked the top bar back to + // "Unassigned" while the database still held the assignment. + it("preserves the assignee when a partial update omits assignee_user_id", () => { + const store = makeAssigneeStore("user-7"); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ ...makeTask("t1", "session-1"), title: "Retitled task" }), + ); + + const state = store.getState(); + expect(state.kanban.tasks[0]).toMatchObject({ assigneeUserId: "user-7" }); + expect(state.kanbanMulti.snapshots.wf1.tasks[0]).toMatchObject({ assigneeUserId: "user-7" }); + }); + + it("adopts an explicit reassignment", () => { + const store = makeAssigneeStore("user-7"); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ ...makeTask("t1", "session-1"), assignee_user_id: "user-9" }), + ); + + expect(store.getState().kanban.tasks[0]).toMatchObject({ assigneeUserId: "user-9" }); + }); + + it("clears the assignee when the event says it is empty", () => { + const store = makeAssigneeStore("user-7"); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ ...makeTask("t1", "session-1"), assignee_user_id: "" }), + ); + + expect(store.getState().kanban.tasks[0]).toMatchObject({ assigneeUserId: undefined }); + }); +}); diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 4086410e37..6fae6498d7 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -101,6 +101,13 @@ function mergeTaskUpdate( payloadKey: "parent_id", taskField: "parentTaskId", }); + // Same contract for the human assignee: a lightweight update that does not + // mention it must not read as "unassigned". Without this, taking a task over + // showed the new owner until the next unrelated event, then blanked. + preserveOmittedField(existing, merged, payload, nextTask, { + payloadKey: "assignee_user_id", + taskField: "assigneeUserId", + }); if (!hasPayloadField(payload, "primary_session_id") && nextTask.primarySessionId === undefined) { merged.primarySessionId = existing.primarySessionId; } diff --git a/apps/web/lib/ws/handlers/workspaces.test.ts b/apps/web/lib/ws/handlers/workspaces.test.ts new file mode 100644 index 0000000000..e6c86e71a2 --- /dev/null +++ b/apps/web/lib/ws/handlers/workspaces.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { createStore } from "zustand"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import { registerWorkspacesHandlers } from "@/lib/ws/handlers/workspaces"; +import type { WsHandlers } from "@/lib/ws/handlers/types"; + +// The handler map is partial by type; a missing entry is a real failure here, +// not something to silently skip. +function dispatch(handlers: WsHandlers, type: string, payload: unknown) { + const handler = handlers[type as keyof WsHandlers]; + if (!handler) throw new Error(`no handler registered for ${type}`); + (handler as (message: unknown) => void)({ type, payload }); +} + +type WorkspaceItem = AppState["workspaces"]["items"][number]; + +function storeWith(items: WorkspaceItem[]): StoreApi { + return createStore( + () => + ({ + workspaces: { items, activeId: items[0]?.id ?? null }, + }) as unknown as AppState, + ); +} + +function workspace(overrides: Partial = {}): WorkspaceItem { + return { + id: "ws-1", + name: "Platform", + description: null, + owner_id: "user-1", + default_executor_id: null, + default_environment_id: null, + default_agent_profile_id: null, + default_config_agent_profile_id: null, + unit_id: "unit-old", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + } as WorkspaceItem; +} + +describe("workspace.updated placement", () => { + // Placement is reach: a move made by another admin, or in another tab, is + // what grants and withdraws access. If the merge drops unit_id the board + // keeps rendering against the unit it just left. + it("applies a new unit_id", () => { + const store = storeWith([workspace()]); + const handlers = registerWorkspacesHandlers(store); + + dispatch(handlers, "workspace.updated", { id: "ws-1", name: "Platform", unit_id: "unit-new" }); + + expect(store.getState().workspaces.items[0].unit_id).toBe("unit-new"); + }); + + // An older backend omits the key entirely. Reading a missing key as "" + // would unplace the workspace, which reads as "reaches nobody". + it("keeps the current placement when the payload omits unit_id", () => { + const store = storeWith([workspace()]); + const handlers = registerWorkspacesHandlers(store); + + dispatch(handlers, "workspace.updated", { id: "ws-1", name: "Platform" }); + + expect(store.getState().workspaces.items[0].unit_id).toBe("unit-old"); + }); + + it("carries placement onto a workspace created in another tab", () => { + const store = storeWith([]); + const handlers = registerWorkspacesHandlers(store); + + dispatch(handlers, "workspace.created", { id: "ws-2", name: "Runtime", unit_id: "unit-new" }); + + expect(store.getState().workspaces.items[0].unit_id).toBe("unit-new"); + }); +}); diff --git a/apps/web/lib/ws/handlers/workspaces.ts b/apps/web/lib/ws/handlers/workspaces.ts index 0a1299e41d..0b55a549ee 100644 --- a/apps/web/lib/ws/handlers/workspaces.ts +++ b/apps/web/lib/ws/handlers/workspaces.ts @@ -18,6 +18,7 @@ export function registerWorkspacesHandlers(store: StoreApi): WsHandler default_environment_id: payload.default_environment_id ?? null, default_agent_profile_id: payload.default_agent_profile_id ?? null, default_config_agent_profile_id: payload.default_config_agent_profile_id ?? null, + unit_id: payload.unit_id ?? "", created_at: payload.created_at ?? new Date().toISOString(), updated_at: payload.updated_at ?? new Date().toISOString(), }; @@ -55,6 +56,15 @@ export function registerWorkspacesHandlers(store: StoreApi): WsHandler "default_config_agent_profile_id" in message.payload ? (message.payload.default_config_agent_profile_id ?? null) : (item.default_config_agent_profile_id ?? null), + // Placement decides who reaches this workspace, so a move + // made in another tab has to land here. Presence-checked + // like default_config_agent_profile_id above: an older + // backend omits the key entirely, and reading it as "" + // would silently unplace the workspace. + unit_id: + "unit_id" in message.payload + ? (message.payload.unit_id ?? "") + : (item.unit_id ?? ""), updated_at: message.payload.updated_at ?? item.updated_at, } : item, diff --git a/apps/web/scripts/demo-team-access-shots.mjs b/apps/web/scripts/demo-team-access-shots.mjs new file mode 100644 index 0000000000..b745f4d985 --- /dev/null +++ b/apps/web/scripts/demo-team-access-shots.mjs @@ -0,0 +1,119 @@ +// Capture screenshots of the team-access surfaces from the seeded demo +// instance. Run after scripts/demo-team-access.sh up: +// +// cd apps/web && node scripts/demo-team-access-shots.mjs +// +// It lives under apps/web because Node resolves imports relative to the +// script file, and Playwright is installed in this workspace. +import { chromium } from "@playwright/test"; +import { mkdirSync } from "node:fs"; + +const BASE = process.env.KANDEV_DEMO_URL ?? "http://127.0.0.1:8231"; +const OUT = process.env.SHOT_DIR ?? "/tmp/kandev-team-access-demo/shots"; +const PASSWORD = "kandev-demo-pw-1"; + +mkdirSync(OUT, { recursive: true }); + +async function signIn(context, email) { + const page = await context.newPage(); + await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); + await page.getByLabel(/email/i).fill(email); + await page.getByLabel(/password/i).fill(PASSWORD); + await Promise.all([ + page.waitForResponse( + (r) => r.url().includes("/api/v1/auth/login") && r.request().method() === "POST", + ), + page.getByRole("button", { name: /sign in|log in/i }).click(), + ]); + await page.waitForLoadState("networkidle"); + await dismissOnboarding(page); + return page; +} + +// Each account hits the first-run wizard on its first sign-in, which would +// otherwise sit on top of every board screenshot. +async function dismissOnboarding(page) { + for (let step = 0; step < 6; step += 1) { + const skip = page.getByRole("button", { name: /^skip$/i }); + if (!(await skip.count())) return; + await skip + .first() + .click() + .catch(() => {}); + await page.waitForTimeout(500); + } +} + +async function shot(page, name, focus) { + await page.waitForTimeout(1200); // let the SPA settle before capture + if (focus) { + const target = page.locator(focus).first(); + if (await target.count()) { + await target.scrollIntoViewIfNeeded(); + await page.waitForTimeout(400); + } + } + await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: false }); + console.log(`captured ${name}.png`); +} + +// The Team access card sits below the workspace form, so scroll it into frame. +// Scroll the *bottom* of the card into frame: the heading alone is already +// on screen, so targeting it scrolls nothing and the members list stays cut off. +const TEAM_CARD = '#add-member-user, [data-testid="workspace-member-row"]:last-of-type'; + +const browser = await chromium.launch(); +try { + // 1. Owner: the Team access card on the shared workspace. + const anaCtx = await browser.newContext({ viewport: { width: 1440, height: 1250 } }); + const ana = await signIn(anaCtx, "ana@example.com"); + + const listResponse = await ana.request.get(`${BASE}/api/v1/workspaces`); + const { workspaces } = await listResponse.json(); + const team = workspaces.find((w) => w.name === "Platform Team"); + const priv = workspaces.find((w) => w.name.startsWith("Ana - security")); + + await ana.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(ana, "01-owner-team-access-shared", TEAM_CARD); + + await ana.goto(`${BASE}/settings/workspace/${priv.id}`, { waitUntil: "networkidle" }); + await shot(ana, "02-owner-team-access-private", TEAM_CARD); + + await ana.goto(`${BASE}/`, { waitUntil: "networkidle" }); + await shot(ana, "03-owner-board"); + + // 2. Member with no invitation: the shared board is simply there. + const brunoCtx = await browser.newContext({ viewport: { width: 1440, height: 1250 } }); + const bruno = await signIn(brunoCtx, "bruno@example.com"); + await bruno.goto(`${BASE}/`, { waitUntil: "networkidle" }); + await shot(bruno, "04-member-sees-shared-board"); + await bruno.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(bruno, "05-member-team-access-readonly", TEAM_CARD); + + // 3. Viewer: reads the same workspace, holds one scope. + const carlaCtx = await browser.newContext({ viewport: { width: 1440, height: 1250 } }); + const carla = await signIn(carlaCtx, "carla@example.com"); + await carla.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(carla, "06-viewer-team-access-readonly", TEAM_CARD); + + // 4. Guest: the shared workspace does not exist for her. + const danaCtx = await browser.newContext({ viewport: { width: 1440, height: 1250 } }); + const dana = await signIn(danaCtx, "dana@example.com"); + await dana.goto(`${BASE}/`, { waitUntil: "networkidle" }); + await shot(dana, "07-guest-sees-only-invited-workspace"); + + // 5. Mobile composition of the members surface. + const mobileCtx = await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 2, + }); + const mobile = await signIn(mobileCtx, "ana@example.com"); + await mobile.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(mobile, "08-mobile-team-access", TEAM_CARD); + + console.log(`\nscreenshots written to ${OUT}`); +} finally { + await browser.close(); +} diff --git a/apps/web/scripts/team-access-docs-shots.mjs b/apps/web/scripts/team-access-docs-shots.mjs new file mode 100644 index 0000000000..e43d1c4cf6 --- /dev/null +++ b/apps/web/scripts/team-access-docs-shots.mjs @@ -0,0 +1,175 @@ +// Capture the documentation screenshots for docs/public/team-access.md from the +// seeded demo instance. Run after scripts/demo-team-access.sh up: +// +// cd apps/web && node scripts/team-access-docs-shots.mjs +import { chromium } from "@playwright/test"; +import { mkdirSync } from "node:fs"; + +const BASE = process.env.KANDEV_DEMO_URL ?? "http://127.0.0.1:8231"; +const OUT = process.env.SHOT_DIR ?? "../../docs/screenshots"; +const PASSWORD = "kandev-demo-pw-1"; +mkdirSync(OUT, { recursive: true }); + +async function signIn(context, email) { + const page = await context.newPage(); + // The hermetic demo has no agent CLIs installed, so the board route would + // otherwise open the onboarding dialog over every shot. + await page.addInitScript(() => + window.localStorage.setItem("kandev.onboarding.completed", "true"), + ); + await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); + await page.getByLabel(/email/i).fill(email); + await page.getByLabel(/password/i).fill(PASSWORD); + await Promise.all([ + page.waitForResponse( + (r) => r.url().includes("/api/v1/auth/login") && r.request().method() === "POST", + ), + page.getByRole("button", { name: /sign in|log in/i }).click(), + ]); + await page.waitForLoadState("networkidle"); + // Fail loudly. Without this a wrong password or a seed that uses different + // emails leaves the browser sitting on /login, and every screenshot below + // silently captures the sign-in card over a good committed one. + if (new URL(page.url()).pathname.startsWith("/login")) { + throw new Error(`sign in as ${email} did not leave /login: wrong seed or password?`); + } + for (let i = 0; i < 6; i += 1) { + const skip = page.getByRole("button", { name: /^skip$/i }); + if (!(await skip.count())) break; + await skip + .first() + .click() + .catch(() => {}); + await page.waitForTimeout(500); + } + return page; +} + +// shot captures `focus` when given, and the whole page otherwise. +// +// It used to scroll the focused element into view and then screenshot the +// page, which meant every "card" shot was really a full page: two shots of the +// same route came out byte-identical, and the subject was a small part of a +// tall image. Clipping to the element is the whole point of naming one. +async function shot(page, name, focus) { + await page.waitForTimeout(1200); + if (focus) { + const target = page.locator(focus).first(); + if (await target.count()) { + await target.scrollIntoViewIfNeeded(); + await page.waitForTimeout(400); + await target.screenshot({ path: `${OUT}/${name}.png` }); + console.log(`captured ${name}.png (clipped to ${focus})`); + return; + } + throw new Error(`shot "${name}": focus selector ${focus} matched nothing`); + } + await page.screenshot({ path: `${OUT}/${name}.png` }); + console.log(`captured ${name}.png`); +} + +// The card itself, not a bottom anchor: shots clip to what they name. +const TEAM_ACCESS_CARD = '[data-testid="workspace-team-access-card"]'; +const browser = await chromium.launch(); +try { + const desktop = { width: 1440, height: 1250 }; + + const ana = await signIn(await browser.newContext({ viewport: desktop }), "ana@example.com"); + const { workspaces } = await (await ana.request.get(`${BASE}/api/v1/workspaces`)).json(); + const team = workspaces.find((w) => w.name === "Platform Team"); + + // The tree replaced per-workspace visibility, so the first two shots are the + // structure and who is in it. + await ana.goto(`${BASE}/settings/units`, { waitUntil: "networkidle" }); + await ana.getByTestId("unit-row").first().waitFor({ state: "visible", timeout: 20000 }); + await shot(ana, "team-access-units-tree"); + + const platformRow = ana.getByTestId("unit-row").filter({ hasText: "Platform" }).first(); + await platformRow.getByTestId("unit-members").click(); + await ana.getByTestId("unit-members-dialog").waitFor({ state: "visible", timeout: 20000 }); + await ana.waitForTimeout(600); + await shot(ana, "team-access-unit-members", '[data-testid="unit-members-dialog"]'); + await ana.keyboard.press("Escape"); + + // Creating a unit, and the placement control that decides who reaches a + // workspace. Placement is the only way to narrow access, so it earns a shot. + await ana.getByTestId("unit-add-child").first().click(); + await ana.getByTestId("unit-name-input").waitFor({ state: "visible", timeout: 20000 }); + await ana.getByTestId("unit-name-input").fill("Security"); + await ana.waitForTimeout(400); + await shot(ana, "team-access-unit-create"); + await ana.getByRole("button", { name: /cancel/i }).click(); + + await ana.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await ana.getByTestId("workspace-placement-card").waitFor({ state: "visible", timeout: 20000 }); + await shot(ana, "team-access-workspace-placement", '[data-testid="workspace-placement-card"]'); + + await ana.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(ana, "team-access-owner-card", TEAM_ACCESS_CARD); + + await ana.goto(`${BASE}/settings/system/users`, { waitUntil: "networkidle" }); + await ana.getByTestId("users-table-card").waitFor({ state: "visible", timeout: 20000 }); + await shot(ana, "team-access-user-roles"); + + const bruno = await signIn(await browser.newContext({ viewport: desktop }), "bruno@example.com"); + await bruno + .context() + .addCookies([{ name: "kandev-active-workspace", value: team.id, url: BASE }]); + await bruno.goto(`${BASE}/`, { waitUntil: "networkidle" }); + await shot(bruno, "team-access-shared-board"); + + // The human assignee, on both boards. Ana assigns Bruno through the API so + // the shots show a task somebody else owns, which is what the "Assign to me" + // affordance is for. + const tasks = await (await ana.request.get(`${BASE}/api/v1/workspaces/${team.id}/tasks`)).json(); + const task = (tasks.tasks ?? tasks)[0]; + const { users } = await (await ana.request.get(`${BASE}/api/v1/users/directory`)).json(); + const brunoId = users.find((u) => u.display_name.includes("Bruno"))?.id; + if (task && brunoId) { + await ana.request.patch(`${BASE}/api/v1/tasks/${task.id}`, { + data: { assignee_user_id: brunoId }, + }); + await ana.goto(`${BASE}/t/${task.id}`, { waitUntil: "networkidle" }); + await ana.getByTestId("task-assignee-control").waitFor({ state: "visible", timeout: 25000 }); + await ana.waitForTimeout(800); + await ana.screenshot({ + path: `${OUT}/team-access-kanban-assignee-topbar.png`, + clip: { x: 540, y: 0, width: 900, height: 56 }, + }); + console.log("captured team-access-kanban-assignee-topbar.png"); + + // The board shot needs the shared workspace selected; the root route + // otherwise opens whichever workspace the cookie last named. + await ana + .context() + .addCookies([{ name: "kandev-active-workspace", value: team.id, url: BASE }]); + await ana.goto(`${BASE}/`, { waitUntil: "networkidle" }); + const badge = ana.getByTestId("kanban-card-assignee").first(); + await badge.waitFor({ state: "visible", timeout: 25000 }); + const card = badge.locator("xpath=ancestor::*[@data-testid][1]"); + await ((await card.count()) ? card : badge).screenshot({ + path: `${OUT}/team-access-kanban-assignee-card.png`, + }); + console.log("captured team-access-kanban-assignee-card.png"); + } + + const carla = await signIn(await browser.newContext({ viewport: desktop }), "carla@example.com"); + await carla.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(carla, "team-access-viewer-card", TEAM_ACCESS_CARD); + + const mobile = await signIn( + await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 2, + }), + "ana@example.com", + ); + await mobile.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" }); + await shot(mobile, "team-access-mobile", TEAM_ACCESS_CARD); + + console.log(`\nwritten to ${OUT}`); +} finally { + await browser.close(); +} diff --git a/apps/web/scripts/tenancy-docs-shots.mjs b/apps/web/scripts/tenancy-docs-shots.mjs new file mode 100644 index 0000000000..8145d4d75a --- /dev/null +++ b/apps/web/scripts/tenancy-docs-shots.mjs @@ -0,0 +1,110 @@ +// Capture the multi-tenancy screenshots from the seeded demo instance. +// cd apps/web && node scripts/tenancy-docs-shots.mjs +import { chromium } from "@playwright/test"; +import { mkdirSync } from "node:fs"; + +const BASE = process.env.KANDEV_DEMO_URL ?? "http://127.0.0.1:8232"; +const OUT = process.env.SHOT_DIR ?? "../../docs/screenshots"; +const PASSWORD = "kandev-demo-pw-1"; +// The operator's email differs by seed: the tenancy seed uses acme.test, the +// team-access seed used for the Organizations shot uses example.com. +const OPERATOR = process.env.KANDEV_DEMO_ADMIN ?? "ana@acme.test"; +mkdirSync(OUT, { recursive: true }); + +async function signIn(context, email) { + const page = await context.newPage(); + await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); + await page.getByLabel(/email/i).fill(email); + await page.getByLabel(/password/i).fill(PASSWORD); + await Promise.all([ + page.waitForResponse( + (r) => r.url().includes("/api/v1/auth/login") && r.request().method() === "POST", + ), + page.getByRole("button", { name: /sign in|log in/i }).click(), + ]); + await page.waitForLoadState("networkidle"); + // Fail loudly. Without this a wrong password or a seed that uses different + // emails leaves the browser sitting on /login, and every screenshot below + // silently captures the sign-in card over a good committed one. + if (new URL(page.url()).pathname.startsWith("/login")) { + throw new Error(`sign in as ${email} did not leave /login: wrong seed or password?`); + } + for (let i = 0; i < 6; i += 1) { + const skip = page.getByRole("button", { name: /^skip$/i }); + if (!(await skip.count())) break; + await skip + .first() + .click() + .catch(() => {}); + await page.waitForTimeout(500); + } + return page; +} + +async function shot(page, name) { + await page.waitForTimeout(1200); + await page.screenshot({ path: `${OUT}/${name}.png` }); + console.log(`captured ${name}.png`); +} + +const desktop = { width: 1440, height: 1100 }; +const browser = await chromium.launch(); +try { + // Operator: the organization list. + const ana = await signIn(await browser.newContext({ viewport: desktop }), OPERATOR); + await ana.goto(`${BASE}/settings/system/organizations`, { waitUntil: "networkidle" }); + await shot(ana, "tenancy-organizations"); + + // The type-to-confirm delete dialog. + // The default organization's delete button is disabled on purpose, so pick + // a row that can actually be deleted. + const deleteButtons = ana.getByRole("button", { name: /delete organization/i }); + const count = await deleteButtons.count(); + let target = null; + for (let i = 0; i < count; i += 1) { + if (await deleteButtons.nth(i).isEnabled()) { + target = deleteButtons.nth(i); + break; + } + } + if (target) { + await target.click(); + await ana.waitForTimeout(700); + await shot(ana, "tenancy-delete-confirm"); + await ana.keyboard.press("Escape"); + await ana.waitForTimeout(400); + } + + // The first-administrator dialog. + const addAdmin = ana.getByRole("button", { name: /add administrator/i }); + if (await addAdmin.count()) { + await addAdmin.first().click(); + await ana.waitForTimeout(700); + await shot(ana, "tenancy-first-admin"); + await ana.keyboard.press("Escape"); + } + + // Acme's shared board. + const bruno = await signIn(await browser.newContext({ viewport: desktop }), "bruno@acme.test"); + await bruno.goto(`${BASE}/`, { waitUntil: "networkidle" }); + await shot(bruno, "tenancy-acme-board"); + + // Globex sees only its own world, on the same server. + const gary = await signIn(await browser.newContext({ viewport: desktop }), "gary@globex.test"); + await gary.goto(`${BASE}/`, { waitUntil: "networkidle" }); + await shot(gary, "tenancy-globex-board"); + + // A suspended organization refuses login. + const ivan = await browser.newContext({ viewport: desktop }); + const page = await ivan.newPage(); + await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" }); + await page.getByLabel(/email/i).fill("ivan@initech.test"); + await page.getByLabel(/password/i).fill(PASSWORD); + await page.getByRole("button", { name: /sign in|log in/i }).click(); + await page.waitForTimeout(1500); + await shot(page, "tenancy-suspended-login"); + + console.log(`\nwritten to ${OUT}`); +} finally { + await browser.close(); +} diff --git a/apps/web/src/locales/_verbatim.json b/apps/web/src/locales/_verbatim.json index bb3acdb8e7..ba681787fc 100644 --- a/apps/web/src/locales/_verbatim.json +++ b/apps/web/src/locales/_verbatim.json @@ -93,6 +93,8 @@ "office:telegram": "Brand or product name, spelled the same in every language.", "office:wakeHeartbeat": "Technical term kept in English across localized developer tooling.", "office:webhook": "Technical term kept in English across localized developer tooling.", + "orgs:adminEmail": "Loanword spelled identically in Portuguese.", + "orgs:namePlaceholder": "Fictional company name used as an example; brand nouns are spelled the same in every language.", "plugins:manifestWebhooks": "Technical term kept in English across localized developer tooling.", "plugins:repo": "Technical term kept in English across localized developer tooling.", "settings:destinationGlobal": "Technical term kept in English across localized developer tooling.", diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json index 222fcaaa71..0f800c0d0c 100644 --- a/apps/web/src/locales/en/agents.json +++ b/apps/web/src/locales/en/agents.json @@ -295,5 +295,6 @@ "savingProfile": "Saving...", "saveProfile": "Save profile", "createProfile": "Create profile", - "creatingProfile": "Creating..." + "creatingProfile": "Creating...", + "adminOnly": "Only administrators can change agents and agent profiles." } diff --git a/apps/web/src/locales/en/auth.json b/apps/web/src/locales/en/auth.json index 3313bc3c1c..7c80b26518 100644 --- a/apps/web/src/locales/en/auth.json +++ b/apps/web/src/locales/en/auth.json @@ -13,16 +13,17 @@ "invalidEmailOrPassword": "Invalid email or password.", "inviteLinkInvalidOrExpired": "This invite link is invalid or has expired. Ask an admin for a new one.", "inviteLinkMissingToken": "This invite link is missing its token.", - "joining": "Joining...", "joinThisKandevDeployment": "Join this Kandev deployment.", + "joining": "Joining...", "orContinueWith": "or continue with", + "organizationUnavailable": "Your organization is currently unavailable. Contact your Kandev administrator.", "password": "Password", - "setupFailed": "Setup failed. Please try again.", "setUpKandev": "Set up Kandev", + "setupFailed": "Setup failed. Please try again.", "setupNoLongerAvailable": "Setup is no longer available - an account already exists.", "signIn": "Sign in", - "signingIn": "Signing in...", "signInToYourKandevAccount": "Sign in to your Kandev account to continue.", + "signingIn": "Signing in...", "somethingWentWrong": "Something went wrong. Please try again.", "tooManyAttempts": "Too many attempts. Please wait a moment and try again.", "youVeBeenInvitedAs": "You've been invited as {{role}}." diff --git a/apps/web/src/locales/en/orgs.json b/apps/web/src/locales/en/orgs.json new file mode 100644 index 0000000000..1a8d3fa294 --- /dev/null +++ b/apps/web/src/locales/en/orgs.json @@ -0,0 +1,38 @@ +{ + "addAdmin": "Add administrator", + "adminCreate": "Create administrator", + "adminCreated": "Administrator created.", + "adminDescription": "An administrator can only create accounts in their own organization, so a new organization needs its first one created here.", + "adminEmail": "Email", + "adminName": "Display name", + "adminPassword": "Password", + "adminPasswordHint": "At least 8 characters. Share it with them and have them change it after signing in.", + "adminTitle": "First administrator for {{name}}", + "boundaryDescription": "Workspaces, tasks, sessions, secrets and accounts are scoped to one organization. As the instance operator you manage organizations themselves; you do not gain access inside any of them, including your own.", + "boundaryTitle": "What an organization separates", + "create": "Create", + "createDescription": "Create the organization first, then add its first administrator. From then on it administers itself.", + "createTitle": "New organization", + "created": "Organization created.", + "defaultBadge": "Default", + "delete": "Delete organization", + "deleteConfirm": "Delete organization", + "deleteConfirmLabel": "Type {{slug}} to confirm", + "deleteTitle": "Delete {{name}}", + "deleteWarning": "This removes every workspace, task, session and account in this organization. It cannot be undone.", + "deleted": "Organization deleted.", + "description": "Each organization is a separate tenant. Users belong to exactly one, and organizations cannot see each other's workspaces, tasks or secrets.", + "genericError": "That change could not be saved.", + "nameLabel": "Organization name", + "namePlaceholder": "Acme Corp", + "navOrganizations": "Organizations", + "operatorOnly": "Only the instance operator can manage organizations.", + "renamed": "Organization renamed.", + "resume": "Resume", + "resumed": "Organization resumed.", + "statusActive": "Active", + "statusSuspended": "Suspended", + "suspend": "Suspend", + "suspended": "Organization suspended.", + "title": "Organizations" +} diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index dc63b8c5c5..67e30cf50f 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -750,5 +750,25 @@ "keyValueKeyPlaceholder": "Key", "keyValueValuePlaceholder": "Value", "externalMcpToolListPendingAgentPermissions": "List live command and tool permission requests for a task, including safe action details and the agent's exact choices.", - "externalMcpToolResolveAgentPermission": "Resolve one live permission request by selecting an exact option returned by the list tool." + "externalMcpToolResolveAgentPermission": "Resolve one live permission request by selecting an exact option returned by the list tool.", + "navUnits": "Organization units", + "unitsTitle": "Organization units", + "unitsDescription": "A unit holds teams, workspaces and people. Being a member of a unit reaches every workspace beneath it, including ones created later.", + "unitsEmpty": "No units yet.", + "unitRootBadge": "Whole organization", + "unitPersonalBadge": "Personal", + "unitAddChild": "Add unit", + "unitMembers": "Members", + "unitDelete": "Delete unit", + "unitCreateTitle": "New unit in {{parent}}", + "unitCreateDescription": "A department and a team are both units. They differ only in where they sit.", + "unitNamePlaceholder": "Platform", + "unitCreate": "Create", + "unitMembersTitle": "Members of {{unit}}", + "unitMembersDescription": "Everyone here reaches every workspace under this unit, at the role you give them.", + "unitNoMembers": "Nobody is in this unit yet.", + "unitAddMember": "Add", + "unitRemoveMember": "Remove member", + "unitPickPerson": "Pick someone", + "unitActionFailed": "That did not work." } diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 768045db09..03e5034f06 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1943,6 +1943,12 @@ "branchPolicySelected": "{{name}} (from {{base}})", "branchPolicyUnavailable": "Base branch unavailable. Refresh branches or edit this policy.", "branchPolicyRequiresSingleRepository": "Branch policies require a single repository with the local executor.", + "assignedTo": "Assigned to", + "unassigned": "Unassigned", + "assignToMe": "Assign to me", + "searchPeople": "Search people", + "noPeopleFound": "No people found", + "assignedToName": "Assigned to {{name}}", "uploadConflictTitle_one": "{{count}} file already exists", "uploadConflictTitle_other": "{{count}} files already exist", "uploadConflictBody": "Choose what to do with each one. Nothing is uploaded until you confirm.", diff --git a/apps/web/src/locales/en/workspaces.json b/apps/web/src/locales/en/workspaces.json index 37ebc87564..bc82593606 100644 --- a/apps/web/src/locales/en/workspaces.json +++ b/apps/web/src/locales/en/workspaces.json @@ -200,5 +200,45 @@ "branchPolicyGitflowCreate": "Create Gitflow policies", "branchPolicyGitflowCreated": "Gitflow policies created", "branchPolicyGitflowFailed": "Could not create the Gitflow policies.", - "branchPolicySummary": "Base: {{base}}. Template: {{template}}. Pull request target: {{target}}." + "branchPolicySummary": "Base: {{base}}. Template: {{template}}. Pull request target: {{target}}.", + "defaultVisibility.description": "Applies to workspaces created from now on. Existing workspaces keep the visibility they already have.", + "defaultVisibility.existingUnaffected": "Changing this never changes an existing workspace.", + "defaultVisibility.label": "New workspaces are", + "defaultVisibility.savedOrg": "New workspaces will be visible to the organization.", + "defaultVisibility.savedPrivate": "New workspaces will be private.", + "defaultVisibility.title": "New workspace defaults", + "teamAccess.addMember": "Add", + "teamAccess.addMemberLabel": "Add a colleague", + "teamAccess.addMemberPlaceholder": "Select a person", + "teamAccess.description": "Choose who can reach this workspace, and give colleagues a role in it.", + "teamAccess.genericError": "That change could not be saved.", + "teamAccess.makeOwner": "Make owner", + "teamAccess.memberAdded": "Member added.", + "teamAccess.memberRemoved": "Member removed.", + "teamAccess.membersLabel": "Members", + "teamAccess.noMembers": "No members yet. The owner always has full access.", + "teamAccess.noUsersToAdd": "Everyone is already a member", + "teamAccess.nowOrgVisible": "This workspace is now visible to the organization.", + "teamAccess.nowPrivate": "This workspace is now private.", + "teamAccess.ownershipTransferred": "{{name}} is now the owner.", + "teamAccess.removeMember": "Remove member", + "teamAccess.roleCollaborator": "Collaborator", + "teamAccess.roleLabel": "Role", + "teamAccess.roleOwner": "Owner", + "teamAccess.roleUpdated": "Role updated.", + "teamAccess.roleViewer": "Viewer", + "teamAccess.title": "Team access", + "teamAccess.visibilityLabel": "Visibility", + "teamAccess.visibilityOrg": "Everyone in the organization", + "teamAccess.visibilityOrgHint": "Every user except guests can open this workspace and work on its tasks.", + "teamAccess.visibilityPrivate": "Private", + "teamAccess.visibilityPrivateHint": "Only the owner and the members listed below can open this workspace.", + "placement.title": "Placement", + "placement.description": "Which unit this workspace sits in, and therefore who reaches it. Moving it is the only way to change that: roles combine by taking the strongest, so nothing can be taken away in place.", + "placement.pick": "Pick a unit", + "placement.search": "Search units", + "placement.none": "No units found", + "placement.moved": "Workspace moved.", + "placement.failed": "The workspace could not be moved.", + "placement.ownerOnly": "Only the workspace owner can move it" } diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json index b24a7821f5..4977eb60a9 100644 --- a/apps/web/src/locales/pseudo/agents.json +++ b/apps/web/src/locales/pseudo/agents.json @@ -295,5 +295,6 @@ "savingProfile": "Śàvĩńĝ...", "saveProfile": "Śàvē ƥŕōƒĩĺē", "createProfile": "Ćŕēàţē ƥŕōƒĩĺē", - "creatingProfile": "Ćŕēàţĩńĝ..." + "creatingProfile": "Ćŕēàţĩńĝ...", + "adminOnly": "Ōńĺŷ àďḿĩńĩśţŕàţōŕś ćàń ćĥàńĝē àĝēńţś àńď àĝēńţ ƥŕōƒĩĺēś." } diff --git a/apps/web/src/locales/pseudo/auth.json b/apps/web/src/locales/pseudo/auth.json index 4ba48ca020..0fdaa08dd3 100644 --- a/apps/web/src/locales/pseudo/auth.json +++ b/apps/web/src/locales/pseudo/auth.json @@ -13,16 +13,17 @@ "invalidEmailOrPassword": "Ĩńvàĺĩď ēḿàĩĺ ōŕ ƥàśśŵōŕď.", "inviteLinkInvalidOrExpired": "Ţĥĩś ĩńvĩţē ĺĩńķ ĩś ĩńvàĺĩď ōŕ ĥàś ēxƥĩŕēď. Àśķ àń àďḿĩń ƒōŕ à ńēŵ ōńē.", "inviteLinkMissingToken": "Ţĥĩś ĩńvĩţē ĺĩńķ ĩś ḿĩśśĩńĝ ĩţś ţōķēń.", - "joining": "Ĵōĩńĩńĝ...", "joinThisKandevDeployment": "Ĵōĩń ţĥĩś Ķàńďēv ďēƥĺōŷḿēńţ.", + "joining": "Ĵōĩńĩńĝ...", "orContinueWith": "ōŕ ćōńţĩńũē ŵĩţĥ", + "organizationUnavailable": "Ŷōũŕ ōŕĝàńĩźàţĩōń ĩś ćũŕŕēńţĺŷ ũńàvàĩĺàƀĺē. Ćōńţàćţ ŷōũŕ Ķàńďēv àďḿĩńĩśţŕàţōŕ.", "password": "Ƥàśśŵōŕď", - "setupFailed": "Śēţũƥ ƒàĩĺēď. Ƥĺēàśē ţŕŷ àĝàĩń.", "setUpKandev": "Śēţ ũƥ Ķàńďēv", + "setupFailed": "Śēţũƥ ƒàĩĺēď. Ƥĺēàśē ţŕŷ àĝàĩń.", "setupNoLongerAvailable": "Śēţũƥ ĩś ńō ĺōńĝēŕ àvàĩĺàƀĺē - àń àććōũńţ àĺŕēàďŷ ēxĩśţś.", "signIn": "Śĩĝń ĩń", - "signingIn": "Śĩĝńĩńĝ ĩń...", "signInToYourKandevAccount": "Śĩĝń ĩń ţō ŷōũŕ Ķàńďēv àććōũńţ ţō ćōńţĩńũē.", + "signingIn": "Śĩĝńĩńĝ ĩń...", "somethingWentWrong": "Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ. Ƥĺēàśē ţŕŷ àĝàĩń.", "tooManyAttempts": "Ţōō ḿàńŷ àţţēḿƥţś. Ƥĺēàśē ŵàĩţ à ḿōḿēńţ àńď ţŕŷ àĝàĩń.", "youVeBeenInvitedAs": "Ŷōũ'vē ƀēēń ĩńvĩţēď àś {{role}}." diff --git a/apps/web/src/locales/pseudo/orgs.json b/apps/web/src/locales/pseudo/orgs.json new file mode 100644 index 0000000000..a07b889720 --- /dev/null +++ b/apps/web/src/locales/pseudo/orgs.json @@ -0,0 +1,38 @@ +{ + "addAdmin": "Àďď àďḿĩńĩśţŕàţōŕ", + "adminCreate": "Ćŕēàţē àďḿĩńĩśţŕàţōŕ", + "adminCreated": "Àďḿĩńĩśţŕàţōŕ ćŕēàţēď.", + "adminDescription": "Àń àďḿĩńĩśţŕàţōŕ ćàń ōńĺŷ ćŕēàţē àććōũńţś ĩń ţĥēĩŕ ōŵń ōŕĝàńĩźàţĩōń, śō à ńēŵ ōŕĝàńĩźàţĩōń ńēēďś ĩţś ƒĩŕśţ ōńē ćŕēàţēď ĥēŕē.", + "adminEmail": "Ēḿàĩĺ", + "adminName": "Ďĩśƥĺàŷ ńàḿē", + "adminPassword": "Ƥàśśŵōŕď", + "adminPasswordHint": "Àţ ĺēàśţ 8 ćĥàŕàćţēŕś. Śĥàŕē ĩţ ŵĩţĥ ţĥēḿ àńď ĥàvē ţĥēḿ ćĥàńĝē ĩţ àƒţēŕ śĩĝńĩńĝ ĩń.", + "adminTitle": "Ƒĩŕśţ àďḿĩńĩśţŕàţōŕ ƒōŕ {{name}}", + "boundaryDescription": "Ŵōŕķśƥàćēś, ţàśķś, śēśśĩōńś, śēćŕēţś àńď àććōũńţś àŕē śćōƥēď ţō ōńē ōŕĝàńĩźàţĩōń. Àś ţĥē ĩńśţàńćē ōƥēŕàţōŕ ŷōũ ḿàńàĝē ōŕĝàńĩźàţĩōńś ţĥēḿśēĺvēś; ŷōũ ďō ńōţ ĝàĩń àććēśś ĩńśĩďē àńŷ ōƒ ţĥēḿ, ĩńćĺũďĩńĝ ŷōũŕ ōŵń.", + "boundaryTitle": "Ŵĥàţ àń ōŕĝàńĩźàţĩōń śēƥàŕàţēś", + "create": "Ćŕēàţē", + "createDescription": "Ćŕēàţē ţĥē ōŕĝàńĩźàţĩōń ƒĩŕśţ, ţĥēń àďď ĩţś ƒĩŕśţ àďḿĩńĩśţŕàţōŕ. Ƒŕōḿ ţĥēń ōń ĩţ àďḿĩńĩśţēŕś ĩţśēĺƒ.", + "createTitle": "Ńēŵ ōŕĝàńĩźàţĩōń", + "created": "Ōŕĝàńĩźàţĩōń ćŕēàţēď.", + "defaultBadge": "Ďēƒàũĺţ", + "delete": "Ďēĺēţē ōŕĝàńĩźàţĩōń", + "deleteConfirm": "Ďēĺēţē ōŕĝàńĩźàţĩōń", + "deleteConfirmLabel": "Ţŷƥē {{slug}} ţō ćōńƒĩŕḿ", + "deleteTitle": "Ďēĺēţē {{name}}", + "deleteWarning": "Ţĥĩś ŕēḿōvēś ēvēŕŷ ŵōŕķśƥàćē, ţàśķ, śēśśĩōń àńď àććōũńţ ĩń ţĥĩś ōŕĝàńĩźàţĩōń. Ĩţ ćàńńōţ ƀē ũńďōńē.", + "deleted": "Ōŕĝàńĩźàţĩōń ďēĺēţēď.", + "description": "Ēàćĥ ōŕĝàńĩźàţĩōń ĩś à śēƥàŕàţē ţēńàńţ. Ũśēŕś ƀēĺōńĝ ţō ēxàćţĺŷ ōńē, àńď ōŕĝàńĩźàţĩōńś ćàńńōţ śēē ēàćĥ ōţĥēŕ'ś ŵōŕķśƥàćēś, ţàśķś ōŕ śēćŕēţś.", + "genericError": "Ţĥàţ ćĥàńĝē ćōũĺď ńōţ ƀē śàvēď.", + "nameLabel": "Ōŕĝàńĩźàţĩōń ńàḿē", + "namePlaceholder": "Àćḿē Ćōŕƥ", + "navOrganizations": "Ōŕĝàńĩźàţĩōńś", + "operatorOnly": "Ōńĺŷ ţĥē ĩńśţàńćē ōƥēŕàţōŕ ćàń ḿàńàĝē ōŕĝàńĩźàţĩōńś.", + "renamed": "Ōŕĝàńĩźàţĩōń ŕēńàḿēď.", + "resume": "Ŕēśũḿē", + "resumed": "Ōŕĝàńĩźàţĩōń ŕēśũḿēď.", + "statusActive": "Àćţĩvē", + "statusSuspended": "Śũśƥēńďēď", + "suspend": "Śũśƥēńď", + "suspended": "Ōŕĝàńĩźàţĩōń śũśƥēńďēď.", + "title": "Ōŕĝàńĩźàţĩōńś" +} diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index e32b154254..146b2e303a 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -750,5 +750,25 @@ "keyValueKeyPlaceholder": "Ķēŷ", "keyValueValuePlaceholder": "Vàĺũē", "externalMcpToolListPendingAgentPermissions": "Ĺĩśţ ĺĩvē ćōḿḿàńď àńď ţōōĺ ƥēŕḿĩśśĩōń ŕēqũēśţś ƒōŕ à ţàśķ, ĩńćĺũďĩńĝ śàƒē àćţĩōń ďēţàĩĺś àńď ţĥē àĝēńţ'ś ēxàćţ ćĥōĩćēś.", - "externalMcpToolResolveAgentPermission": "Ŕēśōĺvē ōńē ĺĩvē ƥēŕḿĩśśĩōń ŕēqũēśţ ƀŷ śēĺēćţĩńĝ àń ēxàćţ ōƥţĩōń ŕēţũŕńēď ƀŷ ţĥē ĺĩśţ ţōōĺ." + "externalMcpToolResolveAgentPermission": "Ŕēśōĺvē ōńē ĺĩvē ƥēŕḿĩśśĩōń ŕēqũēśţ ƀŷ śēĺēćţĩńĝ àń ēxàćţ ōƥţĩōń ŕēţũŕńēď ƀŷ ţĥē ĺĩśţ ţōōĺ.", + "navUnits": "Ōŕĝàńĩźàţĩōń ũńĩţś", + "unitsTitle": "Ōŕĝàńĩźàţĩōń ũńĩţś", + "unitsDescription": "À ũńĩţ ĥōĺďś ţēàḿś, ŵōŕķśƥàćēś àńď ƥēōƥĺē. Ɓēĩńĝ à ḿēḿƀēŕ ōƒ à ũńĩţ ŕēàćĥēś ēvēŕŷ ŵōŕķśƥàćē ƀēńēàţĥ ĩţ, ĩńćĺũďĩńĝ ōńēś ćŕēàţēď ĺàţēŕ.", + "unitsEmpty": "Ńō ũńĩţś ŷēţ.", + "unitRootBadge": "Ŵĥōĺē ōŕĝàńĩźàţĩōń", + "unitPersonalBadge": "Ƥēŕśōńàĺ", + "unitAddChild": "Àďď ũńĩţ", + "unitMembers": "Ḿēḿƀēŕś", + "unitDelete": "Ďēĺēţē ũńĩţ", + "unitCreateTitle": "Ńēŵ ũńĩţ ĩń {{parent}}", + "unitCreateDescription": "À ďēƥàŕţḿēńţ àńď à ţēàḿ àŕē ƀōţĥ ũńĩţś. Ţĥēŷ ďĩƒƒēŕ ōńĺŷ ĩń ŵĥēŕē ţĥēŷ śĩţ.", + "unitNamePlaceholder": "Ƥĺàţƒōŕḿ", + "unitCreate": "Ćŕēàţē", + "unitMembersTitle": "Ḿēḿƀēŕś ōƒ {{unit}}", + "unitMembersDescription": "Ēvēŕŷōńē ĥēŕē ŕēàćĥēś ēvēŕŷ ŵōŕķśƥàćē ũńďēŕ ţĥĩś ũńĩţ, àţ ţĥē ŕōĺē ŷōũ ĝĩvē ţĥēḿ.", + "unitNoMembers": "Ńōƀōďŷ ĩś ĩń ţĥĩś ũńĩţ ŷēţ.", + "unitAddMember": "Àďď", + "unitRemoveMember": "Ŕēḿōvē ḿēḿƀēŕ", + "unitPickPerson": "Ƥĩćķ śōḿēōńē", + "unitActionFailed": "Ţĥàţ ďĩď ńōţ ŵōŕķ." } diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index ff366fd694..644d199d0f 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -1943,6 +1943,12 @@ "branchPolicySelected": "{{name}} (ƒŕōḿ {{base}})", "branchPolicyUnavailable": "Ɓàśē ƀŕàńćĥ ũńàvàĩĺàƀĺē. Ŕēƒŕēśĥ ƀŕàńćĥēś ōŕ ēďĩţ ţĥĩś ƥōĺĩćŷ.", "branchPolicyRequiresSingleRepository": "Ɓŕàńćĥ ƥōĺĩćĩēś ŕēqũĩŕē à śĩńĝĺē ŕēƥōśĩţōŕŷ ŵĩţĥ ţĥē ĺōćàĺ ēxēćũţōŕ.", + "assignedTo": "Àśśĩĝńēď ţō", + "unassigned": "Ũńàśśĩĝńēď", + "assignToMe": "Àśśĩĝń ţō ḿē", + "searchPeople": "Śēàŕćĥ ƥēōƥĺē", + "noPeopleFound": "Ńō ƥēōƥĺē ƒōũńď", + "assignedToName": "Àśśĩĝńēď ţō {{name}}", "uploadConflictTitle_one": "{{count}} ƒĩĺē àĺŕēàďŷ ēxĩśţś", "uploadConflictTitle_other": "{{count}} ƒĩĺēś àĺŕēàďŷ ēxĩśţ", "uploadConflictBody": "Ćĥōōśē ŵĥàţ ţō ďō ŵĩţĥ ēàćĥ ōńē. Ńōţĥĩńĝ ĩś ũƥĺōàďēď ũńţĩĺ ŷōũ ćōńƒĩŕḿ.", diff --git a/apps/web/src/locales/pseudo/workspaces.json b/apps/web/src/locales/pseudo/workspaces.json index d5bc695058..bdd863ecc7 100644 --- a/apps/web/src/locales/pseudo/workspaces.json +++ b/apps/web/src/locales/pseudo/workspaces.json @@ -200,5 +200,45 @@ "branchPolicyGitflowCreate": "Ćŕēàţē Ĝĩţƒĺōŵ ƥōĺĩćĩēś", "branchPolicyGitflowCreated": "Ĝĩţƒĺōŵ ƥōĺĩćĩēś ćŕēàţēď", "branchPolicyGitflowFailed": "Ćōũĺď ńōţ ćŕēàţē ţĥē Ĝĩţƒĺōŵ ƥōĺĩćĩēś.", - "branchPolicySummary": "Ɓàśē: {{base}}. Ţēḿƥĺàţē: {{template}}. Ƥũĺĺ ŕēqũēśţ ţàŕĝēţ: {{target}}." + "branchPolicySummary": "Ɓàśē: {{base}}. Ţēḿƥĺàţē: {{template}}. Ƥũĺĺ ŕēqũēśţ ţàŕĝēţ: {{target}}.", + "defaultVisibility.description": "Àƥƥĺĩēś ţō ŵōŕķśƥàćēś ćŕēàţēď ƒŕōḿ ńōŵ ōń. Ēxĩśţĩńĝ ŵōŕķśƥàćēś ķēēƥ ţĥē vĩśĩƀĩĺĩţŷ ţĥēŷ àĺŕēàďŷ ĥàvē.", + "defaultVisibility.existingUnaffected": "Ćĥàńĝĩńĝ ţĥĩś ńēvēŕ ćĥàńĝēś àń ēxĩśţĩńĝ ŵōŕķśƥàćē.", + "defaultVisibility.label": "Ńēŵ ŵōŕķśƥàćēś àŕē", + "defaultVisibility.savedOrg": "Ńēŵ ŵōŕķśƥàćēś ŵĩĺĺ ƀē vĩśĩƀĺē ţō ţĥē ōŕĝàńĩźàţĩōń.", + "defaultVisibility.savedPrivate": "Ńēŵ ŵōŕķśƥàćēś ŵĩĺĺ ƀē ƥŕĩvàţē.", + "defaultVisibility.title": "Ńēŵ ŵōŕķśƥàćē ďēƒàũĺţś", + "teamAccess.addMember": "Àďď", + "teamAccess.addMemberLabel": "Àďď à ćōĺĺēàĝũē", + "teamAccess.addMemberPlaceholder": "Śēĺēćţ à ƥēŕśōń", + "teamAccess.description": "Ćĥōōśē ŵĥō ćàń ŕēàćĥ ţĥĩś ŵōŕķśƥàćē, àńď ĝĩvē ćōĺĺēàĝũēś à ŕōĺē ĩń ĩţ.", + "teamAccess.genericError": "Ţĥàţ ćĥàńĝē ćōũĺď ńōţ ƀē śàvēď.", + "teamAccess.makeOwner": "Ḿàķē ōŵńēŕ", + "teamAccess.memberAdded": "Ḿēḿƀēŕ àďďēď.", + "teamAccess.memberRemoved": "Ḿēḿƀēŕ ŕēḿōvēď.", + "teamAccess.membersLabel": "Ḿēḿƀēŕś", + "teamAccess.noMembers": "Ńō ḿēḿƀēŕś ŷēţ. Ţĥē ōŵńēŕ àĺŵàŷś ĥàś ƒũĺĺ àććēśś.", + "teamAccess.noUsersToAdd": "Ēvēŕŷōńē ĩś àĺŕēàďŷ à ḿēḿƀēŕ", + "teamAccess.nowOrgVisible": "Ţĥĩś ŵōŕķśƥàćē ĩś ńōŵ vĩśĩƀĺē ţō ţĥē ōŕĝàńĩźàţĩōń.", + "teamAccess.nowPrivate": "Ţĥĩś ŵōŕķśƥàćē ĩś ńōŵ ƥŕĩvàţē.", + "teamAccess.ownershipTransferred": "{{name}} ĩś ńōŵ ţĥē ōŵńēŕ.", + "teamAccess.removeMember": "Ŕēḿōvē ḿēḿƀēŕ", + "teamAccess.roleCollaborator": "Ćōĺĺàƀōŕàţōŕ", + "teamAccess.roleLabel": "Ŕōĺē", + "teamAccess.roleOwner": "Ōŵńēŕ", + "teamAccess.roleUpdated": "Ŕōĺē ũƥďàţēď.", + "teamAccess.roleViewer": "Vĩēŵēŕ", + "teamAccess.title": "Ţēàḿ àććēśś", + "teamAccess.visibilityLabel": "Vĩśĩƀĩĺĩţŷ", + "teamAccess.visibilityOrg": "Ēvēŕŷōńē ĩń ţĥē ōŕĝàńĩźàţĩōń", + "teamAccess.visibilityOrgHint": "Ēvēŕŷ ũśēŕ ēxćēƥţ ĝũēśţś ćàń ōƥēń ţĥĩś ŵōŕķśƥàćē àńď ŵōŕķ ōń ĩţś ţàśķś.", + "teamAccess.visibilityPrivate": "Ƥŕĩvàţē", + "teamAccess.visibilityPrivateHint": "Ōńĺŷ ţĥē ōŵńēŕ àńď ţĥē ḿēḿƀēŕś ĺĩśţēď ƀēĺōŵ ćàń ōƥēń ţĥĩś ŵōŕķśƥàćē.", + "placement.title": "Ƥĺàćēḿēńţ", + "placement.description": "Ŵĥĩćĥ ũńĩţ ţĥĩś ŵōŕķśƥàćē śĩţś ĩń, àńď ţĥēŕēƒōŕē ŵĥō ŕēàćĥēś ĩţ. Ḿōvĩńĝ ĩţ ĩś ţĥē ōńĺŷ ŵàŷ ţō ćĥàńĝē ţĥàţ: ŕōĺēś ćōḿƀĩńē ƀŷ ţàķĩńĝ ţĥē śţŕōńĝēśţ, śō ńōţĥĩńĝ ćàń ƀē ţàķēń àŵàŷ ĩń ƥĺàćē.", + "placement.pick": "Ƥĩćķ à ũńĩţ", + "placement.search": "Śēàŕćĥ ũńĩţś", + "placement.none": "Ńō ũńĩţś ƒōũńď", + "placement.moved": "Ŵōŕķśƥàćē ḿōvēď.", + "placement.failed": "Ţĥē ŵōŕķśƥàćē ćōũĺď ńōţ ƀē ḿōvēď.", + "placement.ownerOnly": "Ōńĺŷ ţĥē ŵōŕķśƥàćē ōŵńēŕ ćàń ḿōvē ĩţ" } diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json index 3cb07df1bc..d2828cb911 100644 --- a/apps/web/src/locales/pt-pt/agents.json +++ b/apps/web/src/locales/pt-pt/agents.json @@ -295,5 +295,6 @@ "dynamicRouteRetryDescription": "Tenta novamente o mesmo candidato depois de um erro do fornecedor. Use esta opção quando a falha puder ser temporária.", "dynamicRouteTryNextDescription": "Ignora este candidato nesta execução e continua com o próximo perfil elegível.", "dynamicRouteStopDescription": "Para o encaminhamento quando este candidato falha. A tarefa aguarda uma ação manual de recuperação.", - "dynamicProfileSaved": "Perfil dinâmico guardado." + "dynamicProfileSaved": "Perfil dinâmico guardado.", + "adminOnly": "Só os administradores podem alterar agentes e perfis de agente." } diff --git a/apps/web/src/locales/pt-pt/auth.json b/apps/web/src/locales/pt-pt/auth.json index daedfdd1a1..213cd052e1 100644 --- a/apps/web/src/locales/pt-pt/auth.json +++ b/apps/web/src/locales/pt-pt/auth.json @@ -4,26 +4,27 @@ "authenticationIsEnabledForThisDeployment": "A autenticação está ativada nesta instalação. Crie a primeira conta abaixo; torna-se a conta de administrador e pode convidar mais utilizadores.", "checkingInvite": "A verificar o convite...", "continueWithProvider": "Continuar com {{provider}}", + "couldNotAcceptInvite": "Não foi possível aceitar este convite.", + "couldNotLoadInvite": "Não foi possível carregar este convite. Tente novamente.", "createAdminAccount": "Criar conta de administrador", "creatingAdminAccount": "A criar a conta de administrador...", "displayName": "Nome a apresentar", "email": "E-mail", - "joining": "A aderir...", - "joinThisKandevDeployment": "Adira a esta instalação do Kandev.", - "orContinueWith": "ou continuar com", - "password": "Palavra-passe", - "setUpKandev": "Configurar o Kandev", - "signIn": "Iniciar sessão", - "signingIn": "A iniciar sessão...", - "signInToYourKandevAccount": "Inicie sessão na sua conta Kandev para continuar.", - "youVeBeenInvitedAs": "Foi convidado como {{role}}.", - "couldNotAcceptInvite": "Não foi possível aceitar este convite.", - "couldNotLoadInvite": "Não foi possível carregar este convite. Tente novamente.", "invalidEmailOrPassword": "E-mail ou palavra-passe inválidos.", "inviteLinkInvalidOrExpired": "Este link de convite é inválido ou expirou. Peça um novo a um administrador.", "inviteLinkMissingToken": "Falta o token a este link de convite.", + "joinThisKandevDeployment": "Adira a esta instalação do Kandev.", + "joining": "A aderir...", + "orContinueWith": "ou continuar com", + "organizationUnavailable": "A sua organização está indisponível de momento. Contacte o administrador do Kandev.", + "password": "Palavra-passe", + "setUpKandev": "Configurar o Kandev", "setupFailed": "A configuração falhou. Tente novamente.", "setupNoLongerAvailable": "A configuração já não está disponível - já existe uma conta.", + "signIn": "Iniciar sessão", + "signInToYourKandevAccount": "Inicie sessão na sua conta Kandev para continuar.", + "signingIn": "A iniciar sessão...", "somethingWentWrong": "Algo correu mal. Tente novamente.", - "tooManyAttempts": "Demasiadas tentativas. Aguarde um momento e tente novamente." + "tooManyAttempts": "Demasiadas tentativas. Aguarde um momento e tente novamente.", + "youVeBeenInvitedAs": "Foi convidado como {{role}}." } diff --git a/apps/web/src/locales/pt-pt/orgs.json b/apps/web/src/locales/pt-pt/orgs.json new file mode 100644 index 0000000000..354d95a45f --- /dev/null +++ b/apps/web/src/locales/pt-pt/orgs.json @@ -0,0 +1,38 @@ +{ + "addAdmin": "Adicionar administrador", + "adminCreate": "Criar administrador", + "adminCreated": "Administrador criado.", + "adminDescription": "Um administrador só pode criar contas na sua própria organização, por isso uma organização nova precisa que o primeiro seja criado aqui.", + "adminEmail": "Email", + "adminName": "Nome a apresentar", + "adminPassword": "Palavra-passe", + "adminPasswordHint": "Pelo menos 8 caracteres. Partilhe-a e peça que a altere após iniciar sessão.", + "adminTitle": "Primeiro administrador de {{name}}", + "boundaryDescription": "Áreas de trabalho, tarefas, sessões, segredos e contas pertencem a uma organização. Como operador da instância, gere as organizações em si; não obtém acesso dentro de nenhuma delas, incluindo a sua.", + "boundaryTitle": "O que uma organização separa", + "create": "Criar", + "createDescription": "Crie primeiro a organização e depois adicione o seu primeiro administrador. A partir daí, administra-se a si própria.", + "createTitle": "Nova organização", + "created": "Organização criada.", + "defaultBadge": "Predefinida", + "delete": "Eliminar organização", + "deleteConfirm": "Eliminar organização", + "deleteConfirmLabel": "Escreva {{slug}} para confirmar", + "deleteTitle": "Eliminar {{name}}", + "deleteWarning": "Isto remove todas as áreas de trabalho, tarefas, sessões e contas desta organização. Não pode ser anulado.", + "deleted": "Organização eliminada.", + "description": "Cada organização é um inquilino separado. Os utilizadores pertencem a exatamente uma e as organizações não conseguem ver as áreas de trabalho, tarefas ou segredos umas das outras.", + "genericError": "Não foi possível guardar essa alteração.", + "nameLabel": "Nome da organização", + "namePlaceholder": "Acme Corp", + "navOrganizations": "Organizações", + "operatorOnly": "Apenas o operador da instância pode gerir organizações.", + "renamed": "Organização renomeada.", + "resume": "Retomar", + "resumed": "Organização retomada.", + "statusActive": "Ativa", + "statusSuspended": "Suspensa", + "suspend": "Suspender", + "suspended": "Organização suspensa.", + "title": "Organizações" +} diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json index bff91b5dc2..c4e52d21f6 100644 --- a/apps/web/src/locales/pt-pt/settings.json +++ b/apps/web/src/locales/pt-pt/settings.json @@ -750,5 +750,25 @@ "taskBehavior": "Comportamento das tarefas", "terminalAndEditors": "Terminal e editores", "workspacesAndAccess": "Workspaces e acesso", - "profileStartModelNotAdvertisedOnHost": "A sondagem do anfitrião não anunciou {{model}}. O executor selecionado decidirá o modelo no arranque." + "profileStartModelNotAdvertisedOnHost": "A sondagem do anfitrião não anunciou {{model}}. O executor selecionado decidirá o modelo no arranque.", + "navUnits": "Unidades da organização", + "unitsTitle": "Unidades da organização", + "unitsDescription": "Uma unidade contém equipas, áreas de trabalho e pessoas. Pertencer a uma unidade dá acesso a todas as áreas de trabalho abaixo dela, incluindo as criadas depois.", + "unitsEmpty": "Ainda não há unidades.", + "unitRootBadge": "Toda a organização", + "unitPersonalBadge": "Pessoal", + "unitAddChild": "Adicionar unidade", + "unitMembers": "Membros", + "unitDelete": "Eliminar unidade", + "unitCreateTitle": "Nova unidade em {{parent}}", + "unitCreateDescription": "Um departamento e uma equipa são ambos unidades. Diferem apenas na posição.", + "unitNamePlaceholder": "Plataforma", + "unitCreate": "Criar", + "unitMembersTitle": "Membros de {{unit}}", + "unitMembersDescription": "Todos aqui alcançam todas as áreas de trabalho sob esta unidade, com o perfil que lhes atribuir.", + "unitNoMembers": "Ainda não há ninguém nesta unidade.", + "unitAddMember": "Adicionar", + "unitRemoveMember": "Remover membro", + "unitPickPerson": "Escolher alguém", + "unitActionFailed": "Não foi possível concluir." } diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index cfb94f3b9f..44737f3db7 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -1943,6 +1943,12 @@ "branchPolicySelected": "{{name}} (de {{base}})", "branchPolicyUnavailable": "O branch base não está disponível. Atualize os branches ou edite esta política.", "branchPolicyRequiresSingleRepository": "As políticas de branches requerem um único repositório com o executor local.", + "assignedTo": "Atribuído a", + "unassigned": "Sem atribuição", + "assignToMe": "Atribuir a mim", + "searchPeople": "Procurar pessoas", + "noPeopleFound": "Nenhuma pessoa encontrada", + "assignedToName": "Atribuído a {{name}}", "uploadConflictTitle_one": "{{count}} ficheiro já existe", "uploadConflictTitle_other": "{{count}} ficheiros já existem", "uploadConflictBody": "Escolha o que fazer com cada um. Nada é carregado até confirmar.", diff --git a/apps/web/src/locales/pt-pt/workspaces.json b/apps/web/src/locales/pt-pt/workspaces.json index 8e6c9f3b8d..1f6cd96af0 100644 --- a/apps/web/src/locales/pt-pt/workspaces.json +++ b/apps/web/src/locales/pt-pt/workspaces.json @@ -200,5 +200,45 @@ "branchPolicyGitflowCreate": "Criar políticas Gitflow", "branchPolicyGitflowCreated": "Políticas Gitflow criadas", "branchPolicyGitflowFailed": "Não foi possível criar as políticas Gitflow.", - "branchPolicySummary": "Base: {{base}}. Modelo: {{template}}. Destino do pull request: {{target}}." + "branchPolicySummary": "Base: {{base}}. Modelo: {{template}}. Destino do pull request: {{target}}.", + "defaultVisibility.description": "Aplica-se às áreas de trabalho criadas a partir de agora. As existentes mantêm a visibilidade que já têm.", + "defaultVisibility.existingUnaffected": "Alterar isto nunca altera uma área de trabalho existente.", + "defaultVisibility.label": "As novas áreas de trabalho são", + "defaultVisibility.savedOrg": "As novas áreas de trabalho ficarão visíveis para a organização.", + "defaultVisibility.savedPrivate": "As novas áreas de trabalho ficarão privadas.", + "defaultVisibility.title": "Predefinições de novas áreas de trabalho", + "teamAccess.addMember": "Adicionar", + "teamAccess.addMemberLabel": "Adicionar um colega", + "teamAccess.addMemberPlaceholder": "Selecione uma pessoa", + "teamAccess.description": "Escolha quem pode aceder a esta área de trabalho e atribua uma função aos colegas.", + "teamAccess.genericError": "Não foi possível guardar essa alteração.", + "teamAccess.makeOwner": "Tornar proprietário", + "teamAccess.memberAdded": "Membro adicionado.", + "teamAccess.memberRemoved": "Membro removido.", + "teamAccess.membersLabel": "Membros", + "teamAccess.noMembers": "Ainda não há membros. O proprietário tem sempre acesso total.", + "teamAccess.noUsersToAdd": "Todos já são membros", + "teamAccess.nowOrgVisible": "Esta área de trabalho está agora visível para a organização.", + "teamAccess.nowPrivate": "Esta área de trabalho é agora privada.", + "teamAccess.ownershipTransferred": "{{name}} é agora o proprietário.", + "teamAccess.removeMember": "Remover membro", + "teamAccess.roleCollaborator": "Colaborador", + "teamAccess.roleLabel": "Função", + "teamAccess.roleOwner": "Proprietário", + "teamAccess.roleUpdated": "Função atualizada.", + "teamAccess.roleViewer": "Observador", + "teamAccess.title": "Acesso da equipa", + "teamAccess.visibilityLabel": "Visibilidade", + "teamAccess.visibilityOrg": "Todos na organização", + "teamAccess.visibilityOrgHint": "Todos os utilizadores, exceto convidados, podem abrir esta área de trabalho e trabalhar nas suas tarefas.", + "teamAccess.visibilityPrivate": "Privada", + "teamAccess.visibilityPrivateHint": "Apenas o proprietário e os membros listados abaixo podem abrir esta área de trabalho.", + "placement.title": "Localização", + "placement.description": "A unidade onde esta área de trabalho está e, por isso, quem lhe acede. Movê-la é a única forma de mudar isso: os perfis combinam-se pelo mais forte, nada pode ser retirado no lugar.", + "placement.pick": "Escolher unidade", + "placement.search": "Procurar unidades", + "placement.none": "Nenhuma unidade encontrada", + "placement.moved": "Área de trabalho movida.", + "placement.failed": "Não foi possível mover a área de trabalho.", + "placement.ownerOnly": "Só o proprietário pode mover" } diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json index c7a3310507..b365628c5b 100644 --- a/apps/web/src/locales/zh-cn/agents.json +++ b/apps/web/src/locales/zh-cn/agents.json @@ -295,5 +295,6 @@ "dynamicRouteRetryDescription": "提供商出错后重试同一候选配置。适用于可能暂时恢复的故障。", "dynamicRouteTryNextDescription": "本次执行跳过此候选配置,继续使用下一个符合条件的配置。", "dynamicRouteStopDescription": "此候选配置失败后停止路由。任务会等待手动恢复操作。", - "dynamicProfileSaved": "动态配置已保存。" + "dynamicProfileSaved": "动态配置已保存。", + "adminOnly": "只有管理员可以更改代理和代理配置文件。" } diff --git a/apps/web/src/locales/zh-cn/auth.json b/apps/web/src/locales/zh-cn/auth.json index 5d56e027c0..27d4bf09a0 100644 --- a/apps/web/src/locales/zh-cn/auth.json +++ b/apps/web/src/locales/zh-cn/auth.json @@ -4,26 +4,27 @@ "authenticationIsEnabledForThisDeployment": "此部署已启用身份验证。请在下方创建首个账户;该账户将成为管理员,并可邀请其他用户。", "checkingInvite": "正在检查邀请…", "continueWithProvider": "使用 {{provider}} 继续", + "couldNotAcceptInvite": "无法接受此邀请。", + "couldNotLoadInvite": "无法加载此邀请。请重试。", "createAdminAccount": "创建管理员账户", "creatingAdminAccount": "正在创建管理员账户…", "displayName": "显示名称", "email": "邮箱", - "joining": "正在加入…", - "joinThisKandevDeployment": "加入此 Kandev 部署。", - "orContinueWith": "或使用以下方式继续", - "password": "密码", - "setUpKandev": "设置 Kandev", - "signIn": "登录", - "signingIn": "正在登录…", - "signInToYourKandevAccount": "登录你的 Kandev 账户以继续。", - "youVeBeenInvitedAs": "你已被邀请为 {{role}}。", - "couldNotAcceptInvite": "无法接受此邀请。", - "couldNotLoadInvite": "无法加载此邀请。请重试。", "invalidEmailOrPassword": "邮箱或密码不正确。", "inviteLinkInvalidOrExpired": "此邀请链接无效或已过期。请向管理员索取新的链接。", "inviteLinkMissingToken": "此邀请链接缺少令牌。", + "joinThisKandevDeployment": "加入此 Kandev 部署。", + "joining": "正在加入…", + "orContinueWith": "或使用以下方式继续", + "organizationUnavailable": "您的组织当前不可用。请联系您的 Kandev 管理员。", + "password": "密码", + "setUpKandev": "设置 Kandev", "setupFailed": "初始化失败。请重试。", "setupNoLongerAvailable": "初始化已不可用 - 账号已存在。", + "signIn": "登录", + "signInToYourKandevAccount": "登录你的 Kandev 账户以继续。", + "signingIn": "正在登录…", "somethingWentWrong": "出了点问题。请重试。", - "tooManyAttempts": "尝试次数过多。请稍候片刻再试。" + "tooManyAttempts": "尝试次数过多。请稍候片刻再试。", + "youVeBeenInvitedAs": "你已被邀请为 {{role}}。" } diff --git a/apps/web/src/locales/zh-cn/orgs.json b/apps/web/src/locales/zh-cn/orgs.json new file mode 100644 index 0000000000..8b3e5d57f3 --- /dev/null +++ b/apps/web/src/locales/zh-cn/orgs.json @@ -0,0 +1,38 @@ +{ + "addAdmin": "添加管理员", + "adminCreate": "创建管理员", + "adminCreated": "管理员已创建。", + "adminDescription": "管理员只能在自己的组织中创建账户,因此新组织需要在此创建首位管理员。", + "adminEmail": "邮箱", + "adminName": "显示名称", + "adminPassword": "密码", + "adminPasswordHint": "至少 8 个字符。请告知对方并要求其登录后修改。", + "adminTitle": "{{name}} 的首位管理员", + "boundaryDescription": "工作区、任务、会话、密钥和账户均归属于一个组织。作为实例运维人员,您管理组织本身;但不会获得任何组织内部的访问权限,包括您自己的组织。", + "boundaryTitle": "组织隔离的内容", + "create": "创建", + "createDescription": "先创建组织,然后添加其首位管理员。此后该组织将自行管理。", + "createTitle": "新建组织", + "created": "组织已创建。", + "defaultBadge": "默认", + "delete": "删除组织", + "deleteConfirm": "删除组织", + "deleteConfirmLabel": "输入 {{slug}} 以确认", + "deleteTitle": "删除 {{name}}", + "deleteWarning": "这将移除该组织中的所有工作区、任务、会话和账户,且无法撤销。", + "deleted": "组织已删除。", + "description": "每个组织都是独立的租户。用户仅属于一个组织,组织之间无法看到彼此的工作区、任务或密钥。", + "genericError": "无法保存该更改。", + "nameLabel": "组织名称", + "namePlaceholder": "Acme Corp", + "navOrganizations": "组织", + "operatorOnly": "只有实例运维人员可以管理组织。", + "renamed": "组织已重命名。", + "resume": "恢复", + "resumed": "组织已恢复。", + "statusActive": "活动", + "statusSuspended": "已暂停", + "suspend": "暂停", + "suspended": "组织已暂停。", + "title": "组织" +} diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index d42a979a40..bc779fc291 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -286,7 +286,7 @@ "externalMcpToolListPendingAgentPermissions": "列出任务中实时待处理的命令与工具权限请求,包括安全的操作详情及代理提供的确切选项。", "externalMcpToolListPendingQuestions": "列出仍在等待回答的澄清问题集。", "externalMcpToolListRepositories": "列出工作区中的仓库,包括每个仓库的默认分支。", - "externalMcpToolListTaskSessions": "按最近启动的顺序列出任务的所有代理会话,以便其他工具通过 ID 定位会话。", + "externalMcpToolListTaskSessions": "按最近启动的顺序列出任务的所有智能体会话,以便其他工具通过 ID 定位会话。", "externalMcpToolResolveAgentPermission": "选择列表工具返回的确切选项,以处理一个实时权限请求。", "externalMcpToolListTasks": "列出工作流中的任务。", "externalMcpToolListWorkflowSteps": "列出工作流中的所有步骤。", @@ -713,7 +713,6 @@ "visualEditorUnavailable": "可视化编辑器不可用", "vsCodeRemoteSsh": "VS Code Remote SSH", "whenEnabledInstallYourProjectS": "启用后,会通过仓库设置脚本安装项目依赖(例如运行 <2>{{command}}),以避免缺少类型错误。", - "externalMcpToolListTaskSessions": "按最近启动的顺序列出任务的所有智能体会话,以便其他工具通过 ID 定位会话。", "shortcutBottomTerminal": "切换底部终端", "shortcutCommandPanel": "命令面板", "shortcutCommandPanelAlt": "命令面板(备用)", @@ -751,5 +750,25 @@ "keyValueAddItem": "添加条目", "keyValueKeyPlaceholder": "键", "keyValueValuePlaceholder": "值", - "profileStartModelNotAdvertisedOnHost": "主机探测未公布 {{model}}。所选执行器将在启动时决定使用的模型。" + "profileStartModelNotAdvertisedOnHost": "主机探测未公布 {{model}}。所选执行器将在启动时决定使用的模型。", + "navUnits": "组织单元", + "unitsTitle": "组织单元", + "unitsDescription": "单元包含团队、工作区和成员。加入某个单元即可访问其下的所有工作区,包括之后创建的。", + "unitsEmpty": "尚无单元。", + "unitRootBadge": "整个组织", + "unitPersonalBadge": "个人", + "unitAddChild": "添加单元", + "unitMembers": "成员", + "unitDelete": "删除单元", + "unitCreateTitle": "在 {{parent}} 中新建单元", + "unitCreateDescription": "部门和团队都是单元,区别只在于所处位置。", + "unitNamePlaceholder": "平台", + "unitCreate": "创建", + "unitMembersTitle": "{{unit}} 的成员", + "unitMembersDescription": "这里的每个人都能以你指定的角色访问该单元下的所有工作区。", + "unitNoMembers": "该单元暂无成员。", + "unitAddMember": "添加", + "unitRemoveMember": "移除成员", + "unitPickPerson": "选择成员", + "unitActionFailed": "操作未成功。" } diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 221b1a71e9..18dd400ff0 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -1943,6 +1943,12 @@ "branchPolicySelected": "{{name}}(从 {{base}})", "branchPolicyUnavailable": "基础分支不可用。请刷新分支或编辑此策略。", "branchPolicyRequiresSingleRepository": "使用本地执行器时,分支策略需要单个仓库。", + "assignedTo": "指派给", + "unassigned": "未指派", + "assignToMe": "指派给我", + "searchPeople": "搜索成员", + "noPeopleFound": "未找到成员", + "assignedToName": "指派给 {{name}}", "uploadConflictTitle_one": "{{count}} 个文件已存在", "uploadConflictTitle_other": "{{count}} 个文件已存在", "uploadConflictBody": "请选择如何处理每个文件。确认之前不会上传任何内容。", diff --git a/apps/web/src/locales/zh-cn/workspaces.json b/apps/web/src/locales/zh-cn/workspaces.json index c718631a6c..620f023857 100644 --- a/apps/web/src/locales/zh-cn/workspaces.json +++ b/apps/web/src/locales/zh-cn/workspaces.json @@ -200,5 +200,45 @@ "branchPolicyGitflowCreate": "创建 Gitflow 策略", "branchPolicyGitflowCreated": "Gitflow 策略已创建", "branchPolicyGitflowFailed": "无法创建 Gitflow 策略。", - "branchPolicySummary": "基础:{{base}}。模板:{{template}}。拉取请求目标:{{target}}。" + "branchPolicySummary": "基础:{{base}}。模板:{{template}}。拉取请求目标:{{target}}。", + "defaultVisibility.description": "适用于此后创建的工作区。现有工作区保持其当前可见性。", + "defaultVisibility.existingUnaffected": "更改此设置绝不会更改现有工作区。", + "defaultVisibility.label": "新工作区为", + "defaultVisibility.savedOrg": "新工作区将对组织可见。", + "defaultVisibility.savedPrivate": "新工作区将为私有。", + "defaultVisibility.title": "新工作区默认设置", + "teamAccess.addMember": "添加", + "teamAccess.addMemberLabel": "添加同事", + "teamAccess.addMemberPlaceholder": "选择一个人", + "teamAccess.description": "选择谁可以访问此工作区,并为同事分配角色。", + "teamAccess.genericError": "无法保存该更改。", + "teamAccess.makeOwner": "设为所有者", + "teamAccess.memberAdded": "成员已添加。", + "teamAccess.memberRemoved": "成员已移除。", + "teamAccess.membersLabel": "成员", + "teamAccess.noMembers": "暂无成员。所有者始终拥有完全访问权限。", + "teamAccess.noUsersToAdd": "所有人都已是成员", + "teamAccess.nowOrgVisible": "此工作区现在对组织可见。", + "teamAccess.nowPrivate": "此工作区现在为私有。", + "teamAccess.ownershipTransferred": "{{name}} 现在是所有者。", + "teamAccess.removeMember": "移除成员", + "teamAccess.roleCollaborator": "协作者", + "teamAccess.roleLabel": "角色", + "teamAccess.roleOwner": "所有者", + "teamAccess.roleUpdated": "角色已更新。", + "teamAccess.roleViewer": "查看者", + "teamAccess.title": "团队访问", + "teamAccess.visibilityLabel": "可见性", + "teamAccess.visibilityOrg": "组织中的所有人", + "teamAccess.visibilityOrgHint": "除访客外的所有用户都可以打开此工作区并处理其任务。", + "teamAccess.visibilityPrivate": "私有", + "teamAccess.visibilityPrivateHint": "只有所有者和下方列出的成员可以打开此工作区。", + "placement.title": "位置", + "placement.description": "该工作区所属的单元,也就决定了谁能访问它。移动它是改变访问范围的唯一方式:角色按最高权限合并,无法就地收回。", + "placement.pick": "选择单元", + "placement.search": "搜索单元", + "placement.none": "未找到单元", + "placement.moved": "工作区已移动。", + "placement.failed": "无法移动该工作区。", + "placement.ownerOnly": "仅工作区所有者可移动" } diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json index 7de077a4db..cb09eee932 100644 --- a/apps/web/src/locales/zh-hk/agents.json +++ b/apps/web/src/locales/zh-hk/agents.json @@ -295,5 +295,6 @@ "dynamicRouteRetryDescription": "供應商發生錯誤後重試相同候選設定檔。適用於可能暫時恢復的故障。", "dynamicRouteTryNextDescription": "本次執行略過此候選設定檔,繼續使用下一個符合條件的設定檔。", "dynamicRouteStopDescription": "此候選設定檔失敗後停止路由。工作會等待手動恢復操作。", - "dynamicProfileSaved": "動態設定檔已儲存。" + "dynamicProfileSaved": "動態設定檔已儲存。", + "adminOnly": "只有管理員可以更改代理和代理設定檔。" } diff --git a/apps/web/src/locales/zh-hk/auth.json b/apps/web/src/locales/zh-hk/auth.json index 5132b82b2c..2112e4e4d1 100644 --- a/apps/web/src/locales/zh-hk/auth.json +++ b/apps/web/src/locales/zh-hk/auth.json @@ -4,26 +4,27 @@ "authenticationIsEnabledForThisDeployment": "此部署已啓用身份驗證。請在下方建立首個帳戶;該帳戶將成為管理員,並可邀請其他用戶。", "checkingInvite": "正在檢查邀請…", "continueWithProvider": "使用 {{provider}} 繼續", + "couldNotAcceptInvite": "無法接受此邀請。", + "couldNotLoadInvite": "無法載入此邀請。請重試。", "createAdminAccount": "建立管理員帳戶", "creatingAdminAccount": "正在建立管理員帳戶…", "displayName": "顯示名稱", "email": "電郵地址", - "joining": "正在加入…", - "joinThisKandevDeployment": "加入此 Kandev 部署。", - "orContinueWith": "或使用以下方式繼續", - "password": "密碼", - "setUpKandev": "設定 Kandev", - "signIn": "登入", - "signingIn": "正在登入…", - "signInToYourKandevAccount": "登入你的 Kandev 帳戶以繼續。", - "youVeBeenInvitedAs": "你已被邀請為 {{role}}。", - "couldNotAcceptInvite": "無法接受此邀請。", - "couldNotLoadInvite": "無法載入此邀請。請重試。", "invalidEmailOrPassword": "電郵地址或密碼不正確。", "inviteLinkInvalidOrExpired": "此邀請連結無效或已過期。請向管理員索取新的連結。", "inviteLinkMissingToken": "此邀請連結缺少權杖。", + "joinThisKandevDeployment": "加入此 Kandev 部署。", + "joining": "正在加入…", + "orContinueWith": "或使用以下方式繼續", + "organizationUnavailable": "您的組織目前無法使用。請聯絡您的 Kandev 管理員。", + "password": "密碼", + "setUpKandev": "設定 Kandev", "setupFailed": "初始化失敗。請重試。", "setupNoLongerAvailable": "初始化已不可用 - 帳戶已存在。", + "signIn": "登入", + "signInToYourKandevAccount": "登入你的 Kandev 帳戶以繼續。", + "signingIn": "正在登入…", "somethingWentWrong": "出了點問題。請重試。", - "tooManyAttempts": "嘗試次數過多。請稍候片刻再試。" + "tooManyAttempts": "嘗試次數過多。請稍候片刻再試。", + "youVeBeenInvitedAs": "你已被邀請為 {{role}}。" } diff --git a/apps/web/src/locales/zh-hk/orgs.json b/apps/web/src/locales/zh-hk/orgs.json new file mode 100644 index 0000000000..610d930b43 --- /dev/null +++ b/apps/web/src/locales/zh-hk/orgs.json @@ -0,0 +1,38 @@ +{ + "addAdmin": "新增管理員", + "adminCreate": "建立管理員", + "adminCreated": "管理員已建立。", + "adminDescription": "管理員只能在自己的組織中建立帳戶,因此新組織需要在此建立首位管理員。", + "adminEmail": "電子郵件", + "adminName": "顯示名稱", + "adminPassword": "密碼", + "adminPasswordHint": "至少 8 個字元。請告知對方並要求其登入後變更。", + "adminTitle": "{{name}} 的首位管理員", + "boundaryDescription": "工作區、任務、工作階段、密鑰和帳戶均屬於一個組織。身為執行個體維運人員,您管理組織本身;但不會取得任何組織內部的存取權限,包括您自己的組織。", + "boundaryTitle": "組織隔離的內容", + "create": "建立", + "createDescription": "先建立組織,然後新增其首位管理員。此後該組織將自行管理。", + "createTitle": "新增組織", + "created": "組織已建立。", + "defaultBadge": "預設", + "delete": "刪除組織", + "deleteConfirm": "刪除組織", + "deleteConfirmLabel": "輸入 {{slug}} 以確認", + "deleteTitle": "刪除 {{name}}", + "deleteWarning": "這將移除該組織中的所有工作區、任務、工作階段和帳戶,且無法復原。", + "deleted": "組織已刪除。", + "description": "每個組織都是獨立的租戶。使用者僅屬於一個組織,組織之間無法看到彼此的工作區、任務或密鑰。", + "genericError": "無法儲存該變更。", + "nameLabel": "組織名稱", + "namePlaceholder": "Acme Corp", + "navOrganizations": "組織", + "operatorOnly": "只有執行個體維運人員可以管理組織。", + "renamed": "組織已重新命名。", + "resume": "恢復", + "resumed": "組織已恢復。", + "statusActive": "使用中", + "statusSuspended": "已暫停", + "suspend": "暫停", + "suspended": "組織已暫停。", + "title": "組織" +} diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json index f11255165b..e02269aa40 100644 --- a/apps/web/src/locales/zh-hk/settings.json +++ b/apps/web/src/locales/zh-hk/settings.json @@ -750,5 +750,25 @@ "keyValueAddItem": "新增條目", "keyValueKeyPlaceholder": "鍵", "keyValueValuePlaceholder": "值", - "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啓動時決定使用的模型。" + "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啓動時決定使用的模型。", + "navUnits": "組織單位", + "unitsTitle": "組織單位", + "unitsDescription": "單位包含團隊、工作區和成員。加入某個單位即可存取其下的所有工作區,包括之後建立的。", + "unitsEmpty": "尚無單位。", + "unitRootBadge": "整個組織", + "unitPersonalBadge": "個人", + "unitAddChild": "新增單位", + "unitMembers": "成員", + "unitDelete": "刪除單位", + "unitCreateTitle": "在 {{parent}} 中新增單位", + "unitCreateDescription": "部門和團隊都是單位,差別只在於所處位置。", + "unitNamePlaceholder": "平台", + "unitCreate": "建立", + "unitMembersTitle": "{{unit}} 的成員", + "unitMembersDescription": "這裡的每個人都能以你指定的角色存取該單位下的所有工作區。", + "unitNoMembers": "該單位尚無成員。", + "unitAddMember": "新增", + "unitRemoveMember": "移除成員", + "unitPickPerson": "選擇成員", + "unitActionFailed": "操作未成功。" } diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 9a3e0d12f9..567547af6d 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -1942,7 +1942,13 @@ "branchPolicyOptionSummary": "從 {{base}} 到 {{target}}", "branchPolicySelected": "{{name}}(從 {{base}})", "branchPolicyUnavailable": "基礎分支不可用。請刷新分支或編輯此策略。", - "branchPolicyRequiresSingleRepository": "使用本地執行器時,分支策略需要單個儲存庫。", + "branchPolicyRequiresSingleRepository": "使用本機執行器時,分支策略需要單一儲存庫。", + "assignedTo": "指派給", + "unassigned": "未指派", + "assignToMe": "指派給我", + "searchPeople": "搜尋成員", + "noPeopleFound": "找不到成員", + "assignedToName": "指派給 {{name}}", "uploadConflictTitle_one": "{{count}} 個檔案已存在", "uploadConflictTitle_other": "{{count}} 個檔案已存在", "uploadConflictBody": "請選擇如何處理每個檔案。確認之前不會上載任何內容。", diff --git a/apps/web/src/locales/zh-hk/workspaces.json b/apps/web/src/locales/zh-hk/workspaces.json index 46bb6a118e..d897b86a8d 100644 --- a/apps/web/src/locales/zh-hk/workspaces.json +++ b/apps/web/src/locales/zh-hk/workspaces.json @@ -200,5 +200,45 @@ "branchPolicyGitflowCreate": "建立 Gitflow 策略", "branchPolicyGitflowCreated": "Gitflow 策略已建立", "branchPolicyGitflowFailed": "無法建立 Gitflow 策略。", - "branchPolicySummary": "基礎:{{base}}。模板:{{template}}。拉取要求目標:{{target}}。" + "branchPolicySummary": "基礎:{{base}}。模板:{{template}}。拉取要求目標:{{target}}。", + "defaultVisibility.description": "適用於此後建立的工作區。現有工作區維持其目前的可見性。", + "defaultVisibility.existingUnaffected": "變更此設定絕不會變更現有工作區。", + "defaultVisibility.label": "新工作區為", + "defaultVisibility.savedOrg": "新工作區將對組織可見。", + "defaultVisibility.savedPrivate": "新工作區將為私人。", + "defaultVisibility.title": "新工作區預設設定", + "teamAccess.addMember": "新增", + "teamAccess.addMemberLabel": "新增同事", + "teamAccess.addMemberPlaceholder": "選擇一位人員", + "teamAccess.description": "選擇誰可以存取此工作區,並為同事指派角色。", + "teamAccess.genericError": "無法儲存該變更。", + "teamAccess.makeOwner": "設為擁有者", + "teamAccess.memberAdded": "已新增成員。", + "teamAccess.memberRemoved": "已移除成員。", + "teamAccess.membersLabel": "成員", + "teamAccess.noMembers": "尚無成員。擁有者一律具有完整存取權限。", + "teamAccess.noUsersToAdd": "所有人都已是成員", + "teamAccess.nowOrgVisible": "此工作區現在對組織可見。", + "teamAccess.nowPrivate": "此工作區現在為私人。", + "teamAccess.ownershipTransferred": "{{name}} 現在是擁有者。", + "teamAccess.removeMember": "移除成員", + "teamAccess.roleCollaborator": "協作者", + "teamAccess.roleLabel": "角色", + "teamAccess.roleOwner": "擁有者", + "teamAccess.roleUpdated": "已更新角色。", + "teamAccess.roleViewer": "檢視者", + "teamAccess.title": "團隊存取", + "teamAccess.visibilityLabel": "可見性", + "teamAccess.visibilityOrg": "組織中的所有人", + "teamAccess.visibilityOrgHint": "除訪客外的所有使用者都可以開啟此工作區並處理其任務。", + "teamAccess.visibilityPrivate": "私人", + "teamAccess.visibilityPrivateHint": "只有擁有者和下方列出的成員可以開啟此工作區。", + "placement.title": "位置", + "placement.description": "該工作區所屬的單位,也就決定了誰能存取它。移動它是改變存取範圍的唯一方式:角色按最高權限合併,無法就地收回。", + "placement.pick": "選擇單位", + "placement.search": "搜尋單位", + "placement.none": "找不到單位", + "placement.moved": "工作區已移動。", + "placement.failed": "無法移動該工作區。", + "placement.ownerOnly": "僅工作區擁有者可移動" } diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json index 3ebce4a002..b8d65d0356 100644 --- a/apps/web/src/locales/zh-tw/agents.json +++ b/apps/web/src/locales/zh-tw/agents.json @@ -295,5 +295,6 @@ "dynamicRouteRetryDescription": "供應商發生錯誤後重試相同候選設定檔。適用於可能暫時恢復的故障。", "dynamicRouteTryNextDescription": "本次執行略過此候選設定檔,繼續使用下一個符合條件的設定檔。", "dynamicRouteStopDescription": "此候選設定檔失敗後停止路由。工作會等待手動恢復操作。", - "dynamicProfileSaved": "動態設定檔已儲存。" + "dynamicProfileSaved": "動態設定檔已儲存。", + "adminOnly": "只有管理員可以更改代理和代理設定檔。" } diff --git a/apps/web/src/locales/zh-tw/auth.json b/apps/web/src/locales/zh-tw/auth.json index d8d3b3dd58..5783fcc7c5 100644 --- a/apps/web/src/locales/zh-tw/auth.json +++ b/apps/web/src/locales/zh-tw/auth.json @@ -4,26 +4,27 @@ "authenticationIsEnabledForThisDeployment": "此部署已啟用身分驗證。請在下方建立首個帳戶;該帳戶將成為管理員,並可邀請其他使用者。", "checkingInvite": "正在檢查邀請…", "continueWithProvider": "使用 {{provider}} 繼續", + "couldNotAcceptInvite": "無法接受此邀請。", + "couldNotLoadInvite": "無法載入此邀請。請重試。", "createAdminAccount": "建立管理員帳戶", "creatingAdminAccount": "正在建立管理員帳戶…", "displayName": "顯示名稱", "email": "電子郵件地址", - "joining": "正在加入…", - "joinThisKandevDeployment": "加入此 Kandev 部署。", - "orContinueWith": "或使用以下方式繼續", - "password": "密碼", - "setUpKandev": "設定 Kandev", - "signIn": "登入", - "signingIn": "正在登入…", - "signInToYourKandevAccount": "登入你的 Kandev 帳戶以繼續。", - "youVeBeenInvitedAs": "你已被邀請為 {{role}}。", - "couldNotAcceptInvite": "無法接受此邀請。", - "couldNotLoadInvite": "無法載入此邀請。請重試。", "invalidEmailOrPassword": "電子郵件地址或密碼不正確。", "inviteLinkInvalidOrExpired": "此邀請連結無效或已過期。請向管理員索取新的連結。", "inviteLinkMissingToken": "此邀請連結缺少權杖。", + "joinThisKandevDeployment": "加入此 Kandev 部署。", + "joining": "正在加入…", + "orContinueWith": "或使用以下方式繼續", + "organizationUnavailable": "您的組織目前無法使用。請聯絡您的 Kandev 管理員。", + "password": "密碼", + "setUpKandev": "設定 Kandev", "setupFailed": "初始化失敗。請重試。", "setupNoLongerAvailable": "初始化已不可用 - 帳戶已存在。", + "signIn": "登入", + "signInToYourKandevAccount": "登入你的 Kandev 帳戶以繼續。", + "signingIn": "正在登入…", "somethingWentWrong": "出了點問題。請重試。", - "tooManyAttempts": "嘗試次數過多。請稍候片刻再試。" + "tooManyAttempts": "嘗試次數過多。請稍候片刻再試。", + "youVeBeenInvitedAs": "你已被邀請為 {{role}}。" } diff --git a/apps/web/src/locales/zh-tw/orgs.json b/apps/web/src/locales/zh-tw/orgs.json new file mode 100644 index 0000000000..610d930b43 --- /dev/null +++ b/apps/web/src/locales/zh-tw/orgs.json @@ -0,0 +1,38 @@ +{ + "addAdmin": "新增管理員", + "adminCreate": "建立管理員", + "adminCreated": "管理員已建立。", + "adminDescription": "管理員只能在自己的組織中建立帳戶,因此新組織需要在此建立首位管理員。", + "adminEmail": "電子郵件", + "adminName": "顯示名稱", + "adminPassword": "密碼", + "adminPasswordHint": "至少 8 個字元。請告知對方並要求其登入後變更。", + "adminTitle": "{{name}} 的首位管理員", + "boundaryDescription": "工作區、任務、工作階段、密鑰和帳戶均屬於一個組織。身為執行個體維運人員,您管理組織本身;但不會取得任何組織內部的存取權限,包括您自己的組織。", + "boundaryTitle": "組織隔離的內容", + "create": "建立", + "createDescription": "先建立組織,然後新增其首位管理員。此後該組織將自行管理。", + "createTitle": "新增組織", + "created": "組織已建立。", + "defaultBadge": "預設", + "delete": "刪除組織", + "deleteConfirm": "刪除組織", + "deleteConfirmLabel": "輸入 {{slug}} 以確認", + "deleteTitle": "刪除 {{name}}", + "deleteWarning": "這將移除該組織中的所有工作區、任務、工作階段和帳戶,且無法復原。", + "deleted": "組織已刪除。", + "description": "每個組織都是獨立的租戶。使用者僅屬於一個組織,組織之間無法看到彼此的工作區、任務或密鑰。", + "genericError": "無法儲存該變更。", + "nameLabel": "組織名稱", + "namePlaceholder": "Acme Corp", + "navOrganizations": "組織", + "operatorOnly": "只有執行個體維運人員可以管理組織。", + "renamed": "組織已重新命名。", + "resume": "恢復", + "resumed": "組織已恢復。", + "statusActive": "使用中", + "statusSuspended": "已暫停", + "suspend": "暫停", + "suspended": "組織已暫停。", + "title": "組織" +} diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json index 77dcbcaf53..eae1ea586b 100644 --- a/apps/web/src/locales/zh-tw/settings.json +++ b/apps/web/src/locales/zh-tw/settings.json @@ -750,5 +750,25 @@ "keyValueAddItem": "新增條目", "keyValueKeyPlaceholder": "鍵", "keyValueValuePlaceholder": "值", - "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啟動時決定使用的模型。" + "profileStartModelNotAdvertisedOnHost": "主機探測未公佈 {{model}}。所選執行器將在啟動時決定使用的模型。", + "navUnits": "組織單位", + "unitsTitle": "組織單位", + "unitsDescription": "單位包含團隊、工作區和成員。加入某個單位即可存取其下的所有工作區,包括之後建立的。", + "unitsEmpty": "尚無單位。", + "unitRootBadge": "整個組織", + "unitPersonalBadge": "個人", + "unitAddChild": "新增單位", + "unitMembers": "成員", + "unitDelete": "刪除單位", + "unitCreateTitle": "在 {{parent}} 中新增單位", + "unitCreateDescription": "部門和團隊都是單位,差別只在於所處位置。", + "unitNamePlaceholder": "平台", + "unitCreate": "建立", + "unitMembersTitle": "{{unit}} 的成員", + "unitMembersDescription": "這裡的每個人都能以你指定的角色存取該單位下的所有工作區。", + "unitNoMembers": "該單位尚無成員。", + "unitAddMember": "新增", + "unitRemoveMember": "移除成員", + "unitPickPerson": "選擇成員", + "unitActionFailed": "操作未成功。" } diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 84947f79f5..00eb50a622 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -1942,7 +1942,13 @@ "branchPolicyOptionSummary": "從 {{base}} 到 {{target}}", "branchPolicySelected": "{{name}}(從 {{base}})", "branchPolicyUnavailable": "基礎分支不可用。請重新整理分支或編輯此策略。", - "branchPolicyRequiresSingleRepository": "使用本地執行器時,分支策略需要單個儲存庫。", + "branchPolicyRequiresSingleRepository": "使用本機執行器時,分支策略需要單一儲存庫。", + "assignedTo": "指派給", + "unassigned": "未指派", + "assignToMe": "指派給我", + "searchPeople": "搜尋成員", + "noPeopleFound": "找不到成員", + "assignedToName": "指派給 {{name}}", "uploadConflictTitle_one": "{{count}} 個檔案已存在", "uploadConflictTitle_other": "{{count}} 個檔案已存在", "uploadConflictBody": "請選擇如何處理每個檔案。確認之前不會上傳任何內容。", diff --git a/apps/web/src/locales/zh-tw/workspaces.json b/apps/web/src/locales/zh-tw/workspaces.json index 1e4ea4dd4a..7e15808efd 100644 --- a/apps/web/src/locales/zh-tw/workspaces.json +++ b/apps/web/src/locales/zh-tw/workspaces.json @@ -200,5 +200,45 @@ "branchPolicyGitflowCreate": "建立 Gitflow 策略", "branchPolicyGitflowCreated": "Gitflow 策略已建立", "branchPolicyGitflowFailed": "無法建立 Gitflow 策略。", - "branchPolicySummary": "基礎:{{base}}。模板:{{template}}。提取要求目標:{{target}}。" + "branchPolicySummary": "基礎:{{base}}。模板:{{template}}。提取要求目標:{{target}}。", + "defaultVisibility.description": "適用於此後建立的工作區。現有工作區維持其目前的可見性。", + "defaultVisibility.existingUnaffected": "變更此設定絕不會變更現有工作區。", + "defaultVisibility.label": "新工作區為", + "defaultVisibility.savedOrg": "新工作區將對組織可見。", + "defaultVisibility.savedPrivate": "新工作區將為私人。", + "defaultVisibility.title": "新工作區預設設定", + "teamAccess.addMember": "新增", + "teamAccess.addMemberLabel": "新增同事", + "teamAccess.addMemberPlaceholder": "選擇一位人員", + "teamAccess.description": "選擇誰可以存取此工作區,並為同事指派角色。", + "teamAccess.genericError": "無法儲存該變更。", + "teamAccess.makeOwner": "設為擁有者", + "teamAccess.memberAdded": "已新增成員。", + "teamAccess.memberRemoved": "已移除成員。", + "teamAccess.membersLabel": "成員", + "teamAccess.noMembers": "尚無成員。擁有者一律具有完整存取權限。", + "teamAccess.noUsersToAdd": "所有人都已是成員", + "teamAccess.nowOrgVisible": "此工作區現在對組織可見。", + "teamAccess.nowPrivate": "此工作區現在為私人。", + "teamAccess.ownershipTransferred": "{{name}} 現在是擁有者。", + "teamAccess.removeMember": "移除成員", + "teamAccess.roleCollaborator": "協作者", + "teamAccess.roleLabel": "角色", + "teamAccess.roleOwner": "擁有者", + "teamAccess.roleUpdated": "已更新角色。", + "teamAccess.roleViewer": "檢視者", + "teamAccess.title": "團隊存取", + "teamAccess.visibilityLabel": "可見性", + "teamAccess.visibilityOrg": "組織中的所有人", + "teamAccess.visibilityOrgHint": "除訪客外的所有使用者都可以開啟此工作區並處理其任務。", + "teamAccess.visibilityPrivate": "私人", + "teamAccess.visibilityPrivateHint": "只有擁有者和下方列出的成員可以開啟此工作區。", + "placement.title": "位置", + "placement.description": "該工作區所屬的單位,也就決定了誰能存取它。移動它是改變存取範圍的唯一方式:角色按最高權限合併,無法就地收回。", + "placement.pick": "選擇單位", + "placement.search": "搜尋單位", + "placement.none": "找不到單位", + "placement.moved": "工作區已移動。", + "placement.failed": "無法移動該工作區。", + "placement.ownerOnly": "僅工作區擁有者可移動" } diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index 37e22d4f59..9b9eafbe50 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -45,6 +45,8 @@ import { UsersTable } from "@/components/settings/system/users-table"; import { DataLogsSettings } from "@/components/settings/system/data-logs-settings"; import { DiskUsageCard } from "@/components/settings/system/disk-usage-card"; import { FeatureTogglesRoute } from "@/components/settings/system/feature-toggles-route"; +import { OrganizationsPage } from "@/components/settings/system/organizations/organizations-page"; +import { UnitsPage } from "@/components/settings/units/units-page"; import { HealthIssuesCard } from "@/components/settings/system/health-issues-card"; import { SystemPageShell } from "@/components/settings/system/system-page-shell"; import { SystemRouteShell } from "@/components/settings/system/system-route-shell"; @@ -208,6 +210,16 @@ const SETTINGS_ROUTES: Record = { ), "/settings/system/backups": () => , "/settings/system/database": () => , + "/settings/units": () => ( + + + + ), + "/settings/system/organizations": () => ( + + + + ), "/settings/system/feature-toggles": () => ( `**, and agent CLIs + authenticate as the backend's OS user. Row scoping does nothing about + either; the isolation waves do. + +## Architecture + +- **The tenant is derived from the identity, never from the request.** + `authn.Identity` gains `OrgID`; no route, payload, header, or WS frame may + name an org. This is the same lesson as `internal/mcp/scope` resolving the + owner from the `AgentExecution` rather than the agent-supplied payload. +- **One tenancy registry, no parallel maps.** `internal/tenancy/registry.go` + classifies every table as `instance`, `tenant-root`, or `descendant` (with + its FK path to `workspaces.org_id`), and records the reason for each + denormalized `org_id`. A completeness test enumerates the live schema and + fails on an unclassified table. This mirrors `internal/runtimeflags/registry.go`. +- **Scope at the service layer, backstop at the transport.** Org checks extend + the existing `authorize*` helpers rather than adding a second mechanism. + The WS dispatch backstop and the MCP scoper gain org resolution; neither + replaces the service-layer check. +- **Denormalize `org_id` only where a query would otherwise leak.** A + descendant table gets its own column when at least one production query + reaches it without joining its root. The registry records which query. +- **Instance templates are shape, org rows are substance.** `org_id = ''` is a + template; an org row shadows one by `template_id`. Every credential-bearing + field is org-only, enforced at the API boundary and by a pinning test. +- **Filesystem tenancy is additive for upgrades.** The default org's + `storage_root` stays `''`, meaning the legacy flat tree, permanently. Only + new orgs get `/orgs//`. No mass file move ships. +- **Standalone executors fail closed.** `local_pc` shares the backend's OS user, + so `HOME` redirection is a convention, not a boundary. Multi-org standalone + requires a per-org OS user or an explicit operator escape hatch. +- **Every wave lands behind the flag, off.** The feature is mergeable at any + point; `features.multiTenancy` only becomes settable once wave 4 completes. + +## Backend Touch Points + +- New packages: `internal/tenancy/` (registry, classification test, org model, + storage-root resolution), `internal/org/` (service, store, HTTP controller, + lifecycle). +- `internal/auth/`: `authn.Identity.OrgID` / `.Instance`, `SystemIdentity`, + `httpmw` org resolution and suspended-org fail-close, invite org binding, + `users.org_id`, per-org email uniqueness. +- `internal/task/service/service_access.go`: org checks in every `authorize*` + helper; identity-free denial under the flag. +- `internal/gateway/websocket/`: `BroadcastToOrg`, subscription org checks, + `//ws:global` allowlist pinning test, port-proxy capability org binding. +- `internal/mcp/scope/`: resolve stream → task → workspace → org; deny on + unresolvable, as it already does for owner. +- `internal/orchestrator/`, `internal/office/`, `internal/jira/`, + `internal/linear/`, `internal/github/`, `internal/gitlab/`, + `internal/azuredevops/`, `internal/sentry/`, `internal/automation/`: per-org + cycle loops carrying `SystemIdentity`. +- `internal/agent/settings/store/`, `internal/agent/executor/`, + `internal/task/repository/sqlite/`: `org_id` + `template_id` columns and + effective-view queries for the eight config kinds. +- `internal/worktree/`, `internal/repoclone/`, `internal/task/service/attachment_service.go`, + `internal/system/` storage maintenance: org storage-root resolution. +- `internal/agent/runtime/lifecycle/`, `internal/agentctl/`: per-org + `HOME`/`XDG_*` for every ACP subprocess and agentctl child; Docker org labels, + naming, volumes, and non-adoption on recovery; SSH executor org ownership; + standalone fail-closed check placed before any process start. +- `internal/githubauth/` broker lease, automation webhook secret, office channel + HMAC, plugin webhook secret: org binding and cross-org redemption refusal. +- `internal/runtimeflags/registry.go`, `profiles.yaml`: `features.multiTenancy` + and `features.multiTenancyTrustedStandalone`, both `false` in every profile. +- `cmd/kandev/e2e_reset.go`: org-aware reset. + +## Frontend Touch Points + +- Boot payload `org` key and store hydration (`apps/web/src/boot-payload.ts`). +- Operator surfaces under Settings > System: Organizations list/create/suspend/ + delete (type-to-confirm on slug), per-org OS user, and instance Templates for + the eight config kinds. +- Org admin surface: Settings > Organization (rename, members, invites already + exist and become org-bound). +- Existing executor / profile / environment / agent / editor / prompt / + notification-provider settings render `source: instance` rows as read-only + with a "Use in this organization" action that creates the shadowing org row. +- Organization-unavailable route for `org_suspended`, distinct from `/login` + so a suspended user is not told their credentials are wrong. +- All new copy through `t()` / `` in five languages + (`pnpm run i18n:zh-hant` for the Traditional Chinese pair), no em dashes. + +## Task Waves + +| Wave | Tasks | Theme | +|---|---|---| +| 1 | 01, 02 | Registry, flag, org table, identity carries the org | +| 2 | 03, 04, 05, 06 | Scoping: background work, service layer, transports, self-authenticating credentials | +| 3 | 07, 08 | Two-tier instance/org configuration | +| 4 | 09, 10, 11, 15 | Runtime isolation: filesystem, credentials, executors, secret keys | +| 5 | 12, 13, 14 | Org lifecycle, frontend, E2E and docs | + +Task 03 is the critical path and the highest-risk item: it changes the meaning +of an identity-free context across the whole backend. It should land before any +wave-2 sibling and be reviewed as a security change. + +## Tasks + +- [ ] [01 — Tenancy registry and feature flag](task-01-tenancy-registry-and-flag.md) +- [ ] [02 — Org entity and identity binding](task-02-org-entity-and-identity.md) +- [ ] [03 — Per-org background work and system identity](task-03-system-identity-background-work.md) +- [ ] [04 — Service-layer org scoping](task-04-service-layer-org-scoping.md) +- [ ] [05 — Transport scoping: WS gateway and MCP](task-05-transport-scoping.md) +- [ ] [06 — Self-authenticating credential org binding](task-06-credential-org-binding.md) +- [ ] [07 — Two-tier configuration storage and effective view](task-07-two-tier-config-storage.md) +- [ ] [08 — Instance template API and operator role](task-08-instance-templates-and-operator.md) +- [ ] [09 — Per-org filesystem roots](task-09-per-org-filesystem-roots.md) +- [ ] [10 — Per-org agent credential home](task-10-per-org-agent-credentials.md) +- [ ] [11 — Executor tenant pinning](task-11-executor-tenant-pinning.md) +- [ ] [12 — Org lifecycle: create, suspend, delete](task-12-org-lifecycle.md) +- [ ] [15 — Per-org secret encryption keys](task-15-per-org-secret-keys.md) +- [ ] [13 — Frontend org and operator surfaces](task-13-frontend-surfaces.md) +- [ ] [14 — E2E, docs, and ADR](task-14-e2e-docs-adr.md) + +## Risks + +- **Inverting the identity-free rule (task 03) can silently break background + work.** A poller that loses its grant fails quietly. Mitigation: the denial + path logs at warn with the caller, and task 03 ships a test that runs each + background entry point with an identity-free context and asserts denial — + the mutation-test standard, not an observed-passing suite. +- **Missed leak surfaces.** The registry completeness test covers tables, not + queries. Task 04 adds a second net: a repository-level test that runs each + list/get method as a foreign org and asserts an empty result or a sentinel. +- **`local_pc` is not a real boundary.** Accepted and specced as fail-closed + rather than papered over. The escape hatch is explicit, operator-only, and + logged. +- **Upgrade path.** The default org keeps the legacy flat tree forever, so no + mass file move ships. The migration aborts the boot rather than leaving an + unassignable row reachable by every tenant; `persistence.Provide`'s + pre-migration backup is the recovery path. +- **Scope creep in the other direction.** Team access already decides who sees + what inside an org, via visibility and scopes. This plan must not add a second + visibility rule keyed on the org; it only bounds the existing one. A request + that starts "when multi-tenancy is on, org admins should also see…" is a + change to `roles-and-scopes.md`, not to this plan. +- **Sequencing pressure from cloud.** If a hosted deployment moves up, the two + items to pull forward are org suspension (already specced as the reversible + billing lever, task 12) and per-org export, which is currently out of scope. + Neither changes the isolation design. diff --git a/docs/plans/multi-tenancy/task-01-tenancy-registry-and-flag.md b/docs/plans/multi-tenancy/task-01-tenancy-registry-and-flag.md new file mode 100644 index 0000000000..3f2a30d9a1 --- /dev/null +++ b/docs/plans/multi-tenancy/task-01-tenancy-registry-and-flag.md @@ -0,0 +1,51 @@ +--- +id: "01-tenancy-registry-and-flag" +title: "Tenancy registry and feature flag" +status: todo +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 01: Tenancy Registry and Feature Flag + +## Acceptance + +- `features.multiTenancy` and `features.multiTenancyTrustedStandalone` are + registered in `internal/runtimeflags/registry.go`, restart-required, and + `"false"` in every profile in `profiles.yaml`. +- Startup aborts with a named configuration error when `features.multiTenancy` + is on and `features.auth` is off. +- `internal/tenancy/registry.go` classifies every live table as `instance`, + `tenant-root`, or `descendant`, and each `descendant` entry records its FK + path to `workspaces.org_id`. +- A completeness test enumerates the tables the running schema actually creates + and fails on any table absent from the registry. +- The completeness test is mutation-verified: adding a throwaway table makes it + fail, and the failure message names the table. + +## Verification + +- `go test ./internal/tenancy/... ./internal/runtimeflags/...` from `apps/backend`. +- `go test ./internal/backendapp/... -run TestStartupRejectsTenancyWithoutAuth`. + +## Files Likely Touched + +- `apps/backend/internal/tenancy/registry.go`, `registry_test.go` +- `apps/backend/internal/runtimeflags/registry.go` +- `profiles.yaml` +- `apps/backend/internal/backendapp/` startup validation +- `apps/web/lib/` feature-flag types (flag metadata is contract-tested) + +## Inputs + +- Spec: Data model (tenancy classification), What (flag semantics). +- Pattern: `internal/runtimeflags/registry.go` typed registration; the + `/runtime-feature-flags` skill's file-by-file checklist. + +## Output Contract + +Report the table classification counts, the mutation used to prove the +completeness test fails, RED/GREEN commands, and set this task plus its plan +checkbox to done. diff --git a/docs/plans/multi-tenancy/task-02-org-entity-and-identity.md b/docs/plans/multi-tenancy/task-02-org-entity-and-identity.md new file mode 100644 index 0000000000..028768b973 --- /dev/null +++ b/docs/plans/multi-tenancy/task-02-org-entity-and-identity.md @@ -0,0 +1,57 @@ +--- +id: "02-org-entity-and-identity" +title: "Org entity and identity binding" +status: todo +wave: 1 +depends_on: ["01-tenancy-registry-and-flag"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 02: Org Entity and Identity Binding + +## Acceptance + +- `orgs` and `org_os_users` tables exist with the spec's columns, replay-safe + on fresh and existing databases, on SQLite and Postgres. +- `users.org_id` and `auth_invites.org_id` are added via idempotent + `ADD COLUMN` migrations; the users email uniqueness becomes + `UNIQUE (org_id, email)` through a table-rebuild migration with a replay + regression test proving values and timestamps survive. +- `authn.Identity` carries `OrgID` and `Instance`; the auth middleware + populates both from the authenticated user. +- A boot migration creates exactly one `is_default` org and assigns every + existing user, workspace, secret, and config row to it. The migration aborts + the boot when any row cannot be assigned. +- With the flag off, `OrgID` is empty, no org route is mounted, and the boot + payload has no `org` key. +- A session or token whose `OrgID` names a missing or suspended org fails + closed with `org_suspended`, distinct from a session challenge. + +## Verification + +- `go test ./internal/tenancy/... ./internal/org/... ./internal/auth/...` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/org/... -run TestMigration` +- `go test ./internal/auth/httpmw/... -run 'TestSuspendedOrg|TestIdentityOrg'` + +## Files Likely Touched + +- `apps/backend/internal/org/{models,store,service,provider}.go` +- `apps/backend/internal/auth/authn/identity.go` +- `apps/backend/internal/auth/httpmw/middleware.go` +- `apps/backend/internal/auth/store/schema.go`, `service_invites.go`, `service_setup.go` +- `apps/backend/internal/user/{models,store}/` +- `apps/backend/internal/tenancy/registry.go` (classify the new tables) + +## Inputs + +- Spec: Data model, State machine, Failure modes. +- Patterns: `internal/auth/store/schema.go` dialect substitution; ADR 0027 + replayable migrations; the destructive-cutover migration conventions in + `apps/backend/AGENTS.md` for the users table rebuild. + +## Output Contract + +Report the migration's fresh-DB and replay results on both dialects, the +abort-on-unassignable-row proof, RED/GREEN commands, and set this task plus its +plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-03-system-identity-background-work.md b/docs/plans/multi-tenancy/task-03-system-identity-background-work.md new file mode 100644 index 0000000000..73cbca9cff --- /dev/null +++ b/docs/plans/multi-tenancy/task-03-system-identity-background-work.md @@ -0,0 +1,61 @@ +--- +id: "03-system-identity-background-work" +title: "Per-org background work and system identity" +status: todo +wave: 2 +depends_on: ["02-org-entity-and-identity"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 03: Per-Org Background Work and System Identity + +Critical path. This task changes what an identity-free context *means* across +the whole backend. Review it as a security change, not a refactor. + +## Acceptance + +- `authn.SystemIdentity(orgID)` exists, is the only constructor for background + contexts, and is unreachable from any HTTP or WS code path (enforced by a + test that greps the import graph or by package placement). +- With `features.multiTenancy` on, a context with no identity reaching any + tenant-scoped service entry point is **denied** with the existing `*NotFound` + sentinels and a warn-level log naming the caller. With the flag off, the + existing "unscoped internal caller" behavior is byte-identical. +- Every background producer iterates active orgs and carries that org's system + identity for the whole cycle: the orchestrator and its scheduler/queue/ + watchers, office schedulers and routines, the Jira / Linear / GitHub / GitLab + / Azure DevOps / Sentry pollers and `healthpoll`, automation runners, workflow + sync, PR status sync, and storage maintenance. +- A suspended org is skipped by every cycle without stopping the loop. +- One org's cycle failure does not abort another org's cycle. + +## Verification + +- `go test ./internal/... -run 'TestIdentityFree|TestPerOrgCycle'` from `apps/backend` +- `go test -race ./internal/orchestrator/... ./internal/office/... ./internal/jira/... ./internal/linear/... ./internal/github/... ./internal/gitlab/...` + +## Files Likely Touched + +- `apps/backend/internal/auth/authn/identity.go` +- `apps/backend/internal/task/service/service_access.go` +- `apps/backend/internal/orchestrator/` (scheduler, queue, watchers, watcher_dispatch) +- `apps/backend/internal/office/{scheduler,routines,runtime,service}/` +- `apps/backend/internal/{jira,linear,github,gitlab,azuredevops,sentry}/` pollers +- `apps/backend/internal/integrations/healthpoll/` +- `apps/backend/internal/workflowsync/`, `internal/automation/` + +## Inputs + +- Spec: What (background work runs per-org), Failure modes (identity-free + denial, suspended org). +- Patterns: the poller `Start`/`Stop` + `goleak` conventions in + `apps/backend/AGENTS.md`; `internal/mcp/scope`'s "never return an + identity-free context from this path" rule, which this task generalizes. + +## Output Contract + +Enumerate every background entry point migrated, with a per-entry-point test +that runs it identity-free and asserts denial. Report which entry points were +found by grep versus by test failure, RED/GREEN commands, and set this task plus +its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-04-service-layer-org-scoping.md b/docs/plans/multi-tenancy/task-04-service-layer-org-scoping.md new file mode 100644 index 0000000000..769abb3897 --- /dev/null +++ b/docs/plans/multi-tenancy/task-04-service-layer-org-scoping.md @@ -0,0 +1,65 @@ +--- +id: "04-service-layer-org-scoping" +title: "Service-layer org scoping" +status: todo +wave: 2 +depends_on: ["02-org-entity-and-identity"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 04: Service-Layer Org Scoping + +## Acceptance + +- `workspaces.org_id` is populated and every `authorize*` helper in + `internal/task/service/service_access.go` checks the org before the owner. + A foreign-org workspace, task, session, environment, repository, or + attachment returns the existing `*NotFound` sentinel. +- Descendant tables listed in the tenancy registry as needing a denormalized + `org_id` receive one, with the registry recording the production query that + justified it. +- `secrets.org_id` is populated; secret reads are org-scoped ahead of the + existing user/workspace scope. +- A repository-level test runs every list and get method as a foreign org and + asserts an empty result or a sentinel — the second net beyond the table + registry. +- Visibility and scopes inside an org are unchanged: this task adds no new + visibility rule, it bounds the existing ones. Every team-access reach-matrix + and scope test still passes unmodified. +- `org` visibility resolves to the workspace's own org: a member of another org + gets 404 on an org-visible workspace. +- Org scopes are held in one org only: `org.members.manage` and + `org.config.manage` grant nothing outside the holder's org. +- Workspace membership is confined to one org: adding a member from another org + is refused with 404, and the tenancy migration drops any membership row whose + member and workspace would land in different orgs, logging each one without + aborting the migration. + +## Verification + +- `go test ./internal/task/... ./internal/secrets/...` from `apps/backend` +- `go test ./internal/task/repository/... -run TestForeignOrgIsolation` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/task/repository/sqlite/...` + +## Files Likely Touched + +- `apps/backend/internal/task/service/service_access.go` +- `apps/backend/internal/task/repository/sqlite/{base_schema,base_migrations,workspace,attachment}.go` +- `apps/backend/internal/task/models/models.go`, `internal/task/dto/` +- `apps/backend/internal/secrets/sqlite_store.go` +- `apps/backend/internal/tenancy/registry.go` + +## Inputs + +- Spec: Data model (denormalization rule), Permissions, Scenarios (foreign-org + 404, operator has no org visibility). +- Patterns: the existing `authorize*` helpers and their `*NotFound` no-leak + convention; the "new user-facing service entry points must apply scoping" + rule in `apps/backend/AGENTS.md`. + +## Output Contract + +Report which descendant tables gained an `org_id` and the query that justified +each, the foreign-org isolation test's coverage count against the method count, +RED/GREEN commands, and set this task plus its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-05-transport-scoping.md b/docs/plans/multi-tenancy/task-05-transport-scoping.md new file mode 100644 index 0000000000..68e56ced76 --- /dev/null +++ b/docs/plans/multi-tenancy/task-05-transport-scoping.md @@ -0,0 +1,54 @@ +--- +id: "05-transport-scoping" +title: "Transport scoping: WS gateway and MCP" +status: todo +wave: 2 +depends_on: ["04-service-layer-org-scoping"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 05: Transport Scoping — WS Gateway and MCP + +## Acceptance + +- `Hub.BroadcastToOrg(orgID, …)` exists and replaces `Hub.Broadcast` for every + tenant-derived event. The remaining `//ws:global` call sites are an explicit + allowlist (release notification, feature-toggle change, restart) pinned by a + test that fails when a new global broadcast is added without an entry. +- The WS dispatch backstop (`internal/gateway/websocket/dispatch_scope.go`) + resolves the org for any payload carrying `task_id`, `session_id`, + `task_environment_id`, or a top-level `task.` `id`, and refuses a + foreign-org reference before dispatch. +- WS subscriptions are org-checked at subscribe time, not only at broadcast + time. +- `internal/mcp/scope` resolves stream → task → workspace → org and attaches an + identity carrying both owner and org. An unresolvable org is denied; the path + never returns an identity-free context. +- A client authenticated to org B receives no frame produced by org A, proven + by a two-client gateway test rather than by inspecting broadcast call sites. + +## Verification + +- `go test -race ./internal/gateway/websocket/... ./internal/mcp/...` from `apps/backend` +- `go test ./internal/gateway/websocket/... -run 'TestGlobalBroadcastAllowlist|TestCrossOrgNoFrame'` + +## Files Likely Touched + +- `apps/backend/internal/gateway/websocket/{hub,dispatch_scope,access}.go` +- `apps/backend/internal/mcp/scope/scope.go` +- Every `hub.Broadcast` call site + +## Inputs + +- Spec: API surface (WS changes), Scenarios (cross-org no frame). +- Patterns: the existing dispatch-backstop rules and their `user_shell.stop` / + `task.state` regression history in `apps/backend/AGENTS.md` — a new action + name silently opts out of the backstop, so the org check must key off the + same ref extraction rather than inventing a parallel one. + +## Output Contract + +Report the global-broadcast allowlist and its justification per entry, the +two-client cross-org test, RED/GREEN commands, and set this task plus its plan +checkbox to done. diff --git a/docs/plans/multi-tenancy/task-06-credential-org-binding.md b/docs/plans/multi-tenancy/task-06-credential-org-binding.md new file mode 100644 index 0000000000..30c6451970 --- /dev/null +++ b/docs/plans/multi-tenancy/task-06-credential-org-binding.md @@ -0,0 +1,59 @@ +--- +id: "06-credential-org-binding" +title: "Self-authenticating credential org binding" +status: todo +wave: 2 +depends_on: ["04-service-layer-org-scoping"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 06: Self-Authenticating Credential Org Binding + +Every credential that authenticates *itself* rather than a session bypasses the +middleware, so each one needs its own org binding. + +## Acceptance + +- The GitHub credential-broker lease (`/api/v1/github/credentials/resolve`) + carries the issuing org, and redemption against a task in another org fails + with no token returned. +- The port-proxy capability HMAC binds the org alongside session and port; + a capability minted in org A is rejected for org B's session even when the + session:port pair matches. +- Automation webhook secrets, office channel HMACs, and plugin webhook secrets + each resolve to exactly one org; a delivery is processed only in that org's + scope. +- The public-endpoint allowlist in `internal/auth/httpmw/middleware.go` is + re-pinned: each public route is annotated with which self-authenticating + credential carries its org, and the pinning test fails when a route is added + without one. +- Task shares and public share links resolve to their org and do not expose + another org's artifact. + +## Verification + +- `go test ./internal/githubauth/... ./internal/github/... ./internal/gateway/websocket/... ./internal/auth/httpmw/...` +- `go test ./internal/... -run 'TestCrossOrgRedemption'` + +## Files Likely Touched + +- `apps/backend/internal/githubauth/`, `internal/github/` broker lease store +- `apps/backend/internal/gateway/websocket/access.go`, port-proxy handler +- `apps/backend/internal/automation/`, `internal/office/channels/`, `internal/plugins/` +- `apps/backend/internal/auth/httpmw/middleware.go` +- `apps/backend/internal/task/` share links + +## Inputs + +- Spec: API surface (changed contracts), Scenarios (cross-org lease, capability, + webhook secret). +- Patterns: the port-proxy capability design and the broker-lease "opaque, + task-scoped, hashed at rest, scope-matched on redeem" contract in + `apps/backend/AGENTS.md` and `docs/specs/auth/spec.md`. + +## Output Contract + +List every self-authenticating credential, its org-binding mechanism, and its +cross-org refusal test. Report RED/GREEN commands and set this task plus its +plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-07-two-tier-config-storage.md b/docs/plans/multi-tenancy/task-07-two-tier-config-storage.md new file mode 100644 index 0000000000..ade70ba0d3 --- /dev/null +++ b/docs/plans/multi-tenancy/task-07-two-tier-config-storage.md @@ -0,0 +1,56 @@ +--- +id: "07-two-tier-config-storage" +title: "Two-tier configuration storage and effective view" +status: todo +wave: 3 +depends_on: ["04-service-layer-org-scoping"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 07: Two-Tier Configuration Storage and Effective View + +## Acceptance + +- `executors`, `executor_profiles`, `environments`, `agents`, `agent_profiles`, + `editors`, `custom_prompts`, and `notification_providers` each gain + `org_id TEXT NOT NULL DEFAULT ''` and `template_id TEXT NOT NULL DEFAULT ''` + via idempotent migrations. +- One shared resolver produces the effective view for an org: own rows, plus + every `org_id = ''` template whose `id` is not the `template_id` of an own + row. There is exactly one implementation; the eight kinds do not each get + their own copy. +- List responses carry `source: "instance" | "org"` and `editable`. +- Creating an org row from a template records `template_id` and shadows it + immediately, with no duplicate appearing in the same response. +- Deleting an org row un-shadows its template in the next response. +- `agent_profiles.workspace_id` (used by Office) keeps working and composes with + the new tier rather than being replaced by it. +- With the flag off, every one of these reads returns exactly today's rows. + +## Verification + +- `go test ./internal/agent/settings/... ./internal/agent/executor/... ./internal/task/repository/...` +- `go test ./internal/... -run 'TestEffectiveConfigView|TestTemplateShadowing'` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/agent/settings/store/...` + +## Files Likely Touched + +- `apps/backend/internal/agent/settings/store/sqlite.go` +- `apps/backend/internal/agent/executor/` +- `apps/backend/internal/task/repository/sqlite/base_migrations.go` +- `apps/backend/internal/notifications/`, `internal/prompts/`, `internal/editors/` +- New shared resolver, e.g. `apps/backend/internal/tenancy/effective.go` +- `apps/backend/internal/tenancy/registry.go` + +## Inputs + +- Spec: What (two-tier configuration), API surface (`source` / `editable`). +- Patterns: existing `deleted_at` soft-delete and `workspace_id` scoping in + `internal/agent/settings/store/sqlite.go`. + +## Output Contract + +Report the single resolver's call sites (must be all eight kinds), the +shadow/un-shadow tests, flag-off byte-identity evidence, RED/GREEN commands, +and set this task plus its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-08-instance-templates-and-operator.md b/docs/plans/multi-tenancy/task-08-instance-templates-and-operator.md new file mode 100644 index 0000000000..589d551f3c --- /dev/null +++ b/docs/plans/multi-tenancy/task-08-instance-templates-and-operator.md @@ -0,0 +1,55 @@ +--- +id: "08-instance-templates-and-operator" +title: "Instance template API and operator role" +status: todo +wave: 3 +depends_on: ["07-two-tier-config-storage"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 08: Instance Template API and Operator Role + +## Acceptance + +- The `operator` instance tier exists on `authn.Identity` and gates + `/api/v1/instance/*`. `RequireOperator()` follows the shape of + `RequireAdmin()` and composes with `RequireRealIdentity()`. +- An operator has **no** read access to any org's workspaces, tasks, sessions, + transcripts, or secrets. A test asserts 404 for each, so the tier cannot + quietly become a visibility role. +- `/api/v1/instance/templates/{kind}` supports list, create, update, delete for + the eight kinds and writes only `org_id = ''` rows. +- A template body carrying a secret value, a sensitive env-var value, or an + integration credential is rejected with + `400 template_may_not_carry_credentials`, and nothing is written. +- The credential-free invariant is a pinning test over the template DTO field + set, so adding a credential-bearing field to a templated kind fails the build + until it is classified. +- On a single-org instance the auth setup wizard's first admin also receives + `operator`. + +## Verification + +- `go test ./internal/org/... ./internal/auth/... -run 'TestOperator|TestTemplate'` +- `go test ./internal/... -run TestTemplateMayNotCarryCredentials` + +## Files Likely Touched + +- `apps/backend/internal/auth/authn/identity.go` +- `apps/backend/internal/org/controller_templates.go`, `service_templates.go` +- `apps/backend/internal/auth/service_setup.go` +- `apps/backend/internal/tenancy/effective.go` + +## Inputs + +- Spec: Permissions table, API surface, What (templates carry shape not + credentials). +- Patterns: `authn.RequireAdmin` / `RequireRealIdentity`; the sensitivity + classification in `internal/common/config/catalog.go`. + +## Output Contract + +Report the operator-cannot-read test matrix, the credential field pinning +test and the mutation used to prove it fails, RED/GREEN commands, and set this +task plus its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-09-per-org-filesystem-roots.md b/docs/plans/multi-tenancy/task-09-per-org-filesystem-roots.md new file mode 100644 index 0000000000..69895c4a3f --- /dev/null +++ b/docs/plans/multi-tenancy/task-09-per-org-filesystem-roots.md @@ -0,0 +1,57 @@ +--- +id: "09-per-org-filesystem-roots" +title: "Per-org filesystem roots" +status: todo +wave: 4 +depends_on: ["04-service-layer-org-scoping"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 09: Per-Org Filesystem Roots + +## Acceptance + +- One resolver owns org storage-root resolution: `orgs.storage_root` when set, + the legacy flat tree when `''`. No caller builds an org path by string + concatenation. +- A new org's root is `/orgs//` created mode `0700`, containing + worktrees, clones, attachments, and the agent credential home. +- An upgraded instance's default org keeps `storage_root = ''` permanently. The + upgrade moves no files, and a test asserts the file count and mtimes under + `` are unchanged across the migration. +- Worktree creation, repository clones, attachment storage, task environments, + and quick terminals all resolve through the resolver. +- Storage maintenance, GC, quarantine, and temp-artifact sweeps run per-org and + never traverse another org's root. Containment checks reject a path that + escapes the resolved root, using the carried `FileInfo` rather than a re-stat. +- A missing or unwritable org root fails task creation and session launch in + that org with a storage error and leaves other orgs working. There is no + fallback to the shared tree. + +## Verification + +- `go test ./internal/worktree/... ./internal/repoclone/... ./internal/system/...` +- `go test ./internal/tenancy/... -run 'TestStorageRoot|TestUpgradeMovesNoFiles'` +- `go test ./internal/task/service/... -run TestAttachmentOrgRoot` + +## Files Likely Touched + +- `apps/backend/internal/tenancy/storage.go` +- `apps/backend/internal/worktree/`, `internal/repoclone/` +- `apps/backend/internal/task/service/attachment_service.go` +- `apps/backend/internal/system/` storage maintenance, quarantine, temp artifacts +- `apps/backend/internal/quickterminal/` + +## Inputs + +- Spec: What (filesystem isolation), Persistence guarantees, Failure modes. +- Patterns: the path/security test conventions in `apps/backend/AGENTS.md` + (fake absolute roots under `t.TempDir()`, carry the `Lstat` result through, + no re-stat before side effects); ADR 0045 install-wide storage maintenance. + +## Output Contract + +Report the resolver's call sites versus a grep for direct `` path +construction, the upgrade no-move evidence, RED/GREEN commands, and set this +task plus its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-10-per-org-agent-credentials.md b/docs/plans/multi-tenancy/task-10-per-org-agent-credentials.md new file mode 100644 index 0000000000..5df6fd5cd0 --- /dev/null +++ b/docs/plans/multi-tenancy/task-10-per-org-agent-credentials.md @@ -0,0 +1,54 @@ +--- +id: "10-per-org-agent-credentials" +title: "Per-org agent credential home" +status: todo +wave: 4 +depends_on: ["09-per-org-filesystem-roots"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 10: Per-Org Agent Credential Home + +## Acceptance + +- Every ACP subprocess and every agentctl child process launched for an org + receives `HOME`, `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, and `XDG_CACHE_HOME` + pointed at that org's credential home. +- The redirection is applied at the runtime-environment seam that covers all + entry points — agent sessions, host-utility probes, sessionless prompts, and + utility agents — not per call site. Coverage is proven at the producer + boundary, not only at one consumer. +- `gh auth`, `claude login`, and provider API-key logins performed inside org A + are invisible to org B. A test asserts an org-B agent reports no credential + when only org A has authenticated. +- Org-scoped secrets and integration credentials are injected from the org's + secret store; instance templates contribute env-var names only. +- With the flag off, the environment of every subprocess is byte-identical to + today. + +## Verification + +- `go test ./internal/agent/runtime/... ./internal/agentctl/server/process/...` +- `go test ./internal/... -run 'TestOrgCredentialHome|TestCrossOrgAgentCredential'` + +## Files Likely Touched + +- `apps/backend/internal/agent/runtime/lifecycle/{process_runner,profile_resolver}.go` +- `apps/backend/internal/agentctl/server/process/` +- `apps/backend/internal/agent/credentials/` +- `apps/backend/internal/secrets/`, `internal/scriptengine/` + +## Inputs + +- Spec: What (agent credentials), Scenarios (`gh auth status` in the wrong org). +- Patterns: the runtime-environment invariant in `apps/backend/AGENTS.md` — + `Agent.Runtime().Env` applies to every ACP subprocess entry point, and new + overrides must be routed through host-utility probes and sessionless prompts + into agentctl child processes before sanitization. + +## Output Contract + +Enumerate the subprocess entry points covered and how the producer-boundary +assertion proves none was missed. Report the flag-off byte-identity evidence, +RED/GREEN commands, and set this task plus its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-11-executor-tenant-pinning.md b/docs/plans/multi-tenancy/task-11-executor-tenant-pinning.md new file mode 100644 index 0000000000..c7069130d7 --- /dev/null +++ b/docs/plans/multi-tenancy/task-11-executor-tenant-pinning.md @@ -0,0 +1,59 @@ +--- +id: "11-executor-tenant-pinning" +title: "Executor tenant pinning" +status: todo +wave: 4 +depends_on: ["10-per-org-agent-credentials"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 11: Executor Tenant Pinning + +## Acceptance + +- `executors_running.org_id` is written on every execution and checked on + recovery. An execution row belonging to another org is not adopted. +- Docker executions are labelled `kandev.org=`, and container names and + volume names are namespaced by org. A container whose label does not match the + recovering org is not adopted, not stopped, and not removed; it is logged. +- Remote and SSH executors are org-owned rows; two orgs cannot both reach one + remote host through Kandev. The remote task directory and prepared checkout + live under the org's remote root. +- `local_pc` executions fail closed: with more than one active org and no + `org_os_users` row for the launching org, the launch is refused **before any + process starts**, with an error naming the missing row and the escape-hatch + flag. The guard is the first statement in the launch path, proven by a + nil-dependency test that fails on a panic if the guard is placed later. +- With `features.multiTenancyTrustedStandalone` set, the launch proceeds using + the org's redirected `HOME` and logs a warning once per org per boot. +- With a per-org OS user configured, the standalone process runs as that user + and its working tree is not readable by another org's user. + +## Verification + +- `go test -race ./internal/agent/runtime/lifecycle/... ./internal/agent/docker/... ./internal/agent/executor/...` +- `go test ./internal/... -run 'TestStandaloneFailsClosed|TestForeignContainerNotAdopted|TestGuardBeforeDependencies'` +- `KANDEV_E2E_CONTAINERS=1` container project for the Docker label path. + +## Files Likely Touched + +- `apps/backend/internal/agent/runtime/lifecycle/{manager,executor_backend,execution_store,process_runner}.go` +- `apps/backend/internal/agent/docker/` +- `apps/backend/internal/ssh/`, remote executor preparation +- `apps/backend/internal/task/repository/sqlite/` (`executors_running.org_id`) + +## Inputs + +- Spec: What (runtime isolation), Failure modes (standalone refusal, foreign + container), Scenarios. +- Patterns: ADR 0003 and ADR 0025 (`executors_running` as the source of truth + for runtime cleanup); the lifecycle-callback identity rule and the + `TestSessionKeyedEntryPointsGuardBeforeDependencies` shape in + `apps/backend/AGENTS.md`. + +## Output Contract + +Report the recovery non-adoption test, the standalone fail-closed guard +position proof, the container-label E2E result, RED/GREEN commands, and set +this task plus its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-12-org-lifecycle.md b/docs/plans/multi-tenancy/task-12-org-lifecycle.md new file mode 100644 index 0000000000..9aa0be5a3a --- /dev/null +++ b/docs/plans/multi-tenancy/task-12-org-lifecycle.md @@ -0,0 +1,56 @@ +--- +id: "12-org-lifecycle" +title: "Org lifecycle: create, suspend, delete" +status: todo +wave: 5 +depends_on: ["08-instance-templates-and-operator", "11-executor-tenant-pinning"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 12: Org Lifecycle — Create, Suspend, Delete + +## Acceptance + +- `POST /api/v1/instance/orgs` creates an org with a unique lowercase slug, its + storage root, and a first-admin invite bound to that org. +- Suspension revokes nothing but fails every session and token closed with + `org_suspended`, stops running executions, and makes background cycles skip + the org. It is fully reversible: resuming restores access with no data change. +- Deletion requires a body naming the org slug verbatim. It stops executions + first; if a stop fails the deletion aborts, the storage root still exists, and + the error names the execution. +- A successful deletion removes every org row across the tenancy registry, + removes the org storage root, and revokes every session and token for its + users. A post-delete sweep asserts no row anywhere still carries the org ID — + this is the registry's payoff and must be driven from the registry, not a + hand-written table list. +- The default org cannot be deleted while it is the only org. +- Org-scoped side tables without a database cascade are deleted explicitly from + the org-deleted handler, and the create → delete lifecycle is covered. + +## Verification + +- `go test ./internal/org/... -run 'TestOrgCreate|TestOrgSuspend|TestOrgDelete'` +- `go test ./internal/org/... -run TestOrgDeleteLeavesNoRows` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/org/...` + +## Files Likely Touched + +- `apps/backend/internal/org/{service_lifecycle,controller,store}.go` +- `apps/backend/internal/tenancy/registry.go` (drives the delete sweep) +- `apps/backend/internal/auth/store/store.go` (session/token revocation) +- `apps/backend/cmd/kandev/e2e_reset.go` + +## Inputs + +- Spec: State machine, Failure modes, Permissions. +- Patterns: the workspace-deletion side-table rule in `apps/backend/AGENTS.md` + (explicit deletion from the deleted handler, lifecycle test, E2E reset + parity); ADR 0009 fail-closed GC semantics. + +## Output Contract + +Report the registry-driven post-delete sweep result, the abort-on-live-execution +proof, the E2E reset parity, RED/GREEN commands, and set this task plus its plan +checkbox to done. diff --git a/docs/plans/multi-tenancy/task-13-frontend-surfaces.md b/docs/plans/multi-tenancy/task-13-frontend-surfaces.md new file mode 100644 index 0000000000..ecb4742b75 --- /dev/null +++ b/docs/plans/multi-tenancy/task-13-frontend-surfaces.md @@ -0,0 +1,63 @@ +--- +id: "13-frontend-surfaces" +title: "Frontend org and operator surfaces" +status: todo +wave: 5 +depends_on: ["12-org-lifecycle"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 13: Frontend Org and Operator Surfaces + +## Acceptance + +- The boot payload carries `org: {id, name, slug}` for authenticated callers + and nothing for anonymous ones; the store hydrates it and a top-level test + proves the value reaches the consuming component, not just the hook. +- Settings > System gains an operator-only Organizations page: list, create, + suspend/resume, per-org OS user, and delete behind a type-to-confirm on the + slug. The confirm token is compared with `===` and therefore NOT translated + (`// i18n-exempt`). +- Settings > System gains an operator-only Templates section for the eight + config kinds. +- Settings > Organization lets an org admin rename the org and manage members + and invites. +- Existing executor / profile / environment / agent / editor / prompt / + notification-provider settings render `source: "instance"` rows read-only + with a "Use in this organization" action that creates the shadowing org row + and re-renders without a duplicate. +- A suspended org lands on a dedicated organization-unavailable route, never on + `/login`. +- With the flag off, no org UI, no operator pages, and no `org` key are present. +- Every string goes through `t()` / `` in all five locales; no em dashes; + `pnpm run i18n:check` and `pnpm run i18n:ratchet` pass. +- Mobile parity: every new page and dialog uses native mobile patterns with no + hover-only or desktop-only required action. + +## Verification + +- From `apps/web`: `pnpm test`, `pnpm run typecheck`, `pnpm run lint`, + `pnpm run i18n:check`, `pnpm run i18n:ratchet` +- `pnpm run i18n:zh-hant` for the Traditional Chinese pair + +## Files Likely Touched + +- `apps/web/src/boot-payload.ts`, `settings-routes.tsx` +- `apps/web/lib/api/domains/org-api.ts`, `apps/web/lib/types/org.ts` +- `apps/web/hooks/domains/org/` +- `apps/web/components/settings/organizations/`, `.../templates/` +- `apps/web/src/locales/*/` + +## Inputs + +- Spec: API surface, Permissions, State machine. +- Patterns: ADR 0021 Go-served SPA boot state; the `/mobile-parity` skill; the + i18n rules in the root `AGENTS.md` (never translate a `===` token, never call + `t()` at module scope, use `count` for plurals). + +## Output Contract + +Report the top-level prop-chain test for the boot `org` value, the i18n gate +results, the mobile parity evidence, and set this task plus its plan checkbox +to done. diff --git a/docs/plans/multi-tenancy/task-14-e2e-docs-adr.md b/docs/plans/multi-tenancy/task-14-e2e-docs-adr.md new file mode 100644 index 0000000000..774874f9c3 --- /dev/null +++ b/docs/plans/multi-tenancy/task-14-e2e-docs-adr.md @@ -0,0 +1,64 @@ +--- +id: "14-e2e-docs-adr" +title: "E2E, docs, and ADR" +status: todo +wave: 5 +depends_on: ["13-frontend-surfaces"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 14: E2E, Docs, and ADR + +## Acceptance + +- A Playwright spec covers the cross-org boundary end to end: two orgs, two + users, and an attempt from org A to open org B's workspace, task, session, + and port preview by URL, each landing on a not-found surface. +- A Playwright spec covers the operator surface: create org, invite its first + admin, suspend (the member sees the organization-unavailable page), resume, + and delete with the slug confirmation. +- A container-project spec (`KANDEV_E2E_CONTAINERS=1`) asserts an org-labelled + container is not adopted by another org's recovery. +- `cmd/kandev/e2e_reset.go` deletes org rows and roots so a worker-scoped + backend starts clean, and no poller recreates rows mid-reset. +- An ADR records the durable decisions: the tenant is derived from the identity + and never from the request, one org per user, identity-free contexts are + denials rather than grants, instance templates carry no credentials, and + `local_pc` fails closed under multi-org. +- `docs/public/**` documents enabling organizations, the operator versus org + admin split, the per-org filesystem and agent-credential model, and the + standalone-executor limitation. Run `/docs-maintainer`. +- Root `AGENTS.md`/`CLAUDE.md` and `apps/backend/AGENTS.md` gain the tenancy + registry rule and the inverted identity-free-context rule, since both change + conventions every future change must follow. +- `docs/specs/auth/spec.md` is amended: its "no workspace sharing" limit stands, + and the shared-agent-credential limit is narrowed to within an org. + +## Verification + +- From `apps/web`: `pnpm run build:e2e` then `pnpm e2e:run` for the new specs + (do not pass flags through `pnpm e2e:raw --`; they are silently dropped) +- `KANDEV_E2E_CONTAINERS=1` container project +- `make -C apps/backend test lint` + +## Files Likely Touched + +- `apps/web/e2e/` new specs and fixtures +- `apps/backend/cmd/kandev/e2e_reset.go` +- `docs/decisions/00XX-organization-tenancy.md`, `docs/decisions/INDEX.md` +- `docs/public/**` +- `AGENTS.md`, `CLAUDE.md`, `apps/backend/AGENTS.md` +- `docs/specs/auth/spec.md`, `docs/specs/INDEX.md` + +## Inputs + +- Spec: Scenarios (the conformance surface for the E2E specs). +- Patterns: `/docs-maintainer`, `/record decision`, `apps/web/e2e/README.md`, + and the E2E reset invariant in `apps/backend/AGENTS.md`. + +## Output Contract + +Report the spec-scenario to E2E-test mapping with any scenario left uncovered +named explicitly, the ADR number, the docs pages touched, and set this task plus +its plan checkbox to done. diff --git a/docs/plans/multi-tenancy/task-15-per-org-secret-keys.md b/docs/plans/multi-tenancy/task-15-per-org-secret-keys.md new file mode 100644 index 0000000000..0f103c53cd --- /dev/null +++ b/docs/plans/multi-tenancy/task-15-per-org-secret-keys.md @@ -0,0 +1,67 @@ +--- +id: "15-per-org-secret-keys" +title: "Per-org secret encryption keys" +status: todo +wave: 4 +depends_on: ["04-service-layer-org-scoping"] +plan: "plan.md" +spec: "../../specs/multi-tenancy/spec.md" +--- + +# Task 15: Per-Org Secret Encryption Keys + +Row scoping decides who may read a secret; it does nothing about the +ciphertext. Today one master key seals every row on the instance, so at rest +every tenant collapses to one key sitting beside the database. This is the one +place the spec's "strong enough for untrusted co-tenants" claim does not hold. + +What this task buys is blast-radius reduction on a partial compromise, and the +envelope structure that a real crypto-shred later requires. It does NOT make +deletion shred an org out of existing backups; see the spec's Out of scope for +why and for what would. + +## Acceptance + +- `org_keys` stores one wrapped data encryption key per org, created with the + org and removed with it. The plaintext DEK is never persisted. +- `MasterKeyProvider` and `/master.key` are unchanged: the master key + wraps DEKs instead of sealing rows, so a self-hosted upgrade changes nothing + operationally and no KMS is introduced. +- Secret writes seal under the owning org's DEK; reads unwrap that org's DEK. +- Deleting an org destroys its DEK, and a test proves its secrets are + unreadable **in the live database** afterwards. +- A second test pins the limit rather than hiding it: restoring a pre-deletion + backup onto the same host makes that org's secrets readable again, because + `org_keys` is inside the snapshot and `master.key` is not. A test named for a + shred that the design does not deliver would be worse than no test at all. +- A missing or unusable `org_keys` row fails every secret read and write in + that org with a named error and **never** falls back to the master key. A + fallback would silently restore the shared-key property this removes. +- The tenancy migration re-wraps existing secrets under the default org's DEK + in the same first-boot pass that assigns `org_id`, and is idempotent. +- A cross-org test proves org A's DEK cannot decrypt org B's ciphertext. +- With `features.multiTenancy` off, encryption is byte-identical to today. + +## Verification + +- `go test ./internal/secrets/... ./internal/org/...` from `apps/backend` +- `go test ./internal/secrets/... -run 'TestCrossOrgDecryptFails|TestDeleteDestroysDEK|TestNoMasterKeyFallback|TestRestoredBackupStillDecrypts'` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/secrets/...` + +## Files Likely Touched + +- `apps/backend/internal/secrets/crypto.go`, `sqlite_store.go` +- `apps/backend/internal/org/keys.go`, `store.go`, `service.go` +- `apps/backend/internal/tenancy/registry.go` (classify `org_keys`) + +## Inputs + +- Spec: What (per-org key bullet), Data model (`org_keys`), Failure modes, + Persistence guarantees, Out of scope (the root key is explicitly not moved). + +## Output Contract + +Report the cross-org decryption test, the live-database deletion test, the test +that pins the restored-backup limit, the no-fallback test and its documented +recovery route, the migration's re-wrap counts, RED/GREEN commands, and set +this task plus its plan checkbox to done. diff --git a/docs/plans/org-units/plan.md b/docs/plans/org-units/plan.md new file mode 100644 index 0000000000..58121c4c34 --- /dev/null +++ b/docs/plans/org-units/plan.md @@ -0,0 +1,87 @@ +--- +spec: docs/specs/workspaces/requirements/org-units.md +created: 2026-08-24 +updated: 2026-08-24 +status: draft +--- + +# Organization Units Plan + +## Scope + +Implement [organization units](../../specs/workspaces/requirements/org-units.md) +against its [system design](../../specs/workspaces/system-design/org-units.md): +a per-organization tree of units that carries child units, workspaces, and +members; membership that inherits downward; personal units in place of private +visibility; and an effective role resolved as the maximum of inherited and +direct grants. + +This **replaces** workspace visibility. `workspaces.visibility`, the +organization default-visibility setting, and the bulk visibility endpoint are +removed rather than kept alongside the tree. There is no compatibility path and +no second reach mechanism. + +Unchanged and consumed as they are: the tenant boundary in `internal/org`, and +the scope registry and role-to-scope mapping in `internal/authz`, which gains +one scope (`unit.manage`). + +## Current state + +The branch currently implements visibility (`org` / `private`) with membership +as an exception, resolved in `internal/authz.ResolveWorkspace`. Reach is one +column plus one membership table. The human assignee and actor attribution are +implemented and are unaffected by this plan; they move to +[human assignee](../../specs/tasks/requirements/human-assignee.md) in the task +system, which owns them. + +## Delivery order + +Each wave leaves a working product. The tree is built first and made +authoritative before the mechanism it replaces is removed, so no wave depends on +a half-migrated reach model. + +| Wave | Work orders | Outcome | +| --- | --- | --- | +| 1 | 01 | The tree exists and every workspace has a placement. Reach still comes from visibility. | +| 2 | 02 | Reach comes from the tree. Visibility is no longer consulted. | +| 3 | 03, 04 | Units and placement are manageable through the API and the interface. | +| 4 | 05 | The replaced mechanism is gone from schema, API, interface, and docs. | +| 5 | 06 | End-to-end evidence and public documentation. | + +## Work orders + +- [x] [01 — Unit tree storage and placement](task-01-unit-tree-storage.md) +- [x] [02 — Reach resolution on the tree](task-02-reach-resolution.md) +- [x] [03 — Unit management API](task-03-unit-management-api.md) +- [x] [04 — Unit tree and placement interface](task-04-unit-interface.md) +- [x] [05 — Remove workspace visibility](task-05-remove-visibility.md) +- [x] [06 — End-to-end coverage and public documentation](task-06-e2e-and-docs.md) + +## Risks + +- **A table rebuild drops the new columns.** `internal/office`'s + priority-to-TEXT migration recreates `tasks`, and other migrations recreate + `workspaces`. Any rebuild must carry `unit_id`. This already caused a silent + column loss on this branch once, so wave 1 owns a regression test at the + rebuild itself. +- **The migration widens reach.** Placement must map a private workspace to its + owner's personal unit, never to the root. Wave 1 owns a test asserting that no + workspace becomes reachable by a user who could not reach it before. +- **Stale reach on connected clients.** Moving a workspace or removing a + membership changes reach for people who are already subscribed. Waves 2 and 3 + reuse the existing revocation path rather than adding a second one. +- **Path denormalization drifts.** `org_units.path` has exactly one writer. A + move rewrites descendants in one transaction, and wave 1 owns a test that + reparents a subtree and asserts every descendant path. + +## Verification strategy + +- Wave 1 and 2 are covered by Go unit and repository tests, including a + migration test that asserts no reach widening and a rebuild-survival test. +- Wave 3 and 4 are covered by handler tests for authorization and refusal codes, + and by frontend unit tests for the tree and placement controls. +- Wave 5 is covered by a repository-wide assertion that no `visibility` + reference remains in schema, API, or interface code. +- Wave 6 owns the end-to-end evidence: a seeded instance where a department + member reaches a team board they were never named on, and a personal + workspace stays unreachable. diff --git a/docs/plans/org-units/task-01-unit-tree-storage.md b/docs/plans/org-units/task-01-unit-tree-storage.md new file mode 100644 index 0000000000..73b7d81e97 --- /dev/null +++ b/docs/plans/org-units/task-01-unit-tree-storage.md @@ -0,0 +1,105 @@ +--- +id: "01-unit-tree-storage" +title: "Unit tree storage and placement" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 01: Unit Tree Storage and Placement + +## Outcome + +`org_units` and `unit_members` exist, every organization has a root unit, every +user has a personal unit, and every workspace has a `unit_id`. Reach still comes +from visibility, so behaviour is unchanged at the end of this order. + +## In scope + +- `org_units` and `unit_members` tables with the indexes named in the design. +- `workspaces.unit_id`, not null. +- `internal/orgunit`: unit lifecycle, membership rows, tree invariants (single + parent, no cycles, one organization, empty before delete), and materialized + path maintenance including subtree reparenting. +- Root unit created with an organization; personal unit created with a user. +- The one-shot data migration described in the design's Persistence section. +- Carrying `unit_id` through every migration that recreates `workspaces` or + `tasks`. + +## Out of scope + +- Reading the tree for authorization; that is order 02. +- HTTP surface; that is order 03. +- Removing `workspaces.visibility`; that is order 05. + +## Requirements + +`REQ-WORKSPACES-ORG-UNITS-001`, `REQ-WORKSPACES-ORG-UNITS-003`. + +Acceptance criteria: `AC-WORKSPACES-ORG-UNITS-001.1` through `.7`, +`AC-WORKSPACES-ORG-UNITS-003.1`, `.5`, `.6`. + +## System design + +[Organization units](../../specs/workspaces/system-design/org-units.md), sections +Data and contracts, Personal units, Persistence. + +## Implementation acceptance + +1. A fresh database has one root unit per organization, one personal unit per + user, and no workspace with an empty `unit_id`. +2. Reparenting a unit rewrites the path of every descendant, and a move that + would create a cycle is refused. +3. The migration places every previously private workspace in its owner's + personal unit, and no user reaches a workspace they could not reach before. + +## Verification + +```bash +cd apps/backend +go test ./internal/orgunit/... ./internal/task/repository/sqlite/... ./internal/office/repository/sqlite/... +go test ./internal/task/service/ -run 'TestUnitPlacement|TestMigrationDoesNotWidenReach' +``` + +## Likely files + +- `apps/backend/internal/orgunit/` (new) +- `apps/backend/internal/task/repository/sqlite/` schema and migrations +- `apps/backend/internal/office/repository/sqlite/base_migrations.go` rebuild lists +- `apps/backend/internal/user/store/` personal unit creation +- `apps/backend/internal/org/service.go` root unit creation + +## Results + +Done. `internal/orgunit` owns the tree, its membership, and its invariants: +single root and single personal unit per user enforced by unique indexes, a +cycle refused on move, protected units refused on move and delete, a personal +unit refusing members, and delete failing closed when the occupancy seam is +unwired. Ancestry is a materialized path, and reparenting rewrites the whole +subtree in one transaction. + +`workspaces.unit_id` is added with its index, and the one-shot backfill gives +every organization a root, every account a personal unit, and every existing +workspace a home. The placement rule is the one that keeps an upgrade from +widening access: an owned workspace lands in its owner's personal unit and never +under the root, including when its owner's account is gone. + +New workspaces are placed at creation through a lazy seam rather than a +user-creation hook, so an account created by any path gets a personal unit the +first time it needs one, with no ordering to get wrong. + +Verified on a fresh instance: an unowned pre-authentication workspace sits at +the root, and both workspaces created by a signed-in user sit in her personal +unit. Every test is mutation-checked; two mutants that only broke the build were +rerun as valid mutations rather than counted as caught. + +RED/GREEN: + +- RED: removing the owner branch from the backfill fails + `TestBackfillDoesNotWidenReach` and + `TestBackfillIsolatesWorkspacesOfMissingOwners`. +- RED: dropping the descendant rewrite from `Reparent` fails + `TestReparentRewritesDescendantPaths`. +- GREEN: `go test ./internal/orgunit/... ./internal/task/... ./internal/office/...` diff --git a/docs/plans/org-units/task-02-reach-resolution.md b/docs/plans/org-units/task-02-reach-resolution.md new file mode 100644 index 0000000000..9dd2433f03 --- /dev/null +++ b/docs/plans/org-units/task-02-reach-resolution.md @@ -0,0 +1,90 @@ +--- +id: "02-reach-resolution" +title: "Reach resolution on the tree" +status: done +wave: 2 +depends_on: ["01-unit-tree-storage"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 02: Reach Resolution on the Tree + +## Outcome + +`internal/authz.ResolveWorkspace` derives the effective role from the tree +instead of from `workspaces.visibility`. Roles combine by maximum, and no path +lowers an inherited role. + +## In scope + +- Ancestor role lookup from the workspace unit's path. +- Effective role as the maximum of the root-unit role, ancestor unit + memberships, and any direct workspace grant. +- Cross-organization refusal evaluated before any role, unchanged. +- Disabled accounts and unreachable workspaces answering as not found. +- Registering the `unit.manage` scope and mapping it to the organization + administrator role and to a unit `owner`. + +## Out of scope + +- The HTTP surface for units, order 03. +- Deleting the visibility column, order 05. This order stops consulting it. + +## Requirements + +`REQ-WORKSPACES-ORG-UNITS-002`, `REQ-WORKSPACES-ORG-UNITS-004`. + +Acceptance criteria: `AC-WORKSPACES-ORG-UNITS-002.2`, `.3`, `.4`, +`AC-WORKSPACES-ORG-UNITS-004.1` through `.5`. + +## System design + +[Organization units](../../specs/workspaces/system-design/org-units.md), section +Resolution. + +## Implementation acceptance + +1. A member of a department reaches a workspace under one of its teams at the + department role, with no direct grant present. +2. A direct `viewer` grant on a workspace whose unit already grants + `collaborator` leaves the effective role at `collaborator`. +3. A workspace in another user's personal unit resolves to no reach for an + organization administrator. + +## Verification + +```bash +cd apps/backend +go test ./internal/authz/... +go test ./internal/task/service/ -run 'TestReach|TestResolveWorkspace' +make lint +``` + +## Likely files + +- `apps/backend/internal/authz/resolve.go`, `scopes.go`, `roles.go` +- `apps/backend/internal/task/service/service_access.go` + +## Results + +Done. `authz.WorkspaceRef` carries `InheritedRole` instead of `Visibility`, and +`ResolveWorkspace` returns the maximum of the inherited role, any direct grant, +and ownership. Nothing subtracts, so a grant can only raise what a unit gives. + +Reach is resolved in two queries for a list rather than one per row: the +caller's unit roles and the units' paths are fetched once and matched by +ancestry in memory. Both the single and the bulk path fail closed, and the bulk +path fails closed for the whole list, because a partial answer would hide +workspaces the caller can reach and read as data loss rather than as a +permission error. + +The service tests now build a real tree instead of setting a flag, which also +means the wiring is exercised: a resolver nothing calls protects nothing. + +RED/GREEN: + +- RED: making `highestRole` return the last non-empty role instead of the + strongest fails `TestRolesCombineByMaximum/a direct grant cannot lower it`. +- GREEN: `go test ./internal/authz/... ./internal/task/... ./internal/orgunit/...` +- GREEN: `make lint` reports 0 issues. diff --git a/docs/plans/org-units/task-03-unit-management-api.md b/docs/plans/org-units/task-03-unit-management-api.md new file mode 100644 index 0000000000..a7594bd437 --- /dev/null +++ b/docs/plans/org-units/task-03-unit-management-api.md @@ -0,0 +1,86 @@ +--- +id: "03-unit-management-api" +title: "Unit management API" +status: done +wave: 3 +depends_on: ["02-reach-resolution"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 03: Unit Management API + +## Outcome + +Units, their members, and workspace placement are manageable over HTTP, with +authorization on every route and live revocation when reach narrows. + +## In scope + +- The routes listed in the design's HTTP surface section, each gated on + `unit.manage`, `member.manage`, or `workspace.manage` as stated there. +- Refusals that name their blocking condition: a non-empty unit on delete, a + cycle on move, an organization mismatch on either. +- Publishing the reach-changed event on membership removal and on workspace + move, reusing the existing revocation path. +- Workspace DTOs carrying `unit_id` and the unit display path. + +## Out of scope + +- Interface work, order 04. +- Removing the visibility routes, order 05. + +## Requirements + +`REQ-WORKSPACES-ORG-UNITS-001`, `REQ-WORKSPACES-ORG-UNITS-002`, +`REQ-WORKSPACES-ORG-UNITS-005`. + +Acceptance criteria: `AC-WORKSPACES-ORG-UNITS-001.5`, `.6`, `.8`, +`AC-WORKSPACES-ORG-UNITS-002.1`, `.5`, `.6`, `AC-WORKSPACES-ORG-UNITS-003.2`, +`AC-WORKSPACES-ORG-UNITS-005.1` through `.5`. + +## System design + +[Organization units](../../specs/workspaces/system-design/org-units.md), sections +Data and contracts, Placement and movement, Failure and recovery. + +## Implementation acceptance + +1. A caller without `unit.manage` receives a refusal on create, rename, move and + delete, and nothing is written. +2. Adding a member to a personal unit is refused. +3. Moving a workspace out of a unit drops the WebSocket subscriptions of users + who no longer reach it. + +## Verification + +```bash +cd apps/backend +go test ./internal/orgunit/... ./internal/task/handlers/... +go test ./internal/task/service/ -run 'TestUnitRevocation|TestWorkspaceMove' +make lint +``` + +## Likely files + +- `apps/backend/internal/orgunit/controller.go` (new) +- `apps/backend/internal/task/handlers/` +- `apps/backend/internal/backendapp/helpers.go` route registration + +## Results + +Done. `/api/v1/units` serves the tree, its members, and workspace placement. +Reading needs only an identity, because a member has to see the shape of their +organization to understand why they reach what they reach; every write needs +`unit.manage`, a new scope held by the organization administrator. + +A unit in another tenant answers 404 rather than 403, so one organization's tree +is not enumerable from another, and moving a workspace validates the +destination's organization before it changes anything. + +RED/GREEN: + +- RED: removing the scope gate from the write group fails three assertions in + `TestUnitWritesRequireUnitManage`. +- GREEN: `go test ./internal/orgunit/... ./internal/authz/... ./internal/task/...` +- GREEN: `make lint` reports 0 issues. diff --git a/docs/plans/org-units/task-04-unit-interface.md b/docs/plans/org-units/task-04-unit-interface.md new file mode 100644 index 0000000000..cce1a0731e --- /dev/null +++ b/docs/plans/org-units/task-04-unit-interface.md @@ -0,0 +1,91 @@ +--- +id: "04-unit-interface" +title: "Unit tree and placement interface" +status: done +wave: 3 +depends_on: ["03-unit-management-api"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 04: Unit Tree and Placement Interface + +## Outcome + +An organization administrator can build the tree, manage a unit's members, and +move a workspace between units, on desktop and on a phone. + +## In scope + +- A unit tree in settings under Access Control, with create, rename, move and + delete. +- A unit members view reusing the existing member picker and role control. +- Workspace placement shown on the workspace, with a move control gated on the + caller's scopes. +- Copy in all five locales. + +## Out of scope + +- Removing the visibility control, order 05. +- Screenshots and public documentation, order 06. + +## Requirements + +`REQ-WORKSPACES-ORG-UNITS-001`, `REQ-WORKSPACES-ORG-UNITS-002`, +`REQ-WORKSPACES-ORG-UNITS-005`. + +Acceptance criteria: `AC-WORKSPACES-ORG-UNITS-001.8`, +`AC-WORKSPACES-ORG-UNITS-002.1`, `AC-WORKSPACES-ORG-UNITS-005.2`. + +## System design + +[Organization units](../../specs/workspaces/system-design/org-units.md), section +Components and responsibilities. + +## Implementation acceptance + +1. The tree renders an organization's units in hierarchy order and hides + controls the caller's scopes do not permit. +2. Moving a workspace updates its displayed placement without a reload. +3. The interface works at a phone viewport, per `/mobile-parity`. + +## Verification + +```bash +cd apps/web +pnpm run typecheck && pnpm run lint +pnpm test -- components/settings/units lib/api/domains/org-units-api.test.ts +pnpm run i18n:check && pnpm run i18n:ratchet +``` + +## Likely files + +- `apps/web/components/settings/units/` (new) +- `apps/web/lib/api/domains/org-units-api.ts` (new) +- `apps/web/components/app-sidebar/sections/settings/settings-menu-sections.ts` +- `apps/web/src/locales/*/settings.json` + +## Results + +Done. `Settings > Access Control > Organization units` renders the tree with a +row per unit, indented by depth derived from the materialized path, and offers +create, delete and member management. A personal unit and the root show no +control that would only ever be refused: a personal unit takes no members and +cannot be deleted, and deleting the root would leave every workspace beneath it +homeless. + +Copy ships in all five locales. + +A gap found later while auditing screenshots: this order claimed workspace +placement was shown on the workspace, and it was not. The API and the e2e +coverage existed, but nothing in the interface could move a workspace, which is +the only way to narrow access in this design. `WorkspacePlacementCard` closes +it, gated on `workspace.manage` and restoring the previous unit when a move is +refused. + +RED/GREEN: + +- RED: treating a personal unit as an ordinary one fails + "offers no member or delete control on a personal unit". +- GREEN: `pnpm test -- components/settings/units lib/api/domains/org-units-api.test.ts` +- GREEN: typecheck, eslint, i18n:check, i18n:ratchet. diff --git a/docs/plans/org-units/task-05-remove-visibility.md b/docs/plans/org-units/task-05-remove-visibility.md new file mode 100644 index 0000000000..2c85368f71 --- /dev/null +++ b/docs/plans/org-units/task-05-remove-visibility.md @@ -0,0 +1,89 @@ +--- +id: "05-remove-visibility" +title: "Remove workspace visibility" +status: done +wave: 4 +depends_on: ["04-unit-interface"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 05: Remove Workspace Visibility + +## Outcome + +The mechanism the tree replaced is gone. There is one reach model in the schema, +the API, the interface, and the documentation. + +## In scope + +- Dropping `workspaces.visibility` and the organization default-visibility + setting. +- Removing `POST /api/v1/workspaces/visibility/bulk` and the `visibility` field + from workspace DTOs and payloads. +- Removing the visibility control, its store state, and its copy from the + interface, including locale entries that become orphans. +- Removing visibility from `docs/public/team-access.md` and from the coverage + manifest. + +## Out of scope + +- New public documentation for units, order 06. + +## Requirements + +`REQ-WORKSPACES-ORG-UNITS-004`. + +Acceptance criterion: `AC-WORKSPACES-ORG-UNITS-004.1`, in that the resolver has +one input path after this order. + +## System design + +[Organization units](../../specs/workspaces/system-design/org-units.md), section +Purpose and boundaries. + +## Implementation acceptance + +1. No `visibility` symbol remains in backend schema, services, handlers, or + frontend state for workspaces. +2. Every suite passes with the column absent. +3. `pnpm run i18n:check` reports no orphaned visibility copy. + +## Verification + +```bash +cd apps/backend && go test ./... && make lint +cd ../web && pnpm run typecheck && pnpm run lint && pnpm test && pnpm run i18n:check +rg -n 'workspace.*visibility|default_workspace_visibility' apps/ docs/public || true +``` + +## Likely files + +- `apps/backend/internal/task/repository/sqlite/` +- `apps/backend/internal/task/dto/dto.go` +- `apps/web/components/settings/workspaces/` +- `docs/public/team-access.md`, `docs/public/coverage.json` + +## Results + +Done. `workspaces.visibility`, the organization default-visibility setting, its +bulk endpoint, the service surface, the DTO field, the store state and the +settings control are gone. There is one reach model in the schema, the API, the +interface and the documentation. + +Two defects surfaced while removing it, both found by driving the running +instance rather than by a test: + +- The workspace `UPDATE` statement is hand-written and never carried `unit_id`, + so a move returned the new placement and persisted nothing. Pinned by + `TestUpdateWorkspacePersistsUnitPlacement`. +- The human-assignee reach check still asked only about a direct membership + row, so it refused a colleague who plainly reached the board through the + tree. Its refusal was also mapped to 500 on the task route rather than 400. + +RED/GREEN: + +- RED: dropping `unit_id` from the update statement fails + `TestUpdateWorkspacePersistsUnitPlacement`. +- GREEN: `go test ./internal/...` 239 packages, 0 failures; `make lint` 0 issues. +- GREEN: typecheck, eslint, i18n:check across the web app. diff --git a/docs/plans/org-units/task-06-e2e-and-docs.md b/docs/plans/org-units/task-06-e2e-and-docs.md new file mode 100644 index 0000000000..547c7dbfed --- /dev/null +++ b/docs/plans/org-units/task-06-e2e-and-docs.md @@ -0,0 +1,81 @@ +--- +id: "06-e2e-and-docs" +title: "End-to-end coverage and public documentation" +status: done +wave: 5 +depends_on: ["05-remove-visibility"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 06: End-to-End Coverage and Public Documentation + +## Outcome + +The user-facing outcome has browser evidence, and the public documentation +describes units rather than visibility. + +## In scope + +- An end-to-end spec covering the inherited-reach outcome: a department member + opens a team board they were never named on, and cannot open a workspace in + another user's personal unit. +- An end-to-end spec covering placement: moving a workspace changes who reaches + it. +- Rewriting `docs/public/team-access.md` around the tree, with screenshots + captured from a seeded instance. +- Extending the demo seed script to build a small department and team tree. + +## Out of scope + +- Inherited configuration such as executors or secrets, which the requirement + excludes. + +## Requirements + +`REQ-WORKSPACES-ORG-UNITS-002`, `REQ-WORKSPACES-ORG-UNITS-003`, +`REQ-WORKSPACES-ORG-UNITS-005`. + +Acceptance criteria: `AC-WORKSPACES-ORG-UNITS-002.2`, +`AC-WORKSPACES-ORG-UNITS-003.3`, `AC-WORKSPACES-ORG-UNITS-005.3`. + +## System design + +[Organization units](../../specs/workspaces/system-design/org-units.md). + +## Implementation acceptance + +1. The end-to-end specs pass against a seeded tree. +2. The public page contains no visibility instructions and its screenshots show + the tree. +3. `/docs-maintainer` reports no stale public references. + +## Verification + +```bash +cd apps/web +pnpm run build:e2e +pnpm e2e:run tests/access/org-units.spec.ts +cd .. && python3 scripts/lint-spec-files.py --all +``` + +## Likely files + +- `apps/web/e2e/tests/access/org-units.spec.ts` (new) +- `docs/public/team-access.md`, `docs/screenshots/` +- `scripts/demo_team_access_seed.py` + +## Results + +Done. `e2e/tests/auth/org-units.spec.ts` runs in the `auth` project and covers +the outcome the tree exists for: a member of a department reaches a board in a +team beneath it with no invitation, moving that board into a personal unit +withdraws the reach, a member cannot rearrange the organization but can read +it, and the tree renders in settings. + +`docs/public/team-access.md` is rewritten around units, with screenshots +captured from a seeded instance through `scripts/team-access-docs-shots.mjs`. +The demo seed builds a Platform department with a Runtime team rather than +flipping a visibility switch. + +GREEN: `pnpm e2e:raw --project=auth -g "organization units"` reports 5 passed. diff --git a/docs/plans/team-access/plan.md b/docs/plans/team-access/plan.md new file mode 100644 index 0000000000..d52934a4ff --- /dev/null +++ b/docs/plans/team-access/plan.md @@ -0,0 +1,185 @@ +--- +spec: docs/specs/auth/requirements/roles-and-scopes.md +created: 2026-08-22 +updated: 2026-08-22 +status: draft +--- + +# Team Access Plan — Roles, Scopes, and Workspace Visibility + +> **Partly superseded.** Workspace visibility and the membership model it came +> with are replaced by +> [organization units](../org-units/plan.md). The scope registry, the human +> assignee, and actor attribution from this plan stand and are not revisited +> there. Work orders 02, 03 and 08 describe a reach model that no longer ships. + +## Scope + +Implement [org roles and scopes](../../specs/auth/requirements/roles-and-scopes.md) and +[workspace visibility and membership](../../specs/workspaces/requirements/org-units.md): a +named scope registry with fixed org and workspace roles, workspace visibility +(`org` / `private`) with an org-level default, membership as the exception and +narrowing mechanism, a human task assignee with takeover, and actor attribution +on every human action. Closes +[#2824](https://github.com/kdlbs/kandev/issues/2824). + +**Independent of multi-tenancy and ships first.** Everything here works on a +single-org instance and on today's auth model. Organizations are the boundary +above; this is the model within one. + +## Current State + +Per-user segregation is implemented and funnels through one predicate: + +```go +// internal/task/service/service_access.go +func workspaceVisibleTo(workspace *models.Workspace, userID string) bool { + return workspace == nil || workspace.OwnerID == "" || workspace.OwnerID == userID +} +``` + +Every `authorize*` helper, the WS dispatch backstop, the MCP scoper and the +office route middleware route through it. That single choke point is the +leverage — and the reason this lands as a security review, not a refactor. + +Authorization today is one bit: `authn.Identity.IsAdmin()`. There is no scope +vocabulary, and `RequireAdmin` is applied per route rather than per capability. + +Three facts from the code correct assumptions in the issue: + +1. **Task assignee is not reusable.** `tasks.assignee_agent_profile_id` names an + Office *agent profile* (ADR 0005 Wave F), not a person. A human assignee is a + new field. +2. **Reviewer/approver participants are agents too.** + `workflow_step_participants` carries `agent_profile_id`, so those roles do not + compose with human members. Out of scope. +3. **Per-human GitHub identity already exists.** `github_user_connections` is + keyed `(workspace_id, user_id)` with per-user credential generations, so + "Approve as X" works per human the moment a member can reach the workspace. + `internal/github` does not change. + +A fourth fact set the design: a workspace is **coarse**. It owns the default +executor, environment and agent profile, the task prefix and sequence, its +Kanban workflow, and integrations resolve a default workspace. A team has one or +two. That is why visibility is the primary mechanism and per-workspace +invitation is the exception — an invite list on a 1-workspace org is the org +roster retyped. + +## Architecture + +- **One scope registry, one resolver.** `internal/authz/` owns scope + identifiers, descriptions, and the static role→scope tables, plus the single + `ScopesFor(user, workspace)` resolver. No second permission map, no per-route + admin bit surviving as a parallel mechanism. Completeness tests both ways: + every guarded action names a registered scope, every registered scope has a + call site. +- **Reach and permission are separate questions.** `workspaceVisibleTo` answers + reach (owner / explicit row / org-visible). `ScopesFor` answers permission. + Conflating them is how a `viewer` accidentally gets a shell. +- **Explicit rows outrank the org default, in both directions.** That single + rule delivers guests-in-one-workspace and members-narrowed-to-viewer without + a second mechanism. +- **Resolve once per request, never cache across requests.** The resolver is hit + many times per board render, so the result is computed once per request and + carried. It is never cached beyond the request: a cache is how a removed + member keeps access for another minute. +- **Fail closed on resolution errors.** A transient failure loading a + membership row yields no scopes. It must never fall back to the org default + role, which is the branch that would silently widen access under load. +- **`workspaces.owner_id` stays authoritative** for the accountable owner; the + `owner` membership row mirrors it and a consistency test asserts they never + disagree. +- **Attribution is threaded, never inferred.** The acting `user_id` travels from + the identity to the persisted row, with no owner fallback — that fallback is + precisely the shared-login behavior this removes. +- **The upgrade never widens access.** Every existing workspace migrates to + `private`. Opening the board is an explicit act. + +## Backend Touch Points + +- New package `internal/authz/`: scope constants, role tables, `ScopesFor`, + registry completeness tests, gin/WS guards. +- `internal/task/service/service_access.go`: reach predicate learns visibility + and membership; per-request resolution cache. +- `internal/task/repository/sqlite/`: `workspaces.visibility`, + `workspace_members`, owner backfill, `tasks.assignee_user_id`, + `task_session_messages.author_user_id`, `queued_messages.author_user_id`, + `task_step_transitions.actor_user_id`. +- `internal/task/handlers/workspace_handlers.go`: visibility, member, and + transfer routes. +- `internal/user/`: `users.role` value migration, `guest` role, + `/api/v1/users/directory`. +- `internal/auth/`: `me` returns org scopes; self-role-change guard; + last-owner guard. +- `internal/org/` or `internal/system/settings`: org default visibility. +- `internal/orchestrator/messagequeue/`: author attribution through the queue. +- `internal/gateway/websocket/`: fan-out to everyone who can reach the + workspace, `workspace.access.updated`, subscription drop on access loss. +- `internal/mcp/scope/`: the agent's in-session identity stays the **task + owner**, not the last prompting member — widening it would make an agent's + reach depend on message timing. +- `cmd/kandev/e2e_reset.go`. + +## Frontend Touch Points + +- Workspace settings: Visibility control, Members section (add via directory + picker, set role, remove, transfer ownership), gated on `scopes`. +- Org settings: default workspace visibility, and the one-time "make my + workspaces org-visible" action. +- Task detail and kanban: human assignee with "Assign to me" as the takeover + affordance; assignee available to existing sidebar filter dimensions. +- Transcript: author attribution on human messages, distinct from agent output; + unattributed renders neutrally, never as the owner. +- Every control gated on the `scopes` array from the DTO, not on a re-derived + ID comparison. +- Access loss while viewing: route out with a clear message rather than a + stalled panel. +- All copy through `t()` in five locales; no em dashes; mobile parity for the + Members section, role picker, and assignee control. + +## Task Waves + +| Wave | Tasks | Theme | +|---|---|---| +| 1 | 01, 02, 03 | Scope registry, visibility and membership storage, the reach predicate | +| 2 | 04, 05 | Management API and directory; WS fan-out and revocation | +| 3 | 06, 07 | Human assignee and takeover; actor attribution | +| 4 | 08, 09 | Frontend surfaces; E2E, docs, ADR | + +## Tasks + +- [ ] [01 — Scope registry and role mapping](task-01-scope-registry.md) +- [ ] [02 — Visibility and membership storage](task-02-visibility-membership-storage.md) +- [ ] [03 — Reach predicate and scope resolution](task-03-reach-and-scope-resolution.md) +- [ ] [04 — Management API, roles, and user directory](task-04-management-api.md) +- [ ] [05 — WebSocket fan-out and revocation](task-05-ws-fanout-and-revocation.md) +- [x] [06 — Human assignee and takeover](task-06-human-assignee-takeover.md) +- [ ] [07 — Actor attribution and audit](task-07-actor-attribution.md) +- [ ] [08 — Frontend team-access surfaces](task-08-frontend-surfaces.md) +- [ ] [09 — E2E, docs, and ADR](task-09-e2e-docs-adr.md) + +## Risks + +- **The reach predicate is load-bearing for the entire privacy model.** One + mistake widens access everywhere at once. Mitigation: task 03 keeps every + existing per-user isolation test unmodified, adds a matrix over the full + reach table, and lands with a security review. +- **Two mechanisms is the real failure mode.** If `RequireAdmin` survives + alongside scopes on any route, permissions drift. Task 01's completeness test + fails on an unmigrated guarded action, and the migration of every existing + `RequireAdmin` site is part of its acceptance. +- **`session.exec` vs `session.prompt` will be argued about.** Separating them + is deliberate: prompting is bounded by the agent's permissions, a shell in the + worktree is not. Do not collapse them for convenience. +- **N+1 on board render.** Without per-request resolution, a shared board issues + a membership query per row. Task 03 ships a query-count assertion, not a + timing assertion. +- **Attribution gaps read as fixed.** A missing `author_user_id` falls back to + empty and renders as neutral rather than failing. Task 07 asserts attribution + at the producer boundary per write path, not at one consumer. +- **Default-visibility regret.** An org that flips the default to `org` exposes + future workspaces, not past ones. Existing workspaces stay `private` until + changed, and the bulk action is explicit and one-time. +- **Interaction with multi-tenancy.** `org` visibility must mean the workspace's + own org, and cross-org membership must be refused. Owned by the multi-tenancy + plan, task 04. diff --git a/docs/plans/team-access/task-01-scope-registry.md b/docs/plans/team-access/task-01-scope-registry.md new file mode 100644 index 0000000000..b791883304 --- /dev/null +++ b/docs/plans/team-access/task-01-scope-registry.md @@ -0,0 +1,61 @@ +--- +id: "01-scope-registry" +title: "Scope registry and role mapping" +status: todo +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/auth/requirements/roles-and-scopes.md" +--- + +# Task 01: Scope Registry and Role Mapping + +## Acceptance + +- `internal/authz/registry.go` declares every org and workspace scope from the + spec with its identifier and human description, plus the static org-role and + workspace-role mappings. One registry; no parallel map, switch, or per-route + constant elsewhere. +- Gin and WS guards take a scope, not a role: `RequireOrgScope(scope)` and a + workspace-scoped equivalent. +- **Every existing `RequireAdmin` route is migrated** to a named org scope. + Migrating them is part of this task, not a follow-up: two mechanisms in the + tree is the failure mode this design exists to avoid. +- A completeness test fails when a guarded action names an unregistered scope, + and a second one fails when a registered scope has no enforcement call site. + Both are mutation-verified: adding a bogus scope and removing a call site + each produce a named failure. +- `users.role` accepts `owner | admin | member | guest`; the migration maps + `admin -> admin`, `member -> member`, and promotes the instance's first admin + to `owner`. `guest` is never assigned by migration. +- Last-`owner` demotion/disable and self-role-change are refused. +- `GET /api/v1/authz/scopes` returns id + description for UI labels. +- With `features.auth` off, the synthetic identity holds every scope, so + behavior is byte-identical to today. + +## Verification + +- `go test ./internal/authz/... ./internal/auth/... ./internal/user/...` +- `go test ./internal/authz/... -run 'TestScopeCompleteness|TestNoUnenforcedScope'` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/user/...` + +## Files Likely Touched + +- `apps/backend/internal/authz/{registry,scopes,guards}.go` + tests +- `apps/backend/internal/auth/authn/identity.go` +- `apps/backend/internal/user/models/models.go`, store, migration +- Every current `authn.RequireAdmin()` call site + +## Inputs + +- Spec: `docs/specs/auth/requirements/roles-and-scopes.md` — Scope registry, Role mappings, + Permissions, Failure modes. +- Patterns: `internal/runtimeflags/registry.go` typed registration with + completeness tests; the existing `RequireAdmin` / `RequireRealIdentity` + composition; `docs/specs/auth/self-actions-guard.md`. + +## Output Contract + +Report the scope count, the `RequireAdmin` call-site count before and after +(after must be zero), both completeness-test mutations and their failure +messages, RED/GREEN commands, and set this task plus its plan checkbox to done. diff --git a/docs/plans/team-access/task-02-visibility-membership-storage.md b/docs/plans/team-access/task-02-visibility-membership-storage.md new file mode 100644 index 0000000000..c3c3c568ba --- /dev/null +++ b/docs/plans/team-access/task-02-visibility-membership-storage.md @@ -0,0 +1,56 @@ +--- +id: "02-visibility-membership-storage" +title: "Visibility and membership storage" +status: todo +wave: 1 +depends_on: ["01-scope-registry"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 02: Visibility and Membership Storage + +## Acceptance + +- `workspaces.visibility` (`org | private`, `NOT NULL DEFAULT 'private'`) is + added via an idempotent `ADD COLUMN` migration. +- `workspace_members` exists with the spec's columns, + `PRIMARY KEY (workspace_id, user_id)`, and cascade deletes from both + `workspaces` and `users`. Replay-safe on fresh and existing databases, on + SQLite and Postgres. +- **The migration sets every existing workspace to `private`.** A test asserts + no existing workspace becomes org-visible — an upgrade never widens access. +- The migration writes an `owner` row for every workspace with a non-empty + `owner_id`, and nothing for pre-auth workspaces (`owner_id = ''`). +- Workspace creation writes the owner row in the same transaction, and applies + the org's default visibility setting. +- The org default visibility setting is persisted and defaults to `private` on + upgrade, so an existing instance changes nothing until someone opts in. +- A consistency test asserts `workspaces.owner_id` always has a matching `owner` + membership row, across create, transfer, and backfill. +- Workspace deletion removes membership rows both by cascade and explicitly from + the workspace-deleted handler; the create → delete lifecycle is covered. + +## Verification + +- `go test ./internal/task/repository/... -run 'TestWorkspaceMembers|TestVisibilityMigration|TestOwnerBackfill'` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/task/repository/sqlite/...` + +## Files Likely Touched + +- `apps/backend/internal/task/repository/sqlite/{base_schema,base_migrations,workspace}.go` +- `apps/backend/internal/task/repository/sqlite/workspace_members.go` +- `apps/backend/internal/task/models/models.go` +- Org/system settings store for the default-visibility value + +## Inputs + +- Spec: Data model, Persistence guarantees (the upgrade never widens access). +- Patterns: ADR 0027 replayable migrations; the workspace-deletion side-table + rule in `apps/backend/AGENTS.md`. + +## Output Contract + +Report fresh-DB and replay results on both dialects, the backfill counts +including the pre-auth zero case, the no-workspace-became-org-visible assertion, +RED/GREEN commands, and set this task plus its plan checkbox to done. diff --git a/docs/plans/team-access/task-03-reach-and-scope-resolution.md b/docs/plans/team-access/task-03-reach-and-scope-resolution.md new file mode 100644 index 0000000000..6303843c45 --- /dev/null +++ b/docs/plans/team-access/task-03-reach-and-scope-resolution.md @@ -0,0 +1,69 @@ +--- +id: "03-reach-and-scope-resolution" +title: "Reach predicate and scope resolution" +status: todo +wave: 1 +depends_on: ["02-visibility-membership-storage"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 03: Reach Predicate and Scope Resolution + +The load-bearing task. Review as a security change. + +## Acceptance + +- `workspaceVisibleTo` implements the spec's reach table exactly: owner, then + explicit membership row, then `visibility = 'org'` for a non-`guest` org + member, else unreachable. A `guest` never reaches an org-visible workspace + without a row. +- `authz.ScopesFor(user, workspace)` implements the spec's resolution order and + is the **only** place permissions are derived. An explicit membership row + outranks the org default in both directions: it admits a guest and it narrows + a member to `viewer`. +- Every guarded workspace action checks a scope through the resolver. A caller + with `workspace.read` but not the action's scope gets **403**; a caller + without `workspace.read` gets **404**. +- Resolution **fails closed**: a transient error loading the workspace or + membership row yields no scopes. It never falls back to the org default role. + A fault-injection test covers this, because it is the branch that silently + widens access under load. +- Scopes are resolved **once per request** and carried. A board render issues a + constant number of membership queries regardless of row count, proven by a + query-count assertion, not a timing assertion. +- No effective-scope result is cached beyond the request. +- Every existing per-user isolation test passes **unmodified**. +- A reach-and-scope matrix covers every row of the spec's Permissions table + across the full surface: workspace, task, session, transcript, diff, preview, + terminal, environment, repository, attachment. +- The in-session agent MCP identity remains the **task owner**, not the last + prompting member. + +## Verification + +- `go test ./internal/task/... ./internal/authz/... ./internal/mcp/... ./internal/gateway/websocket/...` +- `go test ./internal/task/service/... -run 'TestReachMatrix|TestScopeResolution|TestScopeQueryCount|TestScopeFailsClosed'` +- Mutation check: invert the membership branch, and separately make the + fail-closed branch return the org default. Both must fail the matrix; a suite + that still passes is not testing the resolver. + +## Files Likely Touched + +- `apps/backend/internal/task/service/service_access.go` +- `apps/backend/internal/authz/resolve.go` +- `apps/backend/internal/task/service/` per-action scope checks +- `apps/backend/internal/mcp/scope/scope.go` (assert unchanged behavior) + +## Inputs + +- Spec: Permissions (the reach table), Failure modes (403 vs 404, fail closed); + `roles-and-scopes.md` Resolution. +- Patterns: the `*NotFound` no-leak convention and the "new user-facing service + entry points must apply scoping" rule in `apps/backend/AGENTS.md`. + +## Output Contract + +Report the matrix surface count against the authorize-helper count, the +query-count assertion, both mutations and their failures, RED/GREEN commands, +and set this task plus its plan checkbox to done. diff --git a/docs/plans/team-access/task-04-management-api.md b/docs/plans/team-access/task-04-management-api.md new file mode 100644 index 0000000000..a1b4a61525 --- /dev/null +++ b/docs/plans/team-access/task-04-management-api.md @@ -0,0 +1,65 @@ +--- +id: "04-management-api" +title: "Management API, roles, and user directory" +status: todo +wave: 2 +depends_on: ["03-reach-and-scope-resolution"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 04: Management API, Roles, and User Directory + +## Acceptance + +- `PATCH /api/v1/workspaces/{id}` accepts `visibility`, gated on + `workspace.manage`. +- `POST /api/v1/workspaces/visibility/bulk` sets visibility across the caller's + own workspaces, for the one-time opt-in. +- `PATCH /api/v1/orgs/current/settings` accepts `default_workspace_visibility`, + gated on `org.settings.manage`. +- `GET/PUT/DELETE /api/v1/workspaces/{id}/members[/{userId}]` list, add-or- + re-role, and remove, gated on `workspace.read` / `member.manage`. `PUT` is + idempotent and carries the workspace role. +- `POST /api/v1/workspaces/{id}/transfer-ownership` moves the accountable + owner, demotes the previous owner to `collaborator`, and updates + `workspaces.owner_id` in the same transaction. A target with no row is + refused. +- Removing the last `owner` row, or the row matching `owner_id`, is refused with + a distinct code. +- Setting `visibility = 'org'` on a workspace owned by a `guest` is refused. +- Adding a nonexistent, disabled, or already-present user returns 400 with a + distinct code per case and writes nothing. +- `PATCH /api/v1/users/{id}` accepts the four org roles, gated on + `org.members.manage`, with the last-owner and self-change guards. +- `GET /api/v1/users/directory` returns **only** id and display name for active + users. A pinning test over the response field set fails if email, role, or + status is ever added. +- Workspace DTOs carry `visibility`, `member_count`, `viewer_role`, and + `scopes`; `GET /api/v1/auth/me` carries `org_scopes`. + +## Verification + +- `go test ./internal/task/... ./internal/user/... ./internal/auth/... -run 'TestVisibility|TestMember|TestTransferOwnership|TestUserDirectory|TestRoleChange'` +- `go test ./internal/user/... -run TestUserDirectoryFieldSet` + +## Files Likely Touched + +- `apps/backend/internal/task/handlers/workspace_handlers.go` +- `apps/backend/internal/task/service/service_members.go` +- `apps/backend/internal/task/dto/dto.go` +- `apps/backend/internal/user/handlers/handlers.go`, `internal/user/dto/dto.go` +- `apps/backend/internal/auth/httpapi/handlers.go` + +## Inputs + +- Spec: API surface, Permissions, Failure modes; `roles-and-scopes.md` + Permissions. +- Patterns: existing workspace handler shapes; the no-existence-leak rule for + anything reachable cross-user. + +## Output Contract + +Report the error-code matrix, the directory field pinning test and the mutation +proving it fails, RED/GREEN commands, and set this task plus its plan checkbox +to done. diff --git a/docs/plans/team-access/task-05-ws-fanout-and-revocation.md b/docs/plans/team-access/task-05-ws-fanout-and-revocation.md new file mode 100644 index 0000000000..8c0c9b705b --- /dev/null +++ b/docs/plans/team-access/task-05-ws-fanout-and-revocation.md @@ -0,0 +1,54 @@ +--- +id: "05-ws-fanout-and-revocation" +title: "WebSocket fan-out and revocation" +status: todo +wave: 2 +depends_on: ["03-reach-and-scope-resolution"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 05: WebSocket Fan-Out and Revocation + +## Acceptance + +- `Hub.BroadcastToWorkspace` reaches everyone who can **reach** the workspace — + for an org-visible workspace that is the whole org minus guests — resolved + through the same predicate as HTTP, not a parallel membership lookup. +- WS subscription authorization uses the same reach predicate at subscribe time, + and the dispatch backstop checks the action's scope, so a `viewer` cannot + drive a `task.state` or `session.prompt` action over the socket. +- `workspace.access.updated` is emitted on visibility change, membership add, + re-role, remove, and ownership transfer. +- Losing access drops that user's subscriptions for the workspace immediately, + on every open connection they hold, without disturbing other users' + connections or that user's other workspaces. This covers all three loss paths: + removal, visibility narrowed to `private`, and role narrowed to `viewer`. +- A two-client test proves a user who lost access receives no further frames, + and that a still-current user does. +- A user who never had access never receives a frame, proven by client + assertion rather than by inspecting broadcast call sites. + +## Verification + +- `go test -race ./internal/gateway/websocket/...` +- `go test ./internal/gateway/websocket/... -run 'TestReachFanout|TestAccessLossDropsSubscription|TestViewerCannotDispatch'` + +## Files Likely Touched + +- `apps/backend/internal/gateway/websocket/{hub,dispatch_scope,access}.go` +- `apps/backend/internal/task/service/service_events.go` + +## Inputs + +- Spec: API surface (WS changes), Failure modes (access lost mid-session). +- Patterns: the `Hub.BroadcastToWorkspace` / `//ws:global` conventions and the + dispatch-backstop ref-extraction rules in `apps/backend/AGENTS.md` — an action + that invents a new name silently opts out, so the scope check must key off the + same extraction rather than a parallel one. + +## Output Contract + +Report the two-client fan-out and revocation tests covering all three +access-loss paths, the viewer-cannot-dispatch test, the goroutine-leak result, +RED/GREEN commands, and set this task plus its plan checkbox to done. diff --git a/docs/plans/team-access/task-06-human-assignee-takeover.md b/docs/plans/team-access/task-06-human-assignee-takeover.md new file mode 100644 index 0000000000..01fcbd37f3 --- /dev/null +++ b/docs/plans/team-access/task-06-human-assignee-takeover.md @@ -0,0 +1,130 @@ +--- +id: "06-human-assignee-takeover" +title: "Human assignee and takeover" +status: done +wave: 3 +depends_on: ["04-management-api"] +plan: "plan.md" +spec: "../../specs/tasks/requirements/human-assignee.md" +--- + +# Task 06: Human Assignee and Takeover + +## Acceptance + +- `tasks.assignee_user_id` is added via an idempotent migration and is fully + independent of `assignee_agent_profile_id`. Setting one never clears the + other, and an Office task with an agent assignee can also carry a human one. +- `PATCH /api/v1/tasks/{id}` accepts `assignee_user_id`, gated on `task.write`. + A `viewer` receives 403; any caller holding `task.write` may assign to + themselves. +- Assignment is advisory: it gates nothing. A caller who is not the assignee + retains every scope they already hold. +- Setting the assignee to a user who cannot reach the workspace is refused. +- Takeover is reassign plus continue: the session, worktree, executor, and ACP + session are untouched by a reassignment, proven by asserting the execution ID + and worktree path are unchanged across the operation. +- `assignee_user_id` reaches the task DTO, the boot payload, and the sidebar + filter dimensions. +- Task lifecycle events publish through the existing event path so the kanban + updates live. + +## Verification + +- `go test ./internal/task/... -run 'TestAssigneeUser|TestTakeoverPreservesSession'` +- `KANDEV_TEST_POSTGRES_DSN=... go test ./internal/task/repository/sqlite/...` + +## Files Likely Touched + +- `apps/backend/internal/task/repository/sqlite/base_migrations.go` +- `apps/backend/internal/task/models/models.go`, `internal/task/dto/dto.go` +- `apps/backend/internal/task/service/service_tasks.go` +- `apps/backend/pkg/api/v1/` task DTO + +## Inputs + +- Spec: What (human assignee, takeover), Permissions, Scenarios. +- Patterns: ADR 0005 Wave F on why the agent assignee is not a task column; the + task lifecycle event rule in `apps/backend/AGENTS.md` (every task mutation + publishes, or the kanban goes stale). + +## Output Contract + +Report the independence test between the two assignee fields, the +session-preservation evidence for takeover, RED/GREEN commands, and set this +task plus its plan checkbox to done. + +## Result + +Done. `tasks.assignee_user_id` is added by `ensureTeamAccessSchema` and carried +through `internal/office`'s priority-to-TEXT rebuild, which recreates the tasks +table from a hardcoded column list and had been dropping it silently: +`MigrateLogger.Apply` swallows the error, so the only symptom was "table tasks +has no column named assignee_user_id" from unrelated write paths on a fresh +install. Two regression tests pin it, one at the rebuild +(`TestMigrate_PriorityRebuildPreservesAssignee`) and one at the service level +where both repositories share a database +(`TestUpdateTask_AssigneeSurvivesOfficeMigration`); a repository-only test +cannot see the loss. + +Both write surfaces accept the field: `PATCH /api/v1/tasks/{id}` through the +task service, and `PATCH /api/v1/office/tasks/{id}`, which the properties panel +calls. The office route delegates the whole write to the task service's +`SetHumanAssignee` rather than writing the column itself. That is not only to +avoid duplicating the reach rule: the office route carries no `:wsId`, so +`officeWorkspaceScopeMiddleware` does not gate it, and a direct column write +would have let any signed-in user assign a task in a workspace they cannot +reach. With no writer wired the route refuses the mutation. + +Independence and takeover are asserted directly: setting the human assignee +leaves the agent runner untouched and vice versa, and a reassignment changes +exactly one field (`TestUpdateTask_HumanAndAgentAssigneesAreIndependent`, +`TestUpdateTask_TakeoverLeavesExecutionStateUntouched`). + +Delegating also closed a gap found by driving the live instance rather than +the tests: `officeWorkspaceScopeMiddleware` only gates routes carrying a +`:wsId`, so every office task mutation is ungated for browser callers. Against +a seeded two-role instance, a viewer and a non-member could both be refused +only after the write went through the task service. Their refusals now carry +the right status too: viewer 403, non-member 404 (existence stays hidden), and +an assignee who cannot reach the workspace 400 with a showable reason instead +of a 500. + +The UI is an "Assigned to" row in the task properties panel with an +"Assign to me" button, hidden entirely when authentication is disabled. Copy +ships in all five locales. Names come from the user directory merged with the +workspace member list: the member list alone rendered every colleague who +reaches an org-visible workspace as a raw user id, which the screenshot below +caught and no unit test would have. + +![task properties panel with the Assigned to row](../../screenshots/team-access-task-assignee.png) + +Both boards carry it. Kanban was not a second implementation: the office +picker and the kanban control share `useAssignablePeople`, and the field +travels the one existing kanban mapper (`toKanbanTask`) plus the SSR snapshot +mapper. Each of those three hops has its own test, because a dropped hop shows +every task as unassigned with no error anywhere. + +![kanban task top bar with the assignee control](../../screenshots/team-access-kanban-assignee-topbar.png) + +Driving the live instance found two defects the unit tests had not: the +`task.updated` payload is hand-built and never carried `assignee_user_id`, so a +takeover left the previous owner's name on every open client until a reload; +and the frontend merge treats an absent key as "unchanged", so once the field +did arrive it needed the same preserve guard `parent_id` has, or an unrelated +lightweight update blanked it. Both now have regression tests on the exact +symptom. + +Not done: the sidebar **filter dimension** for the human assignee. The filter +UI is built around agent assignees (`use-tasks-tree.ts` filters on +`assigneeIds`), and adding a second people-shaped dimension is a filter-model +change rather than a field pass-through. The field itself reaches the task DTO +and the detail view. + +RED/GREEN: + +- RED: `go test ./internal/task/service/ -run TestUpdateTask_AssigneeSurvivesOfficeMigration` + fails with "table tasks has no column named assignee_user_id" when the office + rebuild's column list is reverted. +- GREEN: `go test ./internal/task/... ./internal/office/...` +- GREEN: `pnpm test -- components/task/simple/components/human-assignee-picker.test.tsx` diff --git a/docs/plans/team-access/task-07-actor-attribution.md b/docs/plans/team-access/task-07-actor-attribution.md new file mode 100644 index 0000000000..4b0830e31e --- /dev/null +++ b/docs/plans/team-access/task-07-actor-attribution.md @@ -0,0 +1,58 @@ +--- +id: "07-actor-attribution" +title: "Actor attribution and audit" +status: todo +wave: 3 +depends_on: ["04-management-api"] +plan: "plan.md" +spec: "../../specs/tasks/requirements/human-assignee.md" +--- + +# Task 07: Actor Attribution and Audit + +This is the layer a shared login cannot provide, and the reason #2824 is not +solved by "just share the password". + +## Acceptance + +- `task_session_messages.author_user_id`, `queued_messages.author_user_id`, and + `task_step_transitions.actor_user_id` are added via idempotent migrations and + populated from the request identity. +- Attribution survives the message queue: a prompt queued by member B and + dispatched later is still attributed to B, not to the dispatching goroutine or + the workspace owner. +- Agent output carries an empty author; engine-driven step transitions carry an + empty actor. Empty means "not a human", never "unknown human". +- No code path falls back to the workspace owner when the identity is absent. + A test asserts that an identity-free write produces an empty author rather + than the owner's ID. +- Agent stop/cancel and task state changes record the acting user. +- Attribution is asserted at each **producer boundary** — one test per write + path — not only at a single consumer that renders it. +- DTOs carry `author_user_id` plus the resolved display name. + +## Verification + +- `go test ./internal/task/... ./internal/orchestrator/... -run 'TestAttribution|TestQueuedMessageAuthor'` +- `go test ./internal/... -run TestNoOwnerFallbackAttribution` + +## Files Likely Touched + +- `apps/backend/internal/task/repository/sqlite/base_migrations.go` +- `apps/backend/internal/orchestrator/messagequeue/` +- `apps/backend/internal/task/service/service_events.go`, session message writes +- `apps/backend/internal/workflow/` step transition recording + +## Inputs + +- Spec: What (attribution), Data model, Persistence guarantees (attribution is + permanent). +- Patterns: the derived-environment-contract rule in `apps/backend/AGENTS.md` — + drive values through the producer seam and pair consumer coverage with a + producer-boundary assertion so a missing publication fails loudly. + +## Output Contract + +List every write path that gained attribution with its producer-boundary test, +report the no-owner-fallback proof, RED/GREEN commands, and set this task plus +its plan checkbox to done. diff --git a/docs/plans/team-access/task-08-frontend-surfaces.md b/docs/plans/team-access/task-08-frontend-surfaces.md new file mode 100644 index 0000000000..d46e02b7d6 --- /dev/null +++ b/docs/plans/team-access/task-08-frontend-surfaces.md @@ -0,0 +1,74 @@ +--- +id: "08-frontend-surfaces" +title: "Frontend team-access surfaces" +status: todo +wave: 4 +depends_on: ["05-ws-fanout-and-revocation", "06-human-assignee-takeover", "07-actor-attribution"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 08: Frontend Team-Access Surfaces + +## Acceptance + +- Workspace settings gains a Visibility control (`Organization` / `Private`) + with copy that states plainly who gains access, and a Members section: add via + the user-directory picker with a role, change a role, remove, transfer + ownership. +- Org settings gains the default workspace visibility, plus a one-time + "Make my existing workspaces visible to the organization" action that names + the exact count it will change and requires confirmation. +- Org member management gains the four org roles with their descriptions read + from `/api/v1/authz/scopes`, so role copy cannot drift from the registry. +- Task detail and kanban show the human assignee with "Assign to me" as the + takeover affordance; assignee is available to the existing sidebar filter + dimensions. +- The transcript attributes human messages to their author, visually distinct + from agent output. An unattributed human message renders neutrally, never as + the workspace owner. +- **Every control is gated on the `scopes` array from the DTO.** The frontend + does not re-derive permission by comparing the current user ID to + `owner_id`, and does not branch on role names. +- A `viewer` sees the board and transcripts with no prompt box, no terminal + entry point, and no task-edit affordances — hidden, not disabled-with-a- + tooltip. +- `workspace.access.updated` re-evaluates access in place; a user who lost + access is routed out with a clear message rather than left on a stalled panel. +- A single-user install with one private workspace shows no new UI: no Members + section entry point, no visibility control clutter, no role pickers. +- A top-level test proves `scopes`, `viewer_role`, `visibility`, and + `assignee_user_id` reach the consuming components, not just the hooks. +- All copy through `t()` / `` in five locales; no em dashes; + `pnpm run i18n:check` and `pnpm run i18n:ratchet` pass. +- Mobile parity for the Visibility control, Members section, role picker, and + assignee control: no hover-only or desktop-only required action. + +## Verification + +- From `apps/web`: `pnpm test`, `pnpm run typecheck`, `pnpm run lint`, + `pnpm run i18n:check`, `pnpm run i18n:ratchet` +- `pnpm run i18n:zh-hant` for the Traditional Chinese pair + +## Files Likely Touched + +- `apps/web/components/settings/workspace/{visibility,members}/` +- `apps/web/components/settings/organization/` +- `apps/web/components/tasks/` assignee control, transcript attribution +- `apps/web/lib/api/domains/{workspace,authz}-api.ts`, `apps/web/lib/types/` +- `apps/web/hooks/domains/workspace/` +- `apps/web/src/locales/*/` + +## Inputs + +- Spec: API surface, Permissions; `roles-and-scopes.md` (scopes are a UI hint, + the server is authoritative). +- Patterns: the `/mobile-parity` skill; the slot/prop-chain rule that a mocked + leaf hides a dropped hop, so the prop chain needs a top-level test. + +## Output Contract + +Report the top-level prop-chain tests, a grep proving no component branches on +`owner_id` comparison or a role name, the i18n gate results, the mobile parity +evidence, the single-user no-new-UI check, and set this task plus its plan +checkbox to done. diff --git a/docs/plans/team-access/task-09-e2e-docs-adr.md b/docs/plans/team-access/task-09-e2e-docs-adr.md new file mode 100644 index 0000000000..e9bb60e63b --- /dev/null +++ b/docs/plans/team-access/task-09-e2e-docs-adr.md @@ -0,0 +1,82 @@ +--- +id: "09-e2e-docs-adr" +title: "E2E, docs, and ADR" +status: todo +wave: 4 +depends_on: ["08-frontend-surfaces"] +plan: "plan.md" +spec: "../../specs/workspaces/requirements/org-units.md" +--- + +# Task 09: E2E, Docs, and ADR + +## Acceptance + +- A Playwright spec covers the #2824 golden path with **no invitation**: an org + whose default visibility is `org`, Ana creates a workspace and a task, Bruno + logs in and sees it on his board, opens the transcript and the preview, + assigns the task to himself, and prompts — attributed to Bruno. +- A Playwright spec covers the private path: default `private`, Bruno gets the + not-found surface, Ana adds him as `collaborator`, Bruno gains access. +- A Playwright spec covers narrowing: Bruno as `viewer` on an org-visible + workspace sees the board but has no prompt box and no terminal, and the + direct API call returns 403. +- A Playwright spec covers revocation: Bruno loses access mid-view (once by + removal, once by visibility flipped to `private`) and is routed out; a + subsequent direct URL visit returns the not-found surface. +- A Playwright spec covers the guest: a `guest` sees no org-visible workspace + and only the one they hold a row on. +- Seed tasks without an agent and locate by task id, so kanban column + assertions do not race the agent auto-advance. +- `cmd/kandev/e2e_reset.go` deletes membership rows and resets visibility so a + worker-scoped backend starts clean. +- An ADR records the durable decisions: permissions are named scopes in one + registry with fixed roles rather than an admin bit; reach and permission are + separate questions; visibility is the primary sharing mechanism and + membership is the exception and narrowing path, chosen because a Kandev + workspace is coarse; an explicit membership row outranks the org default in + both directions; scope resolution fails closed and never falls back to the org + default role; org admin remains a management role with no extra reach; + attribution never falls back to the workspace owner; concurrency is serialized + through the existing message queue rather than a human session lock; and the + agent's in-session MCP identity stays the task owner rather than the last + prompting member. +- `docs/public/**` documents team collaboration: org roles and what each can do, + workspace visibility and the org default, adding members and narrowing them, + takeover, and what stays owner-only. Run `/docs-maintainer`. +- `docs/specs/auth/spec.md` is amended: the "No workspace sharing/membership — + one owner per workspace" v1 limit is replaced by pointers to + `roles-and-scopes.md` and `workspaces/membership.md`, and the roles bullet is + updated from two roles to four. +- Issue #2824 is answered with what shipped and what stayed out of scope + (custom roles, human workflow approvers, group objects, PAT scopes), + including the correction that the existing assignee and participant fields + name agent profiles rather than people. + +## Verification + +- From `apps/web`: `pnpm run build:e2e` then `pnpm e2e:run` for the new specs. + Do not pass flags through `pnpm e2e:raw --`; they are silently dropped and the + whole suite runs instead. +- `make -C apps/backend test lint` + +## Files Likely Touched + +- `apps/web/e2e/` new specs and fixtures +- `apps/backend/cmd/kandev/e2e_reset.go` +- `docs/decisions/00XX-team-access-roles-and-visibility.md`, `docs/decisions/INDEX.md` +- `docs/public/**` +- `docs/specs/auth/spec.md`, `docs/specs/INDEX.md` + +## Inputs + +- Spec: Scenarios in both `roles-and-scopes.md` and `workspaces/membership.md` + — together they are the conformance surface. +- Patterns: `apps/web/e2e/README.md`; the E2E reset invariant and the + seed-without-an-agent rule for kanban column assertions. + +## Output Contract + +Report the scenario-to-E2E mapping across both specs with any uncovered +scenario named explicitly, the ADR number, the docs pages touched, the issue +reply, and set this task plus its plan checkbox to done. diff --git a/docs/public/authentication.md b/docs/public/authentication.md index c5bf0c549f..351bbe12e5 100644 --- a/docs/public/authentication.md +++ b/docs/public/authentication.md @@ -20,11 +20,15 @@ Authentication is a **runtime feature toggle**: the same system as the other fea ## What changes when authentication is on - Everyone signs in with email + password. Browser sessions last 30 days (sliding) and can be revoked from `Settings > Account`. The signed-in user is shown in the bottom-left of the sidebar, with a log-out menu. -- **Workspaces become per-user.** You only see workspaces you own, including their tasks, sessions, repositories, terminals, previews, and live updates. Existing data is assigned to the admin created during setup. +- **Workspaces become per-user by default.** You only see workspaces you own, including their tasks, sessions, repositories, terminals, previews, and live updates. Existing data is assigned to the admin created during setup. A workspace can then be shared with colleagues on purpose: see [Team Access](team-access.md). - Secrets are per-user. A **Global** secret is user-global across that user's workspaces; a **Workspace** secret belongs to one of their workspaces. With authentication disabled, Global is install-global. Executors and agent profiles remain shared across the instance, so they can reference Global secrets only; repositories may bind Global or same-workspace secrets. -- Admins manage users and instance settings, but do **not** see other users' workspaces. +- Admins manage users and instance settings, but do **not** see other users' private workspaces. Admin is a management role, not a visibility role. - Programmatic clients (external MCP, scripts) authenticate with personal access tokens. +## Sharing work with a team + +Authentication makes each person's work private. To give a team a shared board without a shared login, see [Team Access](team-access.md): workspace visibility, member roles, and scoped permissions. + ## Enabling authentication **From the UI:** open `Settings > System > Feature Toggles`, turn on **Authentication & users**, and restart Kandev when prompted (it is a restart-required flag, like the other feature toggles). After the restart the instance is in setup mode: the setup wizard appears and the account you create becomes the admin. All existing workspaces, settings, and secrets carry over to that admin. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index 86086d2572..5698cf132b 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -576,6 +576,52 @@ "/settings/account/tokens" ] }, + { + "id": "team-access", + "audiences": ["user", "operator"], + "stability": "beta", + "docs": ["team-access"], + "sources": [ + "apps/backend/internal/authz/scopes.go", + "apps/backend/internal/authz/roles.go", + "apps/backend/internal/authz/resolve.go", + "apps/backend/internal/task/service/service_access.go", + "apps/backend/internal/task/service/service_members.go", + "apps/web/components/settings/workspaces/workspace-team-access-card.tsx", + "apps/web/components/task/task-assignee-control.tsx", + "apps/web/components/kanban-card-assignee-badge.tsx", + "apps/web/hooks/domains/users/use-assignable-people.ts", + "apps/web/components/settings/workspaces/workspace-placement-card.tsx" + ], + "tests": [ + "apps/backend/internal/authz/resolve_test.go", + "apps/backend/internal/authz/registry_test.go", + "apps/backend/internal/task/service/service_team_access_test.go", + "apps/web/lib/types/team-access.test.ts", + "apps/backend/internal/task/service/service_events_assignee_test.go", + "apps/web/components/task/task-assignee-control.test.tsx", + "apps/web/lib/ws/handlers/tasks-assignee.test.ts", + "apps/web/components/settings/workspaces/workspace-placement-card.test.tsx" + ], + "settingsRoutes": ["/settings/units"] + }, + { + "id": "organizations", + "audiences": ["operator"], + "stability": "experimental", + "docs": ["organizations"], + "sources": [ + "apps/backend/internal/org/service.go", + "apps/backend/internal/org/store.go", + "apps/backend/internal/org/controller.go", + "apps/web/components/settings/system/organizations/organizations-page.tsx" + ], + "tests": [ + "apps/backend/internal/authz/resolve_test.go", + "apps/backend/internal/task/service/service_team_access_test.go" + ], + "settingsRoutes": ["/settings/system/organizations"] + }, { "id": "system-health-and-maintenance", "audiences": [ diff --git a/docs/public/meta.json b/docs/public/meta.json index 1acf59083e..135c630b77 100644 --- a/docs/public/meta.json +++ b/docs/public/meta.json @@ -18,6 +18,8 @@ "integrations", "automation-and-mcp", "authentication", + "team-access", + "organizations", "configuration", "---Install Kandev---", "cli", diff --git a/docs/public/organizations.md b/docs/public/organizations.md new file mode 100644 index 0000000000..e70cd8493b --- /dev/null +++ b/docs/public/organizations.md @@ -0,0 +1,124 @@ +--- +title: "Organizations" +description: "Run one Kandev server for several independent teams or customers, with a hard boundary between them." +--- + +# Organizations + +An organization is a **tenant**: a boundary above users. Two organizations on the same Kandev server cannot see each other's workspaces, tasks, sessions, secrets or accounts, and no role on either side can cross it. + +Use organizations when one server serves groups that must stay separate: two teams that should not read each other's code, an agency and its clients, or a hosted deployment. If everyone on your server is one team, you do not need this. Use [Team Access](team-access.md) instead, which is about sharing *within* one organization. + +> Organizations require [authentication](authentication.md). Enabling them with authentication off is refused at startup, because a tenant boundary with nobody to belong to it is not a boundary. + +## Quick checklist + +1. Enable **Authentication & users** and complete the setup wizard. +2. Enable **Organizations** in `Settings > System > Feature Toggles` and restart. +3. Everything you already had lands in one default organization; the first admin becomes the instance operator. +4. Create further organizations in `Settings > Access Control > Organizations` and give each one its first administrator. +5. From then on, each organization administers itself. + +## Enabling + +`Settings > System > Feature Toggles`, turn on **Organizations**, restart. Or set the environment variable on a fresh server: + +```bash +KANDEV_FEATURES_AUTH=true +KANDEV_FEATURES_MULTI_TENANCY=true +``` + +On the first boot with the flag on, Kandev migrates itself: + +- A single **default organization** is created. +- Every existing user, workspace and secret is put into it. +- The first admin is granted the **instance operator** tier, so the instance is never left with organizations and nobody able to manage them. +- Any workspace membership that would straddle two organizations is dropped. + +Nothing becomes visible to anyone new. The migration is idempotent, so a second boot changes nothing. + +## Two tiers, deliberately separate + +| Tier | Manages | Can read your workspaces? | +|---|---|---| +| **Instance operator** | Organizations, feature toggles, instance configuration | **No** | +| **Organization admin** | Users, invites, settings and shared configuration inside one organization | Only what team access already grants them | + +The operator tier is an *administration* tier, not a visibility one. An operator creates, suspends and deletes organizations, and holds no permission inside any of them, including their own. Their access to their own workspaces comes from being an ordinary member of their own organization, exactly like everyone else. + +## Managing organizations + +`Settings > Access Control > Organizations` is visible only to the instance operator. A non-operator gets a not-found response from those routes, so the page does not appear for them. The entry sits with the other access settings rather than under System, because an organization is a boundary above users; System is for operating the instance. It is hidden entirely until the feature is enabled. + +![The Organizations settings page listing Acme Corp with a Default badge and Active status, Globex Industries as Active, and Initech as Suspended. Each row has Add administrator, Suspend or Resume, and a delete action. Above the list is a New organization form and a card explaining what an organization separates.](../screenshots/tenancy-organizations.png) + +### Creating one + +Create the organization, then give it a first administrator. That second step is not optional bookkeeping: an ordinary admin can only create accounts inside their own tenant, so a brand-new organization has no way to get its first user. This operator-only path is what breaks that circularity, and after it the organization is self-sufficient. + +![A dialog titled "First administrator for Acme Corp" with display name, email and password fields, explaining that an administrator can only create accounts in their own organization so a new organization needs its first one created here.](../screenshots/tenancy-first-admin.png) + +### Suspending + +Suspension is the reversible lever, for a billing lapse or an investigation. Every session and token for that organization fails closed immediately, running work is refused, and **nothing is deleted**. Resuming restores access exactly as it was. + +A suspended user is told what actually happened rather than that their password is wrong, so they contact an administrator instead of resetting a credential that works. + +![The sign-in form showing the message "Your organization is currently unavailable. Contact your Kandev administrator." beneath a correctly filled email and password.](../screenshots/tenancy-suspended-login.png) + +### Deleting + +Deletion removes every workspace, task, session and account in the organization. It requires typing the organization's slug verbatim, and the last remaining organization cannot be deleted. + +**Deletion is not retroactive across backups.** It is irreversible on the live server, but a backup taken while the organization existed still contains it, secrets included, and restoring that backup brings it back. This is not a temporary gap that a later release closes: erasing an organization from backups already written needs key material kept outside the database snapshot, which is a separate decision with its own recovery tradeoffs. + +If you need an organization's data gone from your backups, the lever is deleting the backup files, not waiting for them to age out. Snapshots do not all expire, and one class never does: + +| Snapshot | Where it comes from | Does it age out? | +|---|---|---| +| `kandev-*.db` | Written automatically before an upgrade migration | Only the newest two survive, and only pruned on a boot that changes version | +| `manual-*.db` | `System > Backups` | **Never.** Delete it deliberately | +| `kandev-pre-reset-*.db` | Factory Reset | Excluded from the Backups delete action on purpose, since it is the lifeline if a reset goes wrong | + +A manual snapshot is exactly what a careful operator takes right before a destructive deletion, and it will hold that organization indefinitely. Remove it from `System > Backups`, or from the `backups/` directory beside the database. + +![A confirmation dialog titled "Delete Globex Industries" warning that this removes every workspace, task, session and account in the organization and cannot be undone, with a field to type the slug to confirm.](../screenshots/tenancy-delete-confirm.png) + +## What the boundary looks like in use + +Two organizations on one server. Ana is in Acme; her team's board is shared with her whole organization through [team access](team-access.md). + +![Acme's kanban board seen by Bruno Costa, showing three Acme tasks.](../screenshots/tenancy-acme-board.png) + +Gary is an administrator of Globex on the **same server**. He sees his own organization and nothing else. Acme's org-visible workspace is not merely hidden from him, it does not exist as far as his session is concerned. + +![Globex's kanban board seen by Gary Vasquez, showing only the Globex Secret Project workspace and one Globex task.](../screenshots/tenancy-globex-board.png) + +## How the boundary is enforced + +- **Your organization comes from your account, never from the request.** No route, payload, header or WebSocket frame may name an organization, so no request can move a caller between tenants. There is no organization switcher, and one person belongs to exactly one organization; someone who needs two uses two accounts. +- **The tenant check runs before any role.** It is not a permission that a role can outrank: a workspace in another organization is unreachable by an org admin, by the instance operator, and even by a stale membership row. +- **It fails closed.** An account somehow carrying no organization reaches nothing while the feature is on, rather than matching everything. +- **New accounts inherit their creator's organization.** Admin-created users and invite links both carry the minting admin's organization, so accepting an invite can never place someone in a tenant the inviter cannot see. +- **Email is unique across the whole server**, not per organization. One address is one person, which is why signing in needs no organization picker. + +## Current limits + +These are real and worth knowing before you put two untrusting groups on one server. + +- **Secrets are scoped per organization, but not encrypted per organization.** + Reads are org-scoped, so one organization cannot fetch another's secret + through the API. At rest, every secret on the instance is sealed under a + single master key in the data directory next to the database, so anyone who + can read that directory can decrypt every organization's secrets. + Per-organization encryption keys are designed but not yet implemented. +- **Filesystem and agent credentials are shared.** Worktrees and clones live under one `~/.kandev` tree owned by the OS user running Kandev, and agent CLI logins (`gh auth`, `claude login`, provider API keys) authenticate as that OS user. Organizations are an **application-layer** boundary over Kandev's data: they are not a sandbox. Two organizations that must not share agent credentials need separate Kandev instances or separate OS users today. +- **Executors and agent profiles are still instance-wide.** They are shared configuration, and a shared agent profile means a shared provider credential. +- **No per-organization billing, quotas or usage metering.** Suspension is the only lever. +- **No per-organization export or backup.** Backups are instance-wide and contain every organization. +- **No cross-organization anything**, by design: no shared workspaces, no moving a user or workspace between organizations, no cross-organization search. +- **Users cannot be moved between organizations.** Moving a person would leave their workspaces behind in the old tenant. + +## Turning it off + +Flip the toggle off and restart. Organization assignments are retained, so re-enabling restores the previous boundary without another migration. With it off, the instance behaves as a single organization again and the Organizations page disappears. diff --git a/docs/public/team-access.md b/docs/public/team-access.md new file mode 100644 index 0000000000..e345096f03 --- /dev/null +++ b/docs/public/team-access.md @@ -0,0 +1,184 @@ +--- +title: "Team Access" +description: "Share Kandev workspaces with colleagues using organization units, member roles, and scoped permissions." +--- + +# Team Access + +Kandev starts private: with [authentication](authentication.md) enabled, each person sees only their own workspaces. **Team access** is how a team on a shared server gets shared boards instead, without falling back to a single login everyone knows. + +The whole model is one idea: **workspaces live in a tree, and so do people.** + +- An **organization unit** holds child units, workspaces, and members. Your departments and your teams are both units; they differ only in depth. +- Being a member of a unit reaches **every workspace beneath it**, including ones created later. +- Work that is nobody else's business lives in your **personal unit**, which takes no members. + +There is no per-workspace visibility switch. Where a workspace sits *is* who reaches it. + +> Team access requires authentication. On a single-user install with authentication off, none of this appears and nothing changes. + +## Quick checklist + +1. Enable [authentication](authentication.md) and create the admin account. +2. Build your structure: `Settings > Access Control > Organization units`. +3. Add people to the unit that matches their team. +4. Move a workspace into that unit to share it, or leave it in your personal unit to keep it to yourself. +5. Remember that secrets, integration credentials, and workspace deletion stay with the owner. + +## Building the tree + +A Kandev workspace is coarse. It owns the default executor, environment and agent profile, the task prefix, and its Kanban workflow, so a team usually has one or two. Units are what group them. + +![The Organization units page showing a tree: the organization root at the top, a Platform department beneath it, a Runtime team beneath that, and a personal unit.](../screenshots/team-access-units-tree.png) + +Add a unit under any other. A department and a team are the same thing at different depths, so adding a level later is a move rather than a migration. + +![The Organization units page with a New unit card open beneath the tree, its name field filled in with Security.](../screenshots/team-access-unit-create.png) + +Two units are created for you and behave differently on purpose: + +- The **root** is the whole organization. A workspace parked here is reached by everyone you put in the root. +- A **personal unit** belongs to one person, takes no members, and cannot be deleted. It replaces the old private flag: a workspace nobody else should see simply lives there. + +## Putting people in a unit + +![The members dialog for the Platform unit, listing Bruno Costa as a collaborator with an add-member row beneath.](../screenshots/team-access-unit-members.png) + +Everyone in a unit reaches every workspace under it, at the role you give them. Add someone once when they join the team, not once per board. + +## Moving a workspace + +A workspace sits in exactly one unit, and `Settings > Workspaces > (workspace)` is where you change it. Moving it is how you hand a board to another team, or take it back. + +![The workspace settings page showing a Placement card set to Runtime, above the Team access card.](../screenshots/team-access-workspace-placement.png) + +## Narrowing access + +There is no way to subtract a role, and that is deliberate. Roles combine by taking the strongest: what you inherit from a unit plus anything granted directly on one workspace. A grant can raise your access; nothing lowers it. + +To give fewer people access to something, **move it to a unit with fewer members**. That keeps "why can this person write here" answerable by reading the tree, instead of by working out which of two mechanisms won. + +## What a colleague sees + +Once a workspace sits in a unit your colleague is in, it simply appears. No invitation, no acceptance step. + +![A kanban board titled Platform Team seen by Bruno Costa, showing three tasks in the In Progress column and the same three tasks in the sidebar task list.](../screenshots/team-access-shared-board.png) + +They can open a colleague's task, read the agent conversation, test the preview, and take it over. **Takeover is reassign plus continue**: no lock is taken and no session is torn down. + +Every human action is recorded against the person who took it, which is what a shared login cannot give you: message attribution comes from the authenticated session, not from the browser, so nobody can post as somebody else. + +## Assigning a task to a person + +A task carries two independent assignees: the **agent** that runs it, and the **person** who owns it. Setting one never changes the other, and a task can have both. + +It works the same on both boards. + +On a **kanban** task, the assignee sits in the task's top bar. Open the control to pick a colleague, or choose **Assign to me** to take the task over. + +![The kanban task top bar showing the workflow stepper and, on the right, an assignee control reading Bruno Costa.](../screenshots/team-access-kanban-assignee-topbar.png) + +Cards on the board carry the same name, so you can see who is on what without opening anything. + +![A kanban card titled Upgrade the Postgres driver with an avatar and the name Bruno Costa under the title.](../screenshots/team-access-kanban-assignee-card.png) + +In **office**, it is the **Assigned to** row in the task properties panel, next to the agent **Assignee** row. + +![The task properties panel showing an Assignee row set to No assignee above an Assigned to row with an Assign to me button and the human assignee Bruno Costa.](../screenshots/team-access-task-assignee.png) + +The list offers everyone in the user directory, because who reaches a workspace is not something the browser can work out on its own. The server is the authority: assigning someone who cannot reach it is refused with "that person cannot see this workspace", so a task is never parked on somebody who will never find it. + +Assignment is advisory. It records who owns the work and gates nothing: anybody with `task.write` can reassign anything, including to themselves, and doing so takes no lock and interrupts no running session. That is the whole of takeover, and it is why the button needs no confirmation. + +The control is hidden when authentication is disabled, because then every visitor is the same anonymous user and there is nobody to assign to. + +## Roles + +Two role tiers combine. Your **org role** is on your account; your **workspace role** applies to one workspace. + +**Org roles** + +| Role | Can | +|---|---| +| `admin` | Manage users and invites, org settings, and shared configuration (executors, agent profiles, environments, editors, prompts, notification providers) | +| `member` | Contribute; no management rights | +| `guest` | Reach **only** workspaces they hold an explicit member row on, including none of the org-visible ones | + +An admin is a **management role, not a reach role**. An admin reaches a workspace because they are in a unit above it, exactly like any member, and never reaches anything in someone else's personal unit. + +**Workspace roles** + +| Role | Read board & transcripts | Edit tasks | Prompt / stop agents | Terminal, shell, previews | Manage members, secrets, settings, delete | +|---|---|---|---|---|---| +| `owner` | yes | yes | yes | yes | yes | +| `collaborator` | yes | yes | yes | yes | no | +| `viewer` | yes | no | no | no | no | + +On an org-visible workspace, `admin` and `member` both act as `collaborator` unless an explicit row says otherwise. + +### Viewer + +A viewer reads the board and the agent conversations and can do nothing else. Terminal and shell access is deliberately a **separate permission** from prompting an agent: prompting is bounded by what that agent is allowed to do, but a shell in the worktree is not, so being able to read a transcript never implies being able to run commands. + +![The Team access card as seen by Carla Nunes, a viewer. The workspace Name field is greyed out, the members list shows no management buttons, there is no "Add a colleague" row, and the Delete Workspace card is absent.](../screenshots/team-access-viewer-card.png) + +The UI hides what you cannot do, and the API refuses it independently, so a viewer who calls the endpoint directly gets `403`. + +### Transferring ownership + +Every workspace has exactly one accountable owner. Use **Make owner** on a member row: the new owner takes over and the previous owner becomes a collaborator. You cannot remove the owner's row; transfer first. + +## Mobile + +The same card works on a phone, including the role pickers and the add-member flow. + +![The Team access card on a narrow phone viewport, with the members list and the add-member controls stacked vertically.](../screenshots/team-access-mobile.png) + +## What stays with the owner + +These are shared credentials and destructive actions, so they do not follow membership: + +- Workspace **secrets** and **integration credentials** +- Adding and removing **repositories** +- Renaming, changing defaults, moving it between units, and **deleting** the workspace +- Adding, re-roling and removing **members** + +A member acts under their **own** GitHub connection wherever per-user identity is supported, so "Approve as ..." on a pull request is that person, not the workspace owner. + +## Upgrading an existing server + +Every workspace you already own moves into **your personal unit**, so it stays exactly as private as it was. A workspace with no owner, created before authentication existed, moves to the root, which is where everyone already reached it. + +An upgrade never widens access. Sharing is an explicit act afterwards: move a workspace into a unit, or add people to the unit it is already in. + +## Permission reference + +Every guarded action names a scope. These strings appear in the API and are what the UI gates on. + +| Scope | Grants | +|---|---| +| `workspace.read` | See the workspace, its board, tasks, and transcripts | +| `workspace.manage` | Rename, change defaults, move between units, delete | +| `task.write` | Create, edit, move, assign, archive, delete tasks | +| `session.prompt` | Start or resume a session and message an agent | +| `session.control` | Stop or cancel a running agent | +| `session.exec` | Terminal, shell, file writes, port previews, VS Code, LSP | +| `repository.manage` | Add or remove workspace repositories | +| `secret.manage` | Workspace secrets and integration credentials | +| `member.manage` | Add or remove members, transfer ownership | +| `org.members.manage` | Invite, create, disable and re-role users | +| `org.settings.manage` | Org-wide settings and defaults | +| `unit.manage` | Create, rename, move and delete units, and place workspaces in them | +| `org.config.manage` | Executors, agent profiles, environments, editors, prompts, notification providers | + +`GET /api/v1/authz/scopes` returns this list with descriptions. + +## Current limits + +- **"Organization" means one tenant.** With the Organizations feature off there is exactly one, implicitly, and its root unit covers everyone on the server. To run one server for several independent teams or customers, see [Organizations](organizations.md). +- **The `guest` role has no UI yet.** `Settings > Users` is not ported to the SPA, so a guest is assigned through the API: `PATCH /api/v1/users/{id}` with `{"role": "guest"}`. +- **Roles are fixed.** There is no custom-role builder; scopes are the extension point and adding one is a code change. +- **No standalone groups.** A unit answers "who is in this team", so a set of people that ignores the tree, like every site reliability engineer across three departments, is not modelled yet. +- **No presence.** There are no cursors, typing indicators, or "who else is viewing" markers. +- **Concurrent prompting is serialized, not blocked.** If two people prompt the same session at once, both messages queue in arrival order and both are attributed. Kandev deliberately does not lock a session to one human: a lock would strand the session when someone closes their laptop. +- **Filesystem and agent credentials are not isolated per user.** As with authentication generally, application-layer scoping is the boundary between users' Kandev data; it does not sandbox the filesystem or per-user agent logins. See the limits in [Authentication & Users](authentication.md). diff --git a/docs/screenshots/team-access-kanban-assignee-card.png b/docs/screenshots/team-access-kanban-assignee-card.png new file mode 100644 index 0000000000000000000000000000000000000000..68a9e94b2603865a862701d75e828788332a213c GIT binary patch literal 3777 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/team-access-kanban-assignee-topbar.png b/docs/screenshots/team-access-kanban-assignee-topbar.png new file mode 100644 index 0000000000000000000000000000000000000000..73c3057a2659c0030f689df08173d0fc23da82b7 GIT binary patch literal 6561 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/team-access-owner-card.png b/docs/screenshots/team-access-owner-card.png new file mode 100644 index 0000000000000000000000000000000000000000..b715f38a52178dd2165a047f07dcbf01ce70aec2 GIT binary patch literal 16536 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 Souu>O2{ICj;$@-+f&UG8-9?1} literal 0 HcmV?d00001 diff --git a/docs/screenshots/team-access-shared-board.png b/docs/screenshots/team-access-shared-board.png new file mode 100644 index 0000000000000000000000000000000000000000..86823e9694b0cc92603e01153e847b1ad8902d8c GIT binary patch literal 53147 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/team-access-unit-create.png b/docs/screenshots/team-access-unit-create.png new file mode 100644 index 0000000000000000000000000000000000000000..1699815011a975babc46579d793e29eaf4c32287 GIT binary patch literal 113413 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�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 Qe#XX1>_!A#7S+BH#_7&N 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