feat: enforce grant-scoped organization summaries and account-wide workspace creation with target membership checks for limit requests

This commit is contained in:
Matthew Meszaros
2026-10-10 10:50:44 +00:00
committed by Devin AI
parent 3ececd2934
commit 8d4e1de4e4
9 changed files with 864 additions and 19 deletions
+22 -3
View File
@@ -16,12 +16,19 @@ const MemberAccessRestricted = "member_access_restricted"
var errMemberAccessRestricted = errx.NewWithIdentifier(errx.Forbidden, MemberAccessRestricted,
"Your access to this workspace is limited to selected campaigns and mailboxes, and this is outside it.")
// These services authorize the account or target workspace, not the currently selected membership.
var organizationPolicyRoutes = map[string]bool{
"POST /v1/organization": true,
"POST /v1/organization/:orgId/limit-requests": true,
"GET /v1/organization/:orgId/limit-requests": true,
"DELETE /v1/limit-requests/:id": true,
}
// scopeAwareRoutes is every route a restricted member may call; each one filters to the member's grants.
var scopeAwareRoutes = map[string]bool{
// The member's own account, workspace list and socket.
"GET /v1/me": true,
"GET /v1/organization": true,
"POST /v1/organization": true,
"POST /v1/organization/switch/:id": true,
"GET /v1/organization/current": true,
"GET /v1/me/views/:view": true,
@@ -60,18 +67,25 @@ var scopeAwareRoutes = map[string]bool{
"GET /v1/unibox/:id": true,
}
// ScopeAwareRoutes lists the routes a restricted member may call, for the route-table test.
// ScopeAwareRoutes lists grant-aware and target-authorized routes for the route-table test.
func ScopeAwareRoutes() []string {
out := make([]string, 0, len(scopeAwareRoutes))
out := make([]string, 0, len(scopeAwareRoutes)+len(organizationPolicyRoutes))
for r := range scopeAwareRoutes {
out = append(out, r)
}
for r := range organizationPolicyRoutes {
out = append(out, r)
}
return out
}
// ResourceScopeGate holds a restricted member, and any key or app acting for one, to scope-aware routes and their grants.
func (h *Handler) ResourceScopeGate() gin.HandlerFunc {
return func(c *gin.Context) {
if organizationPolicyRoutes[c.Request.Method+" "+c.FullPath()] {
c.Next()
return
}
m := GetAuthMember(c)
if !m.IsRestricted() {
c.Next()
@@ -93,6 +107,11 @@ func (h *Handler) ResourceScopeGate() gin.HandlerFunc {
c.Abort()
return
}
if scope == nil {
errx.JSON(c, errx.InternalError())
c.Abort()
return
}
c.Set(ResourceScopeKey, scope)
c.Next()
}
+42 -1
View File
@@ -19,11 +19,52 @@ type scopeOrgs struct {
organization.OrganizationService
scope *models.ResourceScope
resolved int
err *errx.Error
}
func (f *scopeOrgs) ResolveMemberScope(context.Context, uuid.UUID, uuid.UUID) (*models.ResourceScope, *errx.Error) {
f.resolved++
return f.scope, nil
return f.scope, f.err
}
func TestResourceScopeGateFailsClosedOnResolutionFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
err *errx.Error
}{
{"nil scope", nil},
{"resolver error", errx.InternalError()},
} {
t.Run(tc.name, func(t *testing.T) {
orgs := &scopeOrgs{err: tc.err}
rec, seen := serveScoped(t, &Handler{OrganizationService: orgs}, restrictedMember(uuid.New()), nil, http.MethodGet, "/v1/organization/current", "/v1/organization/current")
if rec.Code != http.StatusInternalServerError || seen != nil || orgs.resolved != 1 {
t.Fatalf("resolution failure proceeded: HTTP %d seen=%v resolved=%d", rec.Code, seen != nil, orgs.resolved)
}
})
}
}
func TestResourceScopeGateDefersCreationToAccountPolicy(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, auth := range []string{AuthTypeJWT, AuthTypeAPIKey, AuthTypeOAuth} {
t.Run(auth, func(t *testing.T) {
orgs := &scopeOrgs{err: errx.InternalError()}
h := &Handler{OrganizationService: orgs}
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set(AuthTypeKey, auth)
c.Set(SessionMemberKey, restrictedMember(uuid.New()))
})
r.POST("/v1/organization", h.ResourceScopeGate(), func(c *gin.Context) { c.Status(http.StatusAccepted) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/v1/organization", nil))
if w.Code != http.StatusAccepted || orgs.resolved != 0 {
t.Fatalf("creation was tied to selected member resolution: HTTP %d resolved=%d", w.Code, orgs.resolved)
}
})
}
}
func restrictedMember(orgID uuid.UUID) *models.OrganizationMember {