Implement organization assumption check in authorization layer

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-01-08 17:45:25 +01:00
committed by Bryan Frimin
parent 5e602d744b
commit c6094ff572
25 changed files with 1614 additions and 968 deletions

View File

@@ -20,11 +20,14 @@ import (
"fmt"
"net/http"
"github.com/99designs/gqlgen/graphql"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/kit/httpserver"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securetoken"
"go.probo.inc/probo/pkg/server/gqlutils"
)
var (
@@ -56,7 +59,15 @@ func NewAPIKeyMiddleware(svc *iam.Service, tokenSecret string) func(next http.Ha
session := SessionFromContext(ctx)
if keyID != gid.Nil && session != nil {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("api key authentication cannot be used with session authentication"))
httpserver.RenderJSON(
w,
http.StatusUnauthorized,
&graphql.Response{
Errors: gqlerror.List{
gqlutils.Conflictf(ctx, "API key authentication cannot be used with session authentication"),
},
},
)
return
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package connect_v1
import (
"context"
"errors"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/server/gqlutils"
)
type (
AuthorizeFuncOption func(*iam.AuthorizeParams)
AuthorizeFunc func(context.Context, gid.GID, string, ...AuthorizeFuncOption) error
)
func WithAttr(key, value string) AuthorizeFuncOption {
return func(params *iam.AuthorizeParams) {
params.ResourceAttributes[key] = value
}
}
func WithSession(sessionID *gid.GID) AuthorizeFuncOption {
return func(params *iam.AuthorizeParams) {
params.Session = sessionID
}
}
func NewAuthorizeFunc(
svc *iam.Service,
logger *log.Logger,
) AuthorizeFunc {
return func(
ctx context.Context,
objectID gid.GID,
action string,
options ...AuthorizeFuncOption,
) error {
identity := IdentityFromContext(ctx)
session := SessionFromContext(ctx)
params := iam.AuthorizeParams{
Principal: identity.ID,
Resource: objectID,
Action: action,
ResourceAttributes: make(map[string]string),
}
if session != nil {
params.Session = &session.ID
}
for _, option := range options {
option(&params)
}
if err := svc.Authorizer.Authorize(ctx, params); err != nil {
var errInsufficientPermissions *iam.ErrInsufficientPermissions
if errors.As(err, &errInsufficientPermissions) {
return gqlutils.Forbidden(ctx, err)
}
logger.ErrorCtx(ctx, "cannot authorize", log.Error(err))
return gqlutils.Internal(ctx)
}
return nil
}
}

View File

@@ -19,7 +19,6 @@ import (
"net/http"
"github.com/99designs/gqlgen/graphql"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/iam"
@@ -29,29 +28,6 @@ import (
"go.probo.inc/probo/pkg/server/gqlutils"
)
var (
ErrForbidden = &gqlerror.Error{
Message: "You are not authorized to access this resource",
Extensions: map[string]any{
"code": "FORBIDDEN",
},
}
ErrUnauthenticated = &gqlerror.Error{
Message: "You must be authenticated to access this resouce",
Extensions: map[string]any{
"code": "UNAUTHENTICATED",
},
}
ErrAlreadyAuthenticated = &gqlerror.Error{
Message: "authentication not allowed for this resource/action",
Extensions: map[string]any{
"code": "ALREADY_AUTHENTICATED",
},
}
)
func SessionDirective(ctx context.Context, obj any, next graphql.Resolver, required types.SessionRequirement) (any, error) {
session := SessionFromContext(ctx)
apiKey := APIKeyFromContext(ctx)
@@ -60,50 +36,34 @@ func SessionDirective(ctx context.Context, obj any, next graphql.Resolver, requi
case types.SessionRequirementOptional:
case types.SessionRequirementPresent:
if session == nil && apiKey == nil {
return nil, ErrUnauthenticated
return nil, gqlutils.Unauthenticatedf(
ctx,
"authentication is required to access this resouce",
)
}
case types.SessionRequirementNone:
if session != nil && apiKey != nil {
return nil, ErrAlreadyAuthenticated
return nil, gqlutils.Invalidf(
ctx,
"authentication not allowed for this resource/action",
)
}
}
return next(ctx)
}
func IsViewerDirective(ctx context.Context, obj any, next graphql.Resolver) (any, error) {
identity := IdentityFromContext(ctx)
switch node := obj.(type) {
case *types.Identity:
if identity.ID != node.ID {
return nil, ErrForbidden
}
case *types.Membership:
if identity.ID != node.Identity.ID {
return nil, ErrForbidden
}
case *types.Session:
if identity.ID != node.Identity.ID {
return nil, ErrForbidden
}
default:
}
return next(ctx)
}
func NewGraphQLHandler(svc *iam.Service, logger *log.Logger, baseURL *baseurl.BaseURL, cookieConfig securecookie.Config) http.Handler {
config := schema.Config{
Resolvers: &Resolver{
authorize: NewAuthorizeFunc(svc, logger),
logger: logger,
iam: svc,
baseURL: baseURL,
cookieConfig: cookieConfig,
},
Directives: schema.DirectiveRoot{
Session: SessionDirective,
IsViewer: IsViewerDirective,
Session: SessionDirective,
},
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package connect_v1
import (
"net/http"
"github.com/99designs/gqlgen/graphql"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/kit/httpserver"
"go.probo.inc/probo/pkg/server/gqlutils"
)
func NewIdentityPresenceMiddleware() func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
identity := IdentityFromContext(r.Context())
if identity == nil {
httpserver.RenderJSON(
w,
http.StatusUnauthorized,
&graphql.Response{
Errors: gqlerror.List{
gqlutils.Unauthenticatedf(
r.Context(),
"authentication is required to access this resouce",
),
},
},
)
return
}
next.ServeHTTP(w, r)
},
)
}
}

View File

@@ -18,23 +18,20 @@ package connect_v1
import (
"context"
"errors"
"net/http"
"time"
"github.com/99designs/gqlgen/graphql"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api/connect/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
)
type (
Resolver struct {
authorize AuthorizeFunc
logger *log.Logger
iam *iam.Service
baseURL *baseurl.BaseURL
@@ -81,54 +78,5 @@ func NewMux(logger *log.Logger, svc *iam.Service, cookieConfig securecookie.Conf
}
func (r *Resolver) Permission(ctx context.Context, obj types.Node, action string) (bool, error) {
identity := IdentityFromContext(ctx)
err := r.iam.Authorizer.Authorize(
ctx,
iam.AuthorizeParams{
Principal: identity.ID,
Resource: obj.GetID(),
Action: action,
},
)
if err != nil {
var errInsufficientPermissions *iam.ErrInsufficientPermissions
if errors.As(err, &errInsufficientPermissions) {
return false, nil
}
r.logger.ErrorCtx(ctx, "cannot authorize", log.Error(err))
return false, gqlutils.InternalServerError(ctx)
}
return true, nil
}
func (r *Resolver) Authorize(ctx context.Context, objectID gid.GID, action string, attrs map[string]string) bool {
identity := IdentityFromContext(ctx)
err := r.iam.Authorizer.Authorize(
ctx,
iam.AuthorizeParams{
Principal: identity.ID,
Resource: objectID,
Action: action,
ResourceAttributes: attrs,
},
)
if err != nil {
var errInsufficientPermissions *iam.ErrInsufficientPermissions
if errors.As(err, &errInsufficientPermissions) {
graphql.AddError(ctx, err)
return false
}
r.logger.ErrorCtx(ctx, "cannot authorize", log.Error(err))
graphql.AddError(ctx, gqlutils.InternalServerError(ctx))
return false
}
return true
return r.authorize(ctx, obj.GetID(), action) == nil, nil
}

View File

@@ -37,7 +37,7 @@ func (h *SAMLHandler) MetadataHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/samlmetadata+xml")
w.WriteHeader(http.StatusOK)
w.Write(metadataXML)
_, _ = w.Write(metadataXML)
}
func (h *SAMLHandler) ConsumeHandler(w http.ResponseWriter, r *http.Request) {
@@ -97,7 +97,11 @@ func (h *SAMLHandler) ConsumeHandler(w http.ResponseWriter, r *http.Request) {
return
}
securecookie.Set(w, h.cookieConfig, rootSession.ID.String())
if err := securecookie.Set(w, h.cookieConfig, rootSession.ID.String()); err != nil {
h.logger.ErrorCtx(ctx, "cannot set cookie", log.Error(err))
h.renderInternalServerError(w, r)
return
}
redirectURL := h.baseURL.WithPath("/organizations/" + membership.OrganizationID.String()).MustString()
http.Redirect(w, r, redirectURL, http.StatusFound)
}

View File

@@ -13,8 +13,6 @@ directive @goEnum(value: String) on ENUM_VALUE
directive @session(required: SessionRequirement!) on FIELD_DEFINITION
directive @isViewer on FIELD_DEFINITION
scalar CursorKey
scalar Datetime
scalar Upload
@@ -150,7 +148,7 @@ type Identity implements Node {
last: Int
before: CursorKey
orderBy: MembershipOrder
): MembershipConnection @goField(forceResolver: true) @isViewer
): MembershipConnection @goField(forceResolver: true)
pendingInvitations(
first: Int
@@ -158,7 +156,7 @@ type Identity implements Node {
last: Int
before: CursorKey
orderBy: InvitationOrder
): InvitationConnection @goField(forceResolver: true) @isViewer
): InvitationConnection @goField(forceResolver: true)
sessions(
first: Int
@@ -166,14 +164,14 @@ type Identity implements Node {
last: Int
before: CursorKey
orderBy: SessionOrder
): SessionConnection @goField(forceResolver: true) @isViewer
): SessionConnection @goField(forceResolver: true)
personalAPIKeys(
first: Int
after: CursorKey
last: Int
before: CursorKey
): PersonalAPIKeyConnection @goField(forceResolver: true) @isViewer
): PersonalAPIKeyConnection @goField(forceResolver: true)
permission(action: String!): Boolean!
@goField(forceResolver: true)
@@ -272,7 +270,7 @@ type Membership implements Node {
source: MembershipSource!
state: MembershipState!
lastSession: Session @goField(forceResolver: true) @isViewer
lastSession: Session @goField(forceResolver: true)
permission(action: String!): Boolean!
@goField(forceResolver: true)
@@ -297,7 +295,7 @@ type Invitation implements Node {
type Session implements Node {
id: ID!
identity: Identity @goField(forceResolver: true) @isViewer
identity: Identity @goField(forceResolver: true)
ipAddress: String!
userAgent: String!
updatedAt: Datetime!

View File

@@ -67,8 +67,7 @@ type ResolverRoot interface {
}
type DirectiveRoot struct {
IsViewer func(ctx context.Context, obj any, next graphql.Resolver) (res any, err error)
Session func(ctx context.Context, obj any, next graphql.Resolver, required types.SessionRequirement) (res any, err error)
Session func(ctx context.Context, obj any, next graphql.Resolver, required types.SessionRequirement) (res any, err error)
}
type ComplexityRoot struct {
@@ -2281,8 +2280,6 @@ directive @goEnum(value: String) on ENUM_VALUE
directive @session(required: SessionRequirement!) on FIELD_DEFINITION
directive @isViewer on FIELD_DEFINITION
scalar CursorKey
scalar Datetime
scalar Upload
@@ -2418,7 +2415,7 @@ type Identity implements Node {
last: Int
before: CursorKey
orderBy: MembershipOrder
): MembershipConnection @goField(forceResolver: true) @isViewer
): MembershipConnection @goField(forceResolver: true)
pendingInvitations(
first: Int
@@ -2426,7 +2423,7 @@ type Identity implements Node {
last: Int
before: CursorKey
orderBy: InvitationOrder
): InvitationConnection @goField(forceResolver: true) @isViewer
): InvitationConnection @goField(forceResolver: true)
sessions(
first: Int
@@ -2434,14 +2431,14 @@ type Identity implements Node {
last: Int
before: CursorKey
orderBy: SessionOrder
): SessionConnection @goField(forceResolver: true) @isViewer
): SessionConnection @goField(forceResolver: true)
personalAPIKeys(
first: Int
after: CursorKey
last: Int
before: CursorKey
): PersonalAPIKeyConnection @goField(forceResolver: true) @isViewer
): PersonalAPIKeyConnection @goField(forceResolver: true)
permission(action: String!): Boolean!
@goField(forceResolver: true)
@@ -2540,7 +2537,7 @@ type Membership implements Node {
source: MembershipSource!
state: MembershipState!
lastSession: Session @goField(forceResolver: true) @isViewer
lastSession: Session @goField(forceResolver: true)
permission(action: String!): Boolean!
@goField(forceResolver: true)
@@ -2565,7 +2562,7 @@ type Invitation implements Node {
type Session implements Node {
id: ID!
identity: Identity @goField(forceResolver: true) @isViewer
identity: Identity @goField(forceResolver: true)
ipAddress: String!
userAgent: String!
updatedAt: Datetime!
@@ -4683,20 +4680,7 @@ func (ec *executionContext) _Identity_memberships(ctx context.Context, field gra
fc := graphql.GetFieldContext(ctx)
return ec.resolvers.Identity().Memberships(ctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*page.CursorKey), fc.Args["last"].(*int), fc.Args["before"].(*page.CursorKey), fc.Args["orderBy"].(*types.MembershipOrderBy))
},
func(ctx context.Context, next graphql.Resolver) graphql.Resolver {
directive0 := next
directive1 := func(ctx context.Context) (any, error) {
if ec.directives.IsViewer == nil {
var zeroVal *types.MembershipConnection
return zeroVal, errors.New("directive isViewer is not implemented")
}
return ec.directives.IsViewer(ctx, obj, directive0)
}
next = directive1
return next
},
nil,
ec.marshalOMembershipConnection2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐMembershipConnection,
true,
false,
@@ -4745,20 +4729,7 @@ func (ec *executionContext) _Identity_pendingInvitations(ctx context.Context, fi
fc := graphql.GetFieldContext(ctx)
return ec.resolvers.Identity().PendingInvitations(ctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*page.CursorKey), fc.Args["last"].(*int), fc.Args["before"].(*page.CursorKey), fc.Args["orderBy"].(*types.InvitationOrderBy))
},
func(ctx context.Context, next graphql.Resolver) graphql.Resolver {
directive0 := next
directive1 := func(ctx context.Context) (any, error) {
if ec.directives.IsViewer == nil {
var zeroVal *types.InvitationConnection
return zeroVal, errors.New("directive isViewer is not implemented")
}
return ec.directives.IsViewer(ctx, obj, directive0)
}
next = directive1
return next
},
nil,
ec.marshalOInvitationConnection2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐInvitationConnection,
true,
false,
@@ -4807,20 +4778,7 @@ func (ec *executionContext) _Identity_sessions(ctx context.Context, field graphq
fc := graphql.GetFieldContext(ctx)
return ec.resolvers.Identity().Sessions(ctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*page.CursorKey), fc.Args["last"].(*int), fc.Args["before"].(*page.CursorKey), fc.Args["orderBy"].(*types.SessionOrder))
},
func(ctx context.Context, next graphql.Resolver) graphql.Resolver {
directive0 := next
directive1 := func(ctx context.Context) (any, error) {
if ec.directives.IsViewer == nil {
var zeroVal *types.SessionConnection
return zeroVal, errors.New("directive isViewer is not implemented")
}
return ec.directives.IsViewer(ctx, obj, directive0)
}
next = directive1
return next
},
nil,
ec.marshalOSessionConnection2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐSessionConnection,
true,
false,
@@ -4869,20 +4827,7 @@ func (ec *executionContext) _Identity_personalAPIKeys(ctx context.Context, field
fc := graphql.GetFieldContext(ctx)
return ec.resolvers.Identity().PersonalAPIKeys(ctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*page.CursorKey), fc.Args["last"].(*int), fc.Args["before"].(*page.CursorKey))
},
func(ctx context.Context, next graphql.Resolver) graphql.Resolver {
directive0 := next
directive1 := func(ctx context.Context) (any, error) {
if ec.directives.IsViewer == nil {
var zeroVal *types.PersonalAPIKeyConnection
return zeroVal, errors.New("directive isViewer is not implemented")
}
return ec.directives.IsViewer(ctx, obj, directive0)
}
next = directive1
return next
},
nil,
ec.marshalOPersonalAPIKeyConnection2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐPersonalAPIKeyConnection,
true,
false,
@@ -5863,20 +5808,7 @@ func (ec *executionContext) _Membership_lastSession(ctx context.Context, field g
func(ctx context.Context) (any, error) {
return ec.resolvers.Membership().LastSession(ctx, obj)
},
func(ctx context.Context, next graphql.Resolver) graphql.Resolver {
directive0 := next
directive1 := func(ctx context.Context) (any, error) {
if ec.directives.IsViewer == nil {
var zeroVal *types.Session
return zeroVal, errors.New("directive isViewer is not implemented")
}
return ec.directives.IsViewer(ctx, obj, directive0)
}
next = directive1
return next
},
nil,
ec.marshalOSession2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐSession,
true,
false,
@@ -11579,20 +11511,7 @@ func (ec *executionContext) _Session_identity(ctx context.Context, field graphql
func(ctx context.Context) (any, error) {
return ec.resolvers.Session().Identity(ctx, obj)
},
func(ctx context.Context, next graphql.Resolver) graphql.Resolver {
directive0 := next
directive1 := func(ctx context.Context) (any, error) {
if ec.directives.IsViewer == nil {
var zeroVal *types.Identity
return zeroVal, errors.New("directive isViewer is not implemented")
}
return ec.directives.IsViewer(ctx, obj, directive0)
}
next = directive1
return next
},
nil,
ec.marshalOIdentity2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐIdentity,
true,
false,

View File

@@ -21,11 +21,14 @@ import (
"net"
"net/http"
"github.com/99designs/gqlgen/graphql"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/kit/httpserver"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/gqlutils"
)
var (
@@ -64,7 +67,15 @@ func NewSessionMiddleware(svc *iam.Service, cookieConfig securecookie.Config) fu
apiKey := APIKeyFromContext(ctx)
if sessionID != gid.Nil && apiKey != nil {
httpserver.RenderError(w, http.StatusBadRequest, errors.New("session authentication cannot be used with API key authentication"))
httpserver.RenderJSON(
w,
http.StatusUnauthorized,
&graphql.Response{
Errors: gqlerror.List{
gqlutils.Conflictf(ctx, "session authentication cannot be used with API key authentication"),
},
},
)
return
}

File diff suppressed because it is too large Load Diff