feat(server): add project sharing access control
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"img_infinite_canvas/internal/logic"
|
||||
"img_infinite_canvas/internal/svc"
|
||||
"img_infinite_canvas/internal/types"
|
||||
)
|
||||
|
||||
func deleteShareMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.DeleteShareMemberRequest
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := logic.NewDeleteShareMemberLogic(r.Context(), svcCtx)
|
||||
resp, err := l.DeleteShareMember(&req)
|
||||
if err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
} else {
|
||||
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"img_infinite_canvas/internal/logic"
|
||||
"img_infinite_canvas/internal/svc"
|
||||
"img_infinite_canvas/internal/types"
|
||||
)
|
||||
|
||||
func getShareSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ShareSettingsRequest
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := logic.NewGetShareSettingsLogic(r.Context(), svcCtx)
|
||||
resp, err := l.GetShareSettings(&req)
|
||||
if err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
} else {
|
||||
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"img_infinite_canvas/internal/logic"
|
||||
"img_infinite_canvas/internal/svc"
|
||||
"img_infinite_canvas/internal/types"
|
||||
)
|
||||
|
||||
func inviteShareMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.InviteShareMemberRequest
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := logic.NewInviteShareMemberLogic(r.Context(), svcCtx)
|
||||
resp, err := l.InviteShareMember(&req)
|
||||
if err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
} else {
|
||||
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"img_infinite_canvas/internal/logic"
|
||||
"img_infinite_canvas/internal/svc"
|
||||
"img_infinite_canvas/internal/types"
|
||||
)
|
||||
|
||||
func listShareVisitorsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ShareSettingsRequest
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := logic.NewListShareVisitorsLogic(r.Context(), svcCtx)
|
||||
resp, err := l.ListShareVisitors(&req)
|
||||
if err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
} else {
|
||||
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"img_infinite_canvas/internal/logic"
|
||||
"img_infinite_canvas/internal/svc"
|
||||
"img_infinite_canvas/internal/types"
|
||||
)
|
||||
|
||||
func resolveShareHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ResolveShareRequest
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := logic.NewResolveShareLogic(r.Context(), svcCtx)
|
||||
resp, err := l.ResolveShare(&req)
|
||||
if err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
} else {
|
||||
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"img_infinite_canvas/internal/logic"
|
||||
"img_infinite_canvas/internal/svc"
|
||||
"img_infinite_canvas/internal/types"
|
||||
)
|
||||
|
||||
func revokeShareAccessHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.ShareSettingsRequest
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := logic.NewRevokeShareAccessLogic(r.Context(), svcCtx)
|
||||
resp, err := l.RevokeShareAccess(&req)
|
||||
if err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
} else {
|
||||
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,6 +254,41 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||
Path: "/projects/:id/nodes/:nodeId/text-extraction/async",
|
||||
Handler: extractNodeTextAsyncHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/projects/:id/sharing",
|
||||
Handler: getShareSettingsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodDelete,
|
||||
Path: "/projects/:id/sharing/access",
|
||||
Handler: revokeShareAccessHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPut,
|
||||
Path: "/projects/:id/sharing/link",
|
||||
Handler: updateShareLinkHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/projects/:id/sharing/members",
|
||||
Handler: inviteShareMemberHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
Path: "/projects/:id/sharing/members/:memberId",
|
||||
Handler: updateShareMemberHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodDelete,
|
||||
Path: "/projects/:id/sharing/members/:memberId",
|
||||
Handler: deleteShareMemberHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/projects/:id/sharing/visitors",
|
||||
Handler: listShareVisitorsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodPatch,
|
||||
Path: "/projects/:id/title",
|
||||
@@ -269,6 +304,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
|
||||
Path: "/projects/batch-delete",
|
||||
Handler: deleteProjectsHandler(serverCtx),
|
||||
},
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/shares/:shareId",
|
||||
Handler: resolveShareHandler(serverCtx),
|
||||
},
|
||||
},
|
||||
rest.WithPrefix("/api"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"img_infinite_canvas/internal/logic"
|
||||
"img_infinite_canvas/internal/svc"
|
||||
"img_infinite_canvas/internal/types"
|
||||
)
|
||||
|
||||
func updateShareLinkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.UpdateShareLinkRequest
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := logic.NewUpdateShareLinkLogic(r.Context(), svcCtx)
|
||||
resp, err := l.UpdateShareLink(&req)
|
||||
if err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
} else {
|
||||
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code scaffolded by goctl. Safe to edit.
|
||||
// goctl 1.10.1
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/zeromicro/go-zero/rest/httpx"
|
||||
"img_infinite_canvas/internal/logic"
|
||||
"img_infinite_canvas/internal/svc"
|
||||
"img_infinite_canvas/internal/types"
|
||||
)
|
||||
|
||||
func updateShareMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req types.UpdateShareMemberRequest
|
||||
if err := httpx.Parse(r, &req); err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
return
|
||||
}
|
||||
|
||||
l := logic.NewUpdateShareMemberLogic(r.Context(), svcCtx)
|
||||
resp, err := l.UpdateShareMember(&req)
|
||||
if err != nil {
|
||||
httpx.ErrorCtx(r.Context(), w, err)
|
||||
} else {
|
||||
httpx.OkJsonCtx(r.Context(), w, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"img_infinite_canvas/internal/domain/design"
|
||||
sharingmodule "img_infinite_canvas/internal/modules/sharing"
|
||||
)
|
||||
|
||||
type fakeBearerAuthenticator struct{}
|
||||
@@ -18,6 +19,15 @@ func (fakeBearerAuthenticator) UserIDFromBearer(_ context.Context, authorization
|
||||
return "", false
|
||||
}
|
||||
|
||||
type fakeShareAuthorizer struct {
|
||||
access sharingmodule.Access
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeShareAuthorizer) ResolveAccess(_ context.Context, _ string, _ sharingmodule.Actor) (sharingmodule.Access, error) {
|
||||
return f.access, f.err
|
||||
}
|
||||
|
||||
func TestUserContextMiddlewareUsesAuthorizationHeader(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/projects", nil)
|
||||
req.Header.Set("Authorization", "Bearer valid")
|
||||
@@ -58,3 +68,133 @@ func TestUserContextMiddlewareIgnoresLegacyTokenHeaderAndCookie(t *testing.T) {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserContextMiddlewareIgnoresSpoofedUserIDOnPublicRoute(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/dashboard", nil)
|
||||
req.Header.Set("X-User-Id", "91cd197b-8255-4172-9981-77391fd38f33")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
UserContextMiddleware(fakeBearerAuthenticator{})(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := design.UserIDFromContext(r.Context()); got != design.DefaultUserID {
|
||||
t.Fatalf("expected spoofed identity to be ignored, got %s", got)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected public route to continue, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserContextMiddlewareAuthorizesAnonymousCanvasShare(t *testing.T) {
|
||||
ownerID := "d1d2fbbb-97bb-4406-8d0b-ec814cc65092"
|
||||
authorizer := fakeShareAuthorizer{access: sharingmodule.Access{
|
||||
ProjectID: "project-1",
|
||||
OwnerID: ownerID,
|
||||
Permission: sharingmodule.PermissionCanvas,
|
||||
Capabilities: sharingmodule.PermissionCanvas.Capabilities(),
|
||||
}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/projects/project-1", nil)
|
||||
req.Header.Set("X-Share-Id", "XhRewKnS4it7X9kEMFLcdVJentg")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
UserContextMiddleware(fakeBearerAuthenticator{}, authorizer)(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := design.UserIDFromContext(r.Context()); got != ownerID {
|
||||
t.Fatalf("expected owner-scoped repository context, got %s", got)
|
||||
}
|
||||
if _, ok := sharingmodule.AccessFromContext(r.Context()); !ok {
|
||||
t.Fatal("expected effective share access in context")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected shared read to continue, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserContextMiddlewareDeniesCanvasShareChatHistory(t *testing.T) {
|
||||
authorizer := fakeShareAuthorizer{access: sharingmodule.Access{
|
||||
ProjectID: "project-1",
|
||||
OwnerID: "d1d2fbbb-97bb-4406-8d0b-ec814cc65092",
|
||||
Permission: sharingmodule.PermissionCanvas,
|
||||
Capabilities: sharingmodule.PermissionCanvas.Capabilities(),
|
||||
}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/projects/project-1/history", nil)
|
||||
req.Header.Set("X-Share-Id", "XhRewKnS4it7X9kEMFLcdVJentg")
|
||||
rec := httptest.NewRecorder()
|
||||
called := false
|
||||
|
||||
UserContextMiddleware(fakeBearerAuthenticator{}, authorizer)(func(http.ResponseWriter, *http.Request) {
|
||||
called = true
|
||||
})(rec, req)
|
||||
|
||||
if called {
|
||||
t.Fatal("expected canvas-only chat history to be blocked")
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserContextMiddlewareDoesNotLetHeaderOverridePathProject(t *testing.T) {
|
||||
authorizer := fakeShareAuthorizer{access: sharingmodule.Access{
|
||||
ProjectID: "shared-project",
|
||||
OwnerID: "d1d2fbbb-97bb-4406-8d0b-ec814cc65092",
|
||||
Permission: sharingmodule.PermissionEditor,
|
||||
Capabilities: sharingmodule.PermissionEditor.Capabilities(),
|
||||
}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/projects/other-project", nil)
|
||||
req.Header.Set("X-Share-Id", "XhRewKnS4it7X9kEMFLcdVJentg")
|
||||
req.Header.Set("X-Share-Project-Id", "shared-project")
|
||||
rec := httptest.NewRecorder()
|
||||
called := false
|
||||
|
||||
UserContextMiddleware(fakeBearerAuthenticator{}, authorizer)(func(http.ResponseWriter, *http.Request) {
|
||||
called = true
|
||||
})(rec, req)
|
||||
|
||||
if called {
|
||||
t.Fatal("expected mismatched path project to be blocked")
|
||||
}
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected not found, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserContextMiddlewareRequiresEditorForGeneratorTasks(t *testing.T) {
|
||||
authorizer := fakeShareAuthorizer{access: sharingmodule.Access{
|
||||
ProjectID: "project-1",
|
||||
OwnerID: "d1d2fbbb-97bb-4406-8d0b-ec814cc65092",
|
||||
Permission: sharingmodule.PermissionViewer,
|
||||
Capabilities: sharingmodule.PermissionViewer.Capabilities(),
|
||||
}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/generator/tasks?project_id=project-1&task_id=task-1", nil)
|
||||
req.Header.Set("X-Share-Id", "XhRewKnS4it7X9kEMFLcdVJentg")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
UserContextMiddleware(fakeBearerAuthenticator{}, authorizer)(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("expected viewer generator access to be blocked")
|
||||
})(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected forbidden, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserContextMiddlewareProtectsV1Routes(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/generator/tasks", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
called := false
|
||||
|
||||
UserContextMiddleware(fakeBearerAuthenticator{})(func(http.ResponseWriter, *http.Request) {
|
||||
called = true
|
||||
})(rec, req)
|
||||
|
||||
if called {
|
||||
t.Fatal("expected v1 route to require authentication")
|
||||
}
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected unauthorized, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user