Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
116 changes: 103 additions & 13 deletions fgax/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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 {
Expand All @@ -294,13 +310,87 @@ 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)
}

// 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,
Expand Down
64 changes: 64 additions & 0 deletions fgax/checks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

"github.com/theopenlane/utils/ulids"

"github.com/theopenlane/iam/auth"
mock_fga "github.com/theopenlane/iam/fgax/internal/mockery"
)

Expand Down Expand Up @@ -670,3 +671,66 @@
})
}
}

func TestGetParentContextualTuple(t *testing.T) {
validULID := ulids.New().String()

contextWithOrg := auth.NewTestContextWithOrgID(ulids.New().String(), validULID)

tests := []struct {
name string
context context.Context

Check warning on line 682 in fgax/checks_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this 'context.Context' field and pass context as a parameter to methods that need it.

See more on https://sonarcloud.io/project/issues?id=theopenlane_iam&issues=AZ32Vtj1T3TPeQ4pG5Dj&open=AZ32Vtj1T3TPeQ4pG5Dj&pullRequest=462
object string
expectedTupleKey *ofgaclient.ClientTupleKey
}{
{
name: "no organization in context",
context: context.Background(),
object: "program:" + validULID,

Check failure on line 689 in fgax/checks_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "program:" 3 times.

See more on https://sonarcloud.io/project/issues?id=theopenlane_iam&issues=AZ32Vtj1T3TPeQ4pG5Di&open=AZ32Vtj1T3TPeQ4pG5Di&pullRequest=462
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)
})
}
}
69 changes: 69 additions & 0 deletions fgax/fga.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package fgax

import (
"context"
"strings"

openfga "github.com/openfga/go-sdk"
ofgaclient "github.com/openfga/go-sdk/client"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Loading