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>
206 lines
5.5 KiB
Go
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,
|
|
})
|
|
}
|