diff --git a/auth/auth_test.go b/auth/auth_test.go index bd61888..42f28db 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -44,7 +44,7 @@ func TestGetAccessToken(t *testing.T) { name: "happy path from cookie", headerKey: "", headerValue: "", - cookie: &http.Cookie{ //nolint:gosec //nolint:gosec + cookie: &http.Cookie{ //nolint:gosec Name: auth.AccessTokenCookie, Value: testAccessToken, }, diff --git a/fgax/checks.go b/fgax/checks.go index ef248e2..6ea39cb 100644 --- a/fgax/checks.go +++ b/fgax/checks.go @@ -4,18 +4,20 @@ import ( "context" "fmt" "regexp" + "slices" "strings" ofgaclient "github.com/openfga/go-sdk/client" "github.com/rs/zerolog/log" "github.com/theopenlane/utils/ulids" + + "github.com/theopenlane/iam/auth" ) const ( // subject types defaultSubject = userSubject userSubject = "user" - serviceSubject = "service" // object types organizationObject = "organization" @@ -37,6 +39,8 @@ type AccessCheck struct { Relation string // Context is the context of the request used for conditional relationships Context *map[string]any + // ContextualTuples are tuples that are contextual to the request and should be included in the decision process, but are not stored in OpenFGA + ContextualTuples []ofgaclient.ClientTupleKey } // ListAccess is a struct to hold the information needed to list all relations @@ -70,6 +74,11 @@ func (c *Client) BatchCheckObjectAccess(ctx context.Context, checks []AccessChec return nil, err } + ctxTuples := getContextualTuples(opts...) + if len(ctxTuples) > 0 { + check.ContextualTuples = append(check.ContextualTuples, ctxTuples...) + } + checkRequests = append(checkRequests, *check) } @@ -94,22 +103,22 @@ func (c *Client) BatchCheckObjectAccess(ctx context.Context, checks []AccessChec continue } - if result.GetAllowed() { - // get id from the correlation ID - check, ok := getCheckItemByCorrelationID(id, checkRequests) - if !ok { - log.Error().Str("correlationID", id).Msg("correlation ID not found in checks") + // get id from the correlation ID + check, ok := getCheckItemByCorrelationID(id, checkRequests) + if !ok { + log.Error().Str("correlationID", id).Msg("correlation ID not found in checks") - continue - } + continue + } - obj, err := ParseEntity(check.Object) - if err != nil { - log.Error().Err(err).Str("object", check.Object).Msg("error parsing object") + obj, err := ParseEntity(check.Object) + if err != nil { + log.Error().Err(err).Str("object", check.Object).Msg("error parsing object") - return nil, err - } + return nil, err + } + if result.GetAllowed() { allowedObjects = append(allowedObjects, obj.Identifier) } } @@ -283,6 +292,13 @@ func (c *Client) CheckGroupAccess(ctx context.Context, ac AccessCheck, opts ...R func (c *Client) checkTuple(ctx context.Context, check ofgaclient.ClientCheckRequest, opts ...RequestOption) (bool, error) { options := getCheckOptions(opts...) + if !hasParentContextualTuple(check) { + parentContextualTuple := c.getParentContextualTuple(ctx, check.Object) + if parentContextualTuple != nil { + check.ContextualTuples = append(check.ContextualTuples, *parentContextualTuple) + } + } + data, err := c.Ofga.Check(ctx).Body(check). Options(options).Execute() if err != nil { @@ -294,6 +310,68 @@ func (c *Client) checkTuple(ctx context.Context, check ofgaclient.ClientCheckReq return *data.Allowed, nil } +// hasParentContextualTuple determine if the request has an existing parent_context relation and returns true if so +// this is used to prevent overwriting existing parent_context tuples on a request +func hasParentContextualTuple[T ofgaclient.ClientBatchCheckItem | ofgaclient.ClientCheckRequest](check T) bool { + tuples := []ofgaclient.ClientContextualTupleKey{} + + switch req := any(check).(type) { + case ofgaclient.ClientCheckRequest: + tuples = req.ContextualTuples + case ofgaclient.ClientBatchCheckItem: + tuples = req.ContextualTuples + } + + return slices.ContainsFunc(tuples, func(t ofgaclient.ClientTupleKey) bool { + return t.Relation == ParentContextRelation + }) +} + +// getParentContextualTuple returns a parent context tuple if the organization ID is available in the context. User in the check will always be the `organization:ulid-of-organization` +func (c *Client) getParentContextualTuple(ctx context.Context, object string) *ofgaclient.ClientTupleKey { + if c.DisableParentContext { + return nil + } + + // get the organization ID from the context, if available, to add as a parent context tuple for scoping and filters in the authorization model + orgID, _ := auth.GetOrganizationIDFromContext(ctx) //nolint:errcheck + + if orgID == "" { + return nil + } + + entity, err := ParseEntity(object) + if err != nil { + return nil + } + + kind := strings.ToLower(entity.Kind.String()) + if _, ok := c.ParentContextSkipKinds[kind]; ok { + return nil + } + + tk := &ofgaclient.ClientTupleKey{ + User: fmt.Sprintf("%s:%s", organizationObject, orgID), + Relation: ParentContextRelation, + Object: object, + } + + if cond, ok := c.ParentContextConditions[kind]; ok { + tk.SetCondition(cond) + } + + return tk +} + +// ParentContextTuple creates a tuple with the organization object and id using the parent_context relation +func ParentContextTuple(organizationID, object string) ofgaclient.ClientTupleKey { + return ofgaclient.ClientTupleKey{ + User: fmt.Sprintf("%s:%s", organizationObject, organizationID), + Relation: ParentContextRelation, + Object: object, + } +} + // compatibility wrappers for older callers/tests func (c *Client) checkTupleMinimizeLatency(ctx context.Context, check ofgaclient.ClientCheckRequest) (bool, error) { return c.checkTuple(ctx, check) @@ -301,6 +379,18 @@ func (c *Client) checkTupleMinimizeLatency(ctx context.Context, check ofgaclient // batchCheckTuples checks the openFGA store for provided relationship tuples and returns the allowed relations func (c *Client) batchCheckTuples(ctx context.Context, checks []ofgaclient.ClientBatchCheckItem, opts ...RequestOption) ([]string, error) { + for i, check := range checks { + ctxTuples := getContextualTuples(opts...) + if len(ctxTuples) > 0 { + checks[i].ContextualTuples = append(checks[i].ContextualTuples, ctxTuples...) + } else if !hasParentContextualTuple(check) { + parentContextualTuple := c.getParentContextualTuple(ctx, check.Object) + if parentContextualTuple != nil { + checks[i].ContextualTuples = append(checks[i].ContextualTuples, *parentContextualTuple) + } + } + } + res, err := c.Ofga.BatchCheck(ctx).Body( ofgaclient.ClientBatchCheckRequest{ Checks: checks, diff --git a/fgax/checks_test.go b/fgax/checks_test.go index 21aeff1..a2a04a8 100644 --- a/fgax/checks_test.go +++ b/fgax/checks_test.go @@ -12,6 +12,7 @@ import ( "github.com/theopenlane/utils/ulids" + "github.com/theopenlane/iam/auth" mock_fga "github.com/theopenlane/iam/fgax/internal/mockery" ) @@ -670,3 +671,66 @@ func TestValidateListAccess(t *testing.T) { }) } } + +func TestGetParentContextualTuple(t *testing.T) { + validULID := ulids.New().String() + + contextWithOrg := auth.NewTestContextWithOrgID(ulids.New().String(), validULID) + + tests := []struct { + name string + context context.Context + object string + expectedTupleKey *ofgaclient.ClientTupleKey + }{ + { + name: "no organization in context", + context: context.Background(), + object: "program:" + validULID, + expectedTupleKey: nil, + }, + { + name: "organization object", + context: contextWithOrg, + object: "organization:" + validULID, + expectedTupleKey: nil, + }, + { + name: "user object", + context: contextWithOrg, + object: "user:" + validULID, + expectedTupleKey: nil, + }, + { + name: "organization in context", + context: contextWithOrg, + object: "program:" + validULID, + expectedTupleKey: &ofgaclient.ClientTupleKey{ + User: "organization:" + validULID, + Relation: ParentContextRelation, + Object: "program:" + validULID, + }, + }, + } + c := &Client{ + ParentContextSkipKinds: map[string]struct{}{ + "organization": {}, + "user": {}, + "system": {}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ct := c.getParentContextualTuple(tc.context, tc.object) + + if tc.expectedTupleKey == nil { + assert.Nil(t, ct) + return + } + + assert.NotNil(t, ct) + assert.Equal(t, tc.expectedTupleKey, ct) + }) + } +} diff --git a/fgax/fga.go b/fgax/fga.go index 3b5e891..f51ec02 100644 --- a/fgax/fga.go +++ b/fgax/fga.go @@ -2,6 +2,7 @@ package fgax import ( "context" + "strings" openfga "github.com/openfga/go-sdk" ofgaclient "github.com/openfga/go-sdk/client" @@ -17,6 +18,12 @@ type Client struct { Config ofgaclient.ClientConfiguration // MaxBatchWriteSize is the maximum number of writes per batch in a transaction, default 100 MaxBatchWriteSize int + // ParentContextConditions maps entity kind names to the relationship condition applied on parent context tuples + ParentContextConditions map[string]openfga.RelationshipCondition + // ParentContextSkipKinds is an additional set of entity kind names that should not have parent context tuples added + ParentContextSkipKinds map[string]struct{} + // DisableParentContext disables the automatic addition of parent context tuples entirely + DisableParentContext bool } // Config configures the openFGA setup @@ -41,6 +48,22 @@ type Config struct { Credentials Credentials `json:"credentials" koanf:"credentials" jsonschema:"description=credentials for the openFGA client"` // MaxBatchWriteSize is the maximum number of writes per batch in a transaction, default 100 MaxBatchWriteSize int `json:"maxbatchwritesize" koanf:"maxbatchwritesize" jsonschema:"description=maximum number of writes per batch in a transaction, defaults to 100" default:"100"` + // DisableParentContext disables the automatic addition of parent context tuples entirely + DisableParentContext bool `json:"disableparentcontext" koanf:"disableparentcontext" jsonschema:"description=disables the automatic addition of parent context tuples" default:"false"` + // ParentContextSkipKinds is a list of entity kind names that should not have parent context tuples added + ParentContextSkipKinds []string `json:"parentcontextskipkinds" koanf:"parentcontextskipkinds" jsonschema:"description=entity kind names that should not have parent context tuples added"` + // ParentContextConditions defines relationship conditions to apply on parent context tuples per entity kind + ParentContextConditions []ParentContextConditionConfig `json:"parentcontextconditions" koanf:"parentcontextconditions" jsonschema:"description=relationship conditions to apply on parent context tuples per entity kind"` +} + +// ParentContextConditionConfig defines a relationship condition to apply on the parent context tuple for a given entity kind +type ParentContextConditionConfig struct { + // Kind is the entity kind name the condition applies to + Kind string `json:"kind" koanf:"kind" jsonschema:"description=entity kind name the condition applies to"` + // Name is the condition name defined in the authorization model + Name string `json:"name" koanf:"name" jsonschema:"description=condition name defined in the authorization model"` + // Context is the condition context parameters + Context map[string]any `json:"context" koanf:"context" jsonschema:"description=condition context parameters"` } // Credentials for the openFGA client @@ -136,6 +159,40 @@ func WithClientCredentials(clientID, clientSecret, aud, issuer, scopes string) O } } +// WithDisableParentContext disables the automatic addition of parent context tuples for all checks +func WithDisableParentContext() Option { + return func(c *Client) { + c.DisableParentContext = true + } +} + +// WithParentContextSkipKind adds an entity kind that should not have a parent context tuple added +func WithParentContextSkipKind(kind ...string) Option { + return func(c *Client) { + if c.ParentContextSkipKinds == nil { + c.ParentContextSkipKinds = map[string]struct{}{} + } + + for _, k := range kind { + c.ParentContextSkipKinds[strings.ToLower(k)] = struct{}{} + } + } +} + +// WithParentContextCondition registers a relationship condition to be applied on the parent context tuple for the given entity kind +func WithParentContextCondition(kind, conditionName string, conditionContext map[string]any) Option { + return func(c *Client) { + if c.ParentContextConditions == nil { + c.ParentContextConditions = map[string]openfga.RelationshipCondition{} + } + + cond := openfga.NewRelationshipConditionWithDefaults() + cond.SetName(conditionName) + cond.SetContext(conditionContext) + c.ParentContextConditions[kind] = *cond + } +} + // WithToken sets the client credentials func WithToken(token string) Option { return func(c *Client) { @@ -215,6 +272,18 @@ func CreateFGAClientWithStore(ctx context.Context, c Config) (*Client, error) { WithAuthorizationModelID(c.ModelID), ) + if c.DisableParentContext { + opts = append(opts, WithDisableParentContext()) + } + + if len(c.ParentContextSkipKinds) > 0 { + opts = append(opts, WithParentContextSkipKind(c.ParentContextSkipKinds...)) + } + + for _, cond := range c.ParentContextConditions { + opts = append(opts, WithParentContextCondition(cond.Kind, cond.Name, cond.Context)) + } + // create fga client with store ID client, err := NewClient( c.HostURL, diff --git a/fgax/options.go b/fgax/options.go index df18f17..8862fab 100644 --- a/fgax/options.go +++ b/fgax/options.go @@ -38,6 +38,8 @@ type RequestOptions struct { MaxBatchWriteSize int32 // MaxParallelRequests holds the maximum number of parallel requests for batch operations MaxParallelRequests int32 + // ContextualTuples holds tuples that are contextual to the request and should be included in the decision process, but are not stored in OpenFGA + ContextualTuples []ofgaclient.ClientTupleKey } // RequestOption is a functional option for RequestOptions @@ -94,6 +96,13 @@ func WithMaxParallelRequests(count int32) RequestOption { } } +// WithContextualTuples sets contextual tuples for the request that are not stored in OpenFGA but should be included in the decision process +func WithContextualTuples(tuples []ofgaclient.ClientTupleKey) RequestOption { + return func(ro *RequestOptions) { + ro.ContextualTuples = tuples + } +} + // getRequestOptions aggregates functional RequestOptions into a RequestOptions struct func getRequestOptions(opts ...RequestOption) RequestOptions { ro := RequestOptions{ @@ -190,3 +199,9 @@ func getWriteOptions(opts ...RequestOption) ofgaclient.ClientWriteOptions { return o } + +// getContextualTuples returns the contextual tuples on a request option +func getContextualTuples(opts ...RequestOption) []ofgaclient.ClientTupleKey { + ro := getRequestOptions(opts...) + return ro.ContextualTuples +} diff --git a/fgax/test_tools.go b/fgax/test_tools.go index fe0a481..df33f76 100644 --- a/fgax/test_tools.go +++ b/fgax/test_tools.go @@ -18,6 +18,11 @@ func NewMockFGAClient(c *mock_fga.MockSdkClient) *Client { }, Ofga: c, MaxBatchWriteSize: defaultMaxWriteBatchSize, + ParentContextSkipKinds: map[string]struct{}{ + "organization": {}, + "user": {}, + "system": {}, + }, } return &client diff --git a/fgax/testutils/container.go b/fgax/testutils/container.go index 7acd721..b2457dc 100644 --- a/fgax/testutils/container.go +++ b/fgax/testutils/container.go @@ -37,6 +37,12 @@ type OpenFGATestFixture struct { cpu int64 // envVars is a map of environment variables to set in the container envVars map[string]string + // disableParentContext will disable adding the parent context to fga checks + disableParentContext bool + // parentSkipKinds are the entity kinds that should skip adding the parent context tuple + parentSkipKinds []string + // parentContextConditions are entity kinds and and conditions to set on the parent context tuple + parentContextConditions []fgax.ParentContextConditionConfig } // WithModelFile sets the model file path for the openFGA client @@ -102,6 +108,27 @@ func WithEnvVars(envVars map[string]string) Option { } } +// WithDisableParentContext disable the parent context entirely +func WithDisableParentContext() Option { + return func(c *OpenFGATestFixture) { + c.disableParentContext = true + } +} + +// WithSkipParentContextKinds sets the entity kinds on the options +func WithSkipParentContextKinds(kinds ...string) Option { + return func(c *OpenFGATestFixture) { + c.parentSkipKinds = kinds + } +} + +// WithParentSkipConditions sets the conditions on the options +func WithParentSkipConditions(conditions ...fgax.ParentContextConditionConfig) Option { + return func(c *OpenFGATestFixture) { + c.parentContextConditions = conditions + } +} + // NewFGATestcontainer creates a new test container with the provided context and options func NewFGATestcontainer(ctx context.Context, opts ...Option) *OpenFGATestFixture { // setup the default config @@ -148,10 +175,13 @@ func (o *OpenFGATestFixture) NewFgaClient(ctx context.Context) (*fgax.Client, er } fgaConfig := fgax.Config{ - StoreName: o.storeName, - HostURL: host, - ModelFile: o.modelFile, - ModuleFile: o.moduleFile, + StoreName: o.storeName, + HostURL: host, + ModelFile: o.modelFile, + ModuleFile: o.moduleFile, + DisableParentContext: o.disableParentContext, + ParentContextSkipKinds: o.parentSkipKinds, + ParentContextConditions: o.parentContextConditions, } c, err := fgax.CreateFGAClientWithStore(ctx, fgaConfig) diff --git a/fgax/tuples.go b/fgax/tuples.go index 88fef1f..a044c94 100644 --- a/fgax/tuples.go +++ b/fgax/tuples.go @@ -45,6 +45,8 @@ const ( BlockedRelation = "blocked" // ViewerRelation is the relation to assign viewers to an entity ViewerRelation = "viewer" + // ParentContextRelation is the relation for parents of an entity that are only used for contextual checks, this is used for organization context + ParentContextRelation = "parent_context" // AssigneeRelation is the relation for assignee of an entity AssigneeRelation = "assignee" diff --git a/providers/oauth2/login_test.go b/providers/oauth2/login_test.go index d45e2d5..f5bae4a 100644 --- a/providers/oauth2/login_test.go +++ b/providers/oauth2/login_test.go @@ -72,7 +72,7 @@ func TestCallbackHandler(t *testing.T) { "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter":"example_value" }` - expectedToken := &oauth2.Token{ //nolint:gosec + expectedToken := &oauth2.Token{ //nolint:gosec // used in tests only, not real credentials AccessToken: "2YotnFZFEjr1zCsicMWpAA", TokenType: "example", RefreshToken: "tGzv3JOkF0XG5Qx2TlKWIA", diff --git a/tokens/tokenmanager_impersonation_test.go b/tokens/tokenmanager_impersonation_test.go index a8a8f39..0814267 100644 --- a/tokens/tokenmanager_impersonation_test.go +++ b/tokens/tokenmanager_impersonation_test.go @@ -262,7 +262,7 @@ func TestTokenManager_ValidateImpersonationToken(t *testing.T) { assert.Equal(t, validOpts.Scopes, claims.Scopes) }, }, - { //nolint:gosec // obv not a valid token + { //nolint:gosec // obviously not a real token name: "malformed token", token: "not.a.valid.token", wantErr: true,