From 51d8182c237c0e87ff6b02cf1b56d6ee492ade44 Mon Sep 17 00:00:00 2001 From: Asaf Shen Date: Tue, 5 Nov 2024 18:33:50 +0200 Subject: [PATCH 1/6] fix readme (#471) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cc78c6e9..5bc6f7fd 100644 --- a/README.md +++ b/README.md @@ -958,7 +958,7 @@ You can create, update, delete or load access keys, as well as search according // If customClaims is supplied, then those claims will be present in the JWT returned by calls to ExchangeAccessKey. // If description is supplied, then the access key will hold a descriptive text. // If permittedIPs is supplied, then we will only allow using the access key from those IP addresses or CIDR ranges. -res, err := descopeClient.Management.AccessKey().Create(context.Background(), "access-key-1", 0, nil, []*descope.AssociatedTenant{ +res, err := descopeClient.Management.AccessKey().Create(context.Background(), "access-key-1", "key-description", 0, nil, []*descope.AssociatedTenant{ {TenantID: "tenant-ID1", RoleNames: []string{"role-name1"}}, {TenantID: "tenant-ID2"}, }, @@ -980,7 +980,7 @@ if err == nil { // Update access key // If description, roles, tenants, customClaims, or permittedIPs are nil, their existing values will be preserved. If you want to remove them, pass an empty slice or map. updatedDescription := "Updated description" -res, err := descopeClient.Management.AccessKey().Update(context.Background(), "access-key-id", "updated-name", &updatedDescription, []string{"role"}, []*descope.AssociatedTenant{{TenantID: "t1", Roles: []string{"role"}}}, map[string]any{"k1": "v1"}, []string{"1.2.3.4"}) +res, err := descopeClient.Management.AccessKey().Update(context.Background(), "access-key-id", "updated-name", &updatedDescription, []string{"role"}, nil, map[string]any{"k1": "v1"}, []string{"1.2.3.4"}) // Access keys can be deactivated to prevent usage. This can be undone using "activate". err := descopeClient.Management.AccessKey().Deactivate(context.Background(), "access-key-id") From a6031ad8e035f3fa947cdc4a0ae8b2f87c3e043a Mon Sep 17 00:00:00 2001 From: Doron Sharon Date: Wed, 6 Nov 2024 13:09:46 +0200 Subject: [PATCH 2/6] actions/upload-artifact@v4 (#472) --- .github/actions/tests/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/tests/action.yml b/.github/actions/tests/action.yml index 58cf33a7..6f4d482b 100644 --- a/.github/actions/tests/action.yml +++ b/.github/actions/tests/action.yml @@ -22,7 +22,7 @@ runs: shell: bash - name: Upload coverage HTML - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: coverage.html path: | From 4b55cee8aed5957f1c821b53bc1ad29071969813 Mon Sep 17 00:00:00 2001 From: Eliya Sadan Date: Mon, 11 Nov 2024 18:20:44 +0200 Subject: [PATCH 3/6] Add FGA API support (#474) --- README.md | 209 +++++---------------- descope/api/client.go | 25 +++ descope/authztypes.go | 22 +++ descope/internal/mgmt/fga.go | 93 +++++++++ descope/internal/mgmt/fga_test.go | 113 +++++++++++ descope/internal/mgmt/mgmt.go | 7 + descope/internal/mgmt/user_test.go | 30 ++- descope/sdk/mgmt.go | 17 ++ descope/tests/mocks/mgmt/managementmock.go | 48 +++++ 9 files changed, 402 insertions(+), 162 deletions(-) create mode 100644 descope/internal/mgmt/fga.go create mode 100644 descope/internal/mgmt/fga_test.go 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 +} From f2d72b81318e2b08b48e735fa668316761593dae Mon Sep 17 00:00:00 2001 From: Allen Zhou <46854522+allenzhou101@users.noreply.github.com> Date: Mon, 11 Nov 2024 13:56:02 -0800 Subject: [PATCH 4/6] Fix README.md sections (#475) --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2cb28d73..d3c6a350 100644 --- a/README.md +++ b/README.md @@ -1384,7 +1384,8 @@ relations, err := descopeClient.Management.FGA().Check(context.Background(), []* Target: "u1", TargetType: "user" } -}) --> +}) +``` ### Manage Project From 3c37e51a133c98e8fede3bb6475d9e749e14b9a7 Mon Sep 17 00:00:00 2001 From: Aviad Lichtenstadt Date: Wed, 13 Nov 2024 11:30:13 +0200 Subject: [PATCH 5/6] Add option to logout all previous JWTS (#476) This new function call, will expire all jwts created prior to the one in the request related to https://github.com/descope/etc/issues/8242 + tests --- README.md | 8 ++++ descope/api/client.go | 46 +++++++++++-------- descope/internal/auth/auth.go | 31 +++++++++++++ descope/internal/auth/auth_test.go | 22 +++++++++ descope/sdk/auth.go | 6 +++ .../tests/mocks/auth/authenticationmock.go | 20 ++++++++ 6 files changed, 113 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d3c6a350..2a9bb0ac 100644 --- a/README.md +++ b/README.md @@ -642,6 +642,14 @@ invalidate all user's refresh tokens. After calling this function, you must inva descopeClient.Auth.LogoutAll(request, w) ``` +It is also possible to sign the user out of previous session. Calling `logoutPrevious` will +invalidate all user's refresh tokens that were generated prior to the given session. + +```go +// Refresh token will be taken from the request header or cookies automatically +descopeClient.Auth.LogoutPrevious(request) +``` + ### History You can get the current session user history. diff --git a/descope/api/client.go b/descope/api/client.go index 972ced3e..bba23568 100644 --- a/descope/api/client.go +++ b/descope/api/client.go @@ -203,30 +203,32 @@ var ( fgaDeleteRelations: "mgmt/fga/relations/delete", fgaCheck: "mgmt/fga/check", }, - logout: "auth/logout", - logoutAll: "auth/logoutall", - keys: "/keys/", - refresh: "auth/refresh", - selectTenant: "auth/tenant/select", - me: "auth/me", - meTenants: "auth/me/tenants", - history: "auth/me/history", + logout: "auth/logout", + logoutAll: "auth/logoutall", + logoutPrevious: "auth/logoutprevious", + keys: "/keys/", + refresh: "auth/refresh", + selectTenant: "auth/tenant/select", + me: "auth/me", + meTenants: "auth/me/tenants", + history: "auth/me/history", } ) type endpoints struct { - version string - versionV2 string - auth authEndpoints - mgmt mgmtEndpoints - logout string - logoutAll string - keys string - refresh string - selectTenant string - me string - meTenants string - history string + version string + versionV2 string + auth authEndpoints + mgmt mgmtEndpoints + logout string + logoutAll string + logoutPrevious string + keys string + refresh string + selectTenant string + me string + meTenants string + history string } type authEndpoints struct { @@ -601,6 +603,10 @@ func (e *endpoints) LogoutAll() string { return path.Join(e.version, e.logoutAll) } +func (e *endpoints) LogoutPrevious() string { + return path.Join(e.version, e.logoutPrevious) +} + func (e *endpoints) Me() string { return path.Join(e.version, e.me) } diff --git a/descope/internal/auth/auth.go b/descope/internal/auth/auth.go index 9614bbec..e5cdadb5 100644 --- a/descope/internal/auth/auth.go +++ b/descope/internal/auth/auth.go @@ -231,6 +231,37 @@ func (auth *authenticationService) logoutAll(request *http.Request, w http.Respo return nil } +func (auth *authenticationService) LogoutPrevious(request *http.Request) error { + return auth.logoutPrevious(request) +} + +func (auth *authenticationService) LogoutPreviousWithToken(refreshToken string) error { + request := &http.Request{Header: http.Header{}} + request.AddCookie(&http.Cookie{Name: descope.RefreshCookieName, Value: refreshToken}) + return auth.logoutPrevious(request) +} + +func (auth *authenticationService) logoutPrevious(request *http.Request) error { + if request == nil { + return utils.NewInvalidArgumentError("request") + } + + _, refreshToken := provideTokens(request) + if refreshToken == "" { + logger.LogDebug("Unable to find tokens from cookies") + return descope.ErrRefreshToken.WithMessage("Unable to find tokens from cookies") + } + + _, err := auth.validateJWT(refreshToken) + if err != nil { + logger.LogDebug("Invalid refresh token") + return descope.ErrRefreshToken.WithMessage("Invalid refresh token") + } + + _, err = auth.client.DoPostRequest(request.Context(), api.Routes.LogoutPrevious(), nil, &api.HTTPRequest{}, refreshToken) + return err +} + func (auth *authenticationService) Me(request *http.Request) (*descope.UserResponse, error) { if request == nil { return nil, utils.NewInvalidArgumentError("request") diff --git a/descope/internal/auth/auth_test.go b/descope/internal/auth/auth_test.go index f73a8c5b..303f20b5 100644 --- a/descope/internal/auth/auth_test.go +++ b/descope/internal/auth/auth_test.go @@ -762,6 +762,28 @@ func TestLogoutAllWithToken(t *testing.T) { require.NoError(t, err) } +func TestLogoutPrevious(t *testing.T) { + a, err := newTestAuth(nil, func(_ *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(mockAuthSessionBody))}, nil + }) + require.NoError(t, err) + request := &http.Request{Header: http.Header{}} + request.AddCookie(&http.Cookie{Name: descope.RefreshCookieName, Value: jwtRTokenValid}) + + err = a.LogoutPrevious(request) + require.NoError(t, err) +} + +func TestLogoutPreviousWithToken(t *testing.T) { + a, err := newTestAuth(nil, func(_ *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(mockAuthSessionBody))}, nil + }) + require.NoError(t, err) + + err = a.LogoutPreviousWithToken(jwtRTokenValid) + require.NoError(t, err) +} + func TestLogoutNoClaims(t *testing.T) { a, err := newTestAuth(nil, func(_ *http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusOK}, nil diff --git a/descope/sdk/auth.go b/descope/sdk/auth.go index 356a0e88..e291d2f5 100644 --- a/descope/sdk/auth.go +++ b/descope/sdk/auth.go @@ -424,6 +424,12 @@ type Authentication interface { // Use the ResponseWriter (optional) to apply the cookies to the response automatically. LogoutAllWithToken(refreshToken string, w http.ResponseWriter) error + // LogoutPrevious - Use to perform logout from all active sessions that were created prior to the given token. + LogoutPrevious(request *http.Request) error + + // LogoutPreviousWithToken - Use to perform logout from all active sessions that were created prior to the given token. + LogoutPreviousWithToken(refreshToken string) error + // Me - Use to retrieve current session user details. The request requires a valid refresh token. // returns the user details or error if the refresh token is not valid. Me(request *http.Request) (*descope.UserResponse, error) diff --git a/descope/tests/mocks/auth/authenticationmock.go b/descope/tests/mocks/auth/authenticationmock.go index 281ec258..501b15f0 100644 --- a/descope/tests/mocks/auth/authenticationmock.go +++ b/descope/tests/mocks/auth/authenticationmock.go @@ -692,6 +692,12 @@ type MockSession struct { LogoutAllWithTokenAssert func(refreshToken string, w http.ResponseWriter) LogoutAllWithTokenError error + LogoutPreviousAssert func(r *http.Request) + LogoutPreviousError error + + LogoutPreviousWithTokenAssert func(refreshToken string) + LogoutPreviousWithTokenError error + MeAssert func(r *http.Request) MeError error MeResponse *descope.UserResponse @@ -885,6 +891,20 @@ func (m *MockSession) LogoutAllWithToken(refreshToken string, w http.ResponseWri return m.LogoutAllWithTokenError } +func (m *MockSession) LogoutPrevious(r *http.Request) error { + if m.LogoutPreviousAssert != nil { + m.LogoutPreviousAssert(r) + } + return m.LogoutPreviousError +} + +func (m *MockSession) LogoutPreviousWithToken(refreshToken string) error { + if m.LogoutPreviousWithTokenAssert != nil { + m.LogoutPreviousWithTokenAssert(refreshToken) + } + return m.LogoutPreviousWithTokenError +} + func (m *MockSession) Me(r *http.Request) (*descope.UserResponse, error) { if m.MeAssert != nil { m.MeAssert(r) From 18d2b8d79ef5d9bd2e190014579d82ff1468b437 Mon Sep 17 00:00:00 2001 From: Doron Sharon Date: Thu, 14 Nov 2024 11:23:26 +0200 Subject: [PATCH 6/6] Revert "Add option to logout all previous JWTS (#476)" (#477) This reverts commit 3c37e51a133c98e8fede3bb6475d9e749e14b9a7. --- README.md | 8 ---- descope/api/client.go | 46 ++++++++----------- descope/internal/auth/auth.go | 31 ------------- descope/internal/auth/auth_test.go | 22 --------- descope/sdk/auth.go | 6 --- .../tests/mocks/auth/authenticationmock.go | 20 -------- 6 files changed, 20 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 2a9bb0ac..d3c6a350 100644 --- a/README.md +++ b/README.md @@ -642,14 +642,6 @@ invalidate all user's refresh tokens. After calling this function, you must inva descopeClient.Auth.LogoutAll(request, w) ``` -It is also possible to sign the user out of previous session. Calling `logoutPrevious` will -invalidate all user's refresh tokens that were generated prior to the given session. - -```go -// Refresh token will be taken from the request header or cookies automatically -descopeClient.Auth.LogoutPrevious(request) -``` - ### History You can get the current session user history. diff --git a/descope/api/client.go b/descope/api/client.go index bba23568..972ced3e 100644 --- a/descope/api/client.go +++ b/descope/api/client.go @@ -203,32 +203,30 @@ var ( fgaDeleteRelations: "mgmt/fga/relations/delete", fgaCheck: "mgmt/fga/check", }, - logout: "auth/logout", - logoutAll: "auth/logoutall", - logoutPrevious: "auth/logoutprevious", - keys: "/keys/", - refresh: "auth/refresh", - selectTenant: "auth/tenant/select", - me: "auth/me", - meTenants: "auth/me/tenants", - history: "auth/me/history", + logout: "auth/logout", + logoutAll: "auth/logoutall", + keys: "/keys/", + refresh: "auth/refresh", + selectTenant: "auth/tenant/select", + me: "auth/me", + meTenants: "auth/me/tenants", + history: "auth/me/history", } ) type endpoints struct { - version string - versionV2 string - auth authEndpoints - mgmt mgmtEndpoints - logout string - logoutAll string - logoutPrevious string - keys string - refresh string - selectTenant string - me string - meTenants string - history string + version string + versionV2 string + auth authEndpoints + mgmt mgmtEndpoints + logout string + logoutAll string + keys string + refresh string + selectTenant string + me string + meTenants string + history string } type authEndpoints struct { @@ -603,10 +601,6 @@ func (e *endpoints) LogoutAll() string { return path.Join(e.version, e.logoutAll) } -func (e *endpoints) LogoutPrevious() string { - return path.Join(e.version, e.logoutPrevious) -} - func (e *endpoints) Me() string { return path.Join(e.version, e.me) } diff --git a/descope/internal/auth/auth.go b/descope/internal/auth/auth.go index e5cdadb5..9614bbec 100644 --- a/descope/internal/auth/auth.go +++ b/descope/internal/auth/auth.go @@ -231,37 +231,6 @@ func (auth *authenticationService) logoutAll(request *http.Request, w http.Respo return nil } -func (auth *authenticationService) LogoutPrevious(request *http.Request) error { - return auth.logoutPrevious(request) -} - -func (auth *authenticationService) LogoutPreviousWithToken(refreshToken string) error { - request := &http.Request{Header: http.Header{}} - request.AddCookie(&http.Cookie{Name: descope.RefreshCookieName, Value: refreshToken}) - return auth.logoutPrevious(request) -} - -func (auth *authenticationService) logoutPrevious(request *http.Request) error { - if request == nil { - return utils.NewInvalidArgumentError("request") - } - - _, refreshToken := provideTokens(request) - if refreshToken == "" { - logger.LogDebug("Unable to find tokens from cookies") - return descope.ErrRefreshToken.WithMessage("Unable to find tokens from cookies") - } - - _, err := auth.validateJWT(refreshToken) - if err != nil { - logger.LogDebug("Invalid refresh token") - return descope.ErrRefreshToken.WithMessage("Invalid refresh token") - } - - _, err = auth.client.DoPostRequest(request.Context(), api.Routes.LogoutPrevious(), nil, &api.HTTPRequest{}, refreshToken) - return err -} - func (auth *authenticationService) Me(request *http.Request) (*descope.UserResponse, error) { if request == nil { return nil, utils.NewInvalidArgumentError("request") diff --git a/descope/internal/auth/auth_test.go b/descope/internal/auth/auth_test.go index 303f20b5..f73a8c5b 100644 --- a/descope/internal/auth/auth_test.go +++ b/descope/internal/auth/auth_test.go @@ -762,28 +762,6 @@ func TestLogoutAllWithToken(t *testing.T) { require.NoError(t, err) } -func TestLogoutPrevious(t *testing.T) { - a, err := newTestAuth(nil, func(_ *http.Request) (*http.Response, error) { - return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(mockAuthSessionBody))}, nil - }) - require.NoError(t, err) - request := &http.Request{Header: http.Header{}} - request.AddCookie(&http.Cookie{Name: descope.RefreshCookieName, Value: jwtRTokenValid}) - - err = a.LogoutPrevious(request) - require.NoError(t, err) -} - -func TestLogoutPreviousWithToken(t *testing.T) { - a, err := newTestAuth(nil, func(_ *http.Request) (*http.Response, error) { - return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(mockAuthSessionBody))}, nil - }) - require.NoError(t, err) - - err = a.LogoutPreviousWithToken(jwtRTokenValid) - require.NoError(t, err) -} - func TestLogoutNoClaims(t *testing.T) { a, err := newTestAuth(nil, func(_ *http.Request) (*http.Response, error) { return &http.Response{StatusCode: http.StatusOK}, nil diff --git a/descope/sdk/auth.go b/descope/sdk/auth.go index e291d2f5..356a0e88 100644 --- a/descope/sdk/auth.go +++ b/descope/sdk/auth.go @@ -424,12 +424,6 @@ type Authentication interface { // Use the ResponseWriter (optional) to apply the cookies to the response automatically. LogoutAllWithToken(refreshToken string, w http.ResponseWriter) error - // LogoutPrevious - Use to perform logout from all active sessions that were created prior to the given token. - LogoutPrevious(request *http.Request) error - - // LogoutPreviousWithToken - Use to perform logout from all active sessions that were created prior to the given token. - LogoutPreviousWithToken(refreshToken string) error - // Me - Use to retrieve current session user details. The request requires a valid refresh token. // returns the user details or error if the refresh token is not valid. Me(request *http.Request) (*descope.UserResponse, error) diff --git a/descope/tests/mocks/auth/authenticationmock.go b/descope/tests/mocks/auth/authenticationmock.go index 501b15f0..281ec258 100644 --- a/descope/tests/mocks/auth/authenticationmock.go +++ b/descope/tests/mocks/auth/authenticationmock.go @@ -692,12 +692,6 @@ type MockSession struct { LogoutAllWithTokenAssert func(refreshToken string, w http.ResponseWriter) LogoutAllWithTokenError error - LogoutPreviousAssert func(r *http.Request) - LogoutPreviousError error - - LogoutPreviousWithTokenAssert func(refreshToken string) - LogoutPreviousWithTokenError error - MeAssert func(r *http.Request) MeError error MeResponse *descope.UserResponse @@ -891,20 +885,6 @@ func (m *MockSession) LogoutAllWithToken(refreshToken string, w http.ResponseWri return m.LogoutAllWithTokenError } -func (m *MockSession) LogoutPrevious(r *http.Request) error { - if m.LogoutPreviousAssert != nil { - m.LogoutPreviousAssert(r) - } - return m.LogoutPreviousError -} - -func (m *MockSession) LogoutPreviousWithToken(refreshToken string) error { - if m.LogoutPreviousWithTokenAssert != nil { - m.LogoutPreviousWithTokenAssert(refreshToken) - } - return m.LogoutPreviousWithTokenError -} - func (m *MockSession) Me(r *http.Request) (*descope.UserResponse, error) { if m.MeAssert != nil { m.MeAssert(r)