Skip to content

Commit

Permalink
feat: add conditions (#152)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
golanglemonade authored Feb 14, 2025
1 parent f93788d commit f6d5f5d
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 39 deletions.
6 changes: 6 additions & 0 deletions fgax/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion fgax/checks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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{
Expand Down
139 changes: 104 additions & 35 deletions fgax/tuples.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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{} }
Expand All @@ -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:<user-id>`, `org:<org-id>#member`
type Entity struct {
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
56 changes: 53 additions & 3 deletions fgax/tuples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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{
Expand All @@ -156,6 +162,9 @@ func TestTupleKeyToWriteRequest(t *testing.T) {
Kind: "ORGANIZATION",
Identifier: "IDOFTHEORG",
},
Condition: Condition{
Name: "condition_name",
},
},
},
expectedUser: "user:THEBESTUSER",
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -370,15 +395,40 @@ 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",
},
},
},
},
},
}

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)
})
Expand Down

0 comments on commit f6d5f5d

Please sign in to comment.