diff --git a/cli/cmd/testutil_test.go b/cli/cmd/testutil_test.go index d157353..a2427cc 100644 --- a/cli/cmd/testutil_test.go +++ b/cli/cmd/testutil_test.go @@ -2,20 +2,20 @@ package cmd import ( "bytes" - "crypto/sha1" "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" "os" "testing" + + "github.com/anthropics/code-index/cli/internal/client" ) -// projectHash returns the same SHA1 prefix that the client uses for URL routing. +// projectHash returns the same project URL hash the client uses for routing — +// delegated to the real implementation so the per-machine namespacing matches. func projectHash(path string) string { - h := sha1.Sum([]byte(path)) - return fmt.Sprintf("%x", h)[:16] + return client.EncodeProjectPath(path) } // mockServer starts a test HTTP server and registers cleanup. diff --git a/cli/internal/client/client.go b/cli/internal/client/client.go index 6723f12..e59039c 100644 --- a/cli/internal/client/client.go +++ b/cli/internal/client/client.go @@ -2,11 +2,17 @@ package client import ( "bytes" + "crypto/rand" "crypto/sha1" + "encoding/hex" "encoding/json" "fmt" "io" "net/http" + "os" + "path/filepath" + "strings" + "sync" "time" ) @@ -84,13 +90,64 @@ func (c *Client) do(method, path string, body interface{}) (*http.Response, erro return resp, nil } -// encodeProjectPath returns SHA1 hash (first 16 hex chars) of the project path. -// This avoids all URL encoding issues with slashes in paths. +// encodeProjectPath returns the project's URL hash (first 16 hex chars of +// SHA1 of the identity key). Local projects are namespaced per machine — +// "local:{machine_id}:{path}" — so the same filesystem path on different +// machines/users maps to different projects. MUST stay byte-identical to the +// server's projects.LocalProjectKey + hashPath (server/internal/projects). func encodeProjectPath(path string) string { - h := sha1.Sum([]byte(path)) + key := "local:" + machineID() + ":" + path + h := sha1.Sum([]byte(key)) return fmt.Sprintf("%x", h)[:16] } +// EncodeProjectPath is the exported project URL hash, for tests and tooling +// that need to mirror the client's addressing exactly. +func EncodeProjectPath(path string) string { return encodeProjectPath(path) } + +var ( + machineIDOnce sync.Once + machineIDVal string +) + +// machineID returns a stable per-machine (per-home) identifier, persisted at +// ~/.cix/machine_id. Generated on first use. Used to namespace local project +// identity so different developers' machines never collide on the same path. +func machineID() string { + machineIDOnce.Do(func() { machineIDVal = loadOrCreateMachineID() }) + return machineIDVal +} + +func loadOrCreateMachineID() string { + home, err := os.UserHomeDir() + if err != nil { + return "unknown-machine" + } + path := filepath.Join(home, ".cix", "machine_id") + if b, rerr := os.ReadFile(path); rerr == nil { + if id := strings.TrimSpace(string(b)); id != "" { + return id + } + } + buf := make([]byte, 16) + if _, gerr := rand.Read(buf); gerr != nil { + return "unknown-machine" + } + id := hex.EncodeToString(buf) + _ = os.MkdirAll(filepath.Dir(path), 0o755) + _ = os.WriteFile(path, []byte(id+"\n"), 0o600) + return id +} + +// machineLabel returns the OS hostname for display purposes (sent to the +// server as machine_label). Best-effort; empty when unavailable. +func machineLabel() string { + if h, err := os.Hostname(); err == nil { + return h + } + return "" +} + // parseResponse reads and unmarshals JSON response func parseResponse(resp *http.Response, v interface{}) error { defer resp.Body.Close() diff --git a/cli/internal/client/projects.go b/cli/internal/client/projects.go index 1278236..f447748 100644 --- a/cli/internal/client/projects.go +++ b/cli/internal/client/projects.go @@ -73,8 +73,14 @@ func (c *Client) GetProject(path string) (*Project, error) { // CreateProject creates a new project func (c *Client) CreateProject(path string) (*Project, error) { + // machine_id namespaces the project's identity so the same filesystem path + // on a different machine/user is a distinct project; machine_label is the + // hostname for display. The server derives path_hash from + // local:{machine_id}:{host_path} — matching encodeProjectPath. body := map[string]string{ - "host_path": path, + "host_path": path, + "machine_id": machineID(), + "machine_label": machineLabel(), } resp, err := c.do("POST", "/api/v1/projects", body) diff --git a/cli/internal/indexer/indexer_test.go b/cli/internal/indexer/indexer_test.go index fb490d6..56f1ca3 100644 --- a/cli/internal/indexer/indexer_test.go +++ b/cli/internal/indexer/indexer_test.go @@ -2,11 +2,9 @@ package indexer import ( "context" - "crypto/sha1" "crypto/sha256" "encoding/hex" "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" @@ -18,10 +16,10 @@ import ( "github.com/anthropics/code-index/cli/internal/client" ) -// projectHash mirrors the client's URL-encoding logic (SHA1, first 16 hex chars). +// projectHash mirrors the client's project URL hash — delegated to the real +// implementation so per-machine namespacing matches. func projectHash(path string) string { - h := sha1.Sum([]byte(path)) - return fmt.Sprintf("%x", h)[:16] + return client.EncodeProjectPath(path) } // sha256hex computes the hex-encoded SHA-256 of b, matching discovery.hashFile. diff --git a/cli/internal/watcher/watcher_test.go b/cli/internal/watcher/watcher_test.go index d5705e8..b4375fe 100644 --- a/cli/internal/watcher/watcher_test.go +++ b/cli/internal/watcher/watcher_test.go @@ -1,9 +1,7 @@ package watcher import ( - "crypto/sha1" "encoding/json" - "fmt" "io" "log" "net/http" @@ -19,10 +17,10 @@ import ( "github.com/rjeczalik/notify" ) -// projectHash mirrors client.encodeProjectPath. +// projectHash mirrors the client's project URL hash — delegated to the real +// implementation so per-machine namespacing matches. func projectHash(path string) string { - h := sha1.Sum([]byte(path)) - return fmt.Sprintf("%x", h)[:16] + return client.EncodeProjectPath(path) } // mockEventInfo implements notify.EventInfo for testing. diff --git a/doc/openapi.yaml b/doc/openapi.yaml index 02a19dc..ddad11c 100644 --- a/doc/openapi.yaml +++ b/doc/openapi.yaml @@ -87,6 +87,12 @@ tags: modify any workspace. PR1 ships CRUD only; repository attachment, webhooks, and the two-stage search endpoint land in subsequent releases of the workspaces feature branch. + - name: groups + description: | + View-groups: admin-managed sets of users. External projects and + workspaces are shared to a group, granting its members read/search + access. Group CRUD and membership are admin-only; GET /groups is + member-scoped for regular users so the share picker can populate. - name: github-tokens description: | GitHub Personal Access Tokens used by the workspaces feature for @@ -1258,6 +1264,389 @@ paths: "503": $ref: "#/components/responses/WorkspacesDisabled" + /api/v1/groups: + get: + operationId: listGroups + tags: [groups] + summary: List view-groups + description: | + Admins see every group; a regular user sees only the groups they + belong to (so the dashboard's "share to group" picker can populate). + responses: + "200": + description: Group list + content: + application/json: + schema: + $ref: "#/components/schemas/GroupListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + post: + operationId: createGroup + tags: [groups] + summary: Create a view-group (admin only) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateGroupRequest" + responses: + "201": + description: Group created + content: + application/json: + schema: + $ref: "#/components/schemas/Group" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/Unprocessable" + + /api/v1/groups/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + description: View-group ID. + get: + operationId: getGroup + tags: [groups] + summary: Get a view-group (admin only) + responses: + "200": + description: Group + content: + application/json: + schema: + $ref: "#/components/schemas/Group" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + patch: + operationId: updateGroup + tags: [groups] + summary: Update a view-group (admin only) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateGroupRequest" + responses: + "200": + description: Updated group + content: + application/json: + schema: + $ref: "#/components/schemas/Group" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + "422": + $ref: "#/components/responses/Unprocessable" + delete: + operationId: deleteGroup + tags: [groups] + summary: Delete a view-group (admin only) + description: | + Cascades to memberships and project/workspace shares referencing + the group. + responses: + "204": + description: Deleted + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + /api/v1/groups/{id}/members: + parameters: + - name: id + in: path + required: true + schema: + type: string + description: View-group ID. + get: + operationId: listGroupMembers + tags: [groups] + summary: List members of a view-group (admin only) + responses: + "200": + description: Member list + content: + application/json: + schema: + $ref: "#/components/schemas/GroupMemberListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + post: + operationId: addGroupMember + tags: [groups] + summary: Add a user to a view-group (admin only) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AddGroupMemberRequest" + responses: + "204": + description: Member added (idempotent) + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/Unprocessable" + + /api/v1/groups/{id}/members/{userId}: + parameters: + - name: id + in: path + required: true + schema: + type: string + description: View-group ID. + - name: userId + in: path + required: true + schema: + type: string + description: User ID to remove. + delete: + operationId: removeGroupMember + tags: [groups] + summary: Remove a user from a view-group (admin only) + responses: + "204": + description: Member removed (idempotent) + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + /api/v1/projects/{hash}/shares: + parameters: + - name: hash + in: path + required: true + schema: + type: string + description: Project path hash. + get: + operationId: listProjectShares + tags: [groups] + summary: List the view-groups an external project is shared to (admin only) + responses: + "200": + description: Group ids the project is shared to + content: + application/json: + schema: + $ref: "#/components/schemas/GroupIdListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + post: + operationId: shareProjectToGroup + tags: [groups] + summary: Share an external project to a view-group (admin only) + description: | + Only EXTERNAL projects (with a git_repos peer) may be shared — sharing + a personal/local project returns 422. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ShareToGroupRequest" + responses: + "204": + description: Shared (idempotent) + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/Unprocessable" + + /api/v1/projects/{hash}/shares/{groupId}: + parameters: + - name: hash + in: path + required: true + schema: + type: string + description: Project path hash. + - name: groupId + in: path + required: true + schema: + type: string + description: View-group ID. + delete: + operationId: unshareProjectFromGroup + tags: [groups] + summary: Revoke a project↔group share (admin only) + responses: + "204": + description: Unshared (idempotent) + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + /api/v1/projects/{hash}/owner: + parameters: + - name: hash + in: path + required: true + schema: + type: string + description: Project path hash. + put: + operationId: reassignProjectOwner + tags: [projects] + summary: Reassign the owner of a local project (admin only) + description: | + Only LOCAL projects (no git_repos peer) can be reassigned — external + projects are ownerless by design and return 422. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReassignOwnerRequest" + responses: + "200": + description: Updated project + content: + application/json: + schema: + $ref: "#/components/schemas/Project" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/Unprocessable" + + /api/v1/workspaces/{id}/shares: + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Workspace ID. + get: + operationId: listWorkspaceShares + tags: [workspaces] + summary: List the view-groups a workspace is shared to + responses: + "200": + description: Group ids the workspace is shared to + content: + application/json: + schema: + $ref: "#/components/schemas/GroupIdListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "503": + $ref: "#/components/responses/WorkspacesDisabled" + post: + operationId: shareWorkspaceToGroup + tags: [workspaces] + summary: Share a workspace to a view-group + description: | + The workspace owner may share to a group they belong to; an admin may + share to any group. Visibility only — each project inside the workspace + is still access-checked per viewer. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ShareToGroupRequest" + responses: + "204": + description: Shared (idempotent) + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "422": + $ref: "#/components/responses/Unprocessable" + "503": + $ref: "#/components/responses/WorkspacesDisabled" + + /api/v1/workspaces/{id}/shares/{groupId}: + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Workspace ID. + - name: groupId + in: path + required: true + schema: + type: string + description: View-group ID. + delete: + operationId: unshareWorkspaceFromGroup + tags: [workspaces] + summary: Revoke a workspace↔group share (owner or admin) + responses: + "204": + description: Unshared (idempotent) + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "503": + $ref: "#/components/responses/WorkspacesDisabled" + /api/v1/git-repos: post: operationId: addGitRepo @@ -2396,7 +2785,7 @@ components: format: email role: type: string - enum: [admin, viewer] + enum: [admin, user] must_change_password: type: boolean created_at: @@ -2468,7 +2857,7 @@ components: MeResponse: type: object - required: [user, auth_method] + required: [user, auth_method, groups] properties: user: $ref: "#/components/schemas/User" @@ -2478,6 +2867,15 @@ components: description: | Tells the dashboard whether to surface "logout" (session) or hide it (api_key access — there's nothing to log out of). + groups: + type: array + description: | + View-groups the caller belongs to. Lets the dashboard scope the + "share to group" picker without a second round-trip. Empty for a + user in no groups; admins still only list their own memberships + here (the full group list comes from GET /groups). + items: + $ref: "#/components/schemas/Group" ChangePasswordRequest: type: object @@ -2506,17 +2904,17 @@ components: The admin shares this out-of-band. role: type: string - enum: [admin, viewer] + enum: [admin, user] UpdateUserRequest: type: object properties: role: type: string - enum: [admin, viewer] + enum: [admin, user] description: | New role for the user. Refused for the last enabled admin - when set to `viewer`. + when set to `user`. disabled: type: boolean description: | @@ -2953,6 +3351,28 @@ components: Embedding model identifier active when this project was last (re)indexed. NULL on rows that pre-date drift tracking — the dashboard treats NULL as "Unknown" rather than as drift. + owner_user_id: + type: string + nullable: true + description: | + User who owns this personal (locally indexed) project. NULL means + ownerless — the canonical state for EXTERNAL projects (those with a + git_repos peer), which are admin-administered and reachable only via + a view-group share. + display_path: + type: string + description: | + Human-readable path. The real filesystem path for local projects, + the github path for external ones. host_path is the identity key + (namespaced per machine for locals) — clients should show this. + machine_id: + type: string + nullable: true + description: Per-machine UUID a local project was indexed on. NULL for external/legacy. + machine_label: + type: string + nullable: true + description: os.Hostname() of the indexing machine — display only. sqlite_path: type: string nullable: true @@ -2990,6 +3410,17 @@ components: properties: host_path: type: string + description: The real filesystem path being registered (becomes display_path). + machine_id: + type: string + description: | + Per-machine UUID supplied by the CLI (from ~/.cix/machine_id). When + present the project is LOCAL and its identity key is namespaced + local:{machine_id}:{host_path} so the same path on different + machines/users does not collide. Omit for external repos. + machine_label: + type: string + description: os.Hostname() of the indexing machine — display only. UpdateProjectRequest: type: object @@ -3537,6 +3968,13 @@ components: updated_at: type: string format: date-time + owner_user_id: + type: string + nullable: true + description: | + User who created the workspace. NULL only when orphaned by a user + deletion. Visible to the owner, members of any view-group it is + shared to, and admins. WorkspaceListResponse: type: object @@ -3573,6 +4011,112 @@ components: description: type: string + Group: + type: object + required: [id, name, description, created_at, updated_at] + properties: + id: + type: string + name: + type: string + description: + type: string + description: Free-form description. Empty string when absent. + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + GroupMember: + type: object + required: [user_id, email, role, added_at] + properties: + user_id: + type: string + email: + type: string + format: email + role: + type: string + enum: [admin, user] + added_at: + type: string + format: date-time + + GroupListResponse: + type: object + required: [groups, total] + properties: + groups: + type: array + items: + $ref: "#/components/schemas/Group" + total: + type: integer + + GroupMemberListResponse: + type: object + required: [members, total] + properties: + members: + type: array + items: + $ref: "#/components/schemas/GroupMember" + total: + type: integer + + CreateGroupRequest: + type: object + required: [name] + properties: + name: + type: string + minLength: 1 + description: + type: string + + UpdateGroupRequest: + type: object + properties: + name: + type: string + minLength: 1 + description: + type: string + + AddGroupMemberRequest: + type: object + required: [user_id] + properties: + user_id: + type: string + + ShareToGroupRequest: + type: object + required: [group_id] + properties: + group_id: + type: string + + GroupIdListResponse: + type: object + required: [group_ids] + properties: + group_ids: + type: array + items: + type: string + + ReassignOwnerRequest: + type: object + required: [owner_user_id] + properties: + owner_user_id: + type: string + description: New owner; must be an existing, enabled user. + GithubToken: type: object required: [id, name, scopes, created_at] diff --git a/server/cmd/cix-server/main.go b/server/cmd/cix-server/main.go index 987d24b..c0b9697 100644 --- a/server/cmd/cix-server/main.go +++ b/server/cmd/cix-server/main.go @@ -23,6 +23,7 @@ import ( "github.com/dvcdsys/code-index/server/internal/embeddings" "github.com/dvcdsys/code-index/server/internal/githubapi" "github.com/dvcdsys/code-index/server/internal/githubtokens" + "github.com/dvcdsys/code-index/server/internal/groups" "github.com/dvcdsys/code-index/server/internal/gitrepos" "github.com/dvcdsys/code-index/server/internal/httpapi" "github.com/dvcdsys/code-index/server/internal/indexer" @@ -220,6 +221,7 @@ func run() error { usrSvc := users.New(database) sessSvc := sessions.New(database) akSvc := apikeys.New(database) + grpSvc := groups.New(database) if !cfg.AuthDisabled { if err := bootstrapAuth(context.Background(), cfg, logger, usrSvc, akSvc); err != nil { @@ -399,6 +401,7 @@ func run() error { Logger: logger, AuthDisabled: cfg.AuthDisabled, Users: usrSvc, + Groups: grpSvc, Sessions: sessSvc, APIKeys: akSvc, EmbeddingSvc: embedSvc, diff --git a/server/dashboard/src/api/types.ts b/server/dashboard/src/api/types.ts index 7f19573..d9b4b6a 100644 --- a/server/dashboard/src/api/types.ts +++ b/server/dashboard/src/api/types.ts @@ -7,9 +7,17 @@ import type { components } from './generated'; -export type Role = 'admin' | 'viewer'; +export type Role = 'admin' | 'user'; export type User = components['schemas']['User']; + +export type Group = components['schemas']['Group']; +export type GroupMember = components['schemas']['GroupMember']; +export type GroupListResponse = components['schemas']['GroupListResponse']; +export type GroupMemberListResponse = components['schemas']['GroupMemberListResponse']; +export type CreateGroupRequest = components['schemas']['CreateGroupRequest']; +export type UpdateGroupRequest = components['schemas']['UpdateGroupRequest']; +export type GroupIdListResponse = components['schemas']['GroupIdListResponse']; export type UserWithStats = components['schemas']['UserWithStats']; export type Session = components['schemas']['Session']; export type ApiKey = components['schemas']['ApiKey']; diff --git a/server/dashboard/src/app/App.tsx b/server/dashboard/src/app/App.tsx index 260549b..b01bd9c 100644 --- a/server/dashboard/src/app/App.tsx +++ b/server/dashboard/src/app/App.tsx @@ -53,7 +53,7 @@ export default function App() { // mounted, so a deep link to it 404s back to /. const visible = MODULES.filter((m) => { if (!m.requiredRole) return true; - if (m.requiredRole === 'viewer') return true; + if (m.requiredRole === 'user') return true; return user.role === 'admin'; }); diff --git a/server/dashboard/src/app/Sidebar.tsx b/server/dashboard/src/app/Sidebar.tsx index 6a1241e..b8fa374 100644 --- a/server/dashboard/src/app/Sidebar.tsx +++ b/server/dashboard/src/app/Sidebar.tsx @@ -13,11 +13,11 @@ import { MODULES } from '@/modules/registry'; // edits to this component are needed when a module is added. export function Sidebar() { const { user, logout } = useAuth(); - const role = user?.role ?? 'viewer'; + const role = user?.role ?? 'user'; const visible = MODULES.filter((m) => { if (!m.requiredRole) return true; - if (m.requiredRole === 'viewer') return true; + if (m.requiredRole === 'user') return true; return role === 'admin'; }); diff --git a/server/dashboard/src/auth/AuthProvider.tsx b/server/dashboard/src/auth/AuthProvider.tsx index e793dfa..5532209 100644 --- a/server/dashboard/src/auth/AuthProvider.tsx +++ b/server/dashboard/src/auth/AuthProvider.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createContext, useCallback, useMemo, type ReactNode } from 'react'; import { ApiError, api } from '@/api/client'; -import type { BootstrapStatusResponse, LoginRequest, LoginResponse, MeResponse, User } from '@/api/types'; +import type { BootstrapStatusResponse, Group, LoginRequest, LoginResponse, MeResponse, User } from '@/api/types'; // Shape exposed to components — kept narrow on purpose. Use `useAuth` to // consume; `AuthProvider` is the only place that touches the underlying @@ -13,6 +13,8 @@ export interface AuthContextValue { needsBootstrap: boolean; /** Currently authenticated user, or null when logged out. */ user: User | null; + /** View-groups the current user belongs to (drives the share picker). */ + groups: Group[]; /** When true, the user must change their password before reaching the app. */ mustChangePassword: boolean; /** Performs the login flow + warms /me. Throws ApiError on failure. */ @@ -98,6 +100,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { loading: bootstrap.isLoading || (bootstrap.data?.needs_bootstrap === false && me.isLoading), needsBootstrap: bootstrap.data?.needs_bootstrap ?? false, user: me.data?.user ?? null, + groups: me.data?.groups ?? [], mustChangePassword: me.data?.user?.must_change_password ?? false, login, logout, diff --git a/server/dashboard/src/modules/groups/GroupsPage.tsx b/server/dashboard/src/modules/groups/GroupsPage.tsx new file mode 100644 index 0000000..1152b92 --- /dev/null +++ b/server/dashboard/src/modules/groups/GroupsPage.tsx @@ -0,0 +1,100 @@ +import { AlertCircle, Trash2, UsersRound } from 'lucide-react'; +import { toast } from 'sonner'; +import { ApiError } from '@/api/client'; +import { Alert, AlertDescription, AlertTitle } from '@/ui/alert'; +import { Button } from '@/ui/button'; +import { Skeleton } from '@/ui/skeleton'; +import { CreateGroupDialog } from './components/CreateGroupDialog'; +import { GroupMembersDialog } from './components/GroupMembersDialog'; +import { useDeleteGroup, useGroups } from './hooks'; + +export default function GroupsPage() { + const { data, error, isLoading } = useGroups(); + const del = useDeleteGroup(); + + async function onDelete(id: string, name: string) { + if (!window.confirm(`Delete group "${name}"? This removes all its memberships and shares.`)) { + return; + } + try { + await del.mutateAsync(id); + toast.success('Group deleted'); + } catch (err) { + toast.error('Failed to delete group', { + description: err instanceof ApiError ? err.detail : String(err), + }); + } + } + + return ( +
+
+
+

View Groups

+

+ {data ? `${data.total} ${data.total === 1 ? 'group' : 'groups'}` : ' '} +

+
+ +
+ + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : error ? ( + + + Failed to load groups + + {error instanceof ApiError ? error.detail : String(error)} + + + ) : !data || data.groups.length === 0 ? ( + + ) : ( +
+ {data.groups.map((g) => ( +
+
+
{g.name}
+ {g.description ? ( +
{g.description}
+ ) : null} +
+
+ + +
+
+ ))} +
+ )} +
+ ); +} + +function EmptyState() { + return ( +
+ +

No view-groups yet

+

+ Create a group, add users to it, then share external projects and + workspaces to the group so its members can search them. +

+
+ ); +} diff --git a/server/dashboard/src/modules/groups/components/CreateGroupDialog.tsx b/server/dashboard/src/modules/groups/components/CreateGroupDialog.tsx new file mode 100644 index 0000000..276f1ec --- /dev/null +++ b/server/dashboard/src/modules/groups/components/CreateGroupDialog.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react'; +import { Loader2, Plus } from 'lucide-react'; +import { toast } from 'sonner'; +import { ApiError } from '@/api/client'; +import { Button } from '@/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/ui/dialog'; +import { Input } from '@/ui/input'; +import { Label } from '@/ui/label'; +import { useCreateGroup } from '../hooks'; + +// Admin-only: create a view-group. External projects and workspaces are later +// shared TO a group, granting its members read/search access. +export function CreateGroupDialog() { + const [open, setOpen] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const create = useCreateGroup(); + + function reset() { + setName(''); + setDescription(''); + create.reset(); + } + + async function onSubmit() { + const trimmed = name.trim(); + if (!trimmed) return; + try { + await create.mutateAsync({ name: trimmed, description: description.trim() }); + toast.success('Group created'); + setOpen(false); + reset(); + } catch (err) { + toast.error('Failed to create group', { + description: err instanceof ApiError ? err.detail : String(err), + }); + } + } + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + + + + + + Create view-group + + A view-group is a set of users. Share external projects and + workspaces to it to grant the members read/search access. + + +
+
+ + setName(e.target.value)} + placeholder="e.g. Product agents" + /> +
+
+ + setDescription(e.target.value)} + placeholder="Optional" + /> +
+
+ + + + +
+
+ ); +} diff --git a/server/dashboard/src/modules/groups/components/GroupMembersDialog.tsx b/server/dashboard/src/modules/groups/components/GroupMembersDialog.tsx new file mode 100644 index 0000000..6646ec3 --- /dev/null +++ b/server/dashboard/src/modules/groups/components/GroupMembersDialog.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { Loader2, Trash2, UserPlus } from 'lucide-react'; +import { toast } from 'sonner'; +import { ApiError } from '@/api/client'; +import { Button } from '@/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/ui/select'; +import type { Group } from '@/api/types'; +import { useUsers } from '@/modules/users/hooks'; +import { useAddGroupMember, useGroupMembers, useRemoveGroupMember } from '../hooks'; + +// Admin-only: manage which users belong to a view-group. Members get +// read/search access to whatever is shared to the group. +export function GroupMembersDialog({ group }: { group: Group }) { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(''); + const members = useGroupMembers(group.id, open); + const users = useUsers(); + const addMember = useAddGroupMember(); + const removeMember = useRemoveGroupMember(); + + const memberIds = new Set((members.data?.members ?? []).map((m) => m.user_id)); + const candidates = (users.data?.users ?? []).filter((u) => !memberIds.has(u.id)); + + async function add() { + if (!selected) return; + try { + await addMember.mutateAsync({ id: group.id, userId: selected }); + setSelected(''); + } catch (err) { + toast.error('Failed to add member', { + description: err instanceof ApiError ? err.detail : String(err), + }); + } + } + + async function remove(userId: string) { + try { + await removeMember.mutateAsync({ id: group.id, userId }); + } catch (err) { + toast.error('Failed to remove member', { + description: err instanceof ApiError ? err.detail : String(err), + }); + } + } + + return ( + + + + + + + {group.name} — members + + Members can read and search every external project and workspace + shared to this group. + + + +
+
+ +
+ +
+ +
+ {members.isLoading ? ( +

Loading…

+ ) : (members.data?.members ?? []).length === 0 ? ( +

No members yet.

+ ) : ( + members.data!.members.map((m) => ( +
+ + {m.email} + {m.role} + + +
+ )) + )} +
+
+
+ ); +} diff --git a/server/dashboard/src/modules/groups/components/ShareToGroupCard.tsx b/server/dashboard/src/modules/groups/components/ShareToGroupCard.tsx new file mode 100644 index 0000000..1f83d32 --- /dev/null +++ b/server/dashboard/src/modules/groups/components/ShareToGroupCard.tsx @@ -0,0 +1,67 @@ +import type { Group } from '@/api/types'; +import { Button } from '@/ui/button'; +import { Card, CardContent } from '@/ui/card'; + +interface Props { + heading: string; + description: string; + /** Groups the caller may share to. */ + groups: Group[]; + /** IDs currently shared. */ + sharedIds: string[]; + onShare: (groupId: string) => void; + onUnshare: (groupId: string) => void; + busy?: boolean; + emptyText?: string; +} + +// Reused by the project detail (admin, external projects) and workspace detail +// (owner shares to their own groups; admin to any). Pure presentational — +// callers wire the mutations + supply the candidate group list. +export function ShareToGroupCard({ + heading, + description, + groups, + sharedIds, + onShare, + onUnshare, + busy, + emptyText = 'No view-groups available.', +}: Props) { + const shared = new Set(sharedIds); + return ( +
+

{heading}

+

{description}

+ {groups.length === 0 ? ( +

{emptyText}

+ ) : ( + + + {groups.map((g) => { + const isShared = shared.has(g.id); + return ( +
+
+
{g.name}
+ {g.description ? ( +
{g.description}
+ ) : null} +
+ +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/server/dashboard/src/modules/groups/hooks.ts b/server/dashboard/src/modules/groups/hooks.ts new file mode 100644 index 0000000..6170567 --- /dev/null +++ b/server/dashboard/src/modules/groups/hooks.ts @@ -0,0 +1,78 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from '@/api/client'; +import type { + CreateGroupRequest, + Group, + GroupListResponse, + GroupMemberListResponse, + UpdateGroupRequest, +} from '@/api/types'; + +export const groupKeys = { + all: ['groups'] as const, + members: (id: string) => ['groups', id, 'members'] as const, +}; + +export function useGroups() { + return useQuery({ + queryKey: groupKeys.all, + queryFn: ({ signal }) => api.get('/groups', { signal }), + }); +} + +export function useCreateGroup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (body: CreateGroupRequest) => api.post('/groups', body), + onSuccess: () => qc.invalidateQueries({ queryKey: groupKeys.all }), + }); +} + +export function useUpdateGroup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: UpdateGroupRequest }) => + api.patch(`/groups/${id}`, body), + onSuccess: () => qc.invalidateQueries({ queryKey: groupKeys.all }), + }); +} + +export function useDeleteGroup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.delete(`/groups/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: groupKeys.all }), + }); +} + +export function useGroupMembers(id: string, enabled = true) { + return useQuery({ + queryKey: groupKeys.members(id), + queryFn: ({ signal }) => api.get(`/groups/${id}/members`, { signal }), + enabled, + }); +} + +export function useAddGroupMember() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, userId }: { id: string; userId: string }) => + api.post(`/groups/${id}/members`, { user_id: userId }), + onSuccess: (_data, { id }) => { + void qc.invalidateQueries({ queryKey: groupKeys.members(id) }); + void qc.invalidateQueries({ queryKey: groupKeys.all }); + }, + }); +} + +export function useRemoveGroupMember() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, userId }: { id: string; userId: string }) => + api.delete(`/groups/${id}/members/${userId}`), + onSuccess: (_data, { id }) => { + void qc.invalidateQueries({ queryKey: groupKeys.members(id) }); + void qc.invalidateQueries({ queryKey: groupKeys.all }); + }, + }); +} diff --git a/server/dashboard/src/modules/groups/index.ts b/server/dashboard/src/modules/groups/index.ts new file mode 100644 index 0000000..405bdec --- /dev/null +++ b/server/dashboard/src/modules/groups/index.ts @@ -0,0 +1,13 @@ +import { UsersRound } from 'lucide-react'; +import type { Module } from '../types'; +import GroupsPage from './GroupsPage'; + +export const GroupsModule: Module = { + id: 'groups', + label: 'View Groups', + icon: UsersRound, + path: '/groups', + element: GroupsPage, + requiredRole: 'admin', + weight: 45, +}; diff --git a/server/dashboard/src/modules/groups/shareHooks.ts b/server/dashboard/src/modules/groups/shareHooks.ts new file mode 100644 index 0000000..6ca4b8f --- /dev/null +++ b/server/dashboard/src/modules/groups/shareHooks.ts @@ -0,0 +1,67 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api } from '@/api/client'; +import type { GroupIdListResponse } from '@/api/types'; + +// Project ↔ group shares (admin only; external projects). +export function useProjectShares(hash: string, enabled = true) { + return useQuery({ + queryKey: ['project-shares', hash], + queryFn: ({ signal }) => api.get(`/projects/${hash}/shares`, { signal }), + enabled, + }); +} + +export function useShareProject() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ hash, groupId }: { hash: string; groupId: string }) => + api.post(`/projects/${hash}/shares`, { group_id: groupId }), + onSuccess: (_d, { hash }) => qc.invalidateQueries({ queryKey: ['project-shares', hash] }), + }); +} + +export function useUnshareProject() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ hash, groupId }: { hash: string; groupId: string }) => + api.delete(`/projects/${hash}/shares/${groupId}`), + onSuccess: (_d, { hash }) => qc.invalidateQueries({ queryKey: ['project-shares', hash] }), + }); +} + +// Workspace ↔ group shares (owner-in-group or admin). +export function useWorkspaceShares(id: string, enabled = true) { + return useQuery({ + queryKey: ['workspace-shares', id], + queryFn: ({ signal }) => api.get(`/workspaces/${id}/shares`, { signal }), + enabled, + }); +} + +export function useShareWorkspace() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, groupId }: { id: string; groupId: string }) => + api.post(`/workspaces/${id}/shares`, { group_id: groupId }), + onSuccess: (_d, { id }) => qc.invalidateQueries({ queryKey: ['workspace-shares', id] }), + }); +} + +export function useUnshareWorkspace() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, groupId }: { id: string; groupId: string }) => + api.delete(`/workspaces/${id}/shares/${groupId}`), + onSuccess: (_d, { id }) => qc.invalidateQueries({ queryKey: ['workspace-shares', id] }), + }); +} + +// Reassign a local project's owner (admin only). +export function useReassignProjectOwner() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ hash, ownerUserId }: { hash: string; ownerUserId: string }) => + api.put(`/projects/${hash}/owner`, { owner_user_id: ownerUserId }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['projects'] }), + }); +} diff --git a/server/dashboard/src/modules/home/HomePage.tsx b/server/dashboard/src/modules/home/HomePage.tsx index e8511dd..b119798 100644 --- a/server/dashboard/src/modules/home/HomePage.tsx +++ b/server/dashboard/src/modules/home/HomePage.tsx @@ -22,12 +22,12 @@ const DESCRIPTIONS: Record = { export default function HomePage() { const { user } = useAuth(); - const role = user?.role ?? 'viewer'; + const role = user?.role ?? 'user'; const { data: status } = useServerStatus(); const cards = MODULES.filter((m) => m.id !== 'home').filter((m) => { if (!m.requiredRole) return true; - if (m.requiredRole === 'viewer') return true; + if (m.requiredRole === 'user') return true; return role === 'admin'; }); diff --git a/server/dashboard/src/modules/projects/ProjectDetailPage.tsx b/server/dashboard/src/modules/projects/ProjectDetailPage.tsx index 71800f2..fb14bf6 100644 --- a/server/dashboard/src/modules/projects/ProjectDetailPage.tsx +++ b/server/dashboard/src/modules/projects/ProjectDetailPage.tsx @@ -14,6 +14,8 @@ import { DeleteProjectDialog } from './components/DeleteProjectDialog'; import { ForceStopButton } from './components/ForceStopButton'; import { IndexingProgressCard } from './components/IndexingProgressCard'; import { ProjectInfoCard } from './components/ProjectInfoCard'; +import { ProjectShareCard } from './components/ProjectShareCard'; +import { ReassignOwnerDialog } from './components/ReassignOwnerDialog'; import { ReindexProjectButton } from './components/ReindexProjectButton'; import { SyncProjectButton } from './components/SyncProjectButton'; import { SyncSettingsCard } from './components/SyncSettingsCard'; @@ -60,6 +62,7 @@ export function ProjectDetailPage() { const s = summary.data; const drift = !!p.indexed_with_model && !!currentModel && p.indexed_with_model !== currentModel; const isExternal = p.host_path.startsWith('github.com/'); + const displayPath = p.display_path ?? p.host_path; return (
@@ -84,8 +87,11 @@ export function ProjectDetailPage() { ))}

- {p.host_path} + {displayPath}

+ {p.machine_label ? ( +
on {p.machine_label}
+ ) : null}
Hash: {p.path_hash} Created {formatRelative(p.created_at)} @@ -104,15 +110,18 @@ export function ProjectDetailPage() { {isExternal ? ( <> - - + + {p.status === 'indexing' ? ( - + ) : null} ) : null} + {isAdmin && !isExternal ? ( + + ) : null} {isAdmin ? ( - + ) : null}
@@ -146,7 +155,7 @@ export function ProjectDetailPage() {
Reindex from your terminal:{' '} - cix reindex {p.host_path} + cix reindex {displayPath}
@@ -163,6 +172,8 @@ export function ProjectDetailPage() { {isExternal ? : null} + {isExternal && isAdmin ? : null} +

Workspaces

{workspaces.isLoading ? ( diff --git a/server/dashboard/src/modules/projects/components/ProjectCard.tsx b/server/dashboard/src/modules/projects/components/ProjectCard.tsx index 2ffd02c..54be724 100644 --- a/server/dashboard/src/modules/projects/components/ProjectCard.tsx +++ b/server/dashboard/src/modules/projects/components/ProjectCard.tsx @@ -45,10 +45,13 @@ export function ProjectCard({ project }: { project: Project }) {
- {basename(project.host_path)} + {basename(project.display_path ?? project.host_path)}
-
- {project.host_path} +
+ {project.display_path ?? project.host_path}
@@ -100,7 +103,7 @@ export function ProjectCard({ project }: { project: Project }) { > + toast.error(`Failed to ${action}`, { + description: err instanceof ApiError ? err.detail : String(err), + }); + } + + return ( + + share.mutate({ hash, groupId }, { onError: onError('share project') }) + } + onUnshare={(groupId) => + unshare.mutate({ hash, groupId }, { onError: onError('unshare project') }) + } + /> + ); +} diff --git a/server/dashboard/src/modules/projects/components/ReassignOwnerDialog.tsx b/server/dashboard/src/modules/projects/components/ReassignOwnerDialog.tsx new file mode 100644 index 0000000..5ef7ff3 --- /dev/null +++ b/server/dashboard/src/modules/projects/components/ReassignOwnerDialog.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Loader2, UserCog } from 'lucide-react'; +import { toast } from 'sonner'; +import { ApiError } from '@/api/client'; +import { Button } from '@/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/ui/select'; +import { useUsers } from '@/modules/users/hooks'; +import { useReassignProjectOwner } from '@/modules/groups/shareHooks'; + +// Admin-only: reassign the owner of a LOCAL project. External projects are +// ownerless (the server rejects them with 422). +export function ReassignOwnerDialog({ hash, currentOwnerId }: { hash: string; currentOwnerId?: string | null }) { + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(''); + const users = useUsers(); + const reassign = useReassignProjectOwner(); + + async function onSubmit() { + if (!selected) return; + try { + await reassign.mutateAsync({ hash, ownerUserId: selected }); + toast.success('Owner reassigned'); + setOpen(false); + setSelected(''); + } catch (err) { + toast.error('Failed to reassign owner', { + description: err instanceof ApiError ? err.detail : String(err), + }); + } + } + + return ( + + + + + + + Reassign project owner + + Transfer this local project to another user. They will have full + control; the previous owner loses access unless they are an admin. + + + + + + + + + + ); +} diff --git a/server/dashboard/src/modules/registry.ts b/server/dashboard/src/modules/registry.ts index ee26748..90bce6e 100644 --- a/server/dashboard/src/modules/registry.ts +++ b/server/dashboard/src/modules/registry.ts @@ -1,5 +1,6 @@ import { ApiKeysModule } from './api-keys'; import { GithubIntegrationModule } from './github-integration'; +import { GroupsModule } from './groups'; import { HomeModule } from './home'; import { ManagedTunnelsModule } from './managed-tunnels'; import { ProjectsModule } from './projects'; @@ -27,6 +28,7 @@ export const MODULES: Module[] = [ GithubIntegrationModule, ManagedTunnelsModule, UsersModule, + GroupsModule, SettingsModule, ServerModule, ].sort((a, b) => (a.weight ?? 100) - (b.weight ?? 100)); diff --git a/server/dashboard/src/modules/search/components/Filters.tsx b/server/dashboard/src/modules/search/components/Filters.tsx index 45182fe..854c700 100644 --- a/server/dashboard/src/modules/search/components/Filters.tsx +++ b/server/dashboard/src/modules/search/components/Filters.tsx @@ -42,7 +42,7 @@ export function ProjectPicker({ ) : ( projects.map((p) => ( - {p.host_path} + {p.display_path ?? p.host_path} )) )} diff --git a/server/dashboard/src/modules/types.ts b/server/dashboard/src/modules/types.ts index 03c3143..f56cb03 100644 --- a/server/dashboard/src/modules/types.ts +++ b/server/dashboard/src/modules/types.ts @@ -18,7 +18,7 @@ export interface Module { path: string; /** Top-level page rendered for this module. Owns its own internal routes. */ element: ComponentType; - /** Minimum role required to *see* this module in the sidebar. Default: viewer. */ + /** Minimum role required to *see* this module in the sidebar. Default: user. */ requiredRole?: Role; /** Sort order in the sidebar — lower comes first. Default: 100. */ weight?: number; diff --git a/server/dashboard/src/modules/users/components/InviteUserDialog.tsx b/server/dashboard/src/modules/users/components/InviteUserDialog.tsx index 458df94..fc4cd7e 100644 --- a/server/dashboard/src/modules/users/components/InviteUserDialog.tsx +++ b/server/dashboard/src/modules/users/components/InviteUserDialog.tsx @@ -31,13 +31,13 @@ import { useCreateUser } from '../hooks'; export function InviteUserDialog() { const [open, setOpen] = useState(false); const [email, setEmail] = useState(''); - const [role, setRole] = useState('viewer'); + const [role, setRole] = useState('user'); const [pw, setPw] = useState(''); const create = useCreateUser(); function reset() { setEmail(''); - setRole('viewer'); + setRole('user'); setPw(''); create.reset(); } @@ -105,7 +105,7 @@ export function InviteUserDialog() { - Viewer (read-only) + User Admin (full access) diff --git a/server/dashboard/src/modules/users/components/UserRoleSelect.tsx b/server/dashboard/src/modules/users/components/UserRoleSelect.tsx index 18de764..f49a1ec 100644 --- a/server/dashboard/src/modules/users/components/UserRoleSelect.tsx +++ b/server/dashboard/src/modules/users/components/UserRoleSelect.tsx @@ -44,7 +44,7 @@ export function UserRoleSelect({ - Viewer + User Admin diff --git a/server/dashboard/src/modules/workspaces/WorkspaceDetailPage.tsx b/server/dashboard/src/modules/workspaces/WorkspaceDetailPage.tsx index 99c3bf4..852cd7b 100644 --- a/server/dashboard/src/modules/workspaces/WorkspaceDetailPage.tsx +++ b/server/dashboard/src/modules/workspaces/WorkspaceDetailPage.tsx @@ -2,12 +2,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { AlertCircle, ChevronLeft, Trash2 } from 'lucide-react'; import { ApiError, api } from '@/api/client'; +import { useAuth } from '@/auth/useAuth'; import { Alert, AlertDescription, AlertTitle } from '@/ui/alert'; import { Button } from '@/ui/button'; import { Skeleton } from '@/ui/skeleton'; import { AddExistingProjectDialog } from './components/AddExistingProjectDialog'; import { AddRepoDialog } from './components/AddRepoDialog'; import { WorkspaceProjectRow } from './components/WorkspaceProjectRow'; +import { WorkspaceShareCard } from './components/WorkspaceShareCard'; import { WorkspaceSearchDialog } from './components/WorkspaceSearchDialog'; import { isInFlight } from './types'; import type { @@ -22,6 +24,7 @@ const POLL_MS = 3000; export function WorkspaceDetailPage() { const { id = '' } = useParams<{ id: string }>(); const navigate = useNavigate(); + const { user } = useAuth(); const [workspace, setWorkspace] = useState(null); const [projects, setProjects] = useState(null); const [error, setError] = useState(null); @@ -213,6 +216,10 @@ export function WorkspaceDetailPage() {
)}
+ + {user && (user.role === 'admin' || workspace.owner_user_id === user.id) ? ( + + ) : null} ); } diff --git a/server/dashboard/src/modules/workspaces/components/WorkspaceShareCard.tsx b/server/dashboard/src/modules/workspaces/components/WorkspaceShareCard.tsx new file mode 100644 index 0000000..52c1f45 --- /dev/null +++ b/server/dashboard/src/modules/workspaces/components/WorkspaceShareCard.tsx @@ -0,0 +1,43 @@ +import { toast } from 'sonner'; +import { ApiError } from '@/api/client'; +import { useGroups } from '@/modules/groups/hooks'; +import { ShareToGroupCard } from '@/modules/groups/components/ShareToGroupCard'; +import { + useShareWorkspace, + useUnshareWorkspace, + useWorkspaceShares, +} from '@/modules/groups/shareHooks'; + +// Shown to the workspace owner / admins. GET /groups is member-scoped on the +// server, so the candidate list is exactly the groups the caller may share to +// (their own for a user, all for an admin). +export function WorkspaceShareCard({ workspaceId }: { workspaceId: string }) { + const groups = useGroups(); + const shares = useWorkspaceShares(workspaceId); + const share = useShareWorkspace(); + const unshare = useUnshareWorkspace(); + + function onError(action: string) { + return (err: unknown) => + toast.error(`Failed to ${action}`, { + description: err instanceof ApiError ? err.detail : String(err), + }); + } + + return ( + + share.mutate({ id: workspaceId, groupId }, { onError: onError('share workspace') }) + } + onUnshare={(groupId) => + unshare.mutate({ id: workspaceId, groupId }, { onError: onError('unshare workspace') }) + } + /> + ); +} diff --git a/server/dashboard/src/modules/workspaces/types.ts b/server/dashboard/src/modules/workspaces/types.ts index 7b72222..eb243f8 100644 --- a/server/dashboard/src/modules/workspaces/types.ts +++ b/server/dashboard/src/modules/workspaces/types.ts @@ -9,6 +9,7 @@ export type Workspace = { description: string; created_at: string; updated_at: string; + owner_user_id?: string | null; }; export type WorkspaceListResponse = { diff --git a/server/internal/access/access.go b/server/internal/access/access.go new file mode 100644 index 0000000..dae8881 --- /dev/null +++ b/server/internal/access/access.go @@ -0,0 +1,177 @@ +// Package access resolves the auth model's resource visibility: who can see a +// project or workspace, and the share junctions (project_group_shares, +// workspace_group_shares) that grant view-group members read access. +// +// These helpers express the rules for a REGULAR user. Admins bypass them +// entirely (they see everything) — the HTTP layer checks role first and only +// calls in here for non-admins. +// +// Access rule for a project P / user U (admin already handled): +// - P.owner_user_id == U → full access (personal project) +// - P shared to a view-group U belongs to → read/search (external project) +// - else → hidden +// +// Workspace W / user U: visible if owner or shared to a group U belongs to. +// Per-project access is always re-checked when listing/searching a workspace, +// so a hidden project is simply dropped from the result. +package access + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// --- project share CRUD --- + +// ShareProjectToGroup grants a view-group read access to an external project. +// Idempotent (INSERT OR IGNORE). +func ShareProjectToGroup(ctx context.Context, db *sql.DB, projectPath, groupID string) error { + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := db.ExecContext(ctx, + `INSERT OR IGNORE INTO project_group_shares (project_path, group_id, created_at) + VALUES (?, ?, ?)`, projectPath, groupID, now) + if err != nil { + return fmt.Errorf("share project to group: %w", err) + } + return nil +} + +// UnshareProjectFromGroup revokes a project↔group share. Idempotent. +func UnshareProjectFromGroup(ctx context.Context, db *sql.DB, projectPath, groupID string) error { + _, err := db.ExecContext(ctx, + `DELETE FROM project_group_shares WHERE project_path = ? AND group_id = ?`, + projectPath, groupID) + if err != nil { + return fmt.Errorf("unshare project from group: %w", err) + } + return nil +} + +// ListProjectShareGroupIDs returns the group ids a project is shared to. +func ListProjectShareGroupIDs(ctx context.Context, db *sql.DB, projectPath string) ([]string, error) { + return queryIDs(ctx, db, + `SELECT group_id FROM project_group_shares WHERE project_path = ? ORDER BY created_at ASC`, + projectPath) +} + +// --- workspace share CRUD --- + +// ShareWorkspaceToGroup shares a workspace with a view-group. Idempotent. +func ShareWorkspaceToGroup(ctx context.Context, db *sql.DB, workspaceID, groupID string) error { + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := db.ExecContext(ctx, + `INSERT OR IGNORE INTO workspace_group_shares (workspace_id, group_id, created_at) + VALUES (?, ?, ?)`, workspaceID, groupID, now) + if err != nil { + return fmt.Errorf("share workspace to group: %w", err) + } + return nil +} + +// UnshareWorkspaceFromGroup revokes a workspace↔group share. Idempotent. +func UnshareWorkspaceFromGroup(ctx context.Context, db *sql.DB, workspaceID, groupID string) error { + _, err := db.ExecContext(ctx, + `DELETE FROM workspace_group_shares WHERE workspace_id = ? AND group_id = ?`, + workspaceID, groupID) + if err != nil { + return fmt.Errorf("unshare workspace from group: %w", err) + } + return nil +} + +// ListWorkspaceShareGroupIDs returns the group ids a workspace is shared to. +func ListWorkspaceShareGroupIDs(ctx context.Context, db *sql.DB, workspaceID string) ([]string, error) { + return queryIDs(ctx, db, + `SELECT group_id FROM workspace_group_shares WHERE workspace_id = ? ORDER BY created_at ASC`, + workspaceID) +} + +// --- resolution --- + +// ProjectSharedToUser reports whether the project is shared to any view-group +// the user belongs to. +func ProjectSharedToUser(ctx context.Context, db *sql.DB, projectPath, userID string) (bool, error) { + return exists(ctx, db, ` + SELECT 1 + FROM project_group_shares s + JOIN view_group_members m ON m.group_id = s.group_id + WHERE s.project_path = ? AND m.user_id = ? + LIMIT 1`, projectPath, userID) +} + +// WorkspaceSharedToUser reports whether the workspace is shared to any +// view-group the user belongs to. +func WorkspaceSharedToUser(ctx context.Context, db *sql.DB, workspaceID, userID string) (bool, error) { + return exists(ctx, db, ` + SELECT 1 + FROM workspace_group_shares s + JOIN view_group_members m ON m.group_id = s.group_id + WHERE s.workspace_id = ? AND m.user_id = ? + LIMIT 1`, workspaceID, userID) +} + +// IsProjectExternal reports whether a project has a git_repos peer (i.e. is an +// external, server-cloned repo rather than a personal local project). +func IsProjectExternal(ctx context.Context, db *sql.DB, projectPath string) (bool, error) { + return exists(ctx, db, + `SELECT 1 FROM git_repos WHERE project_path = ? LIMIT 1`, projectPath) +} + +// AccessibleProjectHostPaths returns the host_paths a regular user may see: +// the projects they own UNION the external projects shared to a group they +// belong to. Used to filter the project list. +func AccessibleProjectHostPaths(ctx context.Context, db *sql.DB, userID string) ([]string, error) { + return queryIDs(ctx, db, ` + SELECT host_path FROM projects WHERE owner_user_id = ? + UNION + SELECT s.project_path + FROM project_group_shares s + JOIN view_group_members m ON m.group_id = s.group_id + WHERE m.user_id = ?`, userID, userID) +} + +// VisibleWorkspaceIDs returns the workspace ids a regular user may see: the +// workspaces they own UNION the workspaces shared to a group they belong to. +func VisibleWorkspaceIDs(ctx context.Context, db *sql.DB, userID string) ([]string, error) { + return queryIDs(ctx, db, ` + SELECT id FROM workspaces WHERE owner_user_id = ? + UNION + SELECT s.workspace_id + FROM workspace_group_shares s + JOIN view_group_members m ON m.group_id = s.group_id + WHERE m.user_id = ?`, userID, userID) +} + +// --- helpers --- + +func exists(ctx context.Context, db *sql.DB, query string, args ...any) (bool, error) { + var one int + err := db.QueryRowContext(ctx, query, args...).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("exists check: %w", err) + } + return true, nil +} + +func queryIDs(ctx context.Context, db *sql.DB, query string, args ...any) ([]string, error) { + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query ids: %w", err) + } + defer rows.Close() + 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() +} diff --git a/server/internal/access/access_test.go b/server/internal/access/access_test.go new file mode 100644 index 0000000..26a2a9e --- /dev/null +++ b/server/internal/access/access_test.go @@ -0,0 +1,115 @@ +package access + +import ( + "context" + "slices" + "testing" + + "github.com/dvcdsys/code-index/server/internal/db" + "github.com/dvcdsys/code-index/server/internal/groups" + "github.com/dvcdsys/code-index/server/internal/projects" + "github.com/dvcdsys/code-index/server/internal/users" + "github.com/dvcdsys/code-index/server/internal/workspaces" +) + +func TestProjectAndWorkspaceSharing(t *testing.T) { + ctx := context.Background() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer database.Close() + + us := users.New(database) + gs := groups.New(database) + ws := workspaces.New(database) + + alice, _ := us.Create(ctx, "alice@x.com", "password1234", users.RoleUser, false) + bob, _ := us.Create(ctx, "bob@x.com", "password1234", users.RoleUser, false) + + // Personal project owned by alice. + if _, err := projects.Create(ctx, database, projects.CreateRequest{ + HostPath: "/alice/local", OwnerUserID: alice.ID, + }); err != nil { + t.Fatalf("create local project: %v", err) + } + + // External project (ownerless) + its git_repos peer. + if _, err := projects.Create(ctx, database, projects.CreateRequest{ + HostPath: "github.com/x/y@main", + }); err != nil { + t.Fatalf("create external project: %v", err) + } + if _, err := database.ExecContext(ctx, + `INSERT INTO git_repos (project_path, github_url, branch, webhook_secret, created_at, updated_at) + VALUES ('github.com/x/y@main', 'https://github.com/x/y', 'main', 's', '2024-01-01', '2024-01-01')`, + ); err != nil { + t.Fatalf("insert git_repos: %v", err) + } + + // Group with bob as member. + g, _ := gs.Create(ctx, "Product", "") + if err := gs.AddMember(ctx, g.ID, bob.ID); err != nil { + t.Fatalf("add member: %v", err) + } + + // IsProjectExternal. + if ext, _ := IsProjectExternal(ctx, database, "github.com/x/y@main"); !ext { + t.Error("github.com/x/y@main should be external") + } + if ext, _ := IsProjectExternal(ctx, database, "/alice/local"); ext { + t.Error("/alice/local should not be external") + } + + // Before sharing: bob cannot access the external project. + if shared, _ := ProjectSharedToUser(ctx, database, "github.com/x/y@main", bob.ID); shared { + t.Error("bob should not have access before share") + } + + // Share external project to the group. + if err := ShareProjectToGroup(ctx, database, "github.com/x/y@main", g.ID); err != nil { + t.Fatalf("share project: %v", err) + } + if shared, _ := ProjectSharedToUser(ctx, database, "github.com/x/y@main", bob.ID); !shared { + t.Error("bob should have access after share") + } + // alice (not in group) still has no group-share access to it. + if shared, _ := ProjectSharedToUser(ctx, database, "github.com/x/y@main", alice.ID); shared { + t.Error("alice (non-member) should not have group-share access") + } + + // AccessibleProjectHostPaths: alice sees her own local; bob sees the shared external. + aliceHosts, _ := AccessibleProjectHostPaths(ctx, database, alice.ID) + if !contains(aliceHosts, "/alice/local") || contains(aliceHosts, "github.com/x/y@main") { + t.Errorf("alice accessible = %v", aliceHosts) + } + bobHosts, _ := AccessibleProjectHostPaths(ctx, database, bob.ID) + if !contains(bobHosts, "github.com/x/y@main") || contains(bobHosts, "/alice/local") { + t.Errorf("bob accessible = %v", bobHosts) + } + + // Workspace owned by alice, shared to the group. + w, _ := ws.Create(ctx, "team", "") + if err := ShareWorkspaceToGroup(ctx, database, w.ID, g.ID); err != nil { + t.Fatalf("share workspace: %v", err) + } + if seen, _ := WorkspaceSharedToUser(ctx, database, w.ID, bob.ID); !seen { + t.Error("bob should see shared workspace") + } + visForBob, _ := VisibleWorkspaceIDs(ctx, database, bob.ID) + if !contains(visForBob, w.ID) { + t.Errorf("bob visible workspaces = %v", visForBob) + } + + // Unshare removes access. + if err := UnshareProjectFromGroup(ctx, database, "github.com/x/y@main", g.ID); err != nil { + t.Fatalf("unshare: %v", err) + } + if shared, _ := ProjectSharedToUser(ctx, database, "github.com/x/y@main", bob.ID); shared { + t.Error("bob should lose access after unshare") + } +} + +func contains(xs []string, want string) bool { + return slices.Contains(xs, want) +} diff --git a/server/internal/db/db.go b/server/internal/db/db.go index 47133f3..3e2bd8a 100644 --- a/server/internal/db/db.go +++ b/server/internal/db/db.go @@ -62,6 +62,8 @@ var registeredMigrations = []migration{ {7, "git_repos_indexed_sha", func(db *sql.DB, _ OpenOptions) error { return migrateGitReposIndexedSHA(db) }}, {8, "tunnel_config", func(db *sql.DB, _ OpenOptions) error { return migrateTunnelConfig(db) }}, {9, "git_repos_polling", func(db *sql.DB, _ OpenOptions) error { return migrateGitReposPolling(db) }}, + {10, "auth_groups_ownership", func(db *sql.DB, _ OpenOptions) error { return migrateAuthGroupsOwnership(db) }}, + {11, "project_machine_identity", func(db *sql.DB, _ OpenOptions) error { return migrateProjectMachineIdentity(db) }}, } // DriverName is the registered database/sql driver name for modernc.org/sqlite. @@ -314,6 +316,165 @@ func migrateGitReposPolling(db *sql.DB) error { return nil } +// columnExists reports whether table has a column named col. Returns false +// (no error) when the table itself does not exist. +func columnExists(db *sql.DB, table, col string) (bool, error) { + rows, err := db.Query(`PRAGMA table_info(` + table + `)`) + if err != nil { + return false, fmt.Errorf("table_info %s: %w", table, err) + } + defer rows.Close() + for rows.Next() { + var ( + cid int + name, typ string + notnull, pk int + dflt sql.NullString + ) + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + return false, err + } + if name == col { + return true, nil + } + } + return false, rows.Err() +} + +// migrateProjectMachineIdentity adds display_path / machine_id / machine_label +// to projects so local-project identity can be namespaced per machine +// (host_path becomes "local:{machine_id}:{path}" for projects created after +// this change; the CLI computes the matching path_hash). Existing rows keep +// their host_path as-is and get display_path backfilled from it so the +// dashboard has something to show — they remain reachable until re-init. +// Idempotent via columnExists. +func migrateProjectMachineIdentity(db *sql.DB) error { + adds := []struct{ col, ddl string }{ + {"display_path", `ALTER TABLE projects ADD COLUMN display_path TEXT`}, + {"machine_id", `ALTER TABLE projects ADD COLUMN machine_id TEXT`}, + {"machine_label", `ALTER TABLE projects ADD COLUMN machine_label TEXT`}, + } + for _, a := range adds { + have, err := columnExists(db, "projects", a.col) + if err != nil { + return err + } + if have { + continue + } + if _, err := db.Exec(a.ddl); err != nil { + return fmt.Errorf("add projects.%s: %w", a.col, err) + } + } + // Backfill display_path = host_path for any row missing it (existing + // projects predate the column; their host_path is still the real path). + if _, err := db.Exec( + `UPDATE projects SET display_path = host_path WHERE display_path IS NULL`, + ); err != nil { + return fmt.Errorf("backfill display_path: %w", err) + } + return nil +} + +// migrateAuthGroupsOwnership upgrades pre-auth-model databases to the +// owner + view-group sharing model: +// +// - adds projects.owner_user_id and workspaces.owner_user_id (NULL on +// existing rows); +// - creates view_groups / view_group_members / project_group_shares / +// workspace_group_shares (matching schema.go; CREATE TABLE IF NOT EXISTS +// so a fresh DB that already has them is a no-op); +// - one-time data backfill: every existing user becomes admin (pre-migration +// everyone had full access, so this preserves it — the operator demotes +// afterwards); existing LOCAL projects (no git_repos row) and ALL existing +// workspaces are assigned to the first active admin; existing EXTERNAL +// projects intentionally stay ownerless (NULL). +// +// Idempotent: column adds are guarded by columnExists, table creates use +// IF NOT EXISTS. The data backfill is naturally a no-op on a fresh DB (no +// users/projects yet — bootstrap runs after Open) and runs exactly once on an +// upgraded DB because schema_migrations records version 10. +func migrateAuthGroupsOwnership(db *sql.DB) error { + // 1. owner columns (ALTER ADD COLUMN with a NULL-default REFERENCES clause + // is permitted by SQLite). + if have, err := columnExists(db, "projects", "owner_user_id"); err != nil { + return err + } else if !have { + if _, err := db.Exec( + `ALTER TABLE projects ADD COLUMN owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL`, + ); err != nil { + return fmt.Errorf("add projects.owner_user_id: %w", err) + } + } + if have, err := columnExists(db, "workspaces", "owner_user_id"); err != nil { + return err + } else if !have { + if _, err := db.Exec( + `ALTER TABLE workspaces ADD COLUMN owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL`, + ); err != nil { + return fmt.Errorf("add workspaces.owner_user_id: %w", err) + } + } + + // 2. view-group + sharing tables (mirror schema.go). + for _, ddl := range []string{ + `CREATE TABLE IF NOT EXISTS view_groups ( + id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS view_group_members ( + group_id TEXT NOT NULL, user_id TEXT NOT NULL, added_at TEXT NOT NULL, + PRIMARY KEY (group_id, user_id), + FOREIGN KEY (group_id) REFERENCES view_groups(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`, + `CREATE INDEX IF NOT EXISTS idx_view_group_members_user ON view_group_members(user_id)`, + `CREATE TABLE IF NOT EXISTS project_group_shares ( + project_path TEXT NOT NULL, group_id TEXT NOT NULL, created_at TEXT NOT NULL, + PRIMARY KEY (project_path, group_id), + FOREIGN KEY (project_path) REFERENCES projects(host_path) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES view_groups(id) ON DELETE CASCADE)`, + `CREATE INDEX IF NOT EXISTS idx_project_shares_group ON project_group_shares(group_id)`, + `CREATE TABLE IF NOT EXISTS workspace_group_shares ( + workspace_id TEXT NOT NULL, group_id TEXT NOT NULL, created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, group_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES view_groups(id) ON DELETE CASCADE)`, + `CREATE INDEX IF NOT EXISTS idx_workspace_shares_group ON workspace_group_shares(group_id)`, + } { + if _, err := db.Exec(ddl); err != nil { + return fmt.Errorf("create auth-model table/index: %w", err) + } + } + + // 3. one-time data backfill. + if _, err := db.Exec(`UPDATE users SET role = 'admin'`); err != nil { + return fmt.Errorf("promote existing users to admin: %w", err) + } + var firstAdmin sql.NullString + if err := db.QueryRow( + `SELECT id FROM users WHERE role = 'admin' AND disabled_at IS NULL ORDER BY created_at ASC LIMIT 1`, + ).Scan(&firstAdmin); err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("select first active admin: %w", err) + } + if firstAdmin.Valid && firstAdmin.String != "" { + // Local projects (no git_repos peer) → first admin; external stay NULL. + if _, err := db.Exec( + `UPDATE projects SET owner_user_id = ? + WHERE owner_user_id IS NULL + AND host_path NOT IN (SELECT project_path FROM git_repos)`, + firstAdmin.String, + ); err != nil { + return fmt.Errorf("backfill local project owners: %w", err) + } + if _, err := db.Exec( + `UPDATE workspaces SET owner_user_id = ? WHERE owner_user_id IS NULL`, + firstAdmin.String, + ); err != nil { + return fmt.Errorf("backfill workspace owners: %w", err) + } + } + return nil +} + // migrateDropCommunities removes the PR5–PR12 communities + // community_members tables. The PR14 fan-out search doesn't need // them; leaving them around would just confuse anyone reading the diff --git a/server/internal/db/db_test.go b/server/internal/db/db_test.go index 5e3e9ce..4b484a1 100644 --- a/server/internal/db/db_test.go +++ b/server/internal/db/db_test.go @@ -308,6 +308,129 @@ func TestOpenMigratesPreM9DB(t *testing.T) { again.Close() } +// TestOpenMigratesPreAuthModelDB simulates a pre-m10 database (no owner_user_id +// columns, users with the old 'viewer' role, one local + one external project, +// one workspace) and verifies the auth-model backfill: every user becomes +// admin, local projects + workspaces are assigned to the first active admin, +// and external projects (git_repos peer) stay ownerless. +func TestOpenMigratesPreAuthModelDB(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "pre-m10.db") + seed, err := sql.Open(DriverName, "file:"+tmp) + if err != nil { + t.Fatalf("seed Open: %v", err) + } + mustExec := func(q string, args ...any) { + t.Helper() + if _, err := seed.Exec(q, args...); err != nil { + t.Fatalf("seed exec %q: %v", q, err) + } + } + // Pre-m10 shapes: no owner_user_id anywhere; role defaults to 'viewer'. + mustExec(`CREATE TABLE users ( + id TEXT PRIMARY KEY, email TEXT NOT NULL, password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', must_change_password INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, disabled_at TEXT)`) + mustExec(`CREATE TABLE projects ( + host_path TEXT PRIMARY KEY, container_path TEXT NOT NULL, + languages TEXT DEFAULT '[]', settings TEXT DEFAULT '{}', stats TEXT DEFAULT '{}', + status TEXT DEFAULT 'created', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + last_indexed_at TEXT, path_hash TEXT)`) + mustExec(`CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`) + mustExec(`CREATE TABLE git_repos ( + project_path TEXT PRIMARY KEY, github_url TEXT NOT NULL, branch TEXT NOT NULL, + token_id TEXT, webhook_secret TEXT NOT NULL, webhook_id INTEGER, + webhook_mode TEXT NOT NULL DEFAULT 'manual', auto_webhook INTEGER NOT NULL DEFAULT 0, + last_sha TEXT, indexed_sha TEXT, last_error TEXT, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`) + + // alice is older than bob → alice is the first active admin after backfill. + mustExec(`INSERT INTO users (id, email, password_hash, role, created_at, updated_at) + VALUES ('u_alice', 'alice@x.com', 'h', 'viewer', '2024-01-01', '2024-01-01')`) + mustExec(`INSERT INTO users (id, email, password_hash, role, created_at, updated_at) + VALUES ('u_bob', 'bob@x.com', 'h', 'viewer', '2024-06-01', '2024-06-01')`) + mustExec(`INSERT INTO projects (host_path, container_path, created_at, updated_at, path_hash) + VALUES ('/local/proj', '/local/proj', '2024-02-01', '2024-02-01', 'aaaa')`) + mustExec(`INSERT INTO projects (host_path, container_path, created_at, updated_at, path_hash) + VALUES ('github.com/x/y@main', 'github.com/x/y@main', '2024-02-01', '2024-02-01', 'bbbb')`) + mustExec(`INSERT INTO git_repos (project_path, github_url, branch, webhook_secret, created_at, updated_at) + VALUES ('github.com/x/y@main', 'https://github.com/x/y', 'main', 'sekret', '2024-02-01', '2024-02-01')`) + mustExec(`INSERT INTO workspaces (id, name, created_at, updated_at) + VALUES ('ws_1', 'team', '2024-03-01', '2024-03-01')`) + seed.Close() + + database, err := Open(tmp) + if err != nil { + t.Fatalf("Open migrates pre-m10 DB: %v", err) + } + defer database.Close() + defer os.Remove(tmp) + + // 1. every user is now admin. + var nonAdmins int + if err := database.QueryRow(`SELECT COUNT(*) FROM users WHERE role <> 'admin'`).Scan(&nonAdmins); err != nil { + t.Fatalf("count non-admins: %v", err) + } + if nonAdmins != 0 { + t.Errorf("non-admin users after migration = %d, want 0", nonAdmins) + } + + // 2. local project owned by the first active admin (alice). + var localOwner sql.NullString + if err := database.QueryRow( + `SELECT owner_user_id FROM projects WHERE host_path = '/local/proj'`, + ).Scan(&localOwner); err != nil { + t.Fatalf("select local owner: %v", err) + } + if !localOwner.Valid || localOwner.String != "u_alice" { + t.Errorf("local project owner = %v, want u_alice", localOwner) + } + + // 3. external project stays ownerless. + var extOwner sql.NullString + if err := database.QueryRow( + `SELECT owner_user_id FROM projects WHERE host_path = 'github.com/x/y@main'`, + ).Scan(&extOwner); err != nil { + t.Fatalf("select external owner: %v", err) + } + if extOwner.Valid { + t.Errorf("external project owner = %q, want NULL", extOwner.String) + } + + // 4. workspace owned by the first active admin. + var wsOwner sql.NullString + if err := database.QueryRow( + `SELECT owner_user_id FROM workspaces WHERE id = 'ws_1'`, + ).Scan(&wsOwner); err != nil { + t.Fatalf("select workspace owner: %v", err) + } + if !wsOwner.Valid || wsOwner.String != "u_alice" { + t.Errorf("workspace owner = %v, want u_alice", wsOwner) + } + + // 5. new tables exist. + for _, tbl := range []string{"view_groups", "view_group_members", "project_group_shares", "workspace_group_shares"} { + var n int + if err := database.QueryRow( + `SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, tbl, + ).Scan(&n); err != nil { + t.Fatalf("check table %s: %v", tbl, err) + } + if n != 1 { + t.Errorf("table %s missing after migration", tbl) + } + } + + // 6. second Open is idempotent. + database.Close() + again, err := Open(tmp) + if err != nil { + t.Fatalf("second Open (idempotent): %v", err) + } + again.Close() +} + func TestSymbolsIndexExists(t *testing.T) { database, err := Open(":memory:") if err != nil { @@ -1098,3 +1221,49 @@ func readMigrationLedger(t *testing.T, d *sql.DB) []migrationLedgerRow { } return out } + +// TestOpenMigratesPreM11DB verifies the machine-identity migration adds +// display_path/machine_id/machine_label and backfills display_path = host_path +// for pre-existing rows. +func TestOpenMigratesPreM11DB(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "pre-m11.db") + seed, err := sql.Open(DriverName, "file:"+tmp) + if err != nil { + t.Fatalf("seed Open: %v", err) + } + if _, err := seed.Exec(`CREATE TABLE projects ( + host_path TEXT PRIMARY KEY, container_path TEXT NOT NULL, + languages TEXT DEFAULT '[]', settings TEXT DEFAULT '{}', stats TEXT DEFAULT '{}', + status TEXT DEFAULT 'created', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + last_indexed_at TEXT, path_hash TEXT)`); err != nil { + t.Fatalf("seed projects: %v", err) + } + if _, err := seed.Exec( + `INSERT INTO projects (host_path, container_path, created_at, updated_at, path_hash) + VALUES ('/Users/dev/legacy', '/Users/dev/legacy', '2024-01-01', '2024-01-01', 'cafef00dcafef00d')`, + ); err != nil { + t.Fatalf("seed row: %v", err) + } + seed.Close() + + database, err := Open(tmp) + if err != nil { + t.Fatalf("Open migrates pre-m11 DB: %v", err) + } + defer database.Close() + defer os.Remove(tmp) + + var displayPath sql.NullString + var machineID sql.NullString + if err := database.QueryRow( + `SELECT display_path, machine_id FROM projects WHERE host_path = '/Users/dev/legacy'`, + ).Scan(&displayPath, &machineID); err != nil { + t.Fatalf("select new columns: %v", err) + } + if !displayPath.Valid || displayPath.String != "/Users/dev/legacy" { + t.Errorf("display_path = %v, want /Users/dev/legacy", displayPath) + } + if machineID.Valid { + t.Errorf("legacy machine_id = %q, want NULL", machineID.String) + } +} diff --git a/server/internal/db/schema.go b/server/internal/db/schema.go index 60304ae..cbe6646 100644 --- a/server/internal/db/schema.go +++ b/server/internal/db/schema.go @@ -23,7 +23,27 @@ CREATE TABLE IF NOT EXISTS projects ( -- project was last indexed. NULL on legacy rows (pre-PR-E) until next -- reindex. Compared against the live runtime model to surface a "stale -- model" badge on the dashboard project list. - indexed_with_model TEXT + indexed_with_model TEXT, + -- owner_user_id is the user who owns this (personal, locally indexed) + -- project. NULL means ownerless: that is the canonical state for EXTERNAL + -- projects (those with a git_repos row), which are admin-administered and + -- reachable only via a view-group share. Personal projects are private to + -- their owner; admins see everything. Set on CreateProject; NULL on + -- AddGitRepo. FK SET NULL so deleting a user orphans (does not delete) the + -- project for an admin to reassign. + owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + -- host_path (PK) is the project IDENTITY, not necessarily the literal + -- filesystem path. For LOCAL projects it is namespaced as + -- "local:{machine_id}:{display_path}" so the same path on different + -- machines (different users) never collides — the CLI computes path_hash + -- from the same key. For EXTERNAL projects host_path == display_path == + -- the github.com/owner/repo@branch string. display_path is the + -- human-readable path shown in the dashboard; machine_id is the per-home + -- UUID the CLI sends (NULL for external); machine_label is os.Hostname() + -- for display only. + display_path TEXT, + machine_id TEXT, + machine_label TEXT ); -- NOTE: CREATE INDEX on path_hash is intentionally NOT here. Pre-m7 databases @@ -97,7 +117,7 @@ CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL COLLATE NOCASE, password_hash TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'viewer', + role TEXT NOT NULL DEFAULT 'user', must_change_password INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -153,14 +173,20 @@ CREATE TABLE IF NOT EXISTS runtime_settings ( -- Workspaces group indexed projects (rows in the projects table, -- optionally with their git_repos peer) for cross-project semantic -- search. Membership lives in workspace_projects; clone + webhook --- metadata lives in git_repos. Server-wide shared: every authenticated --- user can see and modify any workspace (per the chosen visibility model). +-- metadata lives in git_repos. +-- +-- owner_user_id is the user who created the workspace; visible to the owner, +-- to members of any view-group it is shared to (workspace_group_shares), and +-- to admins. A workspace is decoupled from its projects: access is always +-- evaluated per project, so a project a viewer cannot see is simply hidden +-- from the workspace listing / search. CREATE TABLE IF NOT EXISTS workspaces ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT, created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + updated_at TEXT NOT NULL, + owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL ); -- github_tokens stores GitHub Personal Access Tokens encrypted at rest with @@ -248,6 +274,52 @@ CREATE TABLE IF NOT EXISTS workspace_projects ( CREATE INDEX IF NOT EXISTS idx_workspace_projects_project ON workspace_projects(project_path); +-- view_groups + sharing (auth model). A view-group is an admin-managed set of +-- users; external projects and workspaces are shared TO a group, granting its +-- members read/search access. Group CRUD and membership are admin-only; +-- workspace owners may share their own workspaces to groups they belong to. +CREATE TABLE IF NOT EXISTS view_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS view_group_members ( + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + added_at TEXT NOT NULL, + PRIMARY KEY (group_id, user_id), + FOREIGN KEY (group_id) REFERENCES view_groups(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_view_group_members_user ON view_group_members(user_id); + +-- project_group_shares: an EXTERNAL project (git_repos peer, owner_user_id NULL) +-- shared to a view-group. Members of group_id get read/search on project_path. +CREATE TABLE IF NOT EXISTS project_group_shares ( + project_path TEXT NOT NULL, + group_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (project_path, group_id), + FOREIGN KEY (project_path) REFERENCES projects(host_path) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES view_groups(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_project_shares_group ON project_group_shares(group_id); + +-- workspace_group_shares: a workspace shared to a view-group. Visibility only; +-- the per-project access check still gates which projects a member actually sees. +CREATE TABLE IF NOT EXISTS workspace_group_shares ( + workspace_id TEXT NOT NULL, + group_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, group_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES view_groups(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_workspace_shares_group ON workspace_group_shares(group_id); + -- jobs is the persistent worker queue. Survives process restarts; one -- worker pool drains it. dedupe_key is the partial-unique mechanism that -- collapses webhook bursts (e.g. 50 push deliveries for the same repo @@ -384,6 +456,10 @@ var ExpectedTables = []string{ "github_tokens", "git_repos", "workspace_projects", + "view_groups", + "view_group_members", + "project_group_shares", + "workspace_group_shares", "jobs", "call_edges", "chunks_meta", diff --git a/server/internal/groups/groups.go b/server/internal/groups/groups.go new file mode 100644 index 0000000..baec699 --- /dev/null +++ b/server/internal/groups/groups.go @@ -0,0 +1,294 @@ +// Package groups is the service layer for view-groups: admin-managed sets of +// users used to share external projects and workspaces. A user who belongs to +// a group gets read/search access to whatever is shared to that group. +// +// Scope: group CRUD + membership. The share junctions (project_group_shares, +// workspace_group_shares) and the access resolution that consumes them live +// with the projects/workspaces access layer, not here. +package groups + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +var ( + ErrNotFound = errors.New("group not found") + ErrNameTaken = errors.New("group name already in use") + ErrNameEmpty = errors.New("group name is required") + // ErrUserNotFound is returned by AddMember when the target user does not + // exist (the FK insert fails). + ErrUserNotFound = errors.New("user not found") +) + +// Group is the metadata view of a view-group. +type Group struct { + ID string + Name string + Description string + CreatedAt time.Time + UpdatedAt time.Time +} + +// Member is one row of a group's membership, joined with the user for display. +type Member struct { + UserID string + Email string + Role string + AddedAt time.Time +} + +// Service wraps the view_groups + view_group_members tables. +type Service struct { + DB *sql.DB +} + +// New returns a Service. +func New(db *sql.DB) *Service { return &Service{DB: db} } + +// Create inserts a new group. Name must be non-empty and unique. +func (s *Service) Create(ctx context.Context, name, description string) (Group, error) { + name = strings.TrimSpace(name) + if name == "" { + return Group{}, ErrNameEmpty + } + description = strings.TrimSpace(description) + + id := uuid.NewString() + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.DB.ExecContext(ctx, + `INSERT INTO view_groups (id, name, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`, + id, name, nullableString(description), now, now, + ) + if err != nil { + if isUniqueConstraintViolation(err) { + return Group{}, ErrNameTaken + } + return Group{}, fmt.Errorf("insert group: %w", err) + } + return s.GetByID(ctx, id) +} + +// GetByID returns one group. ErrNotFound when absent. +func (s *Service) GetByID(ctx context.Context, id string) (Group, error) { + row := s.DB.QueryRowContext(ctx, + `SELECT id, name, description, created_at, updated_at + FROM view_groups WHERE id = ?`, id) + return scanRow(row) +} + +// List returns every group, newest first. +func (s *Service) List(ctx context.Context) ([]Group, error) { + rows, err := s.DB.QueryContext(ctx, + `SELECT id, name, description, created_at, updated_at + FROM view_groups ORDER BY created_at DESC`) + if err != nil { + return nil, fmt.Errorf("list groups: %w", err) + } + defer rows.Close() + return scanRows(rows) +} + +// ListForUser returns the groups the given user is a member of, newest first. +// Backs the non-admin GET /groups (so the workspace-share picker can list the +// caller's groups) and the access checks. +func (s *Service) ListForUser(ctx context.Context, userID string) ([]Group, error) { + rows, err := s.DB.QueryContext(ctx, + `SELECT g.id, g.name, g.description, g.created_at, g.updated_at + FROM view_groups g + JOIN view_group_members m ON m.group_id = g.id + WHERE m.user_id = ? + ORDER BY g.created_at DESC`, userID) + if err != nil { + return nil, fmt.Errorf("list groups for user: %w", err) + } + defer rows.Close() + return scanRows(rows) +} + +// Update changes name/description. Pointer-nil = leave alone. +func (s *Service) Update(ctx context.Context, id string, name *string, description *string) (Group, error) { + current, err := s.GetByID(ctx, id) + if err != nil { + return Group{}, err + } + newName := current.Name + if name != nil { + trimmed := strings.TrimSpace(*name) + if trimmed == "" { + return Group{}, ErrNameEmpty + } + newName = trimmed + } + newDesc := current.Description + if description != nil { + newDesc = strings.TrimSpace(*description) + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err = s.DB.ExecContext(ctx, + `UPDATE view_groups SET name = ?, description = ?, updated_at = ? WHERE id = ?`, + newName, nullableString(newDesc), now, id) + if err != nil { + if isUniqueConstraintViolation(err) { + return Group{}, ErrNameTaken + } + return Group{}, fmt.Errorf("update group: %w", err) + } + return s.GetByID(ctx, id) +} + +// Delete removes a group (cascades to memberships and shares). ErrNotFound +// when absent so the handler can choose 404 vs 204. +func (s *Service) Delete(ctx context.Context, id string) error { + res, err := s.DB.ExecContext(ctx, `DELETE FROM view_groups WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete group: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if n == 0 { + return ErrNotFound + } + return nil +} + +// AddMember adds a user to a group. Idempotent (INSERT OR IGNORE). Returns +// ErrNotFound if the group is absent, ErrUserNotFound if the user is absent. +func (s *Service) AddMember(ctx context.Context, groupID, userID string) error { + if _, err := s.GetByID(ctx, groupID); err != nil { + return err + } + now := time.Now().UTC().Format(time.RFC3339Nano) + _, err := s.DB.ExecContext(ctx, + `INSERT OR IGNORE INTO view_group_members (group_id, user_id, added_at) + VALUES (?, ?, ?)`, groupID, userID, now) + if err != nil { + if isForeignKeyViolation(err) { + return ErrUserNotFound + } + return fmt.Errorf("add group member: %w", err) + } + return nil +} + +// RemoveMember removes a user from a group. Idempotent — removing an absent +// membership is a no-op (no error). +func (s *Service) RemoveMember(ctx context.Context, groupID, userID string) error { + _, err := s.DB.ExecContext(ctx, + `DELETE FROM view_group_members WHERE group_id = ? AND user_id = ?`, + groupID, userID) + if err != nil { + return fmt.Errorf("remove group member: %w", err) + } + return nil +} + +// ListMembers returns the members of a group joined with user email/role. +func (s *Service) ListMembers(ctx context.Context, groupID string) ([]Member, error) { + rows, err := s.DB.QueryContext(ctx, + `SELECT u.id, u.email, u.role, m.added_at + FROM view_group_members m + JOIN users u ON u.id = m.user_id + WHERE m.group_id = ? + ORDER BY m.added_at ASC`, groupID) + if err != nil { + return nil, fmt.Errorf("list group members: %w", err) + } + defer rows.Close() + out := []Member{} + for rows.Next() { + var ( + m Member + addedAt string + ) + if err := rows.Scan(&m.UserID, &m.Email, &m.Role, &addedAt); err != nil { + return nil, fmt.Errorf("scan member: %w", err) + } + m.AddedAt, _ = time.Parse(time.RFC3339Nano, addedAt) + out = append(out, m) + } + return out, rows.Err() +} + +// IsMember reports whether a user belongs to a group. +func (s *Service) IsMember(ctx context.Context, userID, groupID string) (bool, error) { + var one int + err := s.DB.QueryRowContext(ctx, + `SELECT 1 FROM view_group_members WHERE group_id = ? AND user_id = ?`, + groupID, userID).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("is member: %w", err) + } + return true, nil +} + +// --- helpers --- + +func scanRow(r interface{ Scan(dest ...any) error }) (Group, error) { + var ( + g Group + description sql.NullString + createdAt string + updatedAt string + ) + err := r.Scan(&g.ID, &g.Name, &description, &createdAt, &updatedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Group{}, ErrNotFound + } + return Group{}, fmt.Errorf("scan group: %w", err) + } + g.Description = description.String + g.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) + g.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) + return g, nil +} + +func scanRows(rows *sql.Rows) ([]Group, error) { + out := []Group{} + for rows.Next() { + g, err := scanRow(rows) + if err != nil { + return nil, err + } + out = append(out, g) + } + return out, rows.Err() +} + +func nullableString(s string) any { + if s == "" { + return nil + } + return s +} + +func isUniqueConstraintViolation(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "UNIQUE constraint failed") || + strings.Contains(msg, "constraint failed: UNIQUE") +} + +func isForeignKeyViolation(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "FOREIGN KEY constraint failed") || + strings.Contains(msg, "constraint failed: FOREIGN KEY") +} diff --git a/server/internal/groups/groups_test.go b/server/internal/groups/groups_test.go new file mode 100644 index 0000000..29078cd --- /dev/null +++ b/server/internal/groups/groups_test.go @@ -0,0 +1,103 @@ +package groups + +import ( + "context" + "errors" + "testing" + + "github.com/dvcdsys/code-index/server/internal/db" + "github.com/dvcdsys/code-index/server/internal/users" +) + +func newTestService(t *testing.T) (*Service, *users.Service) { + t.Helper() + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + return New(database), users.New(database) +} + +func TestGroupCRUDAndMembership(t *testing.T) { + ctx := context.Background() + gs, us := newTestService(t) + + alice, err := us.Create(ctx, "alice@x.com", "password1234", users.RoleUser, false) + if err != nil { + t.Fatalf("create alice: %v", err) + } + bob, err := us.Create(ctx, "bob@x.com", "password1234", users.RoleUser, false) + if err != nil { + t.Fatalf("create bob: %v", err) + } + + g, err := gs.Create(ctx, "Product", "PM agents") + if err != nil { + t.Fatalf("create group: %v", err) + } + if g.Name != "Product" { + t.Errorf("name = %q", g.Name) + } + + // Duplicate name → ErrNameTaken. + if _, err := gs.Create(ctx, "Product", ""); !errors.Is(err, ErrNameTaken) { + t.Errorf("duplicate name err = %v, want ErrNameTaken", err) + } + + // Add alice; bob stays out. + if err := gs.AddMember(ctx, g.ID, alice.ID); err != nil { + t.Fatalf("add member: %v", err) + } + // Idempotent re-add. + if err := gs.AddMember(ctx, g.ID, alice.ID); err != nil { + t.Fatalf("re-add member: %v", err) + } + + if ok, _ := gs.IsMember(ctx, alice.ID, g.ID); !ok { + t.Error("alice should be a member") + } + if ok, _ := gs.IsMember(ctx, bob.ID, g.ID); ok { + t.Error("bob should NOT be a member") + } + + // AddMember for a non-existent user → ErrUserNotFound. + if err := gs.AddMember(ctx, g.ID, "nope"); !errors.Is(err, ErrUserNotFound) { + t.Errorf("add bad user err = %v, want ErrUserNotFound", err) + } + + members, err := gs.ListMembers(ctx, g.ID) + if err != nil { + t.Fatalf("list members: %v", err) + } + if len(members) != 1 || members[0].Email != "alice@x.com" { + t.Errorf("members = %+v", members) + } + + forAlice, err := gs.ListForUser(ctx, alice.ID) + if err != nil { + t.Fatalf("list for user: %v", err) + } + if len(forAlice) != 1 || forAlice[0].ID != g.ID { + t.Errorf("groups for alice = %+v", forAlice) + } + if forBob, _ := gs.ListForUser(ctx, bob.ID); len(forBob) != 0 { + t.Errorf("groups for bob = %+v, want none", forBob) + } + + // Remove alice. + if err := gs.RemoveMember(ctx, g.ID, alice.ID); err != nil { + t.Fatalf("remove member: %v", err) + } + if ok, _ := gs.IsMember(ctx, alice.ID, g.ID); ok { + t.Error("alice should be removed") + } + + // Delete group, then GetByID → ErrNotFound. + if err := gs.Delete(ctx, g.ID); err != nil { + t.Fatalf("delete group: %v", err) + } + if _, err := gs.GetByID(ctx, g.ID); !errors.Is(err, ErrNotFound) { + t.Errorf("get deleted err = %v, want ErrNotFound", err) + } +} diff --git a/server/internal/httpapi/access.go b/server/internal/httpapi/access.go new file mode 100644 index 0000000..b2b8158 --- /dev/null +++ b/server/internal/httpapi/access.go @@ -0,0 +1,162 @@ +package httpapi + +import ( + "errors" + "net/http" + + "github.com/dvcdsys/code-index/server/internal/access" + "github.com/dvcdsys/code-index/server/internal/projects" + "github.com/dvcdsys/code-index/server/internal/users" + "github.com/dvcdsys/code-index/server/internal/workspaces" +) + +// callerIdentity returns the authenticated user id and whether the caller is +// an admin. When auth is disabled (CIX_AUTH_DISABLED), every request is +// treated as admin — matching mustBeAdmin's dev-mode short-circuit. +func (s *Server) callerIdentity(r *http.Request) (userID string, isAdmin bool) { + if s.Deps.AuthDisabled { + return "", true + } + ac, ok := authFromCtx(r.Context()) + if !ok { + return "", false + } + return ac.User.ID, ac.User.Role == users.RoleAdmin +} + +// canAccessProject reports whether the caller has READ access to an +// already-loaded project: admin, owner, or a member of a view-group it is +// shared to. Used where the project is resolved by the handler (not the +// {path} URL param) — e.g. linking a project into a workspace. +func (s *Server) canAccessProject(r *http.Request, p *projects.Project) bool { + userID, isAdmin := s.callerIdentity(r) + if isAdmin { + return true + } + if userID != "" && p.OwnerUserID != nil && *p.OwnerUserID == userID { + return true + } + if userID != "" { + shared, _ := access.ProjectSharedToUser(r.Context(), s.Deps.DB, p.HostPath, userID) + return shared + } + return false +} + +// requireProjectAccess resolves the project named by the {path} hash and +// enforces READ access: admin, owner, or a member of a view-group the project +// is shared to. On any failure it writes the response (404 to hide existence +// from callers without access) and returns nil. +func (s *Server) requireProjectAccess(w http.ResponseWriter, r *http.Request) *projects.Project { + p := resolveProjectFromHash(w, r, s.Deps) + if p == nil { + return nil + } + userID, isAdmin := s.callerIdentity(r) + if isAdmin { + return p + } + if userID != "" && p.OwnerUserID != nil && *p.OwnerUserID == userID { + return p + } + if userID != "" { + shared, err := access.ProjectSharedToUser(r.Context(), s.Deps.DB, p.HostPath, userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "access check failed") + return nil + } + if shared { + return p + } + } + writeError(w, http.StatusNotFound, "project not found") + return nil +} + +// requireProjectOwnership resolves the project and enforces WRITE access: +// admin or the project's owner. External projects (ownerless) are admin-only. +// Returns 403 when the caller can read the project but does not own it, 404 +// when they cannot see it at all. +func (s *Server) requireProjectOwnership(w http.ResponseWriter, r *http.Request) *projects.Project { + p := resolveProjectFromHash(w, r, s.Deps) + if p == nil { + return nil + } + userID, isAdmin := s.callerIdentity(r) + if isAdmin { + return p + } + if userID != "" && p.OwnerUserID != nil && *p.OwnerUserID == userID { + return p + } + if userID != "" { + if shared, _ := access.ProjectSharedToUser(r.Context(), s.Deps.DB, p.HostPath, userID); shared { + writeError(w, http.StatusForbidden, "this action requires the project owner or an admin") + return nil + } + } + writeError(w, http.StatusNotFound, "project not found") + return nil +} + +// loadWorkspace fetches the workspace and writes 404/500 on error, returning +// ok=false. Shared by the visibility/ownership guards below. +func (s *Server) loadWorkspace(w http.ResponseWriter, r *http.Request, id string) (workspaces.Workspace, bool) { + ws, err := s.Deps.Workspaces.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, workspaces.ErrNotFound) { + writeError(w, http.StatusNotFound, "workspace not found") + } else { + writeError(w, http.StatusInternalServerError, "could not load workspace") + } + return workspaces.Workspace{}, false + } + return ws, true +} + +// requireWorkspaceVisible enforces READ visibility: admin, owner, or a member +// of a view-group the workspace is shared to. 404 hides non-visible workspaces. +func (s *Server) requireWorkspaceVisible(w http.ResponseWriter, r *http.Request, id string) (workspaces.Workspace, bool) { + ws, ok := s.loadWorkspace(w, r, id) + if !ok { + return ws, false + } + userID, isAdmin := s.callerIdentity(r) + if isAdmin { + return ws, true + } + if userID != "" && ws.OwnerUserID != nil && *ws.OwnerUserID == userID { + return ws, true + } + if userID != "" { + if shared, _ := access.WorkspaceSharedToUser(r.Context(), s.Deps.DB, id, userID); shared { + return ws, true + } + } + writeError(w, http.StatusNotFound, "workspace not found") + return workspaces.Workspace{}, false +} + +// requireWorkspaceOwnership enforces WRITE access: admin or the owner. 403 when +// the caller can see the workspace but does not own it, 404 when hidden. +func (s *Server) requireWorkspaceOwnership(w http.ResponseWriter, r *http.Request, id string) (workspaces.Workspace, bool) { + ws, ok := s.loadWorkspace(w, r, id) + if !ok { + return ws, false + } + userID, isAdmin := s.callerIdentity(r) + if isAdmin { + return ws, true + } + if userID != "" && ws.OwnerUserID != nil && *ws.OwnerUserID == userID { + return ws, true + } + if userID != "" { + if shared, _ := access.WorkspaceSharedToUser(r.Context(), s.Deps.DB, id, userID); shared { + writeError(w, http.StatusForbidden, "this action requires the workspace owner or an admin") + return workspaces.Workspace{}, false + } + } + writeError(w, http.StatusNotFound, "workspace not found") + return workspaces.Workspace{}, false +} diff --git a/server/internal/httpapi/admin_server_test.go b/server/internal/httpapi/admin_server_test.go index b99c093..6269180 100644 --- a/server/internal/httpapi/admin_server_test.go +++ b/server/internal/httpapi/admin_server_test.go @@ -38,7 +38,7 @@ func newAdminFixture(t *testing.T) *adminFixture { if err != nil { t.Fatalf("seed admin: %v", err) } - viewer, err := usrSvc.Create(context.Background(), "viewer@example.com", "secret-password", users.RoleViewer, false) + viewer, err := usrSvc.Create(context.Background(), "viewer@example.com", "secret-password", users.RoleUser, false) if err != nil { t.Fatalf("seed viewer: %v", err) } diff --git a/server/internal/httpapi/auth.go b/server/internal/httpapi/auth.go index 8ee8314..61c3e43 100644 --- a/server/internal/httpapi/auth.go +++ b/server/internal/httpapi/auth.go @@ -223,9 +223,18 @@ func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "Authentication required") return } + groupPayloads := []groupPayload{} + if s.Deps.Groups != nil { + if gs, err := s.Deps.Groups.ListForUser(r.Context(), ac.User.ID); err == nil { + for _, g := range gs { + groupPayloads = append(groupPayloads, groupToPayload(g)) + } + } + } writeJSON(w, http.StatusOK, map[string]any{ "user": userToPayload(ac.User), "auth_method": ac.Method, + "groups": groupPayloads, }) } diff --git a/server/internal/httpapi/auth_test.go b/server/internal/httpapi/auth_test.go index 5162e95..9bc1564 100644 --- a/server/internal/httpapi/auth_test.go +++ b/server/internal/httpapi/auth_test.go @@ -294,7 +294,7 @@ func TestCreateUser_AdminOnly(t *testing.T) { cookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password")) body, _ := json.Marshal(map[string]string{ - "email": "viewer@example.com", "initial_password": "viewerpass1", "role": "viewer", + "email": "viewer@example.com", "initial_password": "viewerpass1", "role": "user", }) req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(body)), cookie) req.Header.Set("Content-Type", "application/json") @@ -307,7 +307,7 @@ func TestCreateUser_AdminOnly(t *testing.T) { // Now try the same request as the viewer — expect 403. viewerCookie := sessionCookie(loginRR(t, f.Router, "viewer@example.com", "viewerpass1")) body, _ = json.Marshal(map[string]string{ - "email": "another@example.com", "initial_password": "anotherpass1", "role": "viewer", + "email": "another@example.com", "initial_password": "anotherpass1", "role": "user", }) req = withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(body)), viewerCookie) req.Header.Set("Content-Type", "application/json") @@ -371,7 +371,7 @@ func TestApiKey_CreateListRevokeFlow(t *testing.T) { func TestApiKey_ListForOwnerHidesOthers(t *testing.T) { f := newAuthFixture(t) // Seed a viewer + their own key directly via the underlying services. - v, err := f.Deps.Users.Create(context.Background(), "v@b.com", "viewerpass1", users.RoleViewer, false) + v, err := f.Deps.Users.Create(context.Background(), "v@b.com", "viewerpass1", users.RoleUser, false) if err != nil { t.Fatalf("seed viewer: %v", err) } @@ -401,81 +401,134 @@ func TestApiKey_ListForOwnerHidesOthers(t *testing.T) { // project are gated behind the admin role. POST /index/cancel is // intentionally NOT gated — see comment on IndexCancel for why — and is // covered separately by TestIndexCancel_AnyAuthenticatedUser. -func TestProjectMutations_AdminOnly(t *testing.T) { +// TestProjectMutations_OwnerOrAdmin pins the auth model for PATCH/DELETE on a +// project: the owner or an admin may mutate it; a different regular user cannot +// even see it (404, hiding existence). CreateProject sets owner = caller. +func TestProjectMutations_OwnerOrAdmin(t *testing.T) { f := newAuthFixture(t) adminCookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password")) - // Admin creates a project to act on. CreateProject is intentionally - // not admin-only — viewers can register their own projects. - createBody, _ := json.Marshal(map[string]string{"host_path": "/tmp/test-proj"}) - req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody)), adminCookie) + // Seed a regular user (alice) and log in. + aliceBody, _ := json.Marshal(map[string]string{ + "email": "alice@example.com", "initial_password": "alicepass1", "role": "user", + }) + req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(aliceBody)), adminCookie) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() f.Router.ServeHTTP(rr, req) if rr.Code != http.StatusCreated { - t.Fatalf("admin create project status = %d (body=%s)", rr.Code, rr.Body.String()) + t.Fatalf("seed alice status = %d (body=%s)", rr.Code, rr.Body.String()) + } + aliceCookie := sessionCookie(loginRR(t, f.Router, "alice@example.com", "alicepass1")) + + createProject := func(cookie, hostPath string) string { + t.Helper() + body, _ := json.Marshal(map[string]string{"host_path": hostPath}) + req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(body)), cookie) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + f.Router.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("create project %s status = %d (body=%s)", hostPath, rr.Code, rr.Body.String()) + } + var created struct { + PathHash string `json:"path_hash"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &created) + if created.PathHash == "" { + t.Fatalf("created project missing path_hash: %s", rr.Body.String()) + } + return created.PathHash } - var created struct{ PathHash string `json:"path_hash"` } - _ = json.Unmarshal(rr.Body.Bytes(), &created) - if created.PathHash == "" { - t.Fatalf("created project payload missing path_hash: %s", rr.Body.String()) + + do := func(cookie, method, path string, body []byte) int { + t.Helper() + req := withCookie(httptest.NewRequest(method, path, bytes.NewReader(body)), cookie) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + f.Router.ServeHTTP(rr, req) + return rr.Code } - // Seed a viewer + log in as them. - viewerBody, _ := json.Marshal(map[string]string{ - "email": "viewer@example.com", "initial_password": "viewerpass1", "role": "viewer", + patchBody := mustJSON(t, map[string]any{ + "settings": map[string]any{"exclude_patterns": []string{"vendor"}}, }) - req = withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(viewerBody)), adminCookie) + + adminProj := createProject(adminCookie, "/tmp/admin-proj") + aliceProj := createProject(aliceCookie, "/tmp/alice-proj") + + t.Run("non-owner cannot patch (404 hides existence)", func(t *testing.T) { + if code := do(aliceCookie, http.MethodPatch, "/api/v1/projects/"+adminProj, patchBody); code != http.StatusNotFound { + t.Errorf("alice PATCH admin's project = %d, want 404", code) + } + }) + t.Run("non-owner cannot delete (404 hides existence)", func(t *testing.T) { + if code := do(aliceCookie, http.MethodDelete, "/api/v1/projects/"+adminProj, nil); code != http.StatusNotFound { + t.Errorf("alice DELETE admin's project = %d, want 404", code) + } + }) + t.Run("owner can patch own project", func(t *testing.T) { + if code := do(aliceCookie, http.MethodPatch, "/api/v1/projects/"+aliceProj, patchBody); code != http.StatusOK { + t.Errorf("alice PATCH own project = %d, want 200", code) + } + }) + t.Run("admin can patch another user's project", func(t *testing.T) { + if code := do(adminCookie, http.MethodPatch, "/api/v1/projects/"+aliceProj, patchBody); code != http.StatusOK { + t.Errorf("admin PATCH alice's project = %d, want 200", code) + } + }) + t.Run("owner can delete own project", func(t *testing.T) { + ownDel := createProject(aliceCookie, "/tmp/alice-proj-2") + if code := do(aliceCookie, http.MethodDelete, "/api/v1/projects/"+ownDel, nil); code != http.StatusNoContent { + t.Errorf("alice DELETE own project = %d, want 204", code) + } + }) + t.Run("admin can delete another user's project", func(t *testing.T) { + if code := do(adminCookie, http.MethodDelete, "/api/v1/projects/"+aliceProj, nil); code != http.StatusNoContent { + t.Errorf("admin DELETE alice's project = %d, want 204", code) + } + }) +} + +// TestExternalAndTokenEndpoints_AdminOnly locks the review's top findings: +// external-project administration and GitHub-token management are admin-only, +// so a regular user is rejected with 403 (the admin gate runs before any +// service-availability check, so this holds regardless of wiring). +func TestExternalAndTokenEndpoints_AdminOnly(t *testing.T) { + f := newAuthFixture(t) + adminCookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password")) + + userBody, _ := json.Marshal(map[string]string{ + "email": "bob@example.com", "initial_password": "bobpass1234", "role": "user", + }) + req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(userBody)), adminCookie) req.Header.Set("Content-Type", "application/json") - rr = httptest.NewRecorder() + rr := httptest.NewRecorder() f.Router.ServeHTTP(rr, req) if rr.Code != http.StatusCreated { - t.Fatalf("seed viewer status = %d (body=%s)", rr.Code, rr.Body.String()) + t.Fatalf("seed user status = %d (body=%s)", rr.Code, rr.Body.String()) } - viewerCookie := sessionCookie(loginRR(t, f.Router, "viewer@example.com", "viewerpass1")) + userCookie := sessionCookie(loginRR(t, f.Router, "bob@example.com", "bobpass1234")) - // Each gated endpoint must 403 for the viewer, then succeed for the - // admin. PATCH first (mutates settings), DELETE last (destructive). cases := []struct { - name string - method string - path string - body []byte - adminStatus int + name, method, path string }{ - { - name: "patch settings", - method: http.MethodPatch, - path: "/api/v1/projects/" + created.PathHash, - body: mustJSON(t, map[string]any{ - "settings": map[string]any{"exclude_patterns": []string{"vendor"}}, - }), - adminStatus: http.StatusOK, - }, - { - name: "delete project", - method: http.MethodDelete, - path: "/api/v1/projects/" + created.PathHash, - adminStatus: http.StatusNoContent, - }, + {"add git-repo", http.MethodPost, "/api/v1/git-repos"}, + {"list github-tokens", http.MethodGet, "/api/v1/github-tokens"}, + {"create github-token", http.MethodPost, "/api/v1/github-tokens"}, + {"get project git-repo", http.MethodGet, "/api/v1/projects/deadbeefdeadbeef/git-repo"}, + {"reindex project", http.MethodPost, "/api/v1/projects/deadbeefdeadbeef/reindex"}, + {"force-stop project", http.MethodPost, "/api/v1/projects/deadbeefdeadbeef/force-stop"}, + {"webhook-info", http.MethodGet, "/api/v1/projects/deadbeefdeadbeef/webhook-info"}, } for _, c := range cases { - t.Run(c.name+"/viewer-forbidden", func(t *testing.T) { - req := withCookie(httptest.NewRequest(c.method, c.path, bytes.NewReader(c.body)), viewerCookie) + t.Run(c.name, func(t *testing.T) { + req := withCookie(httptest.NewRequest(c.method, c.path, bytes.NewReader([]byte("{}"))), userCookie) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() f.Router.ServeHTTP(rr, req) if rr.Code != http.StatusForbidden { - t.Errorf("viewer %s %s status = %d, want 403", c.method, c.path, rr.Code) - } - }) - t.Run(c.name+"/admin-allowed", func(t *testing.T) { - req := withCookie(httptest.NewRequest(c.method, c.path, bytes.NewReader(c.body)), adminCookie) - req.Header.Set("Content-Type", "application/json") - rr := httptest.NewRecorder() - f.Router.ServeHTTP(rr, req) - if rr.Code != c.adminStatus { - t.Errorf("admin %s %s status = %d, want %d (body=%s)", c.method, c.path, rr.Code, c.adminStatus, rr.Body.String()) + t.Errorf("user %s %s = %d, want 403", c.method, c.path, rr.Code) } }) } @@ -500,7 +553,7 @@ func TestIndexCancel_AnyAuthenticatedUser(t *testing.T) { // Seed a viewer + a project they can cancel against. viewerBody, _ := json.Marshal(map[string]string{ - "email": "viewer@example.com", "initial_password": "viewerpass1", "role": "viewer", + "email": "viewer@example.com", "initial_password": "viewerpass1", "role": "user", }) req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(viewerBody)), adminCookie) req.Header.Set("Content-Type", "application/json") @@ -539,7 +592,7 @@ func TestListUsers_IncludesStats(t *testing.T) { cookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password")) // Seed a viewer + give them an api-key so the row is non-trivial. - v, err := f.Deps.Users.Create(context.Background(), "v@b.com", "viewerpass1", users.RoleViewer, false) + v, err := f.Deps.Users.Create(context.Background(), "v@b.com", "viewerpass1", users.RoleUser, false) if err != nil { t.Fatalf("seed viewer: %v", err) } diff --git a/server/internal/httpapi/githubtokens.go b/server/internal/httpapi/githubtokens.go index 6e86514..1da96a6 100644 --- a/server/internal/httpapi/githubtokens.go +++ b/server/internal/httpapi/githubtokens.go @@ -62,6 +62,9 @@ func (s *Server) githubTokensUnavailable(w http.ResponseWriter) bool { // ListGithubTokens — GET /api/v1/github-tokens. func (s *Server) ListGithubTokens(w http.ResponseWriter, r *http.Request) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } if s.githubTokensUnavailable(w) { return } @@ -92,6 +95,9 @@ func (s *Server) ListGithubTokens(w http.ResponseWriter, r *http.Request) { // actually advertises. The Scopes field on the request stays for // backwards compatibility with older clients but is ignored. func (s *Server) CreateGithubToken(w http.ResponseWriter, r *http.Request) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } if s.githubTokensUnavailable(w) { return } @@ -142,6 +148,9 @@ func (s *Server) CreateGithubToken(w http.ResponseWriter, r *http.Request) { // repository — useful when /user/repos misses SAML-protected org // repos that only surface under /orgs/{login}/repos. func (s *Server) ListTokenAccounts(w http.ResponseWriter, r *http.Request, id string) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } if s.githubTokensUnavailable(w) { return } @@ -200,6 +209,9 @@ func (s *Server) ListTokenRepos( id string, params openapi.ListTokenReposParams, ) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } if s.githubTokensUnavailable(w) { return } @@ -292,6 +304,9 @@ func ptrStr(s string) *string { // DeleteGithubToken — DELETE /api/v1/github-tokens/{id}. func (s *Server) DeleteGithubToken(w http.ResponseWriter, r *http.Request, id string) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } if s.githubTokensUnavailable(w) { return } diff --git a/server/internal/httpapi/gitrepos.go b/server/internal/httpapi/gitrepos.go index b169e47..4000451 100644 --- a/server/internal/httpapi/gitrepos.go +++ b/server/internal/httpapi/gitrepos.go @@ -96,6 +96,11 @@ func gitRepoToPayload(g gitrepos.GitRepo) gitRepoPayload { // and enqueues a clone_repo job. The resulting project belongs to no // workspace — the caller can attach it via POST /workspaces/{id}/projects. func (s *Server) AddGitRepo(w http.ResponseWriter, r *http.Request) { + // External projects are admin-administered and ownerless; only admins may + // add one (it clones a repo, may register a webhook, and uses a stored PAT). + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } if s.gitReposUnavailable(w) { return } @@ -252,6 +257,10 @@ func (s *Server) AddGitRepo(w http.ResponseWriter, r *http.Request) { // GetProjectGitRepo — GET /api/v1/projects/{hash}/git-repo. func (s *Server) GetProjectGitRepo(w http.ResponseWriter, r *http.Request, hash openapi.ProjectHash) { + // Exposes sync config for an external project — admin-only, like the PATCH. + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } if s.gitReposUnavailable(w) { return } @@ -418,6 +427,10 @@ func (s *Server) deregisterWebhookIfAny(ctx context.Context, g gitrepos.GitRepo) // suspected index drift — manual rebuild without losing tokens or // workspace memberships. func (s *Server) ReindexProject(w http.ResponseWriter, r *http.Request, hash string, params openapi.ReindexProjectParams) { + // Reindex of an external repo is an admin operation (clone + index). + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } if s.gitReposUnavailable(w) { return } @@ -500,6 +513,10 @@ func (s *Server) ReindexProject(w http.ResponseWriter, r *http.Request, hash str // and a force-stop landing in the brief clone/fetch window may not catch // an index job the clone goroutine enqueues a moment later — re-run if so. func (s *Server) ForceStopIndex(w http.ResponseWriter, r *http.Request, hash string) { + // Force-stopping an external repo's index run is an admin operation. + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } if s.gitReposUnavailable(w) { return } diff --git a/server/internal/httpapi/groups.go b/server/internal/httpapi/groups.go new file mode 100644 index 0000000..7f2f848 --- /dev/null +++ b/server/internal/httpapi/groups.go @@ -0,0 +1,504 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/dvcdsys/code-index/server/internal/access" + "github.com/dvcdsys/code-index/server/internal/groups" + "github.com/dvcdsys/code-index/server/internal/projects" +) + +// --------------------------------------------------------------------------- +// Payloads (hand-built so date formatting stays RFC3339Nano UTC, matching the +// rest of the API). +// --------------------------------------------------------------------------- + +type groupPayload struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func groupToPayload(g groups.Group) groupPayload { + return groupPayload{ + ID: g.ID, + Name: g.Name, + Description: g.Description, + CreatedAt: g.CreatedAt.UTC().Format(time.RFC3339Nano), + UpdatedAt: g.UpdatedAt.UTC().Format(time.RFC3339Nano), + } +} + +type groupMemberPayload struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + AddedAt string `json:"added_at"` +} + +// groupsUnavailable returns 503 when the service is not wired. +func (s *Server) groupsUnavailable(w http.ResponseWriter) bool { + if s.Deps.Groups == nil { + writeError(w, http.StatusServiceUnavailable, "groups service is not configured on this server") + return true + } + return false +} + +// --------------------------------------------------------------------------- +// Group CRUD (admin-only except ListGroups, which is member-scoped for users) +// --------------------------------------------------------------------------- + +// ListGroups — GET /api/v1/groups. Admins see all; users see their own groups. +func (s *Server) ListGroups(w http.ResponseWriter, r *http.Request) { + if s.groupsUnavailable(w) { + return + } + userID, isAdmin := s.callerIdentity(r) + var ( + list []groups.Group + err error + ) + if isAdmin { + list, err = s.Deps.Groups.List(r.Context()) + } else { + list, err = s.Deps.Groups.ListForUser(r.Context(), userID) + } + if err != nil { + writeError(w, http.StatusInternalServerError, "could not list groups") + return + } + out := make([]groupPayload, 0, len(list)) + for _, g := range list { + out = append(out, groupToPayload(g)) + } + writeJSON(w, http.StatusOK, map[string]any{"groups": out, "total": len(out)}) +} + +// CreateGroup — POST /api/v1/groups (admin only). +func (s *Server) CreateGroup(w http.ResponseWriter, r *http.Request) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } + if s.groupsUnavailable(w) { + return + } + var body struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusUnprocessableEntity, "invalid JSON body") + return + } + g, err := s.Deps.Groups.Create(r.Context(), body.Name, body.Description) + if err != nil { + switch { + case errors.Is(err, groups.ErrNameEmpty): + writeError(w, http.StatusUnprocessableEntity, "name is required") + case errors.Is(err, groups.ErrNameTaken): + writeError(w, http.StatusConflict, "group name already exists") + default: + writeError(w, http.StatusInternalServerError, "could not create group") + } + return + } + writeJSON(w, http.StatusCreated, groupToPayload(g)) +} + +// GetGroup — GET /api/v1/groups/{id} (admin only). +func (s *Server) GetGroup(w http.ResponseWriter, r *http.Request, id string) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } + if s.groupsUnavailable(w) { + return + } + g, err := s.Deps.Groups.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, groups.ErrNotFound) { + writeError(w, http.StatusNotFound, "group not found") + return + } + writeError(w, http.StatusInternalServerError, "could not load group") + return + } + writeJSON(w, http.StatusOK, groupToPayload(g)) +} + +// UpdateGroup — PATCH /api/v1/groups/{id} (admin only). +func (s *Server) UpdateGroup(w http.ResponseWriter, r *http.Request, id string) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } + if s.groupsUnavailable(w) { + return + } + var body struct { + Name *string `json:"name"` + Description *string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusUnprocessableEntity, "invalid JSON body") + return + } + g, err := s.Deps.Groups.Update(r.Context(), id, body.Name, body.Description) + if err != nil { + switch { + case errors.Is(err, groups.ErrNotFound): + writeError(w, http.StatusNotFound, "group not found") + case errors.Is(err, groups.ErrNameEmpty): + writeError(w, http.StatusUnprocessableEntity, "name is required") + case errors.Is(err, groups.ErrNameTaken): + writeError(w, http.StatusConflict, "group name already exists") + default: + writeError(w, http.StatusInternalServerError, "could not update group") + } + return + } + writeJSON(w, http.StatusOK, groupToPayload(g)) +} + +// DeleteGroup — DELETE /api/v1/groups/{id} (admin only). +func (s *Server) DeleteGroup(w http.ResponseWriter, r *http.Request, id string) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } + if s.groupsUnavailable(w) { + return + } + if err := s.Deps.Groups.Delete(r.Context(), id); err != nil { + if errors.Is(err, groups.ErrNotFound) { + writeError(w, http.StatusNotFound, "group not found") + return + } + writeError(w, http.StatusInternalServerError, "could not delete group") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Membership (admin only) +// --------------------------------------------------------------------------- + +// ListGroupMembers — GET /api/v1/groups/{id}/members (admin only). +func (s *Server) ListGroupMembers(w http.ResponseWriter, r *http.Request, id string) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } + if s.groupsUnavailable(w) { + return + } + if _, err := s.Deps.Groups.GetByID(r.Context(), id); err != nil { + if errors.Is(err, groups.ErrNotFound) { + writeError(w, http.StatusNotFound, "group not found") + return + } + writeError(w, http.StatusInternalServerError, "could not load group") + return + } + members, err := s.Deps.Groups.ListMembers(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not list members") + return + } + out := make([]groupMemberPayload, 0, len(members)) + for _, m := range members { + out = append(out, groupMemberPayload{ + UserID: m.UserID, + Email: m.Email, + Role: m.Role, + AddedAt: m.AddedAt.UTC().Format(time.RFC3339Nano), + }) + } + writeJSON(w, http.StatusOK, map[string]any{"members": out, "total": len(out)}) +} + +// AddGroupMember — POST /api/v1/groups/{id}/members (admin only). +func (s *Server) AddGroupMember(w http.ResponseWriter, r *http.Request, id string) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } + if s.groupsUnavailable(w) { + return + } + var body struct { + UserID string `json:"user_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusUnprocessableEntity, "invalid JSON body") + return + } + if body.UserID == "" { + writeError(w, http.StatusUnprocessableEntity, "user_id is required") + return + } + if err := s.Deps.Groups.AddMember(r.Context(), id, body.UserID); err != nil { + switch { + case errors.Is(err, groups.ErrNotFound): + writeError(w, http.StatusNotFound, "group not found") + case errors.Is(err, groups.ErrUserNotFound): + writeError(w, http.StatusUnprocessableEntity, "user not found") + default: + writeError(w, http.StatusInternalServerError, "could not add member") + } + return + } + w.WriteHeader(http.StatusNoContent) +} + +// RemoveGroupMember — DELETE /api/v1/groups/{id}/members/{userId} (admin only). +func (s *Server) RemoveGroupMember(w http.ResponseWriter, r *http.Request, id string, userId string) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } + if s.groupsUnavailable(w) { + return + } + if err := s.Deps.Groups.RemoveMember(r.Context(), id, userId); err != nil { + writeError(w, http.StatusInternalServerError, "could not remove member") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Project shares (admin only; external projects only) +// --------------------------------------------------------------------------- + +func (s *Server) loadProjectByHash(w http.ResponseWriter, r *http.Request, hash string) *projects.Project { + p, err := projects.GetByHash(r.Context(), s.Deps.DB, hash) + if err != nil { + if errors.Is(err, projects.ErrNotFound) { + writeError(w, http.StatusNotFound, "project not found") + return nil + } + writeError(w, http.StatusInternalServerError, "could not load project") + return nil + } + return p +} + +// ListProjectShares — GET /api/v1/projects/{hash}/shares (admin only). +func (s *Server) ListProjectShares(w http.ResponseWriter, r *http.Request, hash string) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } + p := s.loadProjectByHash(w, r, hash) + if p == nil { + return + } + ids, err := access.ListProjectShareGroupIDs(r.Context(), s.Deps.DB, p.HostPath) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not list shares") + return + } + writeJSON(w, http.StatusOK, map[string]any{"group_ids": ids}) +} + +// ShareProjectToGroup — POST /api/v1/projects/{hash}/shares (admin only). +func (s *Server) ShareProjectToGroup(w http.ResponseWriter, r *http.Request, hash string) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } + if s.groupsUnavailable(w) { + return + } + p := s.loadProjectByHash(w, r, hash) + if p == nil { + return + } + external, err := access.IsProjectExternal(r.Context(), s.Deps.DB, p.HostPath) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not check project") + return + } + if !external { + writeError(w, http.StatusUnprocessableEntity, "only external projects can be shared to a group") + return + } + groupID, ok := s.decodeGroupID(w, r) + if !ok { + return + } + if !s.groupExists(w, r, groupID) { + return + } + if err := access.ShareProjectToGroup(r.Context(), s.Deps.DB, p.HostPath, groupID); err != nil { + writeError(w, http.StatusInternalServerError, "could not share project") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// UnshareProjectFromGroup — DELETE /api/v1/projects/{hash}/shares/{groupId} (admin only). +func (s *Server) UnshareProjectFromGroup(w http.ResponseWriter, r *http.Request, hash string, groupId string) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } + p := s.loadProjectByHash(w, r, hash) + if p == nil { + return + } + if err := access.UnshareProjectFromGroup(r.Context(), s.Deps.DB, p.HostPath, groupId); err != nil { + writeError(w, http.StatusInternalServerError, "could not unshare project") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ReassignProjectOwner — PUT /api/v1/projects/{hash}/owner (admin only). +func (s *Server) ReassignProjectOwner(w http.ResponseWriter, r *http.Request, hash string) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } + p := s.loadProjectByHash(w, r, hash) + if p == nil { + return + } + external, err := access.IsProjectExternal(r.Context(), s.Deps.DB, p.HostPath) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not check project") + return + } + if external { + writeError(w, http.StatusUnprocessableEntity, "external projects are ownerless and cannot be reassigned") + return + } + var body struct { + OwnerUserID string `json:"owner_user_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusUnprocessableEntity, "invalid JSON body") + return + } + if body.OwnerUserID == "" { + writeError(w, http.StatusUnprocessableEntity, "owner_user_id is required") + return + } + target, err := s.Deps.Users.GetByID(r.Context(), body.OwnerUserID) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "owner_user_id does not refer to an existing user") + return + } + if target.DisabledAt != nil { + writeError(w, http.StatusUnprocessableEntity, "cannot assign ownership to a disabled user") + return + } + if err := projects.SetOwner(r.Context(), s.Deps.DB, p.HostPath, body.OwnerUserID); err != nil { + writeError(w, http.StatusInternalServerError, "could not reassign owner") + return + } + updated, err := projects.Get(r.Context(), s.Deps.DB, p.HostPath) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not load updated project") + return + } + writeJSON(w, http.StatusOK, projectToOpenAPI(updated)) +} + +// --------------------------------------------------------------------------- +// Workspace shares (owner-in-group or admin) +// --------------------------------------------------------------------------- + +// ListWorkspaceShares — GET /api/v1/workspaces/{id}/shares. +func (s *Server) ListWorkspaceShares(w http.ResponseWriter, r *http.Request, id string) { + if s.workspacesUnavailable(w) { + return + } + if _, ok := s.requireWorkspaceVisible(w, r, id); !ok { + return + } + ids, err := access.ListWorkspaceShareGroupIDs(r.Context(), s.Deps.DB, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not list shares") + return + } + writeJSON(w, http.StatusOK, map[string]any{"group_ids": ids}) +} + +// ShareWorkspaceToGroup — POST /api/v1/workspaces/{id}/shares. The owner may +// share to a group they belong to; an admin may share to any group. +func (s *Server) ShareWorkspaceToGroup(w http.ResponseWriter, r *http.Request, id string) { + if s.workspacesUnavailable(w) || s.groupsUnavailable(w) { + return + } + if _, ok := s.requireWorkspaceOwnership(w, r, id); !ok { + return + } + groupID, ok := s.decodeGroupID(w, r) + if !ok { + return + } + if !s.groupExists(w, r, groupID) { + return + } + // A non-admin owner may only share to groups they are a member of. + userID, isAdmin := s.callerIdentity(r) + if !isAdmin { + member, err := s.Deps.Groups.IsMember(r.Context(), userID, groupID) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not check membership") + return + } + if !member { + writeError(w, http.StatusForbidden, "you can only share to groups you belong to") + return + } + } + if err := access.ShareWorkspaceToGroup(r.Context(), s.Deps.DB, id, groupID); err != nil { + writeError(w, http.StatusInternalServerError, "could not share workspace") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// UnshareWorkspaceFromGroup — DELETE /api/v1/workspaces/{id}/shares/{groupId}. +func (s *Server) UnshareWorkspaceFromGroup(w http.ResponseWriter, r *http.Request, id string, groupId string) { + if s.workspacesUnavailable(w) { + return + } + if _, ok := s.requireWorkspaceOwnership(w, r, id); !ok { + return + } + if err := access.UnshareWorkspaceFromGroup(r.Context(), s.Deps.DB, id, groupId); err != nil { + writeError(w, http.StatusInternalServerError, "could not unshare workspace") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- small shared helpers --- + +func (s *Server) decodeGroupID(w http.ResponseWriter, r *http.Request) (string, bool) { + var body struct { + GroupID string `json:"group_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusUnprocessableEntity, "invalid JSON body") + return "", false + } + if body.GroupID == "" { + writeError(w, http.StatusUnprocessableEntity, "group_id is required") + return "", false + } + return body.GroupID, true +} + +func (s *Server) groupExists(w http.ResponseWriter, r *http.Request, groupID string) bool { + if _, err := s.Deps.Groups.GetByID(r.Context(), groupID); err != nil { + if errors.Is(err, groups.ErrNotFound) { + writeError(w, http.StatusUnprocessableEntity, "group_id does not refer to an existing group") + return false + } + writeError(w, http.StatusInternalServerError, "could not load group") + return false + } + return true +} diff --git a/server/internal/httpapi/groups_test.go b/server/internal/httpapi/groups_test.go new file mode 100644 index 0000000..49ae3fe --- /dev/null +++ b/server/internal/httpapi/groups_test.go @@ -0,0 +1,275 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/dvcdsys/code-index/server/internal/projects" +) + +// seedUser creates a regular user via the admin API and returns a login cookie. +func seedUser(t *testing.T, f *authTestFixture, adminCookie, email, password string) string { + t.Helper() + body, _ := json.Marshal(map[string]string{ + "email": email, "initial_password": password, "role": "user", + }) + req := withCookie(httptest.NewRequest(http.MethodPost, "/api/v1/admin/users", bytes.NewReader(body)), adminCookie) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + f.Router.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("seed user %s status = %d (body=%s)", email, rr.Code, rr.Body.String()) + } + return sessionCookie(loginRR(t, f.Router, email, password)) +} + +func doReq(t *testing.T, f *authTestFixture, cookie, method, path string, body any) (*httptest.ResponseRecorder, []byte) { + t.Helper() + var rdr *bytes.Reader + if body != nil { + b, _ := json.Marshal(body) + rdr = bytes.NewReader(b) + } else { + rdr = bytes.NewReader(nil) + } + req := withCookie(httptest.NewRequest(method, path, rdr), cookie) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + f.Router.ServeHTTP(rr, req) + return rr, rr.Body.Bytes() +} + +func TestGroupCRUD_AdminOnly(t *testing.T) { + f := newAuthFixture(t) + adminCookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password")) + userCookie := seedUser(t, f, adminCookie, "bob@example.com", "bobpass1234") + + // User is forbidden from every mutating group endpoint. + for _, c := range []struct{ method, path string }{ + {http.MethodPost, "/api/v1/groups"}, + {http.MethodGet, "/api/v1/groups/x"}, + {http.MethodPatch, "/api/v1/groups/x"}, + {http.MethodDelete, "/api/v1/groups/x"}, + {http.MethodGet, "/api/v1/groups/x/members"}, + {http.MethodPost, "/api/v1/groups/x/members"}, + {http.MethodDelete, "/api/v1/groups/x/members/y"}, + } { + rr, _ := doReq(t, f, userCookie, c.method, c.path, map[string]any{"name": "n", "user_id": "u"}) + if rr.Code != http.StatusForbidden { + t.Errorf("user %s %s = %d, want 403", c.method, c.path, rr.Code) + } + } + + // Admin lifecycle. + rr, body := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups", map[string]string{"name": "Product"}) + if rr.Code != http.StatusCreated { + t.Fatalf("create group = %d (%s)", rr.Code, body) + } + var g struct{ ID string `json:"id"` } + _ = json.Unmarshal(body, &g) + if g.ID == "" { + t.Fatal("group id empty") + } + + if rr, _ := doReq(t, f, adminCookie, http.MethodGet, "/api/v1/groups/"+g.ID, nil); rr.Code != http.StatusOK { + t.Errorf("get group = %d", rr.Code) + } + if rr, _ := doReq(t, f, adminCookie, http.MethodPatch, "/api/v1/groups/"+g.ID, map[string]string{"description": "PM agents"}); rr.Code != http.StatusOK { + t.Errorf("patch group = %d", rr.Code) + } + + // Add bob (need his id — look him up via admin users list). + rr, body = doReq(t, f, adminCookie, http.MethodGet, "/api/v1/admin/users", nil) + var ul struct { + Users []struct { + ID string `json:"id"` + Email string `json:"email"` + } `json:"users"` + } + _ = json.Unmarshal(body, &ul) + var bobID string + for _, u := range ul.Users { + if u.Email == "bob@example.com" { + bobID = u.ID + } + } + if bobID == "" { + t.Fatal("bob id not found") + } + if rr, _ := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups/"+g.ID+"/members", map[string]string{"user_id": bobID}); rr.Code != http.StatusNoContent { + t.Errorf("add member = %d", rr.Code) + } + // Adding a non-existent user → 422. + if rr, _ := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups/"+g.ID+"/members", map[string]string{"user_id": "nope"}); rr.Code != http.StatusUnprocessableEntity { + t.Errorf("add bad member = %d, want 422", rr.Code) + } + + rr, body = doReq(t, f, adminCookie, http.MethodGet, "/api/v1/groups/"+g.ID+"/members", nil) + var ml struct{ Total int `json:"total"` } + _ = json.Unmarshal(body, &ml) + if rr.Code != http.StatusOK || ml.Total != 1 { + t.Errorf("list members = %d total=%d", rr.Code, ml.Total) + } + + // bob now sees the group via /auth/me and GET /groups (member-scoped). + rr, body = doReq(t, f, userCookie, http.MethodGet, "/api/v1/auth/me", nil) + var me struct { + Groups []struct{ ID string `json:"id"` } `json:"groups"` + } + _ = json.Unmarshal(body, &me) + if rr.Code != http.StatusOK || len(me.Groups) != 1 || me.Groups[0].ID != g.ID { + t.Errorf("/auth/me groups = %+v (status %d)", me.Groups, rr.Code) + } + + // Remove + delete. + if rr, _ := doReq(t, f, adminCookie, http.MethodDelete, "/api/v1/groups/"+g.ID+"/members/"+bobID, nil); rr.Code != http.StatusNoContent { + t.Errorf("remove member = %d", rr.Code) + } + if rr, _ := doReq(t, f, adminCookie, http.MethodDelete, "/api/v1/groups/"+g.ID, nil); rr.Code != http.StatusNoContent { + t.Errorf("delete group = %d", rr.Code) + } +} + +func TestProjectShareAndOwnerReassign(t *testing.T) { + f := newAuthFixture(t) + adminCookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password")) + aliceCookie := seedUser(t, f, adminCookie, "alice@example.com", "alicepass1") + + // Find alice's id. + _, body := doReq(t, f, adminCookie, http.MethodGet, "/api/v1/admin/users", nil) + var ul struct { + Users []struct { + ID string `json:"id"` + Email string `json:"email"` + } `json:"users"` + } + _ = json.Unmarshal(body, &ul) + var aliceID string + for _, u := range ul.Users { + if u.Email == "alice@example.com" { + aliceID = u.ID + } + } + + // Insert an external project (project row + git_repos peer) directly. + extPath := "github.com/x/y@main" + if _, err := projects.Create(t.Context(), f.Deps.DB, projects.CreateRequest{HostPath: extPath}); err != nil { + t.Fatalf("create external project: %v", err) + } + if _, err := f.Deps.DB.ExecContext(t.Context(), + `INSERT INTO git_repos (project_path, github_url, branch, webhook_secret, created_at, updated_at) + VALUES (?, 'https://github.com/x/y', 'main', 's', '2024-01-01', '2024-01-01')`, extPath); err != nil { + t.Fatalf("insert git_repos: %v", err) + } + extHash := projects.HashPath(extPath) + + // Group with alice as member. + _, gbody := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups", map[string]string{"name": "Product"}) + var g struct{ ID string `json:"id"` } + _ = json.Unmarshal(gbody, &g) + doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups/"+g.ID+"/members", map[string]string{"user_id": aliceID}) + + // Before share: alice does not see the external project. + if !aliceSeesProject(t, f, aliceCookie, extHash) { + // expected: not visible + } else { + t.Error("alice should NOT see the external project before share") + } + + // Share to the group (admin). Then alice sees it. + if rr, b := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/projects/"+extHash+"/shares", map[string]string{"group_id": g.ID}); rr.Code != http.StatusNoContent { + t.Fatalf("share project = %d (%s)", rr.Code, b) + } + if !aliceSeesProject(t, f, aliceCookie, extHash) { + t.Error("alice should see the external project after share") + } + + // Reassign owner of an external project → 422. + if rr, _ := doReq(t, f, adminCookie, http.MethodPut, "/api/v1/projects/"+extHash+"/owner", map[string]string{"owner_user_id": aliceID}); rr.Code != http.StatusUnprocessableEntity { + t.Errorf("reassign external owner = %d, want 422", rr.Code) + } + + // Local project owned by admin → reassign to alice → alice now sees it. + localPath := "/tmp/admin-local" + if _, err := projects.Create(t.Context(), f.Deps.DB, projects.CreateRequest{HostPath: localPath, OwnerUserID: f.UserID}); err != nil { + t.Fatalf("create local project: %v", err) + } + localHash := projects.HashPath(localPath) + if aliceSeesProject(t, f, aliceCookie, localHash) { + t.Error("alice should not see admin's local project before reassign") + } + if rr, b := doReq(t, f, adminCookie, http.MethodPut, "/api/v1/projects/"+localHash+"/owner", map[string]string{"owner_user_id": aliceID}); rr.Code != http.StatusOK { + t.Fatalf("reassign local owner = %d (%s)", rr.Code, b) + } + if !aliceSeesProject(t, f, aliceCookie, localHash) { + t.Error("alice should see the local project after reassign") + } +} + +func aliceSeesProject(t *testing.T, f *authTestFixture, cookie, hash string) bool { + t.Helper() + _, body := doReq(t, f, cookie, http.MethodGet, "/api/v1/projects", nil) + var pl struct { + Projects []struct{ PathHash string `json:"path_hash"` } `json:"projects"` + } + _ = json.Unmarshal(body, &pl) + for _, p := range pl.Projects { + if p.PathHash == hash { + return true + } + } + return false +} + +func TestWorkspaceShare_OwnerInGroupOnly(t *testing.T) { + f := newAuthFixture(t) + adminCookie := sessionCookie(loginRR(t, f.Router, "admin@example.com", "secret-password")) + aliceCookie := seedUser(t, f, adminCookie, "alice@example.com", "alicepass1") + + _, body := doReq(t, f, adminCookie, http.MethodGet, "/api/v1/admin/users", nil) + var ul struct { + Users []struct { + ID string `json:"id"` + Email string `json:"email"` + } `json:"users"` + } + _ = json.Unmarshal(body, &ul) + var aliceID string + for _, u := range ul.Users { + if u.Email == "alice@example.com" { + aliceID = u.ID + } + } + + // Two groups: gIn (alice is a member), gOut (she is not). + _, b1 := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups", map[string]string{"name": "in"}) + _, b2 := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups", map[string]string{"name": "out"}) + var gIn, gOut struct{ ID string `json:"id"` } + _ = json.Unmarshal(b1, &gIn) + _ = json.Unmarshal(b2, &gOut) + doReq(t, f, adminCookie, http.MethodPost, "/api/v1/groups/"+gIn.ID+"/members", map[string]string{"user_id": aliceID}) + + // Alice creates a workspace (she owns it). + rr, wbody := doReq(t, f, aliceCookie, http.MethodPost, "/api/v1/workspaces", map[string]string{"name": "alice-ws"}) + if rr.Code != http.StatusCreated { + t.Fatalf("create workspace = %d (%s)", rr.Code, wbody) + } + var ws struct{ ID string `json:"id"` } + _ = json.Unmarshal(wbody, &ws) + + // Share to a group she belongs to → 204. + if rr, b := doReq(t, f, aliceCookie, http.MethodPost, "/api/v1/workspaces/"+ws.ID+"/shares", map[string]string{"group_id": gIn.ID}); rr.Code != http.StatusNoContent { + t.Errorf("share to own group = %d (%s)", rr.Code, b) + } + // Share to a group she does NOT belong to → 403. + if rr, _ := doReq(t, f, aliceCookie, http.MethodPost, "/api/v1/workspaces/"+ws.ID+"/shares", map[string]string{"group_id": gOut.ID}); rr.Code != http.StatusForbidden { + t.Errorf("share to foreign group = %d, want 403", rr.Code) + } + // Admin may share to any group. + if rr, _ := doReq(t, f, adminCookie, http.MethodPost, "/api/v1/workspaces/"+ws.ID+"/shares", map[string]string{"group_id": gOut.ID}); rr.Code != http.StatusNoContent { + t.Errorf("admin share to any group = %d", rr.Code) + } +} diff --git a/server/internal/httpapi/middleware_test.go b/server/internal/httpapi/middleware_test.go index 6b18866..c7e1f40 100644 --- a/server/internal/httpapi/middleware_test.go +++ b/server/internal/httpapi/middleware_test.go @@ -9,8 +9,10 @@ import ( "github.com/dvcdsys/code-index/server/internal/apikeys" apidb "github.com/dvcdsys/code-index/server/internal/db" + "github.com/dvcdsys/code-index/server/internal/groups" "github.com/dvcdsys/code-index/server/internal/sessions" "github.com/dvcdsys/code-index/server/internal/users" + "github.com/dvcdsys/code-index/server/internal/workspaces" ) // authTestFixture bundles a router plus the seeded admin user + a fresh @@ -57,6 +59,8 @@ func newAuthFixture(t *testing.T) *authTestFixture { Users: usrSvc, Sessions: sessSvc, APIKeys: akSvc, + Groups: groups.New(database), + Workspaces: workspaces.New(database), } return &authTestFixture{Router: NewRouter(deps), Deps: deps, UserID: u.ID, FullKey: full} } diff --git a/server/internal/httpapi/openapi/openapi.gen.go b/server/internal/httpapi/openapi/openapi.gen.go index d894964..105a051 100644 --- a/server/internal/httpapi/openapi/openapi.gen.go +++ b/server/internal/httpapi/openapi/openapi.gen.go @@ -49,8 +49,8 @@ func (e AddGitRepoRequestWebhookMode) Valid() bool { // Defines values for CreateUserRequestRole. const ( - CreateUserRequestRoleAdmin CreateUserRequestRole = "admin" - CreateUserRequestRoleViewer CreateUserRequestRole = "viewer" + CreateUserRequestRoleAdmin CreateUserRequestRole = "admin" + CreateUserRequestRoleUser CreateUserRequestRole = "user" ) // Valid indicates whether the value is a known member of the CreateUserRequestRole enum. @@ -58,7 +58,7 @@ func (e CreateUserRequestRole) Valid() bool { switch e { case CreateUserRequestRoleAdmin: return true - case CreateUserRequestRoleViewer: + case CreateUserRequestRoleUser: return true default: return false @@ -104,6 +104,24 @@ func (e GithubAccountType) Valid() bool { } } +// Defines values for GroupMemberRole. +const ( + GroupMemberRoleAdmin GroupMemberRole = "admin" + GroupMemberRoleUser GroupMemberRole = "user" +) + +// Valid indicates whether the value is a known member of the GroupMemberRole enum. +func (e GroupMemberRole) Valid() bool { + switch e { + case GroupMemberRoleAdmin: + return true + case GroupMemberRoleUser: + return true + default: + return false + } +} + // Defines values for HealthResponseStatus. const ( HealthResponseStatusOk HealthResponseStatus = "ok" @@ -556,8 +574,8 @@ func (e UpdateGitRepoSyncRequestSyncMethod) Valid() bool { // Defines values for UpdateUserRequestRole. const ( - UpdateUserRequestRoleAdmin UpdateUserRequestRole = "admin" - UpdateUserRequestRoleViewer UpdateUserRequestRole = "viewer" + UpdateUserRequestRoleAdmin UpdateUserRequestRole = "admin" + UpdateUserRequestRoleUser UpdateUserRequestRole = "user" ) // Valid indicates whether the value is a known member of the UpdateUserRequestRole enum. @@ -565,7 +583,7 @@ func (e UpdateUserRequestRole) Valid() bool { switch e { case UpdateUserRequestRoleAdmin: return true - case UpdateUserRequestRoleViewer: + case UpdateUserRequestRoleUser: return true default: return false @@ -574,8 +592,8 @@ func (e UpdateUserRequestRole) Valid() bool { // Defines values for UserRole. const ( - UserRoleAdmin UserRole = "admin" - UserRoleViewer UserRole = "viewer" + UserRoleAdmin UserRole = "admin" + UserRoleUser UserRole = "user" ) // Valid indicates whether the value is a known member of the UserRole enum. @@ -583,7 +601,7 @@ func (e UserRole) Valid() bool { switch e { case UserRoleAdmin: return true - case UserRoleViewer: + case UserRoleUser: return true default: return false @@ -592,8 +610,8 @@ func (e UserRole) Valid() bool { // Defines values for UserWithStatsRole. const ( - UserWithStatsRoleAdmin UserWithStatsRole = "admin" - UserWithStatsRoleViewer UserWithStatsRole = "viewer" + UserWithStatsRoleAdmin UserWithStatsRole = "admin" + UserWithStatsRoleUser UserWithStatsRole = "user" ) // Valid indicates whether the value is a known member of the UserWithStatsRole enum. @@ -601,7 +619,7 @@ func (e UserWithStatsRole) Valid() bool { switch e { case UserWithStatsRoleAdmin: return true - case UserWithStatsRoleViewer: + case UserWithStatsRoleUser: return true default: return false @@ -855,6 +873,11 @@ type AddGitRepoRequest struct { // AddGitRepoRequestWebhookMode defines model for AddGitRepoRequest.WebhookMode. type AddGitRepoRequestWebhookMode string +// AddGroupMemberRequest defines model for AddGroupMemberRequest. +type AddGroupMemberRequest struct { + UserId string `json:"user_id"` +} + // ApiKey defines model for ApiKey. type ApiKey struct { CreatedAt time.Time `json:"created_at"` @@ -928,9 +951,25 @@ type CreateGithubTokenRequest struct { Token string `json:"token"` } +// CreateGroupRequest defines model for CreateGroupRequest. +type CreateGroupRequest struct { + Description *string `json:"description,omitempty"` + Name string `json:"name"` +} + // CreateProjectRequest defines model for CreateProjectRequest. type CreateProjectRequest struct { + // HostPath The real filesystem path being registered (becomes display_path). HostPath string `json:"host_path"` + + // MachineId Per-machine UUID supplied by the CLI (from ~/.cix/machine_id). When + // present the project is LOCAL and its identity key is namespaced + // local:{machine_id}:{host_path} so the same path on different + // machines/users does not collide. Omit for external repos. + MachineId *string `json:"machine_id,omitempty"` + + // MachineLabel os.Hostname() of the indexing machine — display only. + MachineLabel *string `json:"machine_label,omitempty"` } // CreateUserRequest defines model for CreateUserRequest. @@ -1170,6 +1209,45 @@ type GithubTokenListResponse struct { Total int `json:"total"` } +// Group defines model for Group. +type Group struct { + CreatedAt time.Time `json:"created_at"` + + // Description Free-form description. Empty string when absent. + Description string `json:"description"` + Id string `json:"id"` + Name string `json:"name"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GroupIdListResponse defines model for GroupIdListResponse. +type GroupIdListResponse struct { + GroupIds []string `json:"group_ids"` +} + +// GroupListResponse defines model for GroupListResponse. +type GroupListResponse struct { + Groups []Group `json:"groups"` + Total int `json:"total"` +} + +// GroupMember defines model for GroupMember. +type GroupMember struct { + AddedAt time.Time `json:"added_at"` + Email openapi_types.Email `json:"email"` + Role GroupMemberRole `json:"role"` + UserId string `json:"user_id"` +} + +// GroupMemberRole defines model for GroupMember.Role. +type GroupMemberRole string + +// GroupMemberListResponse defines model for GroupMemberListResponse. +type GroupMemberListResponse struct { + Members []GroupMember `json:"members"` + Total int `json:"total"` +} + // HealthResponse defines model for HealthResponse. type HealthResponse struct { // Reason Set only when `status` is `unhealthy`. @@ -1343,7 +1421,13 @@ type MeResponse struct { // AuthMethod Tells the dashboard whether to surface "logout" (session) or // hide it (api_key access — there's nothing to log out of). AuthMethod MeResponseAuthMethod `json:"auth_method"` - User User `json:"user"` + + // Groups View-groups the caller belongs to. Lets the dashboard scope the + // "share to group" picker without a second round-trip. Empty for a + // user in no groups; admins still only list their own memberships + // here (the full group list comes from GET /groups). + Groups []Group `json:"groups"` + User User `json:"user"` } // MeResponseAuthMethod Tells the dashboard whether to surface "logout" (session) or @@ -1386,6 +1470,11 @@ type Project struct { ContainerPath string `json:"container_path"` CreatedAt time.Time `json:"created_at"` + // DisplayPath Human-readable path. The real filesystem path for local projects, + // the github path for external ones. host_path is the identity key + // (namespaced per machine for locals) — clients should show this. + DisplayPath *string `json:"display_path,omitempty"` + // HostPath Absolute filesystem path on the operator's machine. HostPath string `json:"host_path"` @@ -1396,6 +1485,18 @@ type Project struct { Languages []string `json:"languages"` LastIndexedAt *time.Time `json:"last_indexed_at"` + // MachineId Per-machine UUID a local project was indexed on. NULL for external/legacy. + MachineId *string `json:"machine_id,omitempty"` + + // MachineLabel os.Hostname() of the indexing machine — display only. + MachineLabel *string `json:"machine_label,omitempty"` + + // OwnerUserId User who owns this personal (locally indexed) project. NULL means + // ownerless — the canonical state for EXTERNAL projects (those with a + // git_repos peer), which are admin-administered and reachable only via + // a view-group share. + OwnerUserId *string `json:"owner_user_id,omitempty"` + // PathHash First 16 hex chars of SHA1(host_path) — stable URL identifier. PathHash string `json:"path_hash"` Settings ProjectSettings `json:"settings"` @@ -1458,6 +1559,12 @@ type ProjectWorkspaceList struct { Workspaces []ProjectWorkspaceEntry `json:"workspaces"` } +// ReassignOwnerRequest defines model for ReassignOwnerRequest. +type ReassignOwnerRequest struct { + // OwnerUserId New owner; must be an existing, enabled user. + OwnerUserId string `json:"owner_user_id"` +} + // ReferenceItem defines model for ReferenceItem. type ReferenceItem struct { ChunkType ReferenceItemChunkType `json:"chunk_type"` @@ -1619,6 +1726,11 @@ type SessionListResponse struct { Total int `json:"total"` } +// ShareToGroupRequest defines model for ShareToGroupRequest. +type ShareToGroupRequest struct { + GroupId string `json:"group_id"` +} + // SidecarStatus defines model for SidecarStatus. type SidecarStatus struct { // InFlight Embedding queue depth at the moment of sampling. @@ -1837,6 +1949,12 @@ type UpdateGitRepoSyncResult struct { Note *string `json:"note,omitempty"` } +// UpdateGroupRequest defines model for UpdateGroupRequest. +type UpdateGroupRequest struct { + Description *string `json:"description,omitempty"` + Name *string `json:"name,omitempty"` +} + // UpdateProjectRequest defines model for UpdateProjectRequest. type UpdateProjectRequest struct { Settings *ProjectSettings `json:"settings,omitempty"` @@ -1849,12 +1967,12 @@ type UpdateUserRequest struct { Disabled *bool `json:"disabled,omitempty"` // Role New role for the user. Refused for the last enabled admin - // when set to `viewer`. + // when set to `user`. Role *UpdateUserRequestRole `json:"role,omitempty"` } // UpdateUserRequestRole New role for the user. Refused for the last enabled admin -// when set to `viewer`. +// when set to `user`. type UpdateUserRequestRole string // UpdateWorkspaceRequest Both fields are optional — omitting a field leaves the existing @@ -2003,8 +2121,13 @@ type Workspace struct { Id string `json:"id"` // Name Unique workspace name. - Name string `json:"name"` - UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + + // OwnerUserId User who created the workspace. NULL only when orphaned by a user + // deletion. Visible to the owner, members of any view-group it is + // shared to, and admins. + OwnerUserId *string `json:"owner_user_id,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } // WorkspaceListResponse defines model for WorkspaceListResponse. @@ -2296,12 +2419,27 @@ type AddGitRepoJSONRequestBody = AddGitRepoRequest // CreateGithubTokenJSONRequestBody defines body for CreateGithubToken for application/json ContentType. type CreateGithubTokenJSONRequestBody = CreateGithubTokenRequest +// CreateGroupJSONRequestBody defines body for CreateGroup for application/json ContentType. +type CreateGroupJSONRequestBody = CreateGroupRequest + +// UpdateGroupJSONRequestBody defines body for UpdateGroup for application/json ContentType. +type UpdateGroupJSONRequestBody = UpdateGroupRequest + +// AddGroupMemberJSONRequestBody defines body for AddGroupMember for application/json ContentType. +type AddGroupMemberJSONRequestBody = AddGroupMemberRequest + // CreateProjectJSONRequestBody defines body for CreateProject for application/json ContentType. type CreateProjectJSONRequestBody = CreateProjectRequest // UpdateProjectGitRepoSyncJSONRequestBody defines body for UpdateProjectGitRepoSync for application/json ContentType. type UpdateProjectGitRepoSyncJSONRequestBody = UpdateGitRepoSyncRequest +// ReassignProjectOwnerJSONRequestBody defines body for ReassignProjectOwner for application/json ContentType. +type ReassignProjectOwnerJSONRequestBody = ReassignOwnerRequest + +// ShareProjectToGroupJSONRequestBody defines body for ShareProjectToGroup for application/json ContentType. +type ShareProjectToGroupJSONRequestBody = ShareToGroupRequest + // UpdateProjectJSONRequestBody defines body for UpdateProject for application/json ContentType. type UpdateProjectJSONRequestBody = UpdateProjectRequest @@ -2344,6 +2482,9 @@ type UpdateWorkspaceJSONRequestBody = UpdateWorkspaceRequest // LinkProjectToWorkspaceJSONRequestBody defines body for LinkProjectToWorkspace for application/json ContentType. type LinkProjectToWorkspaceJSONRequestBody = LinkProjectRequest +// ShareWorkspaceToGroupJSONRequestBody defines body for ShareWorkspaceToGroup for application/json ContentType. +type ShareWorkspaceToGroupJSONRequestBody = ShareToGroupRequest + // ServerInterface represents all server handlers. type ServerInterface interface { // List GGUF model files cached on disk (admin only) @@ -2427,6 +2568,30 @@ type ServerInterface interface { // Re-register all webhook_mode=auto repos against the current public URL // (POST /api/v1/github/webhooks/reconcile) ReconcileWebhooks(w http.ResponseWriter, r *http.Request) + // List view-groups + // (GET /api/v1/groups) + ListGroups(w http.ResponseWriter, r *http.Request) + // Create a view-group (admin only) + // (POST /api/v1/groups) + CreateGroup(w http.ResponseWriter, r *http.Request) + // Delete a view-group (admin only) + // (DELETE /api/v1/groups/{id}) + DeleteGroup(w http.ResponseWriter, r *http.Request, id string) + // Get a view-group (admin only) + // (GET /api/v1/groups/{id}) + GetGroup(w http.ResponseWriter, r *http.Request, id string) + // Update a view-group (admin only) + // (PATCH /api/v1/groups/{id}) + UpdateGroup(w http.ResponseWriter, r *http.Request, id string) + // List members of a view-group (admin only) + // (GET /api/v1/groups/{id}/members) + ListGroupMembers(w http.ResponseWriter, r *http.Request, id string) + // Add a user to a view-group (admin only) + // (POST /api/v1/groups/{id}/members) + AddGroupMember(w http.ResponseWriter, r *http.Request, id string) + // Remove a user from a view-group (admin only) + // (DELETE /api/v1/groups/{id}/members/{userId}) + RemoveGroupMember(w http.ResponseWriter, r *http.Request, id string, userId string) // List background jobs (status / type filter) // (GET /api/v1/jobs) ListJobs(w http.ResponseWriter, r *http.Request, params ListJobsParams) @@ -2445,9 +2610,21 @@ type ServerInterface interface { // Reconfigure how an external project is kept in sync // (PATCH /api/v1/projects/{hash}/git-repo) UpdateProjectGitRepoSync(w http.ResponseWriter, r *http.Request, hash string) + // Reassign the owner of a local project (admin only) + // (PUT /api/v1/projects/{hash}/owner) + ReassignProjectOwner(w http.ResponseWriter, r *http.Request, hash string) // Manually re-trigger the clone + index pipeline // (POST /api/v1/projects/{hash}/reindex) ReindexProject(w http.ResponseWriter, r *http.Request, hash string, params ReindexProjectParams) + // List the view-groups an external project is shared to (admin only) + // (GET /api/v1/projects/{hash}/shares) + ListProjectShares(w http.ResponseWriter, r *http.Request, hash string) + // Share an external project to a view-group (admin only) + // (POST /api/v1/projects/{hash}/shares) + ShareProjectToGroup(w http.ResponseWriter, r *http.Request, hash string) + // Revoke a project↔group share (admin only) + // (DELETE /api/v1/projects/{hash}/shares/{groupId}) + UnshareProjectFromGroup(w http.ResponseWriter, r *http.Request, hash string, groupId string) // Webhook URL + secret for manual GitHub setup // (GET /api/v1/projects/{hash}/webhook-info) GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request, hash string) @@ -2556,6 +2733,15 @@ type ServerInterface interface { // Hybrid BM25+dense search across all repos in a workspace // (GET /api/v1/workspaces/{id}/search) WorkspaceSearch(w http.ResponseWriter, r *http.Request, id string, params WorkspaceSearchParams) + // List the view-groups a workspace is shared to + // (GET /api/v1/workspaces/{id}/shares) + ListWorkspaceShares(w http.ResponseWriter, r *http.Request, id string) + // Share a workspace to a view-group + // (POST /api/v1/workspaces/{id}/shares) + ShareWorkspaceToGroup(w http.ResponseWriter, r *http.Request, id string) + // Revoke a workspace↔group share (owner or admin) + // (DELETE /api/v1/workspaces/{id}/shares/{groupId}) + UnshareWorkspaceFromGroup(w http.ResponseWriter, r *http.Request, id string, groupId string) // Liveness probe (public) // (GET /health) GetHealth(w http.ResponseWriter, r *http.Request) @@ -2727,6 +2913,54 @@ func (_ Unimplemented) ReconcileWebhooks(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNotImplemented) } +// List view-groups +// (GET /api/v1/groups) +func (_ Unimplemented) ListGroups(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Create a view-group (admin only) +// (POST /api/v1/groups) +func (_ Unimplemented) CreateGroup(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Delete a view-group (admin only) +// (DELETE /api/v1/groups/{id}) +func (_ Unimplemented) DeleteGroup(w http.ResponseWriter, r *http.Request, id string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Get a view-group (admin only) +// (GET /api/v1/groups/{id}) +func (_ Unimplemented) GetGroup(w http.ResponseWriter, r *http.Request, id string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Update a view-group (admin only) +// (PATCH /api/v1/groups/{id}) +func (_ Unimplemented) UpdateGroup(w http.ResponseWriter, r *http.Request, id string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// List members of a view-group (admin only) +// (GET /api/v1/groups/{id}/members) +func (_ Unimplemented) ListGroupMembers(w http.ResponseWriter, r *http.Request, id string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Add a user to a view-group (admin only) +// (POST /api/v1/groups/{id}/members) +func (_ Unimplemented) AddGroupMember(w http.ResponseWriter, r *http.Request, id string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Remove a user from a view-group (admin only) +// (DELETE /api/v1/groups/{id}/members/{userId}) +func (_ Unimplemented) RemoveGroupMember(w http.ResponseWriter, r *http.Request, id string, userId string) { + w.WriteHeader(http.StatusNotImplemented) +} + // List background jobs (status / type filter) // (GET /api/v1/jobs) func (_ Unimplemented) ListJobs(w http.ResponseWriter, r *http.Request, params ListJobsParams) { @@ -2763,12 +2997,36 @@ func (_ Unimplemented) UpdateProjectGitRepoSync(w http.ResponseWriter, r *http.R w.WriteHeader(http.StatusNotImplemented) } +// Reassign the owner of a local project (admin only) +// (PUT /api/v1/projects/{hash}/owner) +func (_ Unimplemented) ReassignProjectOwner(w http.ResponseWriter, r *http.Request, hash string) { + w.WriteHeader(http.StatusNotImplemented) +} + // Manually re-trigger the clone + index pipeline // (POST /api/v1/projects/{hash}/reindex) func (_ Unimplemented) ReindexProject(w http.ResponseWriter, r *http.Request, hash string, params ReindexProjectParams) { w.WriteHeader(http.StatusNotImplemented) } +// List the view-groups an external project is shared to (admin only) +// (GET /api/v1/projects/{hash}/shares) +func (_ Unimplemented) ListProjectShares(w http.ResponseWriter, r *http.Request, hash string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Share an external project to a view-group (admin only) +// (POST /api/v1/projects/{hash}/shares) +func (_ Unimplemented) ShareProjectToGroup(w http.ResponseWriter, r *http.Request, hash string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Revoke a project↔group share (admin only) +// (DELETE /api/v1/projects/{hash}/shares/{groupId}) +func (_ Unimplemented) UnshareProjectFromGroup(w http.ResponseWriter, r *http.Request, hash string, groupId string) { + w.WriteHeader(http.StatusNotImplemented) +} + // Webhook URL + secret for manual GitHub setup // (GET /api/v1/projects/{hash}/webhook-info) func (_ Unimplemented) GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request, hash string) { @@ -2985,6 +3243,24 @@ func (_ Unimplemented) WorkspaceSearch(w http.ResponseWriter, r *http.Request, i w.WriteHeader(http.StatusNotImplemented) } +// List the view-groups a workspace is shared to +// (GET /api/v1/workspaces/{id}/shares) +func (_ Unimplemented) ListWorkspaceShares(w http.ResponseWriter, r *http.Request, id string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Share a workspace to a view-group +// (POST /api/v1/workspaces/{id}/shares) +func (_ Unimplemented) ShareWorkspaceToGroup(w http.ResponseWriter, r *http.Request, id string) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Revoke a workspace↔group share (owner or admin) +// (DELETE /api/v1/workspaces/{id}/shares/{groupId}) +func (_ Unimplemented) UnshareWorkspaceFromGroup(w http.ResponseWriter, r *http.Request, id string, groupId string) { + w.WriteHeader(http.StatusNotImplemented) +} + // Liveness probe (public) // (GET /health) func (_ Unimplemented) GetHealth(w http.ResponseWriter, r *http.Request) { @@ -3673,11 +3949,8 @@ func (siw *ServerInterfaceWrapper) ReconcileWebhooks(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } -// ListJobs operation middleware -func (siw *ServerInterfaceWrapper) ListJobs(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err +// ListGroups operation middleware +func (siw *ServerInterfaceWrapper) ListGroups(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -3685,50 +3958,8 @@ func (siw *ServerInterfaceWrapper) ListJobs(w http.ResponseWriter, r *http.Reque r = r.WithContext(ctx) - // Parameter object where we will unmarshal all parameters from the context - var params ListJobsParams - - // ------------- Optional query parameter "status" ------------- - - err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) - } - return - } - - // ------------- Optional query parameter "type" ------------- - - err = runtime.BindQueryParameterWithOptions("form", true, false, "type", r.URL.Query(), ¶ms.Type, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "type"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "type", Err: err}) - } - return - } - - // ------------- Optional query parameter "limit" ------------- - - err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) - } - return - } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListJobs(w, r, params) + siw.Handler.ListGroups(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3738,8 +3969,8 @@ func (siw *ServerInterfaceWrapper) ListJobs(w http.ResponseWriter, r *http.Reque handler.ServeHTTP(w, r) } -// ListProjects operation middleware -func (siw *ServerInterfaceWrapper) ListProjects(w http.ResponseWriter, r *http.Request) { +// CreateGroup operation middleware +func (siw *ServerInterfaceWrapper) CreateGroup(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -3748,7 +3979,7 @@ func (siw *ServerInterfaceWrapper) ListProjects(w http.ResponseWriter, r *http.R r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ListProjects(w, r) + siw.Handler.CreateGroup(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3758,8 +3989,20 @@ func (siw *ServerInterfaceWrapper) ListProjects(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } -// CreateProject operation middleware -func (siw *ServerInterfaceWrapper) CreateProject(w http.ResponseWriter, r *http.Request) { +// DeleteGroup operation middleware +func (siw *ServerInterfaceWrapper) DeleteGroup(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } ctx := r.Context() @@ -3768,7 +4011,7 @@ func (siw *ServerInterfaceWrapper) CreateProject(w http.ResponseWriter, r *http. r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.CreateProject(w, r) + siw.Handler.DeleteGroup(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3778,18 +4021,18 @@ func (siw *ServerInterfaceWrapper) CreateProject(w http.ResponseWriter, r *http. handler.ServeHTTP(w, r) } -// ForceStopIndex operation middleware -func (siw *ServerInterfaceWrapper) ForceStopIndex(w http.ResponseWriter, r *http.Request) { +// GetGroup operation middleware +func (siw *ServerInterfaceWrapper) GetGroup(w http.ResponseWriter, r *http.Request) { var err error _ = err - // ------------- Path parameter "hash" ------------- - var hash string + // ------------- Path parameter "id" ------------- + var id string - err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) return } @@ -3800,7 +4043,7 @@ func (siw *ServerInterfaceWrapper) ForceStopIndex(w http.ResponseWriter, r *http r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ForceStopIndex(w, r, hash) + siw.Handler.GetGroup(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3810,18 +4053,18 @@ func (siw *ServerInterfaceWrapper) ForceStopIndex(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } -// GetProjectGitRepo operation middleware -func (siw *ServerInterfaceWrapper) GetProjectGitRepo(w http.ResponseWriter, r *http.Request) { +// UpdateGroup operation middleware +func (siw *ServerInterfaceWrapper) UpdateGroup(w http.ResponseWriter, r *http.Request) { var err error _ = err - // ------------- Path parameter "hash" ------------- - var hash string + // ------------- Path parameter "id" ------------- + var id string - err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) return } @@ -3832,7 +4075,7 @@ func (siw *ServerInterfaceWrapper) GetProjectGitRepo(w http.ResponseWriter, r *h r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetProjectGitRepo(w, r, hash) + siw.Handler.UpdateGroup(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3842,18 +4085,18 @@ func (siw *ServerInterfaceWrapper) GetProjectGitRepo(w http.ResponseWriter, r *h handler.ServeHTTP(w, r) } -// UpdateProjectGitRepoSync operation middleware -func (siw *ServerInterfaceWrapper) UpdateProjectGitRepoSync(w http.ResponseWriter, r *http.Request) { +// ListGroupMembers operation middleware +func (siw *ServerInterfaceWrapper) ListGroupMembers(w http.ResponseWriter, r *http.Request) { var err error _ = err - // ------------- Path parameter "hash" ------------- - var hash string + // ------------- Path parameter "id" ------------- + var id string - err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) return } @@ -3864,7 +4107,7 @@ func (siw *ServerInterfaceWrapper) UpdateProjectGitRepoSync(w http.ResponseWrite r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UpdateProjectGitRepoSync(w, r, hash) + siw.Handler.ListGroupMembers(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3874,18 +4117,18 @@ func (siw *ServerInterfaceWrapper) UpdateProjectGitRepoSync(w http.ResponseWrite handler.ServeHTTP(w, r) } -// ReindexProject operation middleware -func (siw *ServerInterfaceWrapper) ReindexProject(w http.ResponseWriter, r *http.Request) { +// AddGroupMember operation middleware +func (siw *ServerInterfaceWrapper) AddGroupMember(w http.ResponseWriter, r *http.Request) { var err error _ = err - // ------------- Path parameter "hash" ------------- - var hash string + // ------------- Path parameter "id" ------------- + var id string - err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) return } @@ -3895,24 +4138,8 @@ func (siw *ServerInterfaceWrapper) ReindexProject(w http.ResponseWriter, r *http r = r.WithContext(ctx) - // Parameter object where we will unmarshal all parameters from the context - var params ReindexProjectParams - - // ------------- Optional query parameter "full" ------------- - - err = runtime.BindQueryParameterWithOptions("form", true, false, "full", r.URL.Query(), ¶ms.Full, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "full"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "full", Err: err}) - } - return - } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ReindexProject(w, r, hash, params) + siw.Handler.AddGroupMember(w, r, id) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3922,18 +4149,27 @@ func (siw *ServerInterfaceWrapper) ReindexProject(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } -// GetProjectWebhookInfo operation middleware -func (siw *ServerInterfaceWrapper) GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request) { +// RemoveGroupMember operation middleware +func (siw *ServerInterfaceWrapper) RemoveGroupMember(w http.ResponseWriter, r *http.Request) { var err error _ = err - // ------------- Path parameter "hash" ------------- - var hash string + // ------------- Path parameter "id" ------------- + var id string - err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "userId" ------------- + var userId string + + err = runtime.BindStyledParameterWithOptions("simple", "userId", chi.URLParam(r, "userId"), &userId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "userId", Err: err}) return } @@ -3944,7 +4180,7 @@ func (siw *ServerInterfaceWrapper) GetProjectWebhookInfo(w http.ResponseWriter, r = r.WithContext(ctx) handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetProjectWebhookInfo(w, r, hash) + siw.Handler.RemoveGroupMember(w, r, id, userId) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3954,29 +4190,447 @@ func (siw *ServerInterfaceWrapper) GetProjectWebhookInfo(w http.ResponseWriter, handler.ServeHTTP(w, r) } -// DeleteProject operation middleware -func (siw *ServerInterfaceWrapper) DeleteProject(w http.ResponseWriter, r *http.Request) { +// ListJobs operation middleware +func (siw *ServerInterfaceWrapper) ListJobs(w http.ResponseWriter, r *http.Request) { var err error _ = err - // ------------- Path parameter "path" ------------- - var path ProjectHash - - err = runtime.BindStyledParameterWithOptions("simple", "path", chi.URLParam(r, "path"), &path, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) - return - } - ctx := r.Context() ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) r = r.WithContext(ctx) - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.DeleteProject(w, r, path) + // Parameter object where we will unmarshal all parameters from the context + var params ListJobsParams + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + } + return + } + + // ------------- Optional query parameter "type" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "type", r.URL.Query(), ¶ms.Type, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "type"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "type", Err: err}) + } + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListJobs(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListProjects operation middleware +func (siw *ServerInterfaceWrapper) ListProjects(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListProjects(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// CreateProject operation middleware +func (siw *ServerInterfaceWrapper) CreateProject(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateProject(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ForceStopIndex operation middleware +func (siw *ServerInterfaceWrapper) ForceStopIndex(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "hash" ------------- + var hash string + + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ForceStopIndex(w, r, hash) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetProjectGitRepo operation middleware +func (siw *ServerInterfaceWrapper) GetProjectGitRepo(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "hash" ------------- + var hash string + + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetProjectGitRepo(w, r, hash) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateProjectGitRepoSync operation middleware +func (siw *ServerInterfaceWrapper) UpdateProjectGitRepoSync(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "hash" ------------- + var hash string + + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateProjectGitRepoSync(w, r, hash) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ReassignProjectOwner operation middleware +func (siw *ServerInterfaceWrapper) ReassignProjectOwner(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "hash" ------------- + var hash string + + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ReassignProjectOwner(w, r, hash) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ReindexProject operation middleware +func (siw *ServerInterfaceWrapper) ReindexProject(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "hash" ------------- + var hash string + + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + // Parameter object where we will unmarshal all parameters from the context + var params ReindexProjectParams + + // ------------- Optional query parameter "full" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "full", r.URL.Query(), ¶ms.Full, runtime.BindQueryParameterOptions{Type: "boolean", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "full"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "full", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ReindexProject(w, r, hash, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ListProjectShares operation middleware +func (siw *ServerInterfaceWrapper) ListProjectShares(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "hash" ------------- + var hash string + + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListProjectShares(w, r, hash) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ShareProjectToGroup operation middleware +func (siw *ServerInterfaceWrapper) ShareProjectToGroup(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "hash" ------------- + var hash string + + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ShareProjectToGroup(w, r, hash) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UnshareProjectFromGroup operation middleware +func (siw *ServerInterfaceWrapper) UnshareProjectFromGroup(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "hash" ------------- + var hash string + + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + return + } + + // ------------- Path parameter "groupId" ------------- + var groupId string + + err = runtime.BindStyledParameterWithOptions("simple", "groupId", chi.URLParam(r, "groupId"), &groupId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "groupId", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UnshareProjectFromGroup(w, r, hash, groupId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetProjectWebhookInfo operation middleware +func (siw *ServerInterfaceWrapper) GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "hash" ------------- + var hash string + + err = runtime.BindStyledParameterWithOptions("simple", "hash", chi.URLParam(r, "hash"), &hash, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "hash", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetProjectWebhookInfo(w, r, hash) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DeleteProject operation middleware +func (siw *ServerInterfaceWrapper) DeleteProject(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "path" ------------- + var path ProjectHash + + err = runtime.BindStyledParameterWithOptions("simple", "path", chi.URLParam(r, "path"), &path, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DeleteProject(w, r, path) })) for _, middleware := range siw.HandlerMiddlewares { @@ -5079,6 +5733,111 @@ func (siw *ServerInterfaceWrapper) WorkspaceSearch(w http.ResponseWriter, r *htt handler.ServeHTTP(w, r) } +// ListWorkspaceShares operation middleware +func (siw *ServerInterfaceWrapper) ListWorkspaceShares(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListWorkspaceShares(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ShareWorkspaceToGroup operation middleware +func (siw *ServerInterfaceWrapper) ShareWorkspaceToGroup(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ShareWorkspaceToGroup(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UnshareWorkspaceFromGroup operation middleware +func (siw *ServerInterfaceWrapper) UnshareWorkspaceFromGroup(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + // ------------- Path parameter "groupId" ------------- + var groupId string + + err = runtime.BindStyledParameterWithOptions("simple", "groupId", chi.URLParam(r, "groupId"), &groupId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "groupId", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UnshareWorkspaceFromGroup(w, r, id, groupId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // GetHealth operation middleware func (siw *ServerInterfaceWrapper) GetHealth(w http.ResponseWriter, r *http.Request) { @@ -5287,6 +6046,30 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/github/webhooks/reconcile", wrapper.ReconcileWebhooks) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/groups", wrapper.ListGroups) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/groups", wrapper.CreateGroup) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/api/v1/groups/{id}", wrapper.DeleteGroup) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/groups/{id}", wrapper.GetGroup) + }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/api/v1/groups/{id}", wrapper.UpdateGroup) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/groups/{id}/members", wrapper.ListGroupMembers) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/groups/{id}/members", wrapper.AddGroupMember) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/api/v1/groups/{id}/members/{userId}", wrapper.RemoveGroupMember) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/jobs", wrapper.ListJobs) }) @@ -5305,9 +6088,21 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Patch(options.BaseURL+"/api/v1/projects/{hash}/git-repo", wrapper.UpdateProjectGitRepoSync) }) + r.Group(func(r chi.Router) { + r.Put(options.BaseURL+"/api/v1/projects/{hash}/owner", wrapper.ReassignProjectOwner) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/projects/{hash}/reindex", wrapper.ReindexProject) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/projects/{hash}/shares", wrapper.ListProjectShares) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/projects/{hash}/shares", wrapper.ShareProjectToGroup) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/api/v1/projects/{hash}/shares/{groupId}", wrapper.UnshareProjectFromGroup) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/projects/{hash}/webhook-info", wrapper.GetProjectWebhookInfo) }) @@ -5416,6 +6211,15 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/workspaces/{id}/search", wrapper.WorkspaceSearch) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/workspaces/{id}/shares", wrapper.ListWorkspaceShares) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/workspaces/{id}/shares", wrapper.ShareWorkspaceToGroup) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/api/v1/workspaces/{id}/shares/{groupId}", wrapper.UnshareWorkspaceFromGroup) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/health", wrapper.GetHealth) }) @@ -5428,424 +6232,455 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go index 52f4ac1..2be7bf5 100644 --- a/server/internal/httpapi/router.go +++ b/server/internal/httpapi/router.go @@ -15,6 +15,7 @@ import ( "github.com/dvcdsys/code-index/server/internal/embeddings" "github.com/dvcdsys/code-index/server/internal/gitrepos" "github.com/dvcdsys/code-index/server/internal/githubtokens" + "github.com/dvcdsys/code-index/server/internal/groups" "github.com/dvcdsys/code-index/server/internal/httpapi/openapi" "github.com/dvcdsys/code-index/server/internal/indexer" "github.com/dvcdsys/code-index/server/internal/jobs" @@ -63,6 +64,10 @@ type Deps struct { Users *users.Service Sessions *sessions.Service APIKeys *apikeys.Service + // Groups backs the view-group auth model (admin-managed sets of users + // that external projects + workspaces are shared to). Required in + // production; nil + AuthDisabled is tolerated by tests. + Groups *groups.Service // EmbeddingSvc is the in-process embeddings service. May be nil when the // server is started with CIX_EMBEDDINGS_ENABLED=false (e.g. in router // tests). Phase 5 uses it for semantic search. diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index eaae8a1..fb67367 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/dvcdsys/code-index/server/internal/access" "github.com/dvcdsys/code-index/server/internal/embeddings" "github.com/dvcdsys/code-index/server/internal/httpapi/openapi" "github.com/dvcdsys/code-index/server/internal/indexer" @@ -145,7 +146,23 @@ func (s *Server) CreateProject(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnprocessableEntity, "host_path is required") return } - p, err := projects.Create(r.Context(), s.Deps.DB, projects.CreateRequest{HostPath: body.HostPath}) + // A project created here is a personal/local project owned by the caller. + // External projects are created ownerless via AddGitRepo instead. + ownerID, _ := s.callerIdentity(r) + machineID := "" + if body.MachineId != nil { + machineID = *body.MachineId + } + machineLabel := "" + if body.MachineLabel != nil { + machineLabel = *body.MachineLabel + } + p, err := projects.Create(r.Context(), s.Deps.DB, projects.CreateRequest{ + HostPath: body.HostPath, + OwnerUserID: ownerID, + MachineID: machineID, + MachineLabel: machineLabel, + }) if err != nil { if errors.Is(err, projects.ErrConflict) || errors.Is(err, projects.ErrOverlap) { writeError(w, http.StatusConflict, err.Error()) @@ -157,13 +174,34 @@ func (s *Server) CreateProject(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, projectToOpenAPI(p)) } -// ListProjects — GET /api/v1/projects. +// ListProjects — GET /api/v1/projects. Filtered by access: admins see every +// project; a regular user sees the projects they own plus external projects +// shared to a view-group they belong to. func (s *Server) ListProjects(w http.ResponseWriter, r *http.Request) { list, err := projects.List(r.Context(), s.Deps.DB) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } + userID, isAdmin := s.callerIdentity(r) + if !isAdmin { + allowed, err := access.AccessibleProjectHostPaths(r.Context(), s.Deps.DB, userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "access check failed") + return + } + allowedSet := make(map[string]struct{}, len(allowed)) + for _, hp := range allowed { + allowedSet[hp] = struct{}{} + } + filtered := list[:0] + for _, p := range list { + if _, ok := allowedSet[p.HostPath]; ok { + filtered = append(filtered, p) + } + } + list = filtered + } out := make([]openapi.Project, 0, len(list)) for i := range list { out = append(out, projectToOpenAPI(&list[i])) @@ -176,7 +214,7 @@ func (s *Server) ListProjects(w http.ResponseWriter, r *http.Request) { // GetProject — GET /api/v1/projects/{path}. func (s *Server) GetProject(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - p := s.lookupProject(w, r, path) + p := s.requireProjectAccess(w, r) if p == nil { return } @@ -246,10 +284,7 @@ func dirSizeBytes(dir string) (int64, bool) { // changes can shrink the indexing surface (exclude_patterns, max_file_size) // and viewers should not be able to silently de-index a project. func (s *Server) UpdateProject(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - if _, ok := s.mustBeAdmin(w, r); !ok { - return - } - p := s.lookupProject(w, r, path) + p := s.requireProjectOwnership(w, r) if p == nil { return } @@ -278,10 +313,7 @@ func (s *Server) UpdateProject(w http.ResponseWriter, r *http.Request, path open // project also wipes its symbols/refs/embeddings and is destructive enough // that it must not be reachable from a viewer-scoped session or API key. func (s *Server) DeleteProject(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - if _, ok := s.mustBeAdmin(w, r); !ok { - return - } - p := s.lookupProject(w, r, path) + p := s.requireProjectOwnership(w, r) if p == nil { return } @@ -298,7 +330,7 @@ func (s *Server) DeleteProject(w http.ResponseWriter, r *http.Request, path open // SearchSymbols — POST /api/v1/projects/{path}/search/symbols. func (s *Server) SearchSymbols(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - p := s.lookupProject(w, r, path) + p := s.requireProjectAccess(w, r) if p == nil { return } @@ -340,7 +372,7 @@ func (s *Server) SearchSymbols(w http.ResponseWriter, r *http.Request, path open // SearchDefinitions — POST /api/v1/projects/{path}/search/definitions. func (s *Server) SearchDefinitions(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - p := s.lookupProject(w, r, path) + p := s.requireProjectAccess(w, r) if p == nil { return } @@ -383,7 +415,7 @@ func (s *Server) SearchDefinitions(w http.ResponseWriter, r *http.Request, path // SearchReferences — POST /api/v1/projects/{path}/search/references. func (s *Server) SearchReferences(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - p := s.lookupProject(w, r, path) + p := s.requireProjectAccess(w, r) if p == nil { return } @@ -424,7 +456,7 @@ func (s *Server) SearchReferences(w http.ResponseWriter, r *http.Request, path o // SearchFiles — POST /api/v1/projects/{path}/search/files. func (s *Server) SearchFiles(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - p := s.lookupProject(w, r, path) + p := s.requireProjectAccess(w, r) if p == nil { return } @@ -483,7 +515,7 @@ func (s *Server) SearchFiles(w http.ResponseWriter, r *http.Request, path openap // GetProjectSummary — GET /api/v1/projects/{path}/summary. func (s *Server) GetProjectSummary(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - p := s.lookupProject(w, r, path) + p := s.requireProjectAccess(w, r) if p == nil { return } @@ -583,7 +615,7 @@ func (s *Server) GetProjectSummary(w http.ResponseWriter, r *http.Request, path // SemanticSearch — POST /api/v1/projects/{path}/search. func (s *Server) SemanticSearch(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - p := s.lookupProject(w, r, path) + p := s.requireProjectAccess(w, r) if p == nil { return } @@ -742,7 +774,7 @@ func fileGroupsToOpenAPI(in []fileGroupResult) []openapi.FileGroupResult { // IndexBegin — POST /api/v1/projects/{path}/index/begin. func (s *Server) IndexBegin(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - p := s.lookupProject(w, r, path) + p := s.requireProjectOwnership(w, r) if p == nil { return } @@ -783,7 +815,7 @@ func (s *Server) IndexBegin(w http.ResponseWriter, r *http.Request, path openapi // Honours `Accept: application/x-ndjson` to switch into the streaming // variant; otherwise returns the legacy single-JSON summary. func (s *Server) IndexFiles(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash, params openapi.IndexFilesParams) { - p := s.lookupProject(w, r, path) + p := s.requireProjectOwnership(w, r) if p == nil { return } @@ -848,7 +880,7 @@ func (s *Server) IndexFiles(w http.ResponseWriter, r *http.Request, path openapi // IndexFinish — POST /api/v1/projects/{path}/index/finish. func (s *Server) IndexFinish(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - p := s.lookupProject(w, r, path) + p := s.requireProjectOwnership(w, r) if p == nil { return } @@ -893,7 +925,7 @@ func (s *Server) IndexFinish(w http.ResponseWriter, r *http.Request, path openap // theoretical DoS we'd be preventing. If you need owner-scoped semantics // later, key off projects.indexing_run.started_by_user_id. func (s *Server) IndexCancel(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - p := s.lookupProject(w, r, path) + p := s.requireProjectOwnership(w, r) if p == nil { return } @@ -911,7 +943,7 @@ func (s *Server) IndexCancel(w http.ResponseWriter, r *http.Request, path openap // IndexStatus — GET /api/v1/projects/{path}/index/status. func (s *Server) IndexStatus(w http.ResponseWriter, r *http.Request, path openapi.ProjectHash) { - p := s.lookupProject(w, r, path) + p := s.requireProjectOwnership(w, r) if p == nil { return } @@ -963,17 +995,9 @@ func (s *Server) IndexStatus(w http.ResponseWriter, r *http.Request, path openap } // --------------------------------------------------------------------------- -// Internal helpers — type conversion + legacy lookup wrapper +// Internal helpers — type conversion // --------------------------------------------------------------------------- -// lookupProject resolves the {path} URL parameter. Wraps resolveProjectFromHash -// so generated method signatures stay clean. -func (s *Server) lookupProject(w http.ResponseWriter, r *http.Request, _ openapi.ProjectHash) *projects.Project { - // Use the helper that already exists in search.go — it pulls the - // {path} chi URL param from r and writes a 404 on miss. - return resolveProjectFromHash(w, r, s.Deps) -} - // projectToOpenAPI converts the internal projects.Project (string dates, // flat Settings/Stats) into the generated openapi.Project (time.Time dates, // embedded openapi.ProjectSettings/Stats). @@ -1020,6 +1044,16 @@ func projectToOpenAPI(p *projects.Project) openapi.Project { v := *p.IndexedWithModel out.IndexedWithModel = &v } + dp := p.DisplayPath + out.DisplayPath = &dp + if p.MachineID != nil { + v := *p.MachineID + out.MachineId = &v + } + if p.MachineLabel != nil { + v := *p.MachineLabel + out.MachineLabel = &v + } return out } diff --git a/server/internal/httpapi/webhooks.go b/server/internal/httpapi/webhooks.go index 5c9fb62..3c53bf1 100644 --- a/server/internal/httpapi/webhooks.go +++ b/server/internal/httpapi/webhooks.go @@ -20,11 +20,14 @@ import ( // GetProjectWebhookInfo — GET /api/v1/projects/{hash}/webhook-info. // -// Authenticated. Returns the publicly-reachable webhook URL + the HMAC -// secret. Operators copy these into GitHub's webhook config when -// webhook_mode is not 'auto'. 404 when the project is local (no -// git_repos row). +// Admin-only. Returns the publicly-reachable webhook URL + the HMAC +// secret (a credential) for an external project. Operators copy these into +// GitHub's webhook config when webhook_mode is not 'auto'. 404 when the +// project is local (no git_repos row). func (s *Server) GetProjectWebhookInfo(w http.ResponseWriter, r *http.Request, hash openapi.ProjectHash) { + if _, ok := s.mustBeAdmin(w, r); !ok { + return + } if s.gitReposUnavailable(w) { return } diff --git a/server/internal/httpapi/workspaceprojects.go b/server/internal/httpapi/workspaceprojects.go index 1836340..68edcb5 100644 --- a/server/internal/httpapi/workspaceprojects.go +++ b/server/internal/httpapi/workspaceprojects.go @@ -5,10 +5,10 @@ import ( "errors" "net/http" + "github.com/dvcdsys/code-index/server/internal/access" "github.com/dvcdsys/code-index/server/internal/httpapi/openapi" "github.com/dvcdsys/code-index/server/internal/projects" "github.com/dvcdsys/code-index/server/internal/workspaceprojects" - "github.com/dvcdsys/code-index/server/internal/workspaces" ) // workspaceProjectsUnavailable returns 503 when any required service @@ -22,26 +22,15 @@ func (s *Server) workspaceProjectsUnavailable(w http.ResponseWriter) bool { return false } -// requireWorkspace loads the parent workspace and returns 404 if missing. -func (s *Server) requireWorkspace(w http.ResponseWriter, r *http.Request, workspaceID string) bool { - _, err := s.Deps.Workspaces.GetByID(r.Context(), workspaceID) - if err != nil { - if errors.Is(err, workspaces.ErrNotFound) { - writeError(w, http.StatusNotFound, "workspace not found") - } else { - writeError(w, http.StatusInternalServerError, "could not load workspace") - } - return false - } - return true -} - -// ListWorkspaceProjects — GET /api/v1/workspaces/{id}/projects. +// ListWorkspaceProjects — GET /api/v1/workspaces/{id}/projects. Visible to +// owner/group-members/admin; per the decoupled model, projects the caller +// cannot access are dropped from the listing rather than blocking the whole +// workspace. func (s *Server) ListWorkspaceProjects(w http.ResponseWriter, r *http.Request, id string) { if s.workspaceProjectsUnavailable(w) { return } - if !s.requireWorkspace(w, r, id) { + if _, ok := s.requireWorkspaceVisible(w, r, id); !ok { return } memberships, err := s.Deps.WorkspaceProjects.ListByWorkspace(r.Context(), id) @@ -49,8 +38,26 @@ func (s *Server) ListWorkspaceProjects(w http.ResponseWriter, r *http.Request, i writeError(w, http.StatusInternalServerError, "could not list workspace projects: "+err.Error()) return } + userID, isAdmin := s.callerIdentity(r) + var allowed map[string]struct{} + if !isAdmin { + hosts, aerr := access.AccessibleProjectHostPaths(r.Context(), s.Deps.DB, userID) + if aerr != nil { + writeError(w, http.StatusInternalServerError, "access check failed") + return + } + allowed = make(map[string]struct{}, len(hosts)) + for _, hp := range hosts { + allowed[hp] = struct{}{} + } + } out := make([]map[string]any, 0, len(memberships)) for _, m := range memberships { + if !isAdmin { + if _, ok := allowed[m.ProjectPath]; !ok { + continue // hidden from this caller + } + } proj, perr := projects.Get(r.Context(), s.Deps.DB, m.ProjectPath) if perr != nil { // The FK should prevent dangling rows, but if the project @@ -79,7 +86,7 @@ func (s *Server) LinkProjectToWorkspace(w http.ResponseWriter, r *http.Request, if s.workspaceProjectsUnavailable(w) { return } - if !s.requireWorkspace(w, r, id) { + if _, ok := s.requireWorkspaceOwnership(w, r, id); !ok { return } var body openapi.LinkProjectRequest @@ -96,6 +103,11 @@ func (s *Server) LinkProjectToWorkspace(w http.ResponseWriter, r *http.Request, writeError(w, http.StatusInternalServerError, "could not load project: "+perr.Error()) return } + // The caller may only link projects they can access (hide others as 404). + if !s.canAccessProject(r, proj) { + writeError(w, http.StatusNotFound, "project not found") + return + } m, err := s.Deps.WorkspaceProjects.Link(r.Context(), id, proj.HostPath) if err != nil { switch { @@ -128,7 +140,7 @@ func (s *Server) UnlinkProjectFromWorkspace(w http.ResponseWriter, r *http.Reque if s.workspaceProjectsUnavailable(w) { return } - if !s.requireWorkspace(w, r, id) { + if _, ok := s.requireWorkspaceOwnership(w, r, id); !ok { return } proj, perr := projects.GetByHash(r.Context(), s.Deps.DB, string(hash)) diff --git a/server/internal/httpapi/workspaces.go b/server/internal/httpapi/workspaces.go index 9974046..3fc03f7 100644 --- a/server/internal/httpapi/workspaces.go +++ b/server/internal/httpapi/workspaces.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "github.com/dvcdsys/code-index/server/internal/access" "github.com/dvcdsys/code-index/server/internal/httpapi/openapi" "github.com/dvcdsys/code-index/server/internal/workspaces" ) @@ -52,6 +53,25 @@ func (s *Server) ListWorkspaces(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "could not list workspaces") return } + userID, isAdmin := s.callerIdentity(r) + if !isAdmin { + visible, err := access.VisibleWorkspaceIDs(r.Context(), s.Deps.DB, userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "access check failed") + return + } + visibleSet := make(map[string]struct{}, len(visible)) + for _, id := range visible { + visibleSet[id] = struct{}{} + } + filtered := list[:0] + for _, ws := range list { + if _, ok := visibleSet[ws.ID]; ok { + filtered = append(filtered, ws) + } + } + list = filtered + } out := make([]workspacePayload, 0, len(list)) for _, ws := range list { out = append(out, workspaceToPayload(ws)) @@ -76,7 +96,8 @@ func (s *Server) CreateWorkspace(w http.ResponseWriter, r *http.Request) { if body.Description != nil { description = *body.Description } - ws, err := s.Deps.Workspaces.Create(r.Context(), body.Name, description) + ownerID, _ := s.callerIdentity(r) + ws, err := s.Deps.Workspaces.CreateOwned(r.Context(), body.Name, description, ownerID) if err != nil { switch { case errors.Is(err, workspaces.ErrNameEmpty): @@ -96,13 +117,8 @@ func (s *Server) GetWorkspace(w http.ResponseWriter, r *http.Request, id string) if s.workspacesUnavailable(w) { return } - ws, err := s.Deps.Workspaces.GetByID(r.Context(), id) - if err != nil { - if errors.Is(err, workspaces.ErrNotFound) { - writeError(w, http.StatusNotFound, "workspace not found") - return - } - writeError(w, http.StatusInternalServerError, "could not load workspace") + ws, ok := s.requireWorkspaceVisible(w, r, id) + if !ok { return } writeJSON(w, http.StatusOK, workspaceToPayload(ws)) @@ -113,6 +129,9 @@ func (s *Server) UpdateWorkspace(w http.ResponseWriter, r *http.Request, id stri if s.workspacesUnavailable(w) { return } + if _, ok := s.requireWorkspaceOwnership(w, r, id); !ok { + return + } var body openapi.UpdateWorkspaceRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusUnprocessableEntity, "invalid JSON body") @@ -140,6 +159,9 @@ func (s *Server) DeleteWorkspace(w http.ResponseWriter, r *http.Request, id stri if s.workspacesUnavailable(w) { return } + if _, ok := s.requireWorkspaceOwnership(w, r, id); !ok { + return + } if err := s.Deps.Workspaces.Delete(r.Context(), id); err != nil { if errors.Is(err, workspaces.ErrNotFound) { writeError(w, http.StatusNotFound, "workspace not found") diff --git a/server/internal/httpapi/workspacesearch.go b/server/internal/httpapi/workspacesearch.go index b5175c5..af83589 100644 --- a/server/internal/httpapi/workspacesearch.go +++ b/server/internal/httpapi/workspacesearch.go @@ -2,7 +2,6 @@ package httpapi import ( "context" - "errors" "math" "net/http" "runtime" @@ -12,9 +11,9 @@ import ( "golang.org/x/sync/errgroup" + "github.com/dvcdsys/code-index/server/internal/access" "github.com/dvcdsys/code-index/server/internal/chunksfts" "github.com/dvcdsys/code-index/server/internal/httpapi/openapi" - "github.com/dvcdsys/code-index/server/internal/workspaces" ) // Tuning constants for the hybrid workspace search. @@ -144,12 +143,7 @@ func (s *Server) WorkspaceSearch(w http.ResponseWriter, r *http.Request, id stri "embeddings or vectorstore not configured — workspace search requires both") return } - if _, err := s.Deps.Workspaces.GetByID(r.Context(), id); err != nil { - if errors.Is(err, workspaces.ErrNotFound) { - writeError(w, http.StatusNotFound, "workspace not found") - return - } - writeError(w, http.StatusInternalServerError, "could not load workspace") + if _, ok := s.requireWorkspaceVisible(w, r, id); !ok { return } @@ -208,6 +202,29 @@ func (s *Server) WorkspaceSearch(w http.ResponseWriter, r *http.Request, id stri writeError(w, http.StatusInternalServerError, "iterate workspace projects: "+err.Error()) return } + + // Per-project access filter: a regular user only searches the projects in + // this workspace they can actually see (their own + shared external). + // Admins search everything. Decoupled model — hidden projects just drop out. + if userID, isAdmin := s.callerIdentity(r); !isAdmin { + hosts, aerr := access.AccessibleProjectHostPaths(r.Context(), s.Deps.DB, userID) + if aerr != nil { + writeError(w, http.StatusInternalServerError, "access check failed") + return + } + allowed := make(map[string]struct{}, len(hosts)) + for _, hp := range hosts { + allowed[hp] = struct{}{} + } + filtered := members[:0] + for _, m := range members { + if _, ok := allowed[m.ProjectPath]; ok { + filtered = append(filtered, m) + } + } + members = filtered + } + if len(members) == 0 { writeJSON(w, http.StatusOK, workspaceSearchResponse( "empty", diff --git a/server/internal/projects/projects.go b/server/internal/projects/projects.go index e46bd14..3871804 100644 --- a/server/internal/projects/projects.go +++ b/server/internal/projects/projects.go @@ -69,11 +69,42 @@ type Project struct { // indexed under PR-E (or never indexed at all). The dashboard renders // nil as a neutral "Unknown" badge, NOT as drift. IndexedWithModel *string + // OwnerUserID is the user who owns this personal (locally indexed) + // project. nil = ownerless, the canonical state for EXTERNAL projects + // (those with a git_repos row). See the auth model in db/schema.go. + OwnerUserID *string + // DisplayPath is the human-readable path (the real filesystem path for + // local projects, the github path for external). HostPath is the identity + // key and may be namespaced; clients should display DisplayPath. + DisplayPath string + // MachineID / MachineLabel identify the machine a local project was + // indexed on. nil for external (and legacy) projects. + MachineID *string + MachineLabel *string } // CreateRequest mirrors Python ProjectCreate. type CreateRequest struct { + // HostPath is the REAL filesystem path the caller is registering (for + // external repos, the github path). It becomes display_path; the stored + // identity key is derived from it (namespaced with MachineID for locals). HostPath string + // OwnerUserID, when non-empty, is stored as the project's owner (personal + // project). Empty → NULL (ownerless), used by the external-repo path. + OwnerUserID string + // MachineID, when non-empty, marks this as a LOCAL project and namespaces + // the identity key as local:{MachineID}:{HostPath} so the same path on + // different machines/users does not collide. Empty → external (no + // namespacing). MachineLabel is os.Hostname() for display only. + MachineID string + MachineLabel string +} + +// LocalProjectKey returns the namespaced identity key for a local project. +// MUST stay byte-identical to the CLI's key computation (cli client) so the +// path_hash both sides derive matches. Format: "local:{machineID}:{path}". +func LocalProjectKey(machineID, path string) string { + return "local:" + machineID + ":" + path } // UpdateRequest mirrors Python ProjectUpdate. @@ -114,10 +145,19 @@ func hashPath(path string) string { // slashes) risks 404s on subsequent GET/PATCH calls that carry the original // path through their SHA1 hash. func Create(ctx context.Context, db *sql.DB, req CreateRequest) (*Project, error) { - hostPath := req.HostPath + displayPath := req.HostPath now := time.Now().UTC().Format(time.RFC3339Nano) - if conflict, err := findOverlap(ctx, db, hostPath); err != nil { + // Identity key: namespaced per machine for local projects so the same + // filesystem path on different machines/users does not collide; the real + // path is kept as display_path. External projects (no MachineID) use the + // path as-is — it is already globally unique (github.com/owner/repo@branch). + key := displayPath + if req.MachineID != "" { + key = LocalProjectKey(req.MachineID, displayPath) + } + + if conflict, err := findOverlap(ctx, db, key); err != nil { return nil, fmt.Errorf("check overlap: %w", err) } else if conflict != "" { return nil, fmt.Errorf("%w: %s already registered", ErrOverlap, conflict) @@ -134,18 +174,29 @@ func Create(ctx context.Context, db *sql.DB, req CreateRequest) (*Project, error return nil, fmt.Errorf("marshal stats: %w", err) } + var owner any + if req.OwnerUserID != "" { + owner = req.OwnerUserID + } + var machineID, machineLabel any + if req.MachineID != "" { + machineID = req.MachineID + } + if req.MachineLabel != "" { + machineLabel = req.MachineLabel + } _, err = db.ExecContext(ctx, - `INSERT INTO projects (host_path, container_path, languages, settings, stats, status, created_at, updated_at, path_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - hostPath, hostPath, "[]", string(settingsJSON), string(statsJSON), "created", now, now, hashPath(hostPath), + `INSERT INTO projects (host_path, container_path, languages, settings, stats, status, created_at, updated_at, path_hash, owner_user_id, display_path, machine_id, machine_label) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + key, displayPath, "[]", string(settingsJSON), string(statsJSON), "created", now, now, hashPath(key), owner, displayPath, machineID, machineLabel, ) if err != nil { if strings.Contains(err.Error(), "UNIQUE") { - return nil, fmt.Errorf("%w: %s", ErrConflict, hostPath) + return nil, fmt.Errorf("%w: %s", ErrConflict, displayPath) } return nil, fmt.Errorf("insert project: %w", err) } - return Get(ctx, db, hostPath) + return Get(ctx, db, key) } // findOverlap returns the host_path of the first existing project that is a @@ -191,7 +242,7 @@ func findOverlap(ctx context.Context, db *sql.DB, candidate string) (string, err // Get retrieves a project by its host_path. Returns ErrNotFound if absent. func Get(ctx context.Context, db *sql.DB, hostPath string) (*Project, error) { row := db.QueryRowContext(ctx, - `SELECT host_path, container_path, languages, settings, stats, status, created_at, updated_at, last_indexed_at, indexed_with_model + `SELECT host_path, container_path, languages, settings, stats, status, created_at, updated_at, last_indexed_at, indexed_with_model, owner_user_id, display_path, machine_id, machine_label FROM projects WHERE host_path = ?`, hostPath, ) return scanProject(hostPath, row) @@ -219,7 +270,7 @@ func GetByHash(ctx context.Context, db *sql.DB, pathHash string) (*Project, erro // List returns all projects ordered by created_at descending. func List(ctx context.Context, db *sql.DB) ([]Project, error) { rows, err := db.QueryContext(ctx, - `SELECT host_path, container_path, languages, settings, stats, status, created_at, updated_at, last_indexed_at, indexed_with_model + `SELECT host_path, container_path, languages, settings, stats, status, created_at, updated_at, last_indexed_at, indexed_with_model, owner_user_id, display_path, machine_id, machine_label FROM projects ORDER BY created_at DESC`, ) if err != nil { @@ -313,11 +364,16 @@ func scanProject(hostPath string, row *sql.Row) (*Project, error) { createdAt, updatedAt string lastIndexedAt *string indexedWithModel *string + ownerUserID *string + displayPath *string + machineID *string + machineLabel *string ) err := row.Scan( &hp, &containerPath, &langsJSON, &settingsJSON, &statsJSON, - &status, &createdAt, &updatedAt, &lastIndexedAt, &indexedWithModel, + &status, &createdAt, &updatedAt, &lastIndexedAt, &indexedWithModel, &ownerUserID, + &displayPath, &machineID, &machineLabel, ) if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("%w: %s", ErrNotFound, hostPath) @@ -325,7 +381,7 @@ func scanProject(hostPath string, row *sql.Row) (*Project, error) { if err != nil { return nil, fmt.Errorf("scan project row: %w", err) } - return buildProject(hp, containerPath, langsJSON, settingsJSON, statsJSON, status, createdAt, updatedAt, lastIndexedAt, indexedWithModel) + return buildProject(hp, containerPath, langsJSON, settingsJSON, statsJSON, status, createdAt, updatedAt, lastIndexedAt, indexedWithModel, ownerUserID, displayPath, machineID, machineLabel) } func scanProjectRow(rows *sql.Rows) (*Project, error) { @@ -336,18 +392,23 @@ func scanProjectRow(rows *sql.Rows) (*Project, error) { createdAt, updatedAt string lastIndexedAt *string indexedWithModel *string + ownerUserID *string + displayPath *string + machineID *string + machineLabel *string ) if err := rows.Scan( &hostPath, &containerPath, &langsJSON, &settingsJSON, &statsJSON, - &status, &createdAt, &updatedAt, &lastIndexedAt, &indexedWithModel, + &status, &createdAt, &updatedAt, &lastIndexedAt, &indexedWithModel, &ownerUserID, + &displayPath, &machineID, &machineLabel, ); err != nil { return nil, fmt.Errorf("scan project: %w", err) } - return buildProject(hostPath, containerPath, langsJSON, settingsJSON, statsJSON, status, createdAt, updatedAt, lastIndexedAt, indexedWithModel) + return buildProject(hostPath, containerPath, langsJSON, settingsJSON, statsJSON, status, createdAt, updatedAt, lastIndexedAt, indexedWithModel, ownerUserID, displayPath, machineID, machineLabel) } -func buildProject(hostPath, containerPath, langsJSON, settingsJSON, statsJSON, status, createdAt, updatedAt string, lastIndexedAt, indexedWithModel *string) (*Project, error) { +func buildProject(hostPath, containerPath, langsJSON, settingsJSON, statsJSON, status, createdAt, updatedAt string, lastIndexedAt, indexedWithModel, ownerUserID, displayPath, machineID, machineLabel *string) (*Project, error) { var langs []string if err := json.Unmarshal([]byte(langsJSON), &langs); err != nil { langs = nil @@ -363,6 +424,10 @@ func buildProject(hostPath, containerPath, langsJSON, settingsJSON, statsJSON, s stats = Stats{} } + dp := hostPath + if displayPath != nil && *displayPath != "" { + dp = *displayPath + } return &Project{ HostPath: hostPath, ContainerPath: containerPath, @@ -374,5 +439,33 @@ func buildProject(hostPath, containerPath, langsJSON, settingsJSON, statsJSON, s UpdatedAt: updatedAt, LastIndexedAt: lastIndexedAt, IndexedWithModel: indexedWithModel, + OwnerUserID: ownerUserID, + DisplayPath: dp, + MachineID: machineID, + MachineLabel: machineLabel, }, nil } + +// SetOwner reassigns (or clears, when ownerUserID == "") a project's owner. +// Used by the admin reassign-owner endpoint. Returns ErrNotFound if absent. +func SetOwner(ctx context.Context, db *sql.DB, hostPath, ownerUserID string) error { + var owner any + if ownerUserID != "" { + owner = ownerUserID + } + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := db.ExecContext(ctx, + `UPDATE projects SET owner_user_id = ?, updated_at = ? WHERE host_path = ?`, + owner, now, hostPath) + if err != nil { + return fmt.Errorf("set project owner: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("rows affected: %w", err) + } + if n == 0 { + return ErrNotFound + } + return nil +} diff --git a/server/internal/projects/projects_test.go b/server/internal/projects/projects_test.go index 9691136..5d9a18e 100644 --- a/server/internal/projects/projects_test.go +++ b/server/internal/projects/projects_test.go @@ -257,3 +257,49 @@ func TestHashPath_MatchesPython(t *testing.T) { } } + +// TestCreate_MachineNamespacingAvoidsCollision verifies that the same +// filesystem path indexed from two different machines becomes two distinct +// projects (different identity key + hash), while the same machine+path +// collides — and that display_path holds the real path either way. +func TestCreate_MachineNamespacingAvoidsCollision(t *testing.T) { + ctx := context.Background() + d := openTestDB(t) + + const realPath = "/Users/dev/myapp" + + p1, err := Create(ctx, d, CreateRequest{HostPath: realPath, MachineID: "machineA", MachineLabel: "laptop-a"}) + if err != nil { + t.Fatalf("create on machineA: %v", err) + } + p2, err := Create(ctx, d, CreateRequest{HostPath: realPath, MachineID: "machineB", MachineLabel: "laptop-b"}) + if err != nil { + t.Fatalf("create same path on machineB: %v", err) + } + + if p1.HostPath == p2.HostPath { + t.Errorf("identity keys collided: %q", p1.HostPath) + } + if HashPath(p1.HostPath) == HashPath(p2.HostPath) { + t.Error("path_hashes collided across machines") + } + if p1.DisplayPath != realPath || p2.DisplayPath != realPath { + t.Errorf("display_path = %q / %q, want %q", p1.DisplayPath, p2.DisplayPath, realPath) + } + if p1.MachineID == nil || *p1.MachineID != "machineA" { + t.Errorf("p1 machine_id = %v, want machineA", p1.MachineID) + } + if p1.MachineLabel == nil || *p1.MachineLabel != "laptop-a" { + t.Errorf("p1 machine_label = %v, want laptop-a", p1.MachineLabel) + } + + // Same machine + same path → conflict. + if _, err := Create(ctx, d, CreateRequest{HostPath: realPath, MachineID: "machineA"}); !errors.Is(err, ErrConflict) { + t.Errorf("same machine+path err = %v, want ErrConflict", err) + } + + // The namespaced key must equal what the CLI computes. + if got, want := p1.HostPath, LocalProjectKey("machineA", realPath); got != want { + t.Errorf("identity key = %q, want %q", got, want) + } +} diff --git a/server/internal/sessions/sessions_test.go b/server/internal/sessions/sessions_test.go index ee79c37..870ab4c 100644 --- a/server/internal/sessions/sessions_test.go +++ b/server/internal/sessions/sessions_test.go @@ -24,7 +24,7 @@ func newFixture(t *testing.T) fixture { } t.Cleanup(func() { _ = d.Close() }) usrSvc := users.New(d) - u, err := usrSvc.Create(context.Background(), "a@b.com", "password1234", users.RoleViewer, false) + u, err := usrSvc.Create(context.Background(), "a@b.com", "password1234", users.RoleUser, false) if err != nil { t.Fatalf("create user: %v", err) } diff --git a/server/internal/users/users.go b/server/internal/users/users.go index 1cf55d6..d0151f7 100644 --- a/server/internal/users/users.go +++ b/server/internal/users/users.go @@ -19,8 +19,8 @@ import ( // Roles. Kept open-coded (string constants) rather than a typed enum so // SQL queries and HTTP handlers can compare without import gymnastics. const ( - RoleAdmin = "admin" - RoleViewer = "viewer" + RoleAdmin = "admin" + RoleUser = "user" ) // BcryptCost is the work factor for password hashing. 12 is the current @@ -400,7 +400,7 @@ func scanUserRow(r rowScanner) (User, error) { func normalizeEmail(s string) string { return strings.TrimSpace(strings.ToLower(s)) } -func validRole(r string) bool { return r == RoleAdmin || r == RoleViewer } +func validRole(r string) bool { return r == RoleAdmin || r == RoleUser } // isUniqueViolation matches modernc.org/sqlite's UNIQUE-constraint error // without taking a hard import dependency on its error type. The driver diff --git a/server/internal/users/users_test.go b/server/internal/users/users_test.go index d7cc97c..7768cf3 100644 --- a/server/internal/users/users_test.go +++ b/server/internal/users/users_test.go @@ -45,7 +45,7 @@ func TestCreateAndAuthenticate(t *testing.T) { func TestAuthenticate_Wrong(t *testing.T) { s := newTestService(t) - _, _ = s.Create(context.Background(), "a@b.com", "rightpassword", RoleViewer, false) + _, _ = s.Create(context.Background(), "a@b.com", "rightpassword", RoleUser, false) _, err := s.Authenticate(context.Background(), "a@b.com", "wrong") if !errors.Is(err, ErrInvalidLogin) { t.Errorf("err = %v, want ErrInvalidLogin", err) @@ -58,11 +58,11 @@ func TestAuthenticate_Wrong(t *testing.T) { func TestEmailUniqueness(t *testing.T) { s := newTestService(t) - _, err := s.Create(context.Background(), "a@b.com", "password1", RoleViewer, false) + _, err := s.Create(context.Background(), "a@b.com", "password1", RoleUser, false) if err != nil { t.Fatalf("first Create: %v", err) } - _, err = s.Create(context.Background(), "A@B.com", "password2", RoleViewer, false) + _, err = s.Create(context.Background(), "A@B.com", "password2", RoleUser, false) if !errors.Is(err, ErrEmailTaken) { t.Errorf("err = %v, want ErrEmailTaken (case-insensitive uniqueness)", err) } @@ -70,7 +70,7 @@ func TestEmailUniqueness(t *testing.T) { func TestUpdatePassword_ClearsMustChange(t *testing.T) { s := newTestService(t) - u, _ := s.Create(context.Background(), "a@b.com", "initial-password", RoleViewer, true) + u, _ := s.Create(context.Background(), "a@b.com", "initial-password", RoleUser, true) if err := s.UpdatePassword(context.Background(), u.ID, "newpassword123"); err != nil { t.Fatalf("UpdatePassword: %v", err) } @@ -92,12 +92,12 @@ func TestUpdatePassword_ClearsMustChange(t *testing.T) { func TestSetRole_LastAdminBlock(t *testing.T) { s := newTestService(t) a, _ := s.Create(context.Background(), "a@b.com", "password1", RoleAdmin, false) - if err := s.SetRole(context.Background(), a.ID, RoleViewer); !errors.Is(err, ErrLastAdminBlock) { + if err := s.SetRole(context.Background(), a.ID, RoleUser); !errors.Is(err, ErrLastAdminBlock) { t.Errorf("demoting last admin err = %v, want ErrLastAdminBlock", err) } // Add a second admin — now demotion of the first must succeed. _, _ = s.Create(context.Background(), "b@b.com", "password2", RoleAdmin, false) - if err := s.SetRole(context.Background(), a.ID, RoleViewer); err != nil { + if err := s.SetRole(context.Background(), a.ID, RoleUser); err != nil { t.Errorf("demoting with another admin around: %v", err) } } @@ -129,7 +129,7 @@ func TestInvalidRole(t *testing.T) { func TestList(t *testing.T) { s := newTestService(t) for _, em := range []string{"a@b.com", "b@b.com", "c@b.com"} { - _, _ = s.Create(context.Background(), em, "password1234", RoleViewer, false) + _, _ = s.Create(context.Background(), em, "password1234", RoleUser, false) } list, err := s.List(context.Background()) if err != nil { @@ -165,11 +165,11 @@ func TestListWithStats(t *testing.T) { if err != nil { t.Fatalf("create alice: %v", err) } - bob, err := s.Create(ctx, "bob@b.com", "password1234", RoleViewer, false) + bob, err := s.Create(ctx, "bob@b.com", "password1234", RoleUser, false) if err != nil { t.Fatalf("create bob: %v", err) } - carol, err := s.Create(ctx, "carol@b.com", "password1234", RoleViewer, false) + carol, err := s.Create(ctx, "carol@b.com", "password1234", RoleUser, false) if err != nil { t.Fatalf("create carol: %v", err) } diff --git a/server/internal/workspaces/workspaces.go b/server/internal/workspaces/workspaces.go index 38f5a8a..aca0040 100644 --- a/server/internal/workspaces/workspaces.go +++ b/server/internal/workspaces/workspaces.go @@ -40,6 +40,10 @@ type Workspace struct { Description string CreatedAt time.Time UpdatedAt time.Time + // OwnerUserID is the creator. nil only for workspaces orphaned by a user + // deletion (FK SET NULL). Visible to the owner, members of any view-group + // it is shared to, and admins. + OwnerUserID *string } // Service wraps the workspaces table. @@ -50,8 +54,15 @@ type Service struct { // New returns a Service. func New(db *sql.DB) *Service { return &Service{DB: db} } -// Create inserts a new workspace. Name must be non-empty and unique. +// Create inserts a new ownerless workspace. Equivalent to CreateOwned with an +// empty owner — retained for callers (and tests) that don't set ownership. func (s *Service) Create(ctx context.Context, name, description string) (Workspace, error) { + return s.CreateOwned(ctx, name, description, "") +} + +// CreateOwned inserts a new workspace owned by ownerUserID (empty → NULL). +// Name must be non-empty and unique. +func (s *Service) CreateOwned(ctx context.Context, name, description, ownerUserID string) (Workspace, error) { name = strings.TrimSpace(name) if name == "" { return Workspace{}, ErrNameEmpty @@ -62,9 +73,9 @@ func (s *Service) Create(ctx context.Context, name, description string) (Workspa now := time.Now().UTC().Format(time.RFC3339Nano) _, err := s.DB.ExecContext(ctx, - `INSERT INTO workspaces (id, name, description, created_at, updated_at) - VALUES (?, ?, ?, ?, ?)`, - id, name, nullableString(description), now, now, + `INSERT INTO workspaces (id, name, description, created_at, updated_at, owner_user_id) + VALUES (?, ?, ?, ?, ?, ?)`, + id, name, nullableString(description), now, now, nullableString(ownerUserID), ) if err != nil { if isUniqueConstraintViolation(err) { @@ -78,7 +89,7 @@ func (s *Service) Create(ctx context.Context, name, description string) (Workspa // GetByID returns one workspace. ErrNotFound when absent. func (s *Service) GetByID(ctx context.Context, id string) (Workspace, error) { row := s.DB.QueryRowContext(ctx, - `SELECT id, name, description, created_at, updated_at + `SELECT id, name, description, created_at, updated_at, owner_user_id FROM workspaces WHERE id = ?`, id) return scanRow(row) } @@ -86,7 +97,7 @@ func (s *Service) GetByID(ctx context.Context, id string) (Workspace, error) { // List returns every workspace, newest first. func (s *Service) List(ctx context.Context) ([]Workspace, error) { rows, err := s.DB.QueryContext(ctx, - `SELECT id, name, description, created_at, updated_at + `SELECT id, name, description, created_at, updated_at, owner_user_id FROM workspaces ORDER BY created_at DESC`) if err != nil { return nil, fmt.Errorf("list workspaces: %w", err) @@ -149,12 +160,13 @@ func (s *Service) Delete(ctx context.Context, id string) error { func scanRow(r interface{ Scan(dest ...any) error }) (Workspace, error) { var ( - w Workspace - description sql.NullString - createdAt string - updatedAt string + w Workspace + description sql.NullString + createdAt string + updatedAt string + ownerUserID sql.NullString ) - err := r.Scan(&w.ID, &w.Name, &description, &createdAt, &updatedAt) + err := r.Scan(&w.ID, &w.Name, &description, &createdAt, &updatedAt, &ownerUserID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return Workspace{}, ErrNotFound @@ -164,6 +176,9 @@ func scanRow(r interface{ Scan(dest ...any) error }) (Workspace, error) { w.Description = description.String w.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) w.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) + if ownerUserID.Valid { + w.OwnerUserID = &ownerUserID.String + } return w, nil }