rdev/internal/handlers/project_access.go
jordan 4f01015132
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: implement project access enforcement and management API
- 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>
2026-02-21 15:38:37 -07:00

206 lines
5.5 KiB
Go

package handlers
import (
"context"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api"
)
// ProjectAccessHandler handles project-centric key access management endpoints.
type ProjectAccessHandler struct {
authService *auth.Service
}
// NewProjectAccessHandler creates a new project access handler.
func NewProjectAccessHandler(authService *auth.Service) *ProjectAccessHandler {
return &ProjectAccessHandler{authService: authService}
}
// Mount registers the project access routes.
func (h *ProjectAccessHandler) Mount(r api.Router) {
r.Route("/projects/{id}/access", func(r chi.Router) {
r.With(auth.RequireScope(auth.ScopeAdmin)).Get("/", h.List)
r.With(auth.RequireScope(auth.ScopeAdmin)).Post("/", h.Grant)
r.With(auth.RequireScope(auth.ScopeAdmin)).Delete("/{keyId}", h.Revoke)
})
}
// ProjectAccessResponse is the response for GET /projects/{id}/access.
type ProjectAccessResponse struct {
ProjectID string `json:"project_id"`
Keys []KeyResponse `json:"keys"`
UnrestrictedCount int `json:"unrestricted_keys"`
}
// GrantAccessRequest is the JSON body for POST /projects/{id}/access.
type GrantAccessRequest struct {
KeyID string `json:"key_id"`
}
// List returns all keys with access to a project.
// GET /projects/{id}/access
func (h *ProjectAccessHandler) List(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
defer cancel()
// Keys explicitly granted this project
keys, err := h.authService.ListByProjectID(ctx, domain.ProjectID(projectID))
if err != nil {
api.WriteInternalError(w, r, "failed to list project access")
return
}
// Count unrestricted keys (nil project_ids) from all keys
allKeys, err := h.authService.List(ctx)
if err != nil {
api.WriteInternalError(w, r, "failed to list keys")
return
}
unrestrictedCount := 0
for _, k := range allKeys {
if k.ProjectIDs == nil && k.IsActive() {
unrestrictedCount++
}
}
keyResponses := make([]KeyResponse, len(keys))
for i, k := range keys {
keyResponses[i] = apiKeyToResponse(k)
}
api.WriteSuccess(w, r, ProjectAccessResponse{
ProjectID: projectID,
Keys: keyResponses,
UnrestrictedCount: unrestrictedCount,
})
}
// Grant adds a project to a key's project_ids list.
// POST /projects/{id}/access
func (h *ProjectAccessHandler) Grant(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
var req GrantAccessRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
v := validate.New()
v.Required(req.KeyID, "key_id")
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
key, err := h.authService.Get(ctx, req.KeyID)
if err != nil {
if errors.Is(err, auth.ErrKeyNotFound) {
api.WriteNotFound(w, r, "key not found")
return
}
api.WriteInternalError(w, r, "failed to get key")
return
}
if !key.IsActive() {
api.WriteBadRequest(w, r, "key is not active")
return
}
// Unrestricted keys already have access
if key.ProjectIDs == nil {
api.WriteBadRequest(w, r, "key already has unrestricted access to all projects")
return
}
// Admin-scoped keys already have full access
if key.HasScope(domain.ScopeAdmin) {
api.WriteBadRequest(w, r, "key with admin scope already has full access")
return
}
// Check if already granted
pid := domain.ProjectID(projectID)
for _, existing := range key.ProjectIDs {
if existing == pid {
api.WriteSuccess(w, r, map[string]string{
"status": "already_granted",
"project_id": projectID,
"key_id": req.KeyID,
})
return
}
}
// Append and update
newIDs := append(key.ProjectIDs, pid)
if err := h.authService.Update(ctx, req.KeyID, port.APIKeyUpdate{ProjectIDs: &newIDs}); err != nil {
api.WriteInternalError(w, r, "failed to grant access")
return
}
api.WriteSuccess(w, r, map[string]string{
"status": "granted",
"project_id": projectID,
"key_id": req.KeyID,
})
}
// Revoke removes a project from a key's project_ids list.
// DELETE /projects/{id}/access/{keyId}
func (h *ProjectAccessHandler) Revoke(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
keyID := chi.URLParam(r, "keyId")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
key, err := h.authService.Get(ctx, keyID)
if err != nil {
if errors.Is(err, auth.ErrKeyNotFound) {
api.WriteNotFound(w, r, "key not found")
return
}
api.WriteInternalError(w, r, "failed to get key")
return
}
if key.ProjectIDs == nil {
api.WriteBadRequest(w, r, "key has unrestricted access; use PATCH /keys/{id} to restrict it first")
return
}
// Filter out the project ID
pid := domain.ProjectID(projectID)
newIDs := make([]domain.ProjectID, 0, len(key.ProjectIDs))
for _, existing := range key.ProjectIDs {
if existing != pid {
newIDs = append(newIDs, existing)
}
}
if err := h.authService.Update(ctx, keyID, port.APIKeyUpdate{ProjectIDs: &newIDs}); err != nil {
api.WriteInternalError(w, r, "failed to revoke access")
return
}
api.WriteSuccess(w, r, map[string]string{
"status": "revoked",
"project_id": projectID,
"key_id": keyID,
})
}