From f6d5f5d6c85a8a69fa40a2f292e308da691c252c Mon Sep 17 00:00:00 2001 From: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> Date: Fri, 14 Feb 2025 14:04:27 -0700 Subject: [PATCH] feat: add conditions (#152) * add update func Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> * cleanup Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> * more Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> * add context to checks side Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> * consistency Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> * consistency Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> * consistency, use any Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> --------- Signed-off-by: Sarah Funkhouser <147884153+golanglemonade@users.noreply.github.com> --- fgax/checks.go | 6 ++ fgax/checks_test.go | 15 ++++- fgax/tuples.go | 139 +++++++++++++++++++++++++++++++++----------- fgax/tuples_test.go | 56 +++++++++++++++++- 4 files changed, 177 insertions(+), 39 deletions(-) diff --git a/fgax/checks.go b/fgax/checks.go index bdeacef..f0b84fc 100644 --- a/fgax/checks.go +++ b/fgax/checks.go @@ -31,6 +31,8 @@ type AccessCheck struct { SubjectType string // Relation is the relationship being checked (e.g. "view", "edit", "delete") Relation string + // Context is the context of the request used for conditional relationships + Context *map[string]any } // ListAccess is a struct to hold the information needed to list all relations @@ -45,6 +47,8 @@ type ListAccess struct { SubjectType string // Relations is the relationship being checked (e.g. "can_view", "can_edit", "can_delete") Relations []string + // Context is the context of the request used for conditional relationships + Context *map[string]any } // CheckAccess checks if the user has access to the object type with the given relation @@ -73,6 +77,7 @@ func (c *Client) CheckAccess(ctx context.Context, ac AccessCheck) (bool, error) User: sub.String(), Relation: ac.Relation, Object: obj.String(), + Context: ac.Context, } return c.checkTuple(ctx, checkReq) @@ -105,6 +110,7 @@ func (c *Client) ListRelations(ctx context.Context, ac ListAccess) ([]string, er User: sub.String(), Relation: rel, Object: obj.String(), + Context: ac.Context, } checks = append(checks, check) diff --git a/fgax/checks_test.go b/fgax/checks_test.go index b1e28ec..fc04204 100644 --- a/fgax/checks_test.go +++ b/fgax/checks_test.go @@ -85,13 +85,14 @@ func TestCheckAccess(t *testing.T) { wantErr: false, }, { - name: "happy path, valid access", + name: "happy path, valid access with context", ac: AccessCheck{ ObjectType: "organization", ObjectID: "ulid-of-org", SubjectType: "service", Relation: "member", SubjectID: "ulid-of-token", + Context: &map[string]any{"service": "github"}, }, expectedRes: true, wantErr: false, @@ -181,6 +182,18 @@ func TestListRelations(t *testing.T) { expectedRes: []string{"can_view", "can_read"}, wantErr: false, }, + { + name: "happy path with context", + check: ListAccess{ + ObjectType: "organization", + ObjectID: "ulid-of-org", + Relations: []string{"can_delete", "can_view", "can_read"}, + SubjectID: "ulid-of-member", + Context: &map[string]any{"role": "admin"}, + }, + expectedRes: []string{"can_view", "can_read"}, + wantErr: false, + }, { name: "missing object type", check: ListAccess{ diff --git a/fgax/tuples.go b/fgax/tuples.go index 98951a2..7c752f8 100644 --- a/fgax/tuples.go +++ b/fgax/tuples.go @@ -76,6 +76,8 @@ type TupleKey struct { Object Entity // Relation is the relationship between the subject and object Relation Relation `json:"relation"` + // Condition for the relationship + Condition Condition `json:"condition,omitempty"` } // TupleRequest is the fields needed to check a tuple in the FGA store @@ -94,6 +96,10 @@ type TupleRequest struct { SubjectRelation string // Relation is the relationship between the subject and object Relation string + // ConditionName for the relationship + ConditionName string + // ConditionContext for the relationship + ConditionContext *map[string]any } func NewTupleKey() TupleKey { return TupleKey{} } @@ -118,6 +124,14 @@ func (r Relation) String() string { return strings.ToLower(string(r)) } +// Condition represents the type of relation condition for openFGA types +type Condition struct { + // Name of the relationship condition + Name string + // Context settings for the relationship condition + Context *map[string]any +} + // Entity represents an entity/entity-set in OpenFGA. // Example: `user:`, `org:#member` type Entity struct { @@ -169,6 +183,13 @@ func tupleKeyToWriteRequest(writes []TupleKey) (w []ofgaclient.ClientTupleKey) { ctk.SetUser(k.Subject.String()) ctk.SetRelation(k.Relation.String()) + if k.Condition.Name != "" { + ctk.SetCondition(openfga.RelationshipCondition{ + Name: k.Condition.Name, + Context: k.Condition.Context, + }) + } + w = append(w, ctk) } @@ -200,54 +221,93 @@ func (c *Client) WriteTupleKeys(ctx context.Context, writes []TupleKey, deletes } resp, err := c.Ofga.Write(ctx).Body(body).Options(opts).Execute() - if err != nil { - // if we don't ignore duplicate key errors, return the errors now - if !c.IgnoreDuplicateKeyError { - log.Info().Err(err).Interface("writes", resp.Writes).Interface("deletes", resp.Deletes).Msg("error writing relationship tuples") + if err := c.checkWriteResponse(resp, err); err != nil { + return nil, err + } - return resp, err - } + return resp, nil +} + +// UpdateConditionalTupleKey will take a tuple key and delete the existing tuple and create a new tuple with the same key +// this is useful for updating a tuple with a condition because fga does not support conditional updates +// Because the delete doesn't take into account conditions, you can use the same key to delete the existing tuple +// It will return the response from the write request +func (c *Client) UpdateConditionalTupleKey(ctx context.Context, tuple TupleKey) (*ofgaclient.ClientWriteResponse, error) { + opts := ofgaclient.ClientWriteOptions{AuthorizationModelId: openfga.PtrString(c.Config.AuthorizationModelId)} + + body := ofgaclient.ClientWriteRequest{ + Deletes: tupleKeyToDeleteRequest([]TupleKey{tuple}), + } + + resp, err := c.Ofga.Write(ctx).Body(body).Options(opts).Execute() + if err := c.checkWriteResponse(resp, err); err != nil { + return nil, err + } + + body = ofgaclient.ClientWriteRequest{ + Writes: tupleKeyToWriteRequest([]TupleKey{tuple}), + } + + resp, err = c.Ofga.Write(ctx).Body(body).Options(opts).Execute() + if err := c.checkWriteResponse(resp, err); err != nil { + return nil, err + } - for _, writes := range resp.Writes { - if writes.Error != nil { - if strings.Contains(writes.Error.Error(), writeAlreadyExistsError) { - log.Warn().Err(writes.Error).Msg("relationship tuple already exists, skipping") + return resp, nil +} + +// checkWriteResponse checks the response from the write request and returns an error if there are any errors +func (c *Client) checkWriteResponse(resp *ofgaclient.ClientWriteResponse, err error) error { + if err == nil { + return nil + } - continue - } + // if we don't ignore duplicate key errors, return the errors now + if !c.IgnoreDuplicateKeyError { + log.Info().Err(err).Interface("writes", resp.Writes).Interface("deletes", resp.Deletes).Msg("error writing relationship tuples") - log.Error().Err(writes.Error). - Str("user", writes.TupleKey.User). - Str("relation", writes.TupleKey.Relation). - Str("object", writes.TupleKey.Object). - Msg("error creating relationship tuples") + return err + } - // returns the first error encountered - return resp, newWritingTuplesError(writes.TupleKey.User, writes.TupleKey.Relation, writes.TupleKey.Object, "writing", err) + for _, writes := range resp.Writes { + if writes.Error != nil { + if strings.Contains(writes.Error.Error(), writeAlreadyExistsError) { + log.Warn().Err(writes.Error).Msg("relationship tuple already exists, skipping") + + continue } - } - for _, deletes := range resp.Deletes { - if deletes.Error != nil { - if strings.Contains(deletes.Error.Error(), deleteDoesNotExistError) { - log.Warn().Err(deletes.Error).Msg("relationship does not exist, skipping") + log.Error().Err(writes.Error). + Str("user", writes.TupleKey.User). + Str("relation", writes.TupleKey.Relation). + Str("object", writes.TupleKey.Object). + Msg("error creating relationship tuples") - continue - } + // returns the first error encountered + return newWritingTuplesError(writes.TupleKey.User, writes.TupleKey.Relation, writes.TupleKey.Object, "writing", err) + } + } - log.Error().Err(deletes.Error). - Str("user", deletes.TupleKey.User). - Str("relation", deletes.TupleKey.Relation). - Str("object", deletes.TupleKey.Object). - Msg("error deleting relationship tuples") + for _, deletes := range resp.Deletes { + if deletes.Error != nil { + if strings.Contains(deletes.Error.Error(), deleteDoesNotExistError) { + log.Warn().Err(deletes.Error).Msg("relationship does not exist, skipping") - // returns the first delete error encountered - return resp, newWritingTuplesError(deletes.TupleKey.User, deletes.TupleKey.Relation, deletes.TupleKey.Object, "writing", err) + continue } + + log.Error().Err(deletes.Error). + Str("user", deletes.TupleKey.User). + Str("relation", deletes.TupleKey.Relation). + Str("object", deletes.TupleKey.Object). + Msg("error deleting relationship tuples") + + // returns the first delete error encountered + return newWritingTuplesError(deletes.TupleKey.User, deletes.TupleKey.Relation, deletes.TupleKey.Object, "writing", err) } } - return resp, nil + return nil } // deleteRelationshipTuple deletes a relationship tuple in the openFGA store @@ -384,9 +444,18 @@ func GetTupleKey(req TupleRequest) TupleKey { object.Relation = Relation(req.ObjectRelation) } - return TupleKey{ + k := TupleKey{ Subject: sub, Object: object, Relation: Relation(req.Relation), } + + if req.ConditionName != "" { + k.Condition = Condition{ + Name: req.ConditionName, + Context: req.ConditionContext, + } + } + + return k } diff --git a/fgax/tuples_test.go b/fgax/tuples_test.go index f26c087..3855105 100644 --- a/fgax/tuples_test.go +++ b/fgax/tuples_test.go @@ -136,6 +136,12 @@ func TestTupleKeyToWriteRequest(t *testing.T) { Kind: "organization", Identifier: "IDOFTHEORG", }, + Condition: Condition{ + Name: "condition_name", + Context: &map[string]any{ + "key": true, + }, + }, }, }, expectedUser: "user:THEBESTUSER", @@ -144,7 +150,7 @@ func TestTupleKeyToWriteRequest(t *testing.T) { expectedCount: 1, }, { - name: "happy path, should lowercase kind and relations", + name: "happy path, should lowercase kind and relations, no context in condition", writes: []TupleKey{ { Subject: Entity{ @@ -156,6 +162,9 @@ func TestTupleKeyToWriteRequest(t *testing.T) { Kind: "ORGANIZATION", Identifier: "IDOFTHEORG", }, + Condition: Condition{ + Name: "condition_name", + }, }, }, expectedUser: "user:THEBESTUSER", @@ -223,6 +232,12 @@ func TestTupleKeyToWriteRequest(t *testing.T) { assert.Equal(t, tc.expectedUser, ctk[0].User) assert.Equal(t, tc.expectedRelation, ctk[0].Relation) assert.Equal(t, tc.expectedObject, ctk[0].Object) + + if tc.writes[0].Condition.Name != "" { + assert.NotNil(t, ctk[0].Condition) + assert.Equal(t, tc.writes[0].Condition.Name, ctk[0].Condition.Name) + assert.Equal(t, tc.writes[0].Condition.Context, ctk[0].Condition.Context) + } } else { assert.Len(t, ctk, tc.expectedCount) } @@ -252,6 +267,13 @@ func TestTupleKeyToDeleteRequest(t *testing.T) { Kind: "organization", Identifier: "IDOFTHEORG", }, + Condition: Condition{ + Name: "condition_name", + Context: &map[string]any{ + "key": true, + "key2": "value", + }, + }, }, }, expectedUser: "user:THEBESTUSER", @@ -352,10 +374,13 @@ func TestWriteTupleKeys(t *testing.T) { fc := NewMockFGAClient(t, c) + mock_fga.WriteAny(t, c) + testCases := []struct { name string writes []TupleKey deletes []TupleKey + errExp string }{ { name: "happy path with relation", @@ -370,6 +395,33 @@ func TestWriteTupleKeys(t *testing.T) { Kind: "organization", Identifier: "IDOFTHEORG", }, + Condition: Condition{ + Name: "condition_name", + Context: &map[string]any{ + "key": true, + "key2": "value", + }, + }, + }, + }, + deletes: []TupleKey{ + { + Subject: Entity{ + Kind: "user2", + Identifier: "THEBESTESTUSER", + }, + Relation: "member", + Object: Entity{ + Kind: "organization", + Identifier: "IDOFTHEORG", + }, + Condition: Condition{ + Name: "condition_name", + Context: &map[string]any{ + "key": true, + "key2": "value", + }, + }, }, }, }, @@ -377,8 +429,6 @@ func TestWriteTupleKeys(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - mock_fga.WriteAny(t, c) - _, err := fc.WriteTupleKeys(context.Background(), tc.writes, tc.deletes) assert.NoError(t, err) })