Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,44 @@ GET /v1/projects/{owner}/{project}/shared-with

Only the project owner can view the list of shared users and manage sharing. Users who have been granted access to a project cannot see which other users also have access.

## Project Ownership Transfer

The project owner can transfer ownership of a project to another user. This is useful when:
- A project maintainer is leaving and wants to hand over control
- Organizational changes require reassigning project ownership
- Consolidating projects under a different user account

**Transfer ownership:**

```bash
POST /v1/projects/{owner}/{project}/transfer-ownership
{
"new_owner_handle": "new_owner"
}
```

**Important notes:**
- Only the current owner can transfer ownership
- The new owner must be an existing user
- The new owner cannot already have a project with the same handle
- After transfer, the old owner will lose all access to the project
- If the new owner was previously shared with the project, their sharing role will be upgraded to owner
- All embeddings and other project data remain intact during transfer

**Example:**

```bash
# Alice transfers her project to Bob
curl -X POST "https://api.example.com/v1/projects/alice/my-project/transfer-ownership" \
-H "Authorization: Bearer alice_api_key" \
-H "Content-Type: application/json" \
-d '{
"new_owner_handle": "bob"
}'

# After transfer, the project is accessible at /v1/projects/bob/my-project
```

### Shared User Access

Once a project is shared with a user, they can:
Expand Down
28 changes: 28 additions & 0 deletions internal/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions internal/database/queries/queries.sql
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ FROM users_projects
WHERE "user_handle" = $1
AND "project_id" = $2;

-- name: TransferProjectOwnership :one
UPDATE projects
SET "owner" = $2,
"updated_at" = NOW()
WHERE "owner" = $1
AND "project_handle" = $3
RETURNING "project_id", "owner", "project_handle";


-- === LLM Service Definitions (user-shared templates) ===

Expand Down
133 changes: 133 additions & 0 deletions internal/handlers/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,126 @@ func getProjectSharedUsersFunc(ctx context.Context, input *models.GetProjectShar
return response, nil
}

// Transfer project ownership to another user
func transferProjectOwnershipFunc(ctx context.Context, input *models.TransferProjectOwnershipRequest) (*models.TransferProjectOwnershipResponse, error) {
// Get the database connection pool from the context
pool, err := GetDBPool(ctx)
if err != nil {
return nil, huma.Error500InternalServerError("database connection error: %v", err)
}
queries := database.New(pool)

// Get the requesting user from context (set by auth middleware)
requestingUser := ctx.Value(auth.AuthUserKey)
if requestingUser == nil {
return nil, huma.Error500InternalServerError("unable to get requesting user from context")
}

// Validate that new owner is different from current owner
if input.Body.NewOwnerHandle == input.UserHandle {
return nil, huma.Error400BadRequest("new owner must be different from current owner")
}

// Check if project exists and belongs to current user (only owner can transfer)
project, err := queries.RetrieveProject(ctx, database.RetrieveProjectParams{
Owner: input.UserHandle,
ProjectHandle: input.ProjectHandle,
})
if err != nil {
if err == pgx.ErrNoRows {
return nil, huma.Error404NotFound(fmt.Sprintf("project %s/%s not found", input.UserHandle, input.ProjectHandle))
}
return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to retrieve project %s/%s: %v", input.UserHandle, input.ProjectHandle, err))
}

// Check if project belongs to requesting user (only owner can transfer)
if project.Owner != requestingUser.(string) {
return nil, huma.Error403Forbidden(fmt.Sprintf("only the project owner can transfer ownership of project %s/%s", input.UserHandle, input.ProjectHandle))
}

// Check if new owner exists
_, err = queries.RetrieveUser(ctx, input.Body.NewOwnerHandle)
if err != nil {
if err == pgx.ErrNoRows {
return nil, huma.Error404NotFound(fmt.Sprintf("new owner user %s not found", input.Body.NewOwnerHandle))
}
return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to verify new owner user %s: %v", input.Body.NewOwnerHandle, err))
}

// Check if new owner already has a project with the same handle
_, err = queries.RetrieveProject(ctx, database.RetrieveProjectParams{
Owner: input.Body.NewOwnerHandle,
ProjectHandle: input.ProjectHandle,
})
if err == nil {
return nil, huma.Error409Conflict(fmt.Sprintf("new owner %s already has a project with handle %s", input.Body.NewOwnerHandle, input.ProjectHandle))
} else if err != pgx.ErrNoRows {
return nil, huma.Error500InternalServerError(fmt.Sprintf("unable to check for conflicting project: %v", err))
}

// Execute ownership transfer within a transaction
var transferredProject database.TransferProjectOwnershipRow
err = database.WithTransaction(ctx, pool, func(tx pgx.Tx) error {
queries := database.New(tx)

// 1. Transfer ownership in projects table
transferred, err := queries.TransferProjectOwnership(ctx, database.TransferProjectOwnershipParams{
Owner: input.UserHandle,
Owner_2: input.Body.NewOwnerHandle,
ProjectHandle: input.ProjectHandle,
})
if err != nil {
return fmt.Errorf("unable to transfer project ownership: %v", err)
}
transferredProject = transferred

// 2. Remove old owner from users_projects table
err = queries.UnlinkProjectFromUser(ctx, database.UnlinkProjectFromUserParams{
UserHandle: input.UserHandle,
ProjectID: project.ProjectID,
})
if err != nil {
return fmt.Errorf("unable to unlink old owner from project: %v", err)
}

// 3. If new owner was previously shared with the project (as editor/reader), remove that entry first
err = queries.UnlinkProjectFromUser(ctx, database.UnlinkProjectFromUserParams{
UserHandle: input.Body.NewOwnerHandle,
ProjectID: project.ProjectID,
})
// Ignore error if the entry doesn't exist - UnlinkProjectFromUser uses DELETE which doesn't return ErrNoRows
// We don't care if they weren't previously shared
if err != nil {
// Just log and continue - this is not a critical error
}

// 4. Add new owner to users_projects table with owner role
_, err = queries.LinkProjectToUser(ctx, database.LinkProjectToUserParams{
UserHandle: input.Body.NewOwnerHandle,
ProjectID: project.ProjectID,
Role: "owner",
})
if err != nil {
return fmt.Errorf("unable to link new owner to project: %v", err)
}

return nil
})

if err != nil {
return nil, huma.Error500InternalServerError(err.Error())
}

// Build response
response := &models.TransferProjectOwnershipResponse{}
response.Body.ProjectID = int(transferredProject.ProjectID)
response.Body.ProjectHandle = transferredProject.ProjectHandle
response.Body.OldOwner = input.UserHandle
response.Body.NewOwner = transferredProject.Owner

return response, nil
}

// RegisterProjectRoutes registers all the project routes with the API
func RegisterProjectsRoutes(pool *pgxpool.Pool, api huma.API) error {
// Define huma.Operations for each route
Expand Down Expand Up @@ -667,6 +787,18 @@ func RegisterProjectsRoutes(pool *pgxpool.Pool, api huma.API) error {
},
Tags: []string{"projects"},
}
transferProjectOwnershipOp := huma.Operation{
OperationID: "transferProjectOwnership",
Method: http.MethodPost,
Path: "/v1/projects/{user_handle}/{project_handle}/transfer-ownership",
DefaultStatus: http.StatusOK,
Summary: "Transfer ownership of a project to another user",
Security: []map[string][]string{
{"adminAuth": []string{"admin"}},
{"ownerAuth": []string{"owner"}},
},
Tags: []string{"projects"},
}

huma.Register(api, putProjectOp, addPoolToContext(pool, putProjectFunc))
huma.Register(api, postProjectOp, addPoolToContext(pool, postProjectFunc))
Expand All @@ -676,5 +808,6 @@ func RegisterProjectsRoutes(pool *pgxpool.Pool, api huma.API) error {
huma.Register(api, shareProjectOp, addPoolToContext(pool, shareProjectFunc))
huma.Register(api, unshareProjectOp, addPoolToContext(pool, unshareProjectFunc))
huma.Register(api, getProjectSharedUsersOp, addPoolToContext(pool, getProjectSharedUsersFunc))
huma.Register(api, transferProjectOwnershipOp, addPoolToContext(pool, transferProjectOwnershipFunc))
return nil
}
Loading
Loading