Files
warmbly/internal/api/middleware/resource_scope.go
T

274 lines
8.2 KiB
Go

package middleware
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/models"
)
// ResourceScopeKey holds a restricted caller's resolved *models.ResourceScope.
const ResourceScopeKey = "resource_scope"
// MemberAccessRestricted is the code for a restricted member on a surface their scope does not cover.
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/switch/:id": true,
"GET /v1/organization/current": true,
"GET /v1/me/views/:view": true,
"PUT /v1/me/views/:view": true,
"DELETE /v1/me/views/:view": true,
"GET /v1/me/danger-zone": true,
"POST /v1/me/danger-zone/delete": true,
"DELETE /v1/me/danger-zone/delete": true,
"GET /v1/invitations": true,
"POST /v1/invitations/accept": true,
"POST /v1/getaway": true,
"GET /v1/subscription": true,
"GET /v1/subscription/features": true,
"GET /v1/campaigns": true,
"GET /v1/campaigns-overview": true,
"GET /v1/campaigns/:id": true,
"GET /v1/campaigns/:id/logs": true,
"GET /v1/campaigns/:id/ab-variants": true,
"GET /v1/timezones": true,
"GET /v1/campaigns/:id/steps": true,
"GET /v1/analytics/dashboard": true,
"GET /v1/analytics/campaigns/compare": true,
"GET /v1/analytics/campaigns/:id": true,
"GET /v1/analytics/campaigns/:id/daily": true,
"GET /v1/analytics/campaigns/:id/hourly": true,
"GET /v1/analytics/accounts": true,
"GET /v1/analytics/accounts/:id": true,
"GET /v1/emails": true,
"GET /v1/emails/:id": true,
"GET /v1/unibox": true,
"GET /v1/unibox/count": true,
"GET /v1/unibox/overview": true,
"GET /v1/unibox/thread": true,
"GET /v1/unibox/thread/labels": true,
"GET /v1/unibox/scheduled": true,
"GET /v1/unibox/:id": true,
}
// ScopeAwareRoutes lists grant-aware and target-authorized routes for the route-table test.
func ScopeAwareRoutes() []string {
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()
return
}
if !scopeAwareRoutes[c.Request.Method+" "+c.FullPath()] {
errx.JSON(c, errMemberAccessRestricted)
c.Abort()
return
}
if h.OrganizationService == nil {
errx.JSON(c, errx.InternalError())
c.Abort()
return
}
scope, xerr := h.OrganizationService.ResolveMemberScope(c.Request.Context(), m.OrganizationID, m.UserID)
if xerr != nil {
errx.JSON(c, xerr)
c.Abort()
return
}
if scope == nil {
errx.JSON(c, errx.InternalError())
c.Abort()
return
}
c.Set(ResourceScopeKey, scope)
c.Next()
}
}
// grantList is a restricted member's grants as an allowlist, which is never empty.
func grantList(ids []uuid.UUID) []uuid.UUID {
if len(ids) == 0 {
return models.NoneMatch()
}
return ids
}
// GetResourceScope is the caller's resolved scope, or nil when they reach the whole workspace.
func GetResourceScope(c *gin.Context) *models.ResourceScope {
if v, ok := c.Get(ResourceScopeKey); ok {
if s, ok := v.(*models.ResourceScope); ok {
return s
}
}
return nil
}
// IsScopeRestricted reports whether the caller acts for a restricted member.
func IsScopeRestricted(c *gin.Context) bool {
return GetResourceScope(c) != nil || GetAuthMember(c).IsRestricted()
}
// AllowedCampaigns is the caller's campaign allowlist: nil is every campaign, and an empty grant matches none.
func AllowedCampaigns(c *gin.Context) []uuid.UUID {
if s := GetResourceScope(c); s != nil {
return grantList(s.Campaigns)
}
if GetAuthMember(c).IsRestricted() {
return models.NoneMatch()
}
return nil
}
// CampaignAllowed reports whether the caller may read campaign id.
func CampaignAllowed(c *gin.Context, id uuid.UUID) bool {
allowed := AllowedCampaigns(c)
if allowed == nil {
return true
}
for _, v := range allowed {
if v == id && v != uuid.Nil {
return true
}
}
return false
}
// AllowedFolders are the campaign folders the caller may see, nil for every folder.
func AllowedFolders(c *gin.Context) []uuid.UUID {
if s := GetResourceScope(c); s != nil {
return grantList(s.Folders)
}
if GetAuthMember(c).IsRestricted() {
return models.NoneMatch()
}
return nil
}
// AllowedEmailAccounts is the caller's mailbox allowlist (an API key's list and a restricted member's grants together); nil is every mailbox.
func AllowedEmailAccounts(c *gin.Context) []uuid.UUID {
var key []uuid.UUID
if c.GetString(AuthTypeKey) == AuthTypeAPIKey {
key = GetAPIKeyAllowedEmailAccounts(c)
}
var member []uuid.UUID
if s := GetResourceScope(c); s != nil {
member = grantList(s.Mailboxes)
} else if GetAuthMember(c).IsRestricted() {
member = models.NoneMatch()
}
switch {
case len(key) == 0:
return member
case member == nil:
return key
}
both := make([]uuid.UUID, 0, len(key))
for _, id := range key {
for _, m := range member {
if id == m {
both = append(both, id)
break
}
}
}
return grantList(both)
}
// EmailAccountAllowed reports whether the caller may act on mailbox id.
func EmailAccountAllowed(c *gin.Context, id uuid.UUID) bool {
allowed := AllowedEmailAccounts(c)
if allowed == nil {
return true
}
for _, v := range allowed {
if v == id && v != uuid.Nil {
return true
}
}
return false
}
// ErrEmailAccountNotAllowed is the refusal for a mailbox outside the caller's allowlist.
func ErrEmailAccountNotAllowed(c *gin.Context) *errx.Error {
if IsScopeRestricted(c) {
return errx.New(errx.NotFound, "email account not found")
}
return errx.New(errx.Forbidden, "email account is not allowed for this API key")
}
// RequireEmailAccountParam enforces the caller's mailbox allowlist on a route parameter.
func RequireEmailAccountParam(param string) gin.HandlerFunc {
return func(c *gin.Context) {
if AllowedEmailAccounts(c) == nil {
c.Next()
return
}
accountID, err := uuid.Parse(c.Param(param))
if err != nil {
errx.Handle(c, errx.ErrUuid)
c.Abort()
return
}
if EmailAccountAllowed(c, accountID) {
c.Next()
return
}
errx.Handle(c, ErrEmailAccountNotAllowed(c))
c.Abort()
}
}
// RequireCampaignParam answers not found for a campaign outside a restricted caller's scope; routes without the param pass.
func RequireCampaignParam(param string) gin.HandlerFunc {
return func(c *gin.Context) {
if AllowedCampaigns(c) == nil || c.Param(param) == "" {
c.Next()
return
}
id, err := uuid.Parse(c.Param(param))
if err != nil {
errx.Handle(c, errx.ErrUuid)
c.Abort()
return
}
if CampaignAllowed(c, id) {
c.Next()
return
}
errx.Handle(c, errx.New(errx.NotFound, "campaign not found"))
c.Abort()
}
}