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, }) }