diff --git a/README.md b/README.md index 5bc6f7fd..2cb28d73 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ These sections show how to use the SDK to perform API management functions. Befo 10. [Impersonate](#impersonate) 11. [Audit](#audit) 12. [Embedded Links](#embedded-links) -13. [Manage ReBAC Authz](#manage-rebac-authz) +13. [Manage FGA (Fine-grained Authorization)](#manage-fga-fine-grained-authorization) 14. [Manage Project](#manage-project) 15. [Manage SSO Applications](#manage-sso-applications) @@ -1319,180 +1319,73 @@ err := descopeClient.Management.Audit().CreateEvent(context.Background(), &desco }) ``` -### Manage ReBAC Authz +### Manage FGA (Fine-grained Authorization) Descope supports full relation based access control (ReBAC) using a zanzibar like schema and operations. -A schema is comprized of namespaces (entities like documents, folders, orgs, etc.) and each namespace has relation definitions to define relations. -Each relation definition can be simple (either you have it or not) or complex (union of nodes). +A schema is comprized of types (entities like documents, folders, orgs, etc.) and each type has relation definitions and permission to define relations to other types. A simple example for a file system like schema would be: ```yaml -# Example schema for the authz tests -name: Files -namespaces: - - name: org - relationDefinitions: - - name: parent - - name: member - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationLeft - relationDefinition: parent - relationDefinitionNamespace: org - targetRelationDefinition: member - targetRelationDefinitionNamespace: org - - name: folder - relationDefinitions: - - name: parent - - name: owner - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationRight - relationDefinition: parent - relationDefinitionNamespace: folder - targetRelationDefinition: owner - targetRelationDefinitionNamespace: folder - - name: editor - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationRight - relationDefinition: parent - relationDefinitionNamespace: folder - targetRelationDefinition: editor - targetRelationDefinitionNamespace: folder - - nType: child - expression: - neType: targetSet - targetRelationDefinition: owner - targetRelationDefinitionNamespace: folder - - name: viewer - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationRight - relationDefinition: parent - relationDefinitionNamespace: folder - targetRelationDefinition: viewer - targetRelationDefinitionNamespace: folder - - nType: child - expression: - neType: targetSet - targetRelationDefinition: editor - targetRelationDefinitionNamespace: folder - - name: doc - relationDefinitions: - - name: parent - - name: owner - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationRight - relationDefinition: parent - relationDefinitionNamespace: doc - targetRelationDefinition: owner - targetRelationDefinitionNamespace: folder - - name: editor - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationRight - relationDefinition: parent - relationDefinitionNamespace: doc - targetRelationDefinition: editor - targetRelationDefinitionNamespace: folder - - nType: child - expression: - neType: targetSet - targetRelationDefinition: owner - targetRelationDefinitionNamespace: doc - - name: viewer - complexDefinition: - nType: union - children: - - nType: child - expression: - neType: self - - nType: child - expression: - neType: relationRight - relationDefinition: parent - relationDefinitionNamespace: doc - targetRelationDefinition: viewer - targetRelationDefinitionNamespace: folder - - nType: child - expression: - neType: targetSet - targetRelationDefinition: editor - targetRelationDefinitionNamespace: doc -``` +model AuthZ 1.0 + +type user + +type org + relation member: user + relation parent: org + +type folder + relation parent: folder + relation owner: user | org#member + relation editor: user + relation viewer: user + + permission can_create: owner | parent.owner + permission can_edit: editor | can_create + permission can_view: viewer | can_edit + +type doc + relation parent: folder + relation owner: user | org#member + relation editor: user + relation viewer: user + + permission can_create: owner | parent.owner + permission can_edit: editor | can_create + permission can_view: viewer | can_edit +``` + Descope SDK allows you to fully manage the schema and relations as well as perform simple (and not so simple) checks regarding the existence of relations. ```go -// Load the existing schema -schema, err := descopeClient.Management.Authz().LoadSchema(context.Background()) -if err != nil { - // handle error -} - -// Save schema and make sure to remove all namespaces not listed -err := descopeClient.Management.Authz().SaveSchema(context.Background(), schema, true) +// Save schema +err := descopeClient.Management.FGA().SaveSchema(context.Background(), schema) // Create a relation between a resource and user -err := descopeClient.Management.Authz().CreateRelations(context.Background(), []*descope.AuthzRelation { - { - resource: "some-doc", - relationDefinition: "owner", - namespace: "doc", - target: "u1", +err := descopeClient.Management.FGA().CreateRelations(context.Background(), []*descope.FGARelation { + { + Resource: "some-doc", + ResourceType: "doc", + Relation: "owner", + Target: "u1", + TargetType: "user" }, }) -// Check if target has the relevant relation -// The answer should be true because an owner is also a viewer -relations, err := descopeClient.Management.Authz().HasRelations(context.Background(), []*descope.AuthzRelationQuery{ +// Check if target has a relevant relation +// The answer should be true because an owner can also view +relations, err := descopeClient.Management.FGA().Check(context.Background(), []*descope.FGARelation{ { - resource: "some-doc", - relationDefinition: "viewer", - namespace: "doc", - target: "u1", + Resource: "some-doc", + ResourceType: "doc", + Relation: "can_view", + Target: "u1", + TargetType: "user" } -}) -``` +}) --> + ### Manage Project diff --git a/descope/api/client.go b/descope/api/client.go index 52ef2f0a..972ced3e 100644 --- a/descope/api/client.go +++ b/descope/api/client.go @@ -198,6 +198,10 @@ var ( authzRETargetAll: "mgmt/authz/re/targetall", authzRETargetWithRelation: "mgmt/authz/re/targetwithrelation", authzGetModified: "mgmt/authz/getmodified", + fgaSaveSchema: "mgmt/fga/schema", + fgaCreateRelations: "mgmt/fga/relations", + fgaDeleteRelations: "mgmt/fga/relations/delete", + fgaCheck: "mgmt/fga/check", }, logout: "auth/logout", logoutAll: "auth/logoutall", @@ -408,6 +412,11 @@ type mgmtEndpoints struct { authzRETargetAll string authzRETargetWithRelation string authzGetModified string + + fgaSaveSchema string + fgaCreateRelations string + fgaDeleteRelations string + fgaCheck string } func (e *endpoints) SignInOTP() string { @@ -1090,6 +1099,22 @@ func (e *endpoints) ManagementAuthzGetModified() string { return path.Join(e.version, e.mgmt.authzGetModified) } +func (e *endpoints) ManagementFGASaveSchema() string { + return path.Join(e.version, e.mgmt.fgaSaveSchema) +} + +func (e *endpoints) ManagementFGACreateRelations() string { + return path.Join(e.version, e.mgmt.fgaCreateRelations) +} + +func (e *endpoints) ManagementFGADeleteRelations() string { + return path.Join(e.version, e.mgmt.fgaDeleteRelations) +} + +func (e *endpoints) ManagementFGACheck() string { + return path.Join(e.version, e.mgmt.fgaCheck) +} + type sdkInfo struct { name string version string diff --git a/descope/authztypes.go b/descope/authztypes.go index e67abcbc..d4c693a0 100644 --- a/descope/authztypes.go +++ b/descope/authztypes.go @@ -90,3 +90,25 @@ type AuthzModified struct { Targets []string `json:"targets"` SchemaChanged bool `json:"schemaChanged"` } + +// FGA + +// FGASchema holds the schema for a project +type FGASchema struct { + Schema string `json:"schema"` +} + +// FGARelation defines a relation between resource and target +type FGARelation struct { + Resource string `json:"resource"` + ResourceType string `json:"resourceType"` + Relation string `json:"relation"` + Target string `json:"target"` + TargetType string `json:"targetType"` +} + +// FGACheck holds the result of a check +type FGACheck struct { + Allowed bool `json:"allowed"` + Relation *FGARelation `json:"relation"` +} diff --git a/descope/internal/mgmt/fga.go b/descope/internal/mgmt/fga.go new file mode 100644 index 00000000..b1c2650c --- /dev/null +++ b/descope/internal/mgmt/fga.go @@ -0,0 +1,93 @@ +package mgmt + +import ( + "context" + + "github.com/descope/go-sdk/descope" + "github.com/descope/go-sdk/descope/api" + "github.com/descope/go-sdk/descope/internal/utils" + "github.com/descope/go-sdk/descope/sdk" +) + +type fga struct { + managementBase +} + +var _ sdk.FGA = &fga{} + +func (f *fga) SaveSchema(ctx context.Context, schema *descope.FGASchema) error { + if schema == nil { + return utils.NewInvalidArgumentError("schema") + } + body := map[string]any{ + "dsl": schema.Schema, + } + _, err := f.client.DoPostRequest(ctx, api.Routes.ManagementFGASaveSchema(), body, nil, f.conf.ManagementKey) + return err +} + +func (f *fga) CreateRelations(ctx context.Context, relations []*descope.FGARelation) error { + if len(relations) == 0 { + return utils.NewInvalidArgumentError("relations") + } + + body := map[string]any{ + "tuples": relations, + } + + _, err := f.client.DoPostRequest(ctx, api.Routes.ManagementFGACreateRelations(), body, nil, f.conf.ManagementKey) + return err +} + +func (f *fga) DeleteRelations(ctx context.Context, relations []*descope.FGARelation) error { + if len(relations) == 0 { + return utils.NewInvalidArgumentError("relations") + } + + body := map[string]any{ + "tuples": relations, + } + + _, err := f.client.DoPostRequest(ctx, api.Routes.ManagementFGADeleteRelations(), body, nil, f.conf.ManagementKey) + return err +} + +type CheckResponseTuple struct { + Allowed bool `json:"allowed"` + Tuple *descope.FGARelation `json:"tuple"` +} + +type checkResponse struct { + CheckResponseTuple []*CheckResponseTuple `json:"tuples"` +} + +func (f *fga) Check(ctx context.Context, relations []*descope.FGARelation) ([]*descope.FGACheck, error) { + if len(relations) == 0 { + return nil, utils.NewInvalidArgumentError("relations") + } + + body := map[string]any{ + "tuples": relations, + } + + res, err := f.client.DoPostRequest(ctx, api.Routes.ManagementFGACheck(), body, nil, f.conf.ManagementKey) + if err != nil { + return nil, err + } + + var response *checkResponse + err = utils.Unmarshal([]byte(res.BodyStr), &response) + if err != nil { + return nil, err + } + + checks := make([]*descope.FGACheck, len(response.CheckResponseTuple)) + for i, tuple := range response.CheckResponseTuple { + checks[i] = &descope.FGACheck{ + Relation: tuple.Tuple, + Allowed: tuple.Allowed, + } + } + + return checks, nil +} diff --git a/descope/internal/mgmt/fga_test.go b/descope/internal/mgmt/fga_test.go new file mode 100644 index 00000000..685e5c2f --- /dev/null +++ b/descope/internal/mgmt/fga_test.go @@ -0,0 +1,113 @@ +package mgmt + +import ( + "context" + "net/http" + "testing" + + "github.com/descope/go-sdk/descope" + "github.com/descope/go-sdk/descope/internal/utils" + "github.com/descope/go-sdk/descope/tests/helpers" + "github.com/stretchr/testify/require" +) + +func TestSaveFGASchemaSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(func(r *http.Request) { + require.Equal(t, r.Header.Get("Authorization"), "Bearer a:key") + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.NotNil(t, req["dsl"]) + require.Equal(t, "some schema", req["dsl"]) + })) + err := mgmt.FGA().SaveSchema(context.Background(), &descope.FGASchema{Schema: "some schema"}) + require.NoError(t, err) +} + +func TestSaveFGASchemaMissingSchema(t *testing.T) { + mgmt := newTestMgmt(nil, nil) + err := mgmt.FGA().SaveSchema(context.Background(), nil) + require.Error(t, err) + require.ErrorContains(t, err, utils.NewInvalidArgumentError("schema").Message) +} + +func TestCreateFGARelationsSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(func(r *http.Request) { + require.Equal(t, r.Header.Get("Authorization"), "Bearer a:key") + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.NotNil(t, req["tuples"]) + require.Equal(t, "g1", req["tuples"].([]any)[0].(map[string]any)["resource"]) + require.Equal(t, "group", req["tuples"].([]any)[0].(map[string]any)["resourceType"]) + require.Equal(t, "member", req["tuples"].([]any)[0].(map[string]any)["relation"]) + require.Equal(t, "u1", req["tuples"].([]any)[0].(map[string]any)["target"]) + require.Equal(t, "user", req["tuples"].([]any)[0].(map[string]any)["targetType"]) + })) + err := mgmt.FGA().CreateRelations(context.Background(), []*descope.FGARelation{{Resource: "g1", ResourceType: "group", Relation: "member", Target: "u1", TargetType: "user"}}) + require.NoError(t, err) +} + +func TestCreateFGARelationsMissingTuples(t *testing.T) { + mgmt := newTestMgmt(nil, nil) + err := mgmt.FGA().CreateRelations(context.Background(), nil) + require.Error(t, err) + require.ErrorContains(t, err, utils.NewInvalidArgumentError("relations").Message) +} + +func TestDeleteFGARelationsSuccess(t *testing.T) { + mgmt := newTestMgmt(nil, helpers.DoOk(func(r *http.Request) { + require.Equal(t, r.Header.Get("Authorization"), "Bearer a:key") + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.NotNil(t, req["tuples"]) + require.Equal(t, "g1", req["tuples"].([]any)[0].(map[string]any)["resource"]) + require.Equal(t, "group", req["tuples"].([]any)[0].(map[string]any)["resourceType"]) + require.Equal(t, "member", req["tuples"].([]any)[0].(map[string]any)["relation"]) + require.Equal(t, "u1", req["tuples"].([]any)[0].(map[string]any)["target"]) + require.Equal(t, "user", req["tuples"].([]any)[0].(map[string]any)["targetType"]) + })) + err := mgmt.FGA().DeleteRelations(context.Background(), []*descope.FGARelation{{Resource: "g1", ResourceType: "group", Relation: "member", Target: "u1", TargetType: "user"}}) + require.NoError(t, err) +} + +func TestDeleteFGARelationsMissingTuples(t *testing.T) { + mgmt := newTestMgmt(nil, nil) + err := mgmt.FGA().DeleteRelations(context.Background(), nil) + require.Error(t, err) + require.ErrorContains(t, err, utils.NewInvalidArgumentError("relations").Message) +} + +func TestCheckFGARelationsSuccess(t *testing.T) { + response := map[string]any{ + "tuples": []*descope.FGACheck{ + { + Allowed: true, + Relation: &descope.FGARelation{Resource: "g1", ResourceType: "group", Relation: "member", Target: "u1", TargetType: "user"}, + }, + }} + mgmt := newTestMgmt(nil, helpers.DoOkWithBody(func(r *http.Request) { + require.Equal(t, r.Header.Get("Authorization"), "Bearer a:key") + req := map[string]any{} + require.NoError(t, helpers.ReadBody(r, &req)) + require.NotNil(t, req["tuples"]) + relation := req["tuples"].([]any)[0].(map[string]any) + require.NotNil(t, relation) + require.Equal(t, "g1", relation["resource"]) + require.Equal(t, "group", relation["resourceType"]) + require.Equal(t, "member", relation["relation"]) + require.Equal(t, "u1", relation["target"]) + require.Equal(t, "user", relation["targetType"]) + }, response)) + checks, err := mgmt.FGA().Check(context.Background(), []*descope.FGARelation{ + {Resource: "g1", ResourceType: "group", Relation: "member", Target: "u1", TargetType: "user"}, + }) + require.NoError(t, err) + require.Len(t, checks, 1) + require.True(t, checks[0].Allowed) +} + +func TestCheckFGARelationsMissingTuples(t *testing.T) { + mgmt := newTestMgmt(nil, nil) + _, err := mgmt.FGA().Check(context.Background(), nil) + require.Error(t, err) + require.ErrorContains(t, err, utils.NewInvalidArgumentError("relations").Message) +} diff --git a/descope/internal/mgmt/mgmt.go b/descope/internal/mgmt/mgmt.go index 2942657f..d8ad13dc 100644 --- a/descope/internal/mgmt/mgmt.go +++ b/descope/internal/mgmt/mgmt.go @@ -34,6 +34,7 @@ type managementService struct { project sdk.Project audit sdk.Audit authz sdk.Authz + fga sdk.FGA } func NewManagement(conf ManagementParams, c *api.Client) *managementService { @@ -53,6 +54,7 @@ func NewManagement(conf ManagementParams, c *api.Client) *managementService { service.audit = &audit{managementBase: base} service.authz = &authz{managementBase: base} service.password = &passwordManagement{managementBase: base} + service.fga = &fga{managementBase: base} return service } @@ -126,6 +128,11 @@ func (mgmt *managementService) Authz() sdk.Authz { return mgmt.authz } +func (mgmt *managementService) FGA() sdk.FGA { + mgmt.ensureManagementKey() + return mgmt.fga +} + func (mgmt *managementService) ensureManagementKey() { if mgmt.conf.ManagementKey == "" { logger.LogInfo("Management key is missing, make sure to add it in the Config struct or the environment variable \"%s\"", descope.EnvironmentVariableManagementKey) // notest diff --git a/descope/internal/mgmt/user_test.go b/descope/internal/mgmt/user_test.go index 68e27997..a3cf5ab9 100644 --- a/descope/internal/mgmt/user_test.go +++ b/descope/internal/mgmt/user_test.go @@ -146,6 +146,7 @@ func TestUsersInviteBatchSuccess(t *testing.T) { users = append(users, u1, u2) sendSMS := true + sendMail := true called := false invite := true @@ -155,10 +156,17 @@ func TestUsersInviteBatchSuccess(t *testing.T) { req := map[string]any{} require.NoError(t, helpers.ReadBody(r, &req)) if invite { - assert.EqualValues(t, true, req["invite"]) - assert.EqualValues(t, "https://some.domain.com", req["inviteUrl"]) - assert.Nil(t, req["sendMail"]) - assert.EqualValues(t, true, req["sendSMS"]) + if sendSMS { + assert.EqualValues(t, true, req["invite"]) + assert.EqualValues(t, "https://some.domain.com", req["inviteUrl"]) + assert.Nil(t, req["sendMail"]) + assert.EqualValues(t, true, req["sendSMS"]) + } else if sendMail { + assert.EqualValues(t, true, req["invite"]) + assert.EqualValues(t, "https://some.domain.com", req["inviteUrl"]) + assert.EqualValues(t, true, req["sendMail"]) + assert.Nil(t, req["sendSMS"]) + } } else { assert.Nil(t, req["invite"]) } @@ -204,6 +212,20 @@ func TestUsersInviteBatchSuccess(t *testing.T) { assert.EqualValues(t, u2.Email, res.FailedUsers[0].User.Email) assert.EqualValues(t, "some failure", res.FailedUsers[0].Failure) + sendSMS = false + res, err = m.User().InviteBatch(context.Background(), users, &descope.InviteOptions{ + InviteURL: "https://some.domain.com", + SendMail: &sendMail, + }) + require.True(t, called) + require.NoError(t, err) + require.NotNil(t, res) + require.Len(t, res.CreatedUsers, 1) + require.Len(t, res.FailedUsers, 1) + assert.EqualValues(t, u1.Email, res.CreatedUsers[0].Email) + assert.EqualValues(t, u2.Email, res.FailedUsers[0].User.Email) + assert.EqualValues(t, "some failure", res.FailedUsers[0].Failure) + invite = false res, err = m.User().CreateBatch(context.Background(), users) require.True(t, called) diff --git a/descope/sdk/mgmt.go b/descope/sdk/mgmt.go index e7046fcc..36150854 100644 --- a/descope/sdk/mgmt.go +++ b/descope/sdk/mgmt.go @@ -801,6 +801,20 @@ type Authz interface { GetModified(ctx context.Context, since time.Time) (*descope.AuthzModified, error) } +type FGA interface { + // SaveSchema creates a new schema for the project. + SaveSchema(ctx context.Context, schema *descope.FGASchema) error + + // CreateRelation creates new relations for the project. + CreateRelations(ctx context.Context, relations []*descope.FGARelation) error + + // DeleteRelations deletes relations for the project. + DeleteRelations(ctx context.Context, relations []*descope.FGARelation) error + + // Check checks if the given relations are satisfied. + Check(ctx context.Context, relations []*descope.FGARelation) ([]*descope.FGACheck, error) +} + // Provides various APIs for managing a Descope project programmatically. A management key must // be provided in the DecopeClient configuration or by setting the DESCOPE_MANAGEMENT_KEY // environment variable. Management keys can be generated in the Descope console. @@ -846,4 +860,7 @@ type Management interface { // Provides functions for ReBAC authz management Authz() Authz + + // Provides functions for FGA authz management + FGA() FGA } diff --git a/descope/tests/mocks/mgmt/managementmock.go b/descope/tests/mocks/mgmt/managementmock.go index 4c029691..b76f14a2 100644 --- a/descope/tests/mocks/mgmt/managementmock.go +++ b/descope/tests/mocks/mgmt/managementmock.go @@ -23,6 +23,7 @@ type MockManagement struct { *MockProject *MockAudit *MockAuthz + *MockFGA } func (m *MockManagement) JWT() sdk.JWT { @@ -81,6 +82,10 @@ func (m *MockManagement) Password() sdk.PasswordManagement { return m.MockPasswordManagement } +func (m *MockManagement) FGA() sdk.FGA { + return m.MockFGA +} + // Mock JWT type MockJWT struct { @@ -1466,3 +1471,46 @@ func (m *MockAuthz) GetModified(_ context.Context, since time.Time) (*descope.Au } return m.GetModifiedResponse, m.GetModifiedError } + +type MockFGA struct { + SaveSchemaAssert func(schema *descope.FGASchema) error + SaveSchemaError error + + CreateRelationsAssert func(relations []*descope.FGARelation) error + CreateRelationsError error + + DeleteRelationsAssert func(relations []*descope.FGARelation) error + DeleteRelationsError error + + CheckAssert func(relations []*descope.FGARelation) + CheckResponse []*descope.FGACheck + CheckError error +} + +func (m *MockFGA) SaveSchema(_ context.Context, schema *descope.FGASchema) error { + if m.SaveSchemaAssert != nil { + m.SaveSchemaAssert(schema) + } + return m.SaveSchemaError +} + +func (m *MockFGA) CreateRelations(_ context.Context, relations []*descope.FGARelation) error { + if m.CreateRelationsAssert != nil { + m.CreateRelationsAssert(relations) + } + return m.CreateRelationsError +} + +func (m *MockFGA) DeleteRelations(_ context.Context, relations []*descope.FGARelation) error { + if m.DeleteRelationsAssert != nil { + m.DeleteRelationsAssert(relations) + } + return m.DeleteRelationsError +} + +func (m *MockFGA) Check(_ context.Context, relations []*descope.FGARelation) ([]*descope.FGACheck, error) { + if m.CheckAssert != nil { + m.CheckAssert(relations) + } + return m.CheckResponse, m.CheckError +}