feat: implement app access control and upload token validation in storage

This commit is contained in:
Tony
2026-06-15 15:17:02 +08:00
parent d391ac678f
commit 09a22900cd
11 changed files with 297 additions and 22 deletions
+46
View File
@@ -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 != ""
}
+12
View File
@@ -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{}{
+7 -6
View File
@@ -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 {
+12 -1
View File
@@ -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"`
+75
View File
@@ -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
}
+59
View File
@@ -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
}
+1 -1
View File
@@ -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,
}))
+13
View File
@@ -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")