feat: implement app access control and upload token validation in storage
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shorebird-server/internal/api/middleware"
|
||||
"github.com/shorebird-server/internal/db"
|
||||
)
|
||||
|
||||
func requireAppAccess(w http.ResponseWriter, r *http.Request, store db.Store, appID uuid.UUID, manage bool) (*db.AppRow, bool) {
|
||||
claims := middleware.GetClaims(r)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
||||
return nil, false
|
||||
}
|
||||
app, err := store.GetAppByID(r.Context(), appID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "App not found", nil)
|
||||
return nil, false
|
||||
}
|
||||
if canAccessOrganization(r, store, claims.UserID, app.OrganizationID, manage) {
|
||||
return app, true
|
||||
}
|
||||
if manage {
|
||||
respondError(w, http.StatusForbidden, "Organization admin access required", nil)
|
||||
} else {
|
||||
respondError(w, http.StatusForbidden, "Organization access required", nil)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func canAccessOrganization(r *http.Request, store db.Store, userID, orgID int, manage bool) bool {
|
||||
user, _ := store.GetUserByID(r.Context(), userID)
|
||||
if user != nil && user.IsAdmin {
|
||||
return true
|
||||
}
|
||||
role, err := store.GetOrganizationRole(r.Context(), userID, orgID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if manage {
|
||||
return role == "admin"
|
||||
}
|
||||
return role != ""
|
||||
}
|
||||
@@ -17,6 +17,9 @@ type AdminHandler struct {
|
||||
// AddTargetDevice handles POST /api/v1/admin/patches/{patchId}/target-devices
|
||||
// Restricts a patch to only be delivered to specific devices (by client_id).
|
||||
func (h *AdminHandler) AddTargetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
|
||||
@@ -41,6 +44,9 @@ func (h *AdminHandler) AddTargetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// RemoveTargetDevice handles DELETE /api/v1/admin/patches/{patchId}/target-devices/{clientId}
|
||||
func (h *AdminHandler) RemoveTargetDevice(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
// For simplicity, target device removal is handled by the database cascade
|
||||
// In a full implementation, add a RemovePatchTargetDevice DB method
|
||||
respondError(w, http.StatusNotImplemented, "Not yet implemented", nil)
|
||||
@@ -48,6 +54,9 @@ func (h *AdminHandler) RemoveTargetDevice(w http.ResponseWriter, r *http.Request
|
||||
|
||||
// GetTargetDevices handles GET /api/v1/admin/patches/{patchId}/target-devices
|
||||
func (h *AdminHandler) GetTargetDevices(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
|
||||
@@ -69,6 +78,9 @@ func (h *AdminHandler) GetTargetDevices(w http.ResponseWriter, r *http.Request)
|
||||
// GetPatchEvents handles GET /api/v1/admin/apps/{appId}/events
|
||||
// Returns patch events for analytics.
|
||||
func (h *AdminHandler) GetPatchEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
// This would query patch_events table for the app
|
||||
// For now, return a stub
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
|
||||
@@ -285,12 +285,7 @@ func (h *AppHandler) TransferApp(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *AppHandler) canManageOrganization(r *http.Request, userID, orgID int) bool {
|
||||
user, _ := h.DB.GetUserByID(r.Context(), userID)
|
||||
if user != nil && user.IsAdmin {
|
||||
return true
|
||||
}
|
||||
role, err := h.DB.GetOrganizationRole(r.Context(), userID, orgID)
|
||||
return err == nil && role == "admin"
|
||||
return canAccessOrganization(r, h.DB, userID, orgID, true)
|
||||
}
|
||||
|
||||
// ChannelHandler handles channel-related API endpoints.
|
||||
@@ -306,6 +301,9 @@ func (h *ChannelHandler) GetChannels(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, id, false); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
channels, err := h.DB.GetChannelsByAppID(r.Context(), id)
|
||||
if err != nil {
|
||||
@@ -333,6 +331,9 @@ func (h *ChannelHandler) CreateChannel(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, id, true); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateChannelRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/shorebird-server/internal/api/middleware"
|
||||
"github.com/shorebird-server/internal/db"
|
||||
"github.com/shorebird-server/internal/models"
|
||||
"github.com/shorebird-server/internal/storage"
|
||||
@@ -179,7 +180,17 @@ func (h *DeviceHandler) PatchEvents(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// RollbackPatch handles POST /api/v1/apps/{appId}/patches/rollback (admin endpoint)
|
||||
func (h *DeviceHandler) RollbackPatch(w http.ResponseWriter, r *http.Request) {
|
||||
// This is an admin operation. For now, allow authenticated users.
|
||||
claims := middleware.GetClaims(r)
|
||||
if claims == nil {
|
||||
respondError(w, http.StatusUnauthorized, "Unauthorized", nil)
|
||||
return
|
||||
}
|
||||
user, err := h.DB.GetUserByID(r.Context(), claims.UserID)
|
||||
if err != nil || !user.IsAdmin {
|
||||
respondError(w, http.StatusForbidden, "Admin access required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ReleaseID int `json:"release_id"`
|
||||
PatchNumber int `json:"patch_number"`
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/shorebird-server/internal/db"
|
||||
"github.com/shorebird-server/internal/models"
|
||||
"github.com/shorebird-server/internal/storage"
|
||||
@@ -21,6 +22,14 @@ type PatchHandler struct {
|
||||
// CreatePatch handles POST /api/v1/apps/{appId}/patches
|
||||
func (h *PatchHandler) CreatePatch(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appId")
|
||||
parsedAppID, err := uuid.Parse(appID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, parsedAppID, true); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreatePatchRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
@@ -62,6 +71,14 @@ func (h *PatchHandler) CreatePatch(w http.ResponseWriter, r *http.Request) {
|
||||
// CreatePatchArtifact handles POST /api/v1/apps/{appId}/patches/{patchId}/artifacts
|
||||
func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appId")
|
||||
parsedAppID, err := uuid.Parse(appID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, parsedAppID, true); !ok {
|
||||
return
|
||||
}
|
||||
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
|
||||
@@ -95,6 +112,10 @@ func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Reques
|
||||
respondError(w, http.StatusNotFound, "Release not found", nil)
|
||||
return
|
||||
}
|
||||
if release.AppID != parsedAppID {
|
||||
respondError(w, http.StatusNotFound, "Patch not found for this app", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate a storage key for the public bucket
|
||||
filename := "dlc.vmcode" // standard patch artifact name
|
||||
@@ -136,11 +157,24 @@ func (h *PatchHandler) CreatePatchArtifact(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
// GetPatches handles GET /api/v1/apps/{appId}/releases/{releaseId}/patches
|
||||
func (h *PatchHandler) GetPatches(w http.ResponseWriter, r *http.Request) {
|
||||
appID, err := uuid.Parse(chi.URLParam(r, "appId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, appID, false); !ok {
|
||||
return
|
||||
}
|
||||
releaseID, err := strconv.Atoi(chi.URLParam(r, "releaseId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
|
||||
return
|
||||
}
|
||||
release, err := h.DB.GetReleaseByID(r.Context(), releaseID)
|
||||
if err != nil || release.AppID != appID {
|
||||
respondError(w, http.StatusNotFound, "Release not found for this app", nil)
|
||||
return
|
||||
}
|
||||
|
||||
patches, err := h.DB.GetPatchesByReleaseID(r.Context(), releaseID)
|
||||
if err != nil {
|
||||
@@ -172,11 +206,22 @@ func (h *PatchHandler) GetPatches(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// PromotePatch handles POST /api/v1/apps/{appId}/patches/promote
|
||||
func (h *PatchHandler) PromotePatch(w http.ResponseWriter, r *http.Request) {
|
||||
appID, err := uuid.Parse(chi.URLParam(r, "appId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, appID, true); !ok {
|
||||
return
|
||||
}
|
||||
var req models.PromotePatchRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
return
|
||||
}
|
||||
if !h.patchBelongsToApp(w, r, req.PatchID, appID) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.DB.PromotePatch(r.Context(), req.PatchID, req.ChannelID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to promote patch", nil)
|
||||
@@ -188,6 +233,22 @@ func (h *PatchHandler) PromotePatch(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// UpdatePatch handles PATCH /api/v1/apps/{appId}/patches/{patchId}
|
||||
func (h *PatchHandler) UpdatePatch(w http.ResponseWriter, r *http.Request) {
|
||||
appID, err := uuid.Parse(chi.URLParam(r, "appId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, appID, true); !ok {
|
||||
return
|
||||
}
|
||||
patchID, err := strconv.Atoi(chi.URLParam(r, "patchId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid patch ID", nil)
|
||||
return
|
||||
}
|
||||
if !h.patchBelongsToApp(w, r, patchID, appID) {
|
||||
return
|
||||
}
|
||||
var req models.UpdatePatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", nil)
|
||||
@@ -198,3 +259,17 @@ func (h *PatchHandler) UpdatePatch(w http.ResponseWriter, r *http.Request) {
|
||||
// This would require an additional DB method
|
||||
respondJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *PatchHandler) patchBelongsToApp(w http.ResponseWriter, r *http.Request, patchID int, appID uuid.UUID) bool {
|
||||
patch, err := h.DB.GetPatchByID(r.Context(), patchID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Patch not found", nil)
|
||||
return false
|
||||
}
|
||||
release, err := h.DB.GetReleaseByID(r.Context(), patch.ReleaseID)
|
||||
if err != nil || release.AppID != appID {
|
||||
respondError(w, http.StatusNotFound, "Patch not found for this app", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ func (h *ReleaseHandler) GetReleases(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, appID, false); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
sideloadable := r.URL.Query().Get("sideloadable") == "true"
|
||||
|
||||
@@ -81,6 +84,9 @@ func (h *ReleaseHandler) CreateRelease(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, appID, true); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateReleaseRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
@@ -112,11 +118,22 @@ func (h *ReleaseHandler) CreateRelease(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// UpdateRelease handles PATCH /api/v1/apps/{appId}/releases/{releaseId}
|
||||
func (h *ReleaseHandler) UpdateRelease(w http.ResponseWriter, r *http.Request) {
|
||||
appID, err := parseUUID(chi.URLParam(r, "appId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, appID, true); !ok {
|
||||
return
|
||||
}
|
||||
releaseID, err := parseIntParam(r, "releaseId")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
|
||||
return
|
||||
}
|
||||
if !h.releaseBelongsToApp(w, r, releaseID, appID) {
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateReleaseRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
@@ -133,11 +150,22 @@ func (h *ReleaseHandler) UpdateRelease(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *ReleaseHandler) DeleteRelease(w http.ResponseWriter, r *http.Request) {
|
||||
appID, err := parseUUID(chi.URLParam(r, "appId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, appID, true); !ok {
|
||||
return
|
||||
}
|
||||
releaseID, err := parseIntParam(r, "releaseId")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
|
||||
return
|
||||
}
|
||||
if !h.releaseBelongsToApp(w, r, releaseID, appID) {
|
||||
return
|
||||
}
|
||||
if err := h.DB.DeleteRelease(r.Context(), releaseID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to delete release", strPtr(err.Error()))
|
||||
return
|
||||
@@ -148,11 +176,22 @@ func (h *ReleaseHandler) DeleteRelease(w http.ResponseWriter, r *http.Request) {
|
||||
// CreateReleaseArtifact handles POST /api/v1/apps/{appId}/releases/{releaseId}/artifacts
|
||||
func (h *ReleaseHandler) CreateReleaseArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
appID := chi.URLParam(r, "appId")
|
||||
parsedAppID, err := parseUUID(appID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, parsedAppID, true); !ok {
|
||||
return
|
||||
}
|
||||
releaseID, err := parseIntParam(r, "releaseId")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
|
||||
return
|
||||
}
|
||||
if !h.releaseBelongsToApp(w, r, releaseID, parsedAppID) {
|
||||
return
|
||||
}
|
||||
|
||||
// The CLI sends multipart form data. Parse it.
|
||||
if err := r.ParseMultipartForm(100 << 20); err != nil { // 100MB max
|
||||
@@ -216,11 +255,22 @@ func (h *ReleaseHandler) CreateReleaseArtifact(w http.ResponseWriter, r *http.Re
|
||||
|
||||
// GetReleaseArtifacts handles GET /api/v1/apps/{appId}/releases/{releaseId}/artifacts
|
||||
func (h *ReleaseHandler) GetReleaseArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
appID, err := parseUUID(chi.URLParam(r, "appId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
|
||||
return
|
||||
}
|
||||
if _, ok := requireAppAccess(w, r, h.DB, appID, false); !ok {
|
||||
return
|
||||
}
|
||||
releaseID, err := parseIntParam(r, "releaseId")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid release ID", nil)
|
||||
return
|
||||
}
|
||||
if !h.releaseBelongsToApp(w, r, releaseID, appID) {
|
||||
return
|
||||
}
|
||||
|
||||
var archPtr, platformPtr *string
|
||||
if a := r.URL.Query().Get("arch"); a != "" {
|
||||
@@ -260,3 +310,12 @@ func (h *ReleaseHandler) GetReleaseArtifacts(w http.ResponseWriter, r *http.Requ
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{"artifacts": result})
|
||||
}
|
||||
|
||||
func (h *ReleaseHandler) releaseBelongsToApp(w http.ResponseWriter, r *http.Request, releaseID int, appID uuid.UUID) bool {
|
||||
release, err := h.DB.GetReleaseByID(r.Context(), releaseID)
|
||||
if err != nil || release.AppID != appID {
|
||||
respondError(w, http.StatusNotFound, "Release not found for this app", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Version"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@ package handlers
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/shorebird-server/internal/storage"
|
||||
)
|
||||
|
||||
const maxStorageUploadBytes = 2 << 30
|
||||
|
||||
// StorageHandler provides upload/download endpoints for local storage.
|
||||
type StorageHandler struct {
|
||||
store storage.Store
|
||||
@@ -21,8 +24,18 @@ func NewStorageHandler(store storage.Store) *StorageHandler {
|
||||
|
||||
// Upload handles POST /storage/upload/{scope}/{key}
|
||||
func (h *StorageHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxStorageUploadBytes)
|
||||
scope := chi.URLParam(r, "scope")
|
||||
key := chi.URLParam(r, "*")
|
||||
if v, ok := h.store.(interface {
|
||||
ValidateUploadToken(scope, objectKey, token string, expires int64) bool
|
||||
}); ok {
|
||||
expires, _ := strconv.ParseInt(r.URL.Query().Get("expires"), 10, 64)
|
||||
if !v.ValidateUploadToken(scope, key, r.URL.Query().Get("token"), expires) {
|
||||
respondError(w, http.StatusForbidden, "Invalid or expired upload URL", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isPublic := scope == "patches"
|
||||
ct := r.Header.Get("Content-Type")
|
||||
|
||||
Reference in New Issue
Block a user