feat(server): add project sharing access control
This commit is contained in:
@@ -2,20 +2,35 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
authmodule "img_infinite_canvas/internal/modules/auth"
|
||||
sharingmodule "img_infinite_canvas/internal/modules/sharing"
|
||||
)
|
||||
|
||||
type bearerAuthenticator interface {
|
||||
UserIDFromBearer(ctx context.Context, authorization string) (string, bool)
|
||||
}
|
||||
|
||||
func UserContextMiddleware(authenticator bearerAuthenticator) func(http.HandlerFunc) http.HandlerFunc {
|
||||
type profileAuthenticator interface {
|
||||
GetProfile(ctx context.Context, userID string) (authmodule.User, error)
|
||||
}
|
||||
|
||||
type shareRequestAuthorizer interface {
|
||||
ResolveAccess(ctx context.Context, shareID string, actor sharingmodule.Actor) (sharingmodule.Access, error)
|
||||
}
|
||||
|
||||
func UserContextMiddleware(authenticator bearerAuthenticator, shareAuthorizers ...shareRequestAuthorizer) func(http.HandlerFunc) http.HandlerFunc {
|
||||
var shareAuthorizer shareRequestAuthorizer
|
||||
if len(shareAuthorizers) > 0 {
|
||||
shareAuthorizer = shareAuthorizers[0]
|
||||
}
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get("X-User-Id")
|
||||
userID := ""
|
||||
authenticated := false
|
||||
if authenticator != nil {
|
||||
if tokenUserID, ok := userIDFromRequestAuth(r.Context(), r, authenticator); ok {
|
||||
@@ -23,11 +38,54 @@ func UserContextMiddleware(authenticator bearerAuthenticator) func(http.HandlerF
|
||||
authenticated = true
|
||||
}
|
||||
}
|
||||
if requiresAuthenticatedUser(r) && !authenticated {
|
||||
actor := sharingmodule.Actor{Authenticated: authenticated, UserID: userID}
|
||||
if authenticated && requestUsesShareAccess(r) {
|
||||
if profiles, ok := authenticator.(profileAuthenticator); ok {
|
||||
profile, err := profiles.GetProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
writeAuthRequired(w)
|
||||
return
|
||||
}
|
||||
actor.Email = profile.Email
|
||||
actor.CountryCode = profile.CountryCode
|
||||
actor.Phone = profile.Phone
|
||||
actor.Name = profile.Name
|
||||
actor.AvatarURL = profile.AvatarURL
|
||||
}
|
||||
}
|
||||
|
||||
ctx := sharingmodule.ContextWithActor(r.Context(), actor)
|
||||
ctx = design.ContextWithUserID(ctx, userID)
|
||||
shareAuthorized := false
|
||||
shareID := strings.TrimSpace(r.Header.Get("X-Share-Id"))
|
||||
projectID := shareProjectID(r)
|
||||
if shareID != "" && r.Method == http.MethodDelete && r.URL.Path == "/api/assets" {
|
||||
writeShareAccessError(w, sharingmodule.ErrForbidden)
|
||||
return
|
||||
}
|
||||
if shareID != "" && projectID != "" && shareAuthorizer != nil {
|
||||
access, err := shareAuthorizer.ResolveAccess(ctx, shareID, actor)
|
||||
if err != nil {
|
||||
writeShareAccessError(w, err)
|
||||
return
|
||||
}
|
||||
if access.ProjectID != projectID {
|
||||
writeShareAccessError(w, sharingmodule.ErrNotFound)
|
||||
return
|
||||
}
|
||||
if !access.Allows(requiredShareCapability(r)) {
|
||||
writeShareAccessError(w, sharingmodule.ErrForbidden)
|
||||
return
|
||||
}
|
||||
ctx = sharingmodule.ContextWithAccess(ctx, access)
|
||||
ctx = design.ContextWithUserID(ctx, access.OwnerID)
|
||||
shareAuthorized = true
|
||||
}
|
||||
|
||||
if requiresAuthenticatedUser(r) && !authenticated && !shareAuthorized {
|
||||
writeAuthRequired(w)
|
||||
return
|
||||
}
|
||||
ctx := design.ContextWithUserID(r.Context(), userID)
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
@@ -39,16 +97,20 @@ func userIDFromRequestAuth(ctx context.Context, r *http.Request, authenticator b
|
||||
|
||||
func requiresAuthenticatedUser(r *http.Request) bool {
|
||||
path := r.URL.Path
|
||||
if strings.HasPrefix(path, "/v1/") {
|
||||
return true
|
||||
}
|
||||
if !strings.HasPrefix(path, "/api/") {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/auth/") || path == "/api/auth/options" || path == "/api/health" || path == "/api/dashboard" {
|
||||
if strings.HasPrefix(path, "/api/auth/") ||
|
||||
strings.HasPrefix(path, "/api/shares/") ||
|
||||
path == "/api/auth/options" ||
|
||||
path == "/api/health" ||
|
||||
path == "/api/dashboard" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(path, "/api/projects") ||
|
||||
strings.HasPrefix(path, "/api/account") ||
|
||||
strings.HasPrefix(path, "/api/assets") ||
|
||||
strings.HasPrefix(path, "/api/generator/")
|
||||
return true
|
||||
}
|
||||
|
||||
func writeAuthRequired(w http.ResponseWriter) {
|
||||
@@ -57,10 +119,76 @@ func writeAuthRequired(w http.ResponseWriter) {
|
||||
_, _ = w.Write([]byte(`{"error":"login required","errorCode":"auth.login_required","code":401}`))
|
||||
}
|
||||
|
||||
func requestUsesShareAccess(r *http.Request) bool {
|
||||
return strings.TrimSpace(r.Header.Get("X-Share-Id")) != "" || strings.HasPrefix(r.URL.Path, "/api/shares/")
|
||||
}
|
||||
|
||||
func shareProjectID(r *http.Request) string {
|
||||
const prefix = "/api/projects/"
|
||||
if strings.HasPrefix(r.URL.Path, prefix) {
|
||||
remainder := strings.TrimPrefix(r.URL.Path, prefix)
|
||||
projectID, _, _ := strings.Cut(remainder, "/")
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" || projectID == "async" || projectID == "batch-delete" {
|
||||
return ""
|
||||
}
|
||||
return projectID
|
||||
}
|
||||
if r.URL.Path == "/api/generator/tasks" {
|
||||
if headerProjectID := strings.TrimSpace(r.Header.Get("X-Share-Project-Id")); headerProjectID != "" {
|
||||
return headerProjectID
|
||||
}
|
||||
return strings.TrimSpace(r.URL.Query().Get("project_id"))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func requiredShareCapability(r *http.Request) sharingmodule.Capability {
|
||||
path := r.URL.Path
|
||||
if path == "/api/generator/tasks" {
|
||||
return sharingmodule.CapabilityEdit
|
||||
}
|
||||
if strings.Contains(path, "/sharing") || (r.Method == http.MethodDelete && shareProjectRoot(path)) {
|
||||
return sharingmodule.CapabilityManage
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
return sharingmodule.CapabilityEdit
|
||||
}
|
||||
if strings.HasSuffix(path, "/agent/ws") {
|
||||
return sharingmodule.CapabilityEdit
|
||||
}
|
||||
if strings.Contains(path, "/agent/") || strings.HasSuffix(path, "/history") || strings.HasSuffix(path, "/events") {
|
||||
return sharingmodule.CapabilityViewChat
|
||||
}
|
||||
return sharingmodule.CapabilityViewCanvas
|
||||
}
|
||||
|
||||
func shareProjectRoot(path string) bool {
|
||||
remainder := strings.TrimPrefix(path, "/api/projects/")
|
||||
return remainder != path && !strings.Contains(remainder, "/")
|
||||
}
|
||||
|
||||
func writeShareAccessError(w http.ResponseWriter, err error) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
switch {
|
||||
case errors.Is(err, sharingmodule.ErrLoginRequired):
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"login required for edit access","errorCode":"share.login_required","code":401}`))
|
||||
case errors.Is(err, sharingmodule.ErrForbidden):
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":"share access forbidden","errorCode":"share.forbidden","code":403}`))
|
||||
case errors.Is(err, sharingmodule.ErrNotFound):
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"share not found","errorCode":"share.not_found","code":404}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"share authorization failed","errorCode":"share.internal","code":500}`))
|
||||
}
|
||||
}
|
||||
|
||||
func LegacyUserContextMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get("X-User-Id")
|
||||
ctx := design.ContextWithUserID(r.Context(), userID)
|
||||
ctx := design.ContextWithUserID(r.Context(), "")
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user