All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Fix no-op RequireProjectAccess middleware to enforce project_ids
- Apply project access middleware to all project-scoped routes
- Filter GET /projects by allowed project IDs for restricted keys
- Add GET /me endpoint with key identity, scopes, and project access info
- Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in)
- Add GET/POST/DELETE /projects/{id}/access for project-centric access management
- Auto-grant creating key access when using POST /project/create-and-build
- Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation
- Move newProvisionerWithDeps test helper from production code to test file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
230 lines
7.4 KiB
Go
230 lines
7.4 KiB
Go
// Package handlers provides HTTP handlers for the rdev API.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/orchard9/rdev/internal/auth"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/logging"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/service"
|
|
"github.com/orchard9/rdev/internal/validate"
|
|
"github.com/orchard9/rdev/pkg/api"
|
|
)
|
|
|
|
// CreateAndBuildHandler handles the combined create-project-and-build endpoint.
|
|
type CreateAndBuildHandler struct {
|
|
infraService *service.ProjectInfraService
|
|
buildService *service.BuildService
|
|
authService *auth.Service
|
|
}
|
|
|
|
// NewCreateAndBuildHandler creates a new create-and-build handler.
|
|
func NewCreateAndBuildHandler(
|
|
infraService *service.ProjectInfraService,
|
|
buildService *service.BuildService,
|
|
) *CreateAndBuildHandler {
|
|
return &CreateAndBuildHandler{
|
|
infraService: infraService,
|
|
buildService: buildService,
|
|
}
|
|
}
|
|
|
|
// WithAuthService sets an auth service for auto-granting project access to the creating key.
|
|
func (h *CreateAndBuildHandler) WithAuthService(authService *auth.Service) *CreateAndBuildHandler {
|
|
h.authService = authService
|
|
return h
|
|
}
|
|
|
|
// Mount registers the create-and-build route.
|
|
func (h *CreateAndBuildHandler) Mount(r api.Router) {
|
|
// Requires both project execute (create) and build write (start build)
|
|
r.With(auth.RequireScope(auth.ScopeBuildWrite, auth.ScopeAdmin)).
|
|
Post("/project/create-and-build", h.CreateAndBuild)
|
|
}
|
|
|
|
// CreateAndBuildRequest is the request body for POST /project/create-and-build.
|
|
type CreateAndBuildRequest struct {
|
|
// Project creation fields
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Private bool `json:"private,omitempty"`
|
|
Template string `json:"template,omitempty"`
|
|
|
|
// Build fields
|
|
Prompt string `json:"prompt"`
|
|
Variables map[string]string `json:"variables,omitempty"`
|
|
AutoCommit bool `json:"auto_commit"`
|
|
AutoPush bool `json:"auto_push"`
|
|
CallbackURL string `json:"callback_url,omitempty"`
|
|
|
|
// Access control: additional key IDs to grant access to the new project
|
|
GrantToKeyIDs []string `json:"grant_to_key_ids,omitempty"`
|
|
}
|
|
|
|
// CreateAndBuildResponse is the response for POST /project/create-and-build.
|
|
type CreateAndBuildResponse struct {
|
|
// Project info
|
|
ProjectID string `json:"project_id"`
|
|
Name string `json:"name"`
|
|
Domain string `json:"domain"`
|
|
URL string `json:"url"`
|
|
|
|
// Git info
|
|
Git map[string]string `json:"git,omitempty"`
|
|
|
|
// Build info
|
|
TaskID string `json:"task_id"`
|
|
Status string `json:"status"`
|
|
StatusURL string `json:"status_url"`
|
|
}
|
|
|
|
// CreateAndBuild creates a project and immediately enqueues a build task.
|
|
// POST /project/create-and-build
|
|
func (h *CreateAndBuildHandler) CreateAndBuild(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
|
|
defer cancel()
|
|
|
|
if h.infraService == nil {
|
|
api.WriteInternalError(w, r, "project infrastructure service not configured")
|
|
return
|
|
}
|
|
if h.buildService == nil {
|
|
api.WriteInternalError(w, r, "build service not configured")
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
|
var req CreateAndBuildRequest
|
|
if err := api.DecodeJSON(r, &req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
v := validate.New()
|
|
v.Required(req.Name, "name")
|
|
v.Required(req.Prompt, "prompt")
|
|
if err := v.Error(); err != nil {
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
|
|
// Validate callback URL to prevent SSRF
|
|
if req.CallbackURL != "" {
|
|
if err := domain.ValidateCallbackURL(req.CallbackURL); err != nil {
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
// Step 1: Create the project
|
|
projectResult, err := h.infraService.CreateProject(ctx, service.CreateProjectRequest{
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
Private: req.Private,
|
|
Template: req.Template,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, domain.ErrInvalidProjectName) {
|
|
api.WriteBadRequest(w, r, err.Error())
|
|
return
|
|
}
|
|
log := logging.FromContext(ctx).WithHandler("CreateAndBuild")
|
|
log.Error("project creation failed", logging.FieldError, err.Error(), logging.FieldProjectName, req.Name)
|
|
api.WriteInternalError(w, r, "failed to create project")
|
|
return
|
|
}
|
|
|
|
// Auto-grant: if creating key is restricted (non-admin with explicit project_ids), add the new project
|
|
if h.authService != nil {
|
|
log := logging.FromContext(ctx).WithHandler("CreateAndBuild")
|
|
if apiKey := auth.GetAPIKey(ctx); apiKey != nil &&
|
|
!apiKey.HasScope(domain.ScopeAdmin) && apiKey.ProjectIDs != nil {
|
|
newIDs := append(apiKey.ProjectIDs, domain.ProjectID(projectResult.ProjectID))
|
|
if err := h.authService.Update(ctx, string(apiKey.ID), port.APIKeyUpdate{ProjectIDs: &newIDs}); err != nil {
|
|
log.Warn("failed to auto-grant creating key access to new project",
|
|
logging.FieldError, err.Error(),
|
|
logging.FieldProjectID, projectResult.ProjectID,
|
|
)
|
|
// non-fatal: project still usable, admin can grant access manually
|
|
}
|
|
}
|
|
|
|
// Grant to additional key IDs specified in request
|
|
for _, keyID := range req.GrantToKeyIDs {
|
|
key, err := h.authService.Get(ctx, keyID)
|
|
if err != nil || key == nil || !key.IsActive() {
|
|
log.Warn("failed to grant access: key not found or inactive", "key_id", keyID)
|
|
continue
|
|
}
|
|
// Unrestricted or admin keys already have access
|
|
if key.ProjectIDs == nil || key.HasScope(domain.ScopeAdmin) {
|
|
continue
|
|
}
|
|
newIDs := append(key.ProjectIDs, domain.ProjectID(projectResult.ProjectID))
|
|
if err := h.authService.Update(ctx, keyID, port.APIKeyUpdate{ProjectIDs: &newIDs}); err != nil {
|
|
log.Warn("failed to grant access to key",
|
|
"key_id", keyID,
|
|
logging.FieldError, err.Error(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: Enqueue the build task
|
|
spec := domain.BuildSpec{
|
|
Prompt: req.Prompt,
|
|
Template: req.Template,
|
|
Variables: req.Variables,
|
|
AutoCommit: req.AutoCommit,
|
|
AutoPush: req.AutoPush,
|
|
CallbackURL: req.CallbackURL,
|
|
GitCloneURL: projectResult.CloneHTTP, // Required for git ops on shared worker pods
|
|
}
|
|
|
|
taskID, err := h.buildService.StartBuild(ctx, projectResult.ProjectID, spec)
|
|
if err != nil {
|
|
log := logging.FromContext(ctx).WithHandler("CreateAndBuild")
|
|
log.Error("build enqueue failed after project creation",
|
|
logging.FieldError, err.Error(),
|
|
logging.FieldProjectID, projectResult.ProjectID,
|
|
)
|
|
// Project was created but build failed to enqueue.
|
|
// Return the project info with a generic error and retry URL.
|
|
api.WriteJSON(w, r, http.StatusCreated, map[string]any{
|
|
"project_id": projectResult.ProjectID,
|
|
"name": projectResult.Name,
|
|
"domain": projectResult.Domain,
|
|
"url": projectResult.URL,
|
|
"build_error": "project created but build failed to enqueue",
|
|
"retry_url": "/projects/" + projectResult.ProjectID + "/builds",
|
|
})
|
|
return
|
|
}
|
|
|
|
resp := CreateAndBuildResponse{
|
|
ProjectID: projectResult.ProjectID,
|
|
Name: projectResult.Name,
|
|
Domain: projectResult.Domain,
|
|
URL: projectResult.URL,
|
|
TaskID: taskID,
|
|
Status: "pending",
|
|
StatusURL: "/builds/" + taskID,
|
|
}
|
|
|
|
if projectResult.CloneHTTP != "" {
|
|
resp.Git = map[string]string{
|
|
"owner": projectResult.GitRepoOwner,
|
|
"name": projectResult.GitRepoName,
|
|
"clone_ssh": projectResult.CloneSSH,
|
|
"clone_http": projectResult.CloneHTTP,
|
|
"html_url": projectResult.HTMLURL,
|
|
}
|
|
}
|
|
|
|
api.WriteCreated(w, r, resp)
|
|
}
|