package api import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/pocket-id/pocket-id/backend/internal/apperror" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/utils" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) func TestAPICrudAndPermissionDiff(t *testing.T) { db := testutils.NewDatabaseForTest(t) // Removing a permission (or an API) relies on the ON DELETE CASCADE that production enforces, so exercise it here // The shared test harness disables foreign keys, so enable them for this connection require.NoError(t, db.Exec("PRAGMA foreign_keys = ON").Error) svc := New(Dependencies{DB: db}).service created, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders API", Resource: "https://api.orders.example.com"}) require.NoError(t, err) assert.NotEmpty(t, created.ID) // The resource is unique. _, err = svc.Create(t.Context(), apiCreateDto{Name: "Dup", Resource: "https://api.orders.example.com"}) require.True(t, apperror.IsCode(err, apperror.CodeAlreadyInUse)) desc := "Read orders" updated, err := svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: "read:orders", Name: "Read orders", Description: &desc}, {Key: "write:orders", Name: "Write orders"}, }}) require.NoError(t, err) assert.Len(t, updated.Permissions, 2) // Grant a client the read:orders permission for both subject types, then remove that permission // and confirm the grants are cleaned up while write:orders (and its key) survives. readPerm := findPermission(updated, "read:orders") require.NotNil(t, readPerm) require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error) require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeUser}).Error) require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeClient}).Error) updated, err = svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: "write:orders", Name: "Write orders (renamed)"}, }}) require.NoError(t, err) require.Len(t, updated.Permissions, 1) assert.Equal(t, "write:orders", updated.Permissions[0].Key) assert.Equal(t, "Write orders (renamed)", updated.Permissions[0].Name) var grantCount int64 require.NoError(t, db.Model(&OidcClientAllowedAPIPermission{}).Where("api_permission_id = ?", readPerm.ID).Count(&grantCount).Error) assert.Equal(t, int64(0), grantCount) renamed, err := svc.Update(t.Context(), created.ID, apiUpdateDto{Name: "Orders"}) require.NoError(t, err) assert.Equal(t, "Orders", renamed.Name) require.NotNil(t, renamed.UpdatedAt) require.NoError(t, svc.Delete(t.Context(), created.ID)) _, err = svc.Get(t.Context(), nil, created.ID) require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) } // clientGrantFor returns the client's grant on one API from the client-side listing, which is what remains after the // per-client bulk setter/getter were removed in favour of the single write path on /apis/:id/clients/:clientId. func clientGrantFor(t *testing.T, svc *Service, clientID, apiID string) ClientAPIGrant { t.Helper() grants, err := svc.ListClientAPIs(t.Context(), clientID) require.NoError(t, err) for _, grant := range grants { if grant.API.ID == apiID { return grant } } return ClientAPIGrant{} } func TestClientApiAccessAllowList(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error) orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"}) require.NoError(t, err) orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: "read:orders", Name: "Read"}, {Key: "write:orders", Name: "Write"}, }}) require.NoError(t, err) readID := findPermission(orders, "read:orders").ID writeID := findPermission(orders, "write:orders").ID // Unknown IDs are filtered out, the subject types are stored independently, and a permission implies access to its API. applied, err := svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{ UserDelegatedPermissionIDs: []string{readID, "does-not-exist"}, ClientPermissionIDs: []string{writeID, "does-not-exist"}, }) require.NoError(t, err) assert.ElementsMatch(t, []string{readID}, applied.UserDelegatedPermissionIDs) assert.ElementsMatch(t, []string{writeID}, applied.ClientPermissionIDs) assert.True(t, applied.UserDelegatedAccess) assert.True(t, applied.ClientAccess) got := clientGrantFor(t, svc, "client-1", orders.ID) assert.ElementsMatch(t, []string{readID}, got.UserDelegatedPermissionIDs) assert.ElementsMatch(t, []string{writeID}, got.ClientPermissionIDs) assert.True(t, got.UserDelegatedAccess) assert.True(t, got.ClientAccess) // The same permission can be granted for both subject types, and both sets are fully replaced on each call. _, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{ UserDelegatedPermissionIDs: []string{readID, writeID}, ClientPermissionIDs: []string{readID}, }) require.NoError(t, err) got = clientGrantFor(t, svc, "client-1", orders.ID) assert.ElementsMatch(t, []string{readID, writeID}, got.UserDelegatedPermissionIDs) assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs) // Clearing one subject type leaves the other untouched. _, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{ClientPermissionIDs: []string{readID}}) require.NoError(t, err) got = clientGrantFor(t, svc, "client-1", orders.ID) assert.Empty(t, got.UserDelegatedPermissionIDs) assert.False(t, got.UserDelegatedAccess) assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs) // Clearing everything drops the API from the client's list entirely. _, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{}) require.NoError(t, err) grants, err := svc.ListClientAPIs(t.Context(), "client-1") require.NoError(t, err) assert.Empty(t, grants) // An API can be granted on its own, without a single permission. applied, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{UserDelegatedAccess: true}) require.NoError(t, err) assert.True(t, applied.UserDelegatedAccess) assert.Empty(t, applied.UserDelegatedPermissionIDs) got = clientGrantFor(t, svc, "client-1", orders.ID) assert.True(t, got.UserDelegatedAccess) assert.False(t, got.ClientAccess) // An unknown client is rejected (surfaces as 404 at the HTTP layer). _, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "nope", APIClientGrant{UserDelegatedPermissionIDs: []string{readID}}) require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) _, err = svc.ListClientAPIs(t.Context(), "nope") require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) } // TestSetAPIClientAccessDropsClientGrantsForPublicClients guards that a machine-to-machine grant cannot be written for a // client that can never authenticate for the client credentials grant, even by a direct API call that bypasses the UI. func TestSetAPIClientAccessDropsClientGrantsForPublicClients(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "public-1"}, Name: "Public", IsPublic: true}).Error) orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"}) require.NoError(t, err) orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: "write:orders", Name: "Write"}, }}) require.NoError(t, err) writeID := findPermission(orders, "write:orders").ID applied, err := svc.SetAPIClientAccess(t.Context(), orders.ID, "public-1", APIClientGrant{ UserDelegatedAccess: true, ClientAccess: true, ClientPermissionIDs: []string{writeID}, }) require.NoError(t, err) assert.False(t, applied.ClientAccess) assert.Empty(t, applied.ClientPermissionIDs) assert.True(t, applied.UserDelegatedAccess) _, _, hasAccess, err := svc.AllowedScopesForAudience(t.Context(), nil, "public-1", "https://api.orders.example.com", oidc.SubjectTypeClient) require.NoError(t, err) assert.False(t, hasAccess) } // TestAllowedScopesForAudienceFiltersBySubjectType guards that the scopes resolved for a flow // only come from the grants of that flow's subject type. func TestAllowedScopesForAudienceFiltersBySubjectType(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error) orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"}) require.NoError(t, err) orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: "read:orders", Name: "Read"}, {Key: "write:orders", Name: "Write"}, }}) require.NoError(t, err) readID := findPermission(orders, "read:orders").ID writeID := findPermission(orders, "write:orders").ID _, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{ UserDelegatedPermissionIDs: []string{readID}, ClientPermissionIDs: []string{writeID}, }) require.NoError(t, err) userScopes, exists, hasAccess, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeUser) require.NoError(t, err) require.True(t, exists) require.True(t, hasAccess) assert.ElementsMatch(t, []string{"read:orders"}, userScopes) clientScopes, exists, hasAccess, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeClient) require.NoError(t, err) require.True(t, exists) require.True(t, hasAccess) assert.ElementsMatch(t, []string{"write:orders"}, clientScopes) // The fosite widening still sees the union of both subject types. scopes, audiences, err := svc.ClientAPIScopesAndAudiences(t.Context(), nil, "client-1", false) require.NoError(t, err) assert.ElementsMatch(t, []string{"read:orders", "write:orders"}, scopes) assert.ElementsMatch(t, []string{"https://api.orders.example.com"}, audiences) } // TestAccessWithoutPermissions covers granting an API to a client without any permission, which is what // an MCP client needs: the resource is reachable and the token simply carries no custom scope. func TestAccessWithoutPermissions(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service const resource = "https://api.orders.example.com" require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error) orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: resource}) require.NoError(t, err) orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: "read:orders", Name: "Read"}, }}) require.NoError(t, err) // Without any grant the client cannot reach the API at all scopes, exists, hasAccess, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", resource, oidc.SubjectTypeUser) require.NoError(t, err) require.True(t, exists) assert.False(t, hasAccess) assert.Empty(t, scopes) _, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{UserDelegatedAccess: true}) require.NoError(t, err) // Access is granted for user-delegated flows only, and it comes with no scope scopes, _, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, "client-1", resource, oidc.SubjectTypeUser) require.NoError(t, err) assert.True(t, hasAccess) assert.Empty(t, scopes) _, _, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, "client-1", resource, oidc.SubjectTypeClient) require.NoError(t, err) assert.False(t, hasAccess) // Fosite still has to accept the audience, otherwise the request is rejected before the resource is resolved scopes, audiences, err := svc.ClientAPIScopesAndAudiences(t.Context(), nil, "client-1", false) require.NoError(t, err) assert.Empty(t, scopes) assert.ElementsMatch(t, []string{resource}, audiences) } func TestUpdatePermissionsRejectsReservedKeys(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"}) require.NoError(t, err) for _, key := range []string{"openid", "profile", "email", "email_verified", "groups", "offline_access", "Email"} { _, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: key, Name: "Reserved"}, }}) require.Error(t, err, "key %q must be rejected", key) require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed)) } } func TestUpdatePermissionsRejectsDuplicateKeys(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"}) require.NoError(t, err) // Two rows with the same key must be rejected rather than silently coalesced last-wins _, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: "read:orders", Name: "Read"}, {Key: "read:orders", Name: "Read again"}, }}) require.Error(t, err) require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed)) } func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"}) require.NoError(t, err) // A space corrupts the space-delimited scope claim, and the unit separator is the consent delimiter for _, key := range []string{"read orders", "read\x1forders", "read\"orders", "bad\\key", "tab\tkey"} { _, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: key, Name: "Invalid"}, }}) require.Error(t, err, "key %q must be rejected", key) require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed)) } // A valid scope-token key is accepted _, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: "read:orders", Name: "Read"}, }}) require.NoError(t, err) } func TestCreateRejectsIssuerResource(t *testing.T) { db := testutils.NewDatabaseForTest(t) const issuer = "https://id.example.com" svc := New(Dependencies{DB: db, Issuer: issuer}).service // The issuer itself, a trailing-slash variant, and a different-cased variant are all reserved for _, resource := range []string{issuer, issuer + "/", "https://ID.example.com"} { _, err := svc.Create(t.Context(), apiCreateDto{Name: "Reserved", Resource: resource}) require.Error(t, err, "resource %q must be rejected", resource) require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed)) } // A normal resource is accepted _, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"}) require.NoError(t, err) } func TestCreateAcceptsAbsoluteResourceURIs(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service for _, resource := range []string{"https://api.orders.example.com", "api://PocketID", "urn:my-app"} { _, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: resource}) require.NoError(t, err, "resource %q must be accepted", resource) } } func TestCreateTrimsResourceTrailingSlashes(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service created, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com///"}) require.NoError(t, err) assert.Equal(t, "https://api.orders.example.com", created.Audience) // A trailing-slash variant must conflict with the canonical resource instead of creating a second audience _, err = svc.Create(t.Context(), apiCreateDto{Name: "Duplicate", Resource: "https://api.orders.example.com/"}) require.True(t, apperror.IsCode(err, apperror.CodeAlreadyInUse)) } func TestDescribePermissions(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"}) require.NoError(t, err) desc := "Read orders" _, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: "read:orders", Name: "Read orders", Description: &desc}, {Key: "write:orders", Name: "Write orders"}, }}) require.NoError(t, err) infos, err := svc.DescribePermissions(t.Context(), "https://api.orders.example.com/", []string{"read:orders", "unknown"}) require.NoError(t, err) require.Len(t, infos, 1) assert.Equal(t, "read:orders", infos[0].Key) assert.Equal(t, "Read orders", infos[0].Name) require.NotNil(t, infos[0].Description) assert.Equal(t, "Read orders", *infos[0].Description) } // TestCimdClientAccess covers the API-wide opt-in that lets every metadata document client reach an API without an individual grant. func TestCimdClientAccess(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service const cimdClientID = "https://app.example.com/oauth-client.json" const resource = "https://api.orders.example.com" require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: cimdClientID}, Name: "MCP client", ClientType: model.OidcClientTypeCIMD, IsPublic: true}).Error) require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error) orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: resource}) require.NoError(t, err) orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: "read:orders", Name: "Read"}, {Key: "write:orders", Name: "Write"}, }}) require.NoError(t, err) readID := findPermission(orders, "read:orders").ID writeID := findPermission(orders, "write:orders").ID // Before the API opts in, a metadata document client has no more access than any other client. scopes, exists, hasAccess, err := svc.AllowedScopesForAudience(t.Context(), nil, cimdClientID, resource, oidc.SubjectTypeUser) require.NoError(t, err) require.True(t, exists) assert.False(t, hasAccess) assert.Empty(t, scopes) // Permissions of other APIs are ignored, the same way unknown client grants are. updated, err := svc.SetCIMDAccess(t.Context(), orders.ID, apiCimdAccessUpdateDto{Enabled: true, PermissionIDs: []string{readID, "does-not-exist"}}) require.NoError(t, err) assert.True(t, updated.AllowCIMDClients) assert.True(t, findPermission(updated, "read:orders").AllowedForCIMDClients) assert.False(t, findPermission(updated, "write:orders").AllowedForCIMDClients) // The metadata document client now reaches the API without an individual grant, but only with the selected permissions. scopes, exists, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, cimdClientID, resource, oidc.SubjectTypeUser) require.NoError(t, err) require.True(t, exists) assert.True(t, hasAccess) assert.ElementsMatch(t, []string{"read:orders"}, scopes) // The opt-in never covers the client credentials grant, and never applies to regularly registered clients. scopes, _, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, cimdClientID, resource, oidc.SubjectTypeClient) require.NoError(t, err) assert.False(t, hasAccess) assert.Empty(t, scopes) scopes, _, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, "client-1", resource, oidc.SubjectTypeUser) require.NoError(t, err) assert.False(t, hasAccess) assert.Empty(t, scopes) // The fosite scope and audience widening has to see the implicit access too, otherwise the request is rejected before the resource is resolved. widened, audiences, err := svc.ClientAPIScopesAndAudiences(t.Context(), nil, cimdClientID, true) require.NoError(t, err) assert.ElementsMatch(t, []string{"read:orders"}, widened) assert.ElementsMatch(t, []string{resource}, audiences) // An individual grant of the same permission does not show up twice. _, err = svc.SetAPIClientAccess(t.Context(), orders.ID, cimdClientID, APIClientGrant{UserDelegatedPermissionIDs: []string{readID}, ClientPermissionIDs: []string{writeID}}) require.NoError(t, err) scopes, _, _, err = svc.AllowedScopesForAudience(t.Context(), nil, cimdClientID, resource, oidc.SubjectTypeUser) require.NoError(t, err) assert.ElementsMatch(t, []string{"read:orders"}, scopes) // The admin view keeps implicit access apart from the grants that can be edited per client. grant := clientGrantFor(t, svc, cimdClientID, orders.ID) assert.True(t, grant.CIMDGrantedAccess) assert.ElementsMatch(t, []string{readID}, grant.CIMDGrantedPermissionIDs) assert.ElementsMatch(t, []string{readID}, grant.UserDelegatedPermissionIDs) grant = clientGrantFor(t, svc, "client-1", orders.ID) assert.False(t, grant.CIMDGrantedAccess) assert.Empty(t, grant.CIMDGrantedPermissionIDs) // Opening the API without selecting any permission still lets a metadata document client reach it, only without a scope. _, err = svc.SetAPIClientAccess(t.Context(), orders.ID, cimdClientID, APIClientGrant{}) require.NoError(t, err) _, err = svc.SetCIMDAccess(t.Context(), orders.ID, apiCimdAccessUpdateDto{Enabled: true}) require.NoError(t, err) scopes, _, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, cimdClientID, resource, oidc.SubjectTypeUser) require.NoError(t, err) assert.True(t, hasAccess) assert.Empty(t, scopes) _, audiences, err = svc.ClientAPIScopesAndAudiences(t.Context(), nil, cimdClientID, true) require.NoError(t, err) assert.ElementsMatch(t, []string{resource}, audiences) // Switching the access off revokes it but keeps the selection, so it can be turned back on unchanged. updated, err = svc.SetCIMDAccess(t.Context(), orders.ID, apiCimdAccessUpdateDto{Enabled: false, PermissionIDs: []string{readID}}) require.NoError(t, err) assert.False(t, updated.AllowCIMDClients) assert.True(t, findPermission(updated, "read:orders").AllowedForCIMDClients) _, _, hasAccess, err = svc.AllowedScopesForAudience(t.Context(), nil, cimdClientID, resource, oidc.SubjectTypeUser) require.NoError(t, err) assert.False(t, hasAccess) grant = clientGrantFor(t, svc, cimdClientID, orders.ID) assert.False(t, grant.CIMDGrantedAccess) assert.Empty(t, grant.CIMDGrantedPermissionIDs) // An API reached only through the opt-in is still listed for the client, marked as coming from it _, err = svc.SetCIMDAccess(t.Context(), orders.ID, apiCimdAccessUpdateDto{Enabled: true, PermissionIDs: []string{readID}}) require.NoError(t, err) clientAPIs, err := svc.ListClientAPIs(t.Context(), cimdClientID) require.NoError(t, err) require.Len(t, clientAPIs, 1) assert.Equal(t, orders.ID, clientAPIs[0].API.ID) assert.True(t, clientAPIs[0].CIMDGrantedAccess) assert.ElementsMatch(t, []string{readID}, clientAPIs[0].CIMDGrantedPermissionIDs) assert.False(t, clientAPIs[0].UserDelegatedAccess) assert.Empty(t, clientAPIs[0].UserDelegatedPermissionIDs) // It is listed already, so the selection does not offer it a second time assignable, _, err := svc.ListAssignableAPIs(t.Context(), cimdClientID, "", utils.ListRequestOptions{}) require.NoError(t, err) assert.Empty(t, assignable) assignable, _, err = svc.ListAssignableAPIs(t.Context(), "client-1", "", utils.ListRequestOptions{}) require.NoError(t, err) require.Len(t, assignable, 1) } // TestApiSideClientGrants covers managing the client grants from the API's side of the relation. func TestApiSideClientGrants(t *testing.T) { db := testutils.NewDatabaseForTest(t) svc := New(Dependencies{DB: db}).service require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Zulu"}).Error) require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-2"}, Name: "Alpha"}).Error) orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"}) require.NoError(t, err) orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: "read:orders", Name: "Read"}, {Key: "write:orders", Name: "Write"}, }}) require.NoError(t, err) readID := findPermission(orders, "read:orders").ID writeID := findPermission(orders, "write:orders").ID billing, err := svc.Create(t.Context(), apiCreateDto{Name: "Billing", Resource: "https://api.billing.example.com"}) require.NoError(t, err) billing, err = svc.UpdatePermissions(t.Context(), billing.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{ {Key: "read:invoices", Name: "Read invoices"}, }}) require.NoError(t, err) invoicesID := findPermission(billing, "read:invoices").ID // An API without grants lists no clients. clients, _, err := svc.ListAPIClients(t.Context(), orders.ID, "", utils.ListRequestOptions{}) require.NoError(t, err) assert.Empty(t, clients) // Permissions of another API cannot be granted through this API, and a permission implies access for its subject type. applied, err := svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{ UserDelegatedPermissionIDs: []string{readID, invoicesID}, ClientPermissionIDs: []string{writeID}, }) require.NoError(t, err) assert.ElementsMatch(t, []string{readID}, applied.UserDelegatedPermissionIDs) assert.ElementsMatch(t, []string{writeID}, applied.ClientPermissionIDs) assert.True(t, applied.UserDelegatedAccess) assert.True(t, applied.ClientAccess) _, err = svc.SetAPIClientAccess(t.Context(), billing.ID, "client-1", APIClientGrant{UserDelegatedPermissionIDs: []string{invoicesID}}) require.NoError(t, err) // Replacing the grants of one API leaves what the client holds on other APIs untouched. _, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-1", APIClientGrant{UserDelegatedPermissionIDs: []string{readID, writeID}}) require.NoError(t, err) ordersGrant := clientGrantFor(t, svc, "client-1", orders.ID) billingGrant := clientGrantFor(t, svc, "client-1", billing.ID) assert.ElementsMatch(t, []string{readID, writeID}, ordersGrant.UserDelegatedPermissionIDs) assert.ElementsMatch(t, []string{invoicesID}, billingGrant.UserDelegatedPermissionIDs) assert.True(t, ordersGrant.UserDelegatedAccess) assert.True(t, billingGrant.UserDelegatedAccess) assert.Empty(t, ordersGrant.ClientPermissionIDs) assert.False(t, ordersGrant.ClientAccess) // Each client is listed once with only the permissions it holds on this API, ordered by name. _, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "client-2", APIClientGrant{ClientPermissionIDs: []string{writeID}}) require.NoError(t, err) clients, _, err = svc.ListAPIClients(t.Context(), orders.ID, "", utils.ListRequestOptions{}) require.NoError(t, err) require.Len(t, clients, 2) assert.Equal(t, "client-2", clients[0].Client.ID) assert.Empty(t, clients[0].UserDelegatedPermissionIDs) assert.ElementsMatch(t, []string{writeID}, clients[0].ClientPermissionIDs) assert.Equal(t, "client-1", clients[1].Client.ID) assert.ElementsMatch(t, []string{readID, writeID}, clients[1].UserDelegatedPermissionIDs) // A client granted access without a single permission still shows up as having access to the API. _, err = svc.SetAPIClientAccess(t.Context(), billing.ID, "client-2", APIClientGrant{UserDelegatedAccess: true}) require.NoError(t, err) billingClients, _, err := svc.ListAPIClients(t.Context(), billing.ID, "", utils.ListRequestOptions{}) require.NoError(t, err) require.Len(t, billingClients, 2) assert.Equal(t, "client-2", billingClients[0].Client.ID) assert.True(t, billingClients[0].UserDelegatedAccess) assert.False(t, billingClients[0].ClientAccess) assert.Empty(t, billingClients[0].UserDelegatedPermissionIDs) // Revoking access to one API keeps the grants of the other one. require.NoError(t, svc.RemoveAPIClientAccess(t.Context(), orders.ID, "client-1")) remaining, err := svc.ListClientAPIs(t.Context(), "client-1") require.NoError(t, err) require.Len(t, remaining, 1) assert.Equal(t, billing.ID, remaining[0].API.ID) assert.ElementsMatch(t, []string{invoicesID}, remaining[0].UserDelegatedPermissionIDs) // The client's side of the relation lists the same grants, one row per API it may reach. clientAPIs, err := svc.ListClientAPIs(t.Context(), "client-2") require.NoError(t, err) require.Len(t, clientAPIs, 2) assert.Equal(t, billing.ID, clientAPIs[0].API.ID) assert.True(t, clientAPIs[0].UserDelegatedAccess) assert.Empty(t, clientAPIs[0].UserDelegatedPermissionIDs) assert.Equal(t, orders.ID, clientAPIs[1].API.ID) assert.Len(t, clientAPIs[1].API.Permissions, 2) assert.True(t, clientAPIs[1].ClientAccess) assert.ElementsMatch(t, []string{writeID}, clientAPIs[1].ClientPermissionIDs) // The selections an admin picks from only offer what is not granted yet, so pagination reflects what can still be added. assignableClients, pagination, err := svc.ListAssignableClients(t.Context(), orders.ID, "", utils.ListRequestOptions{}) require.NoError(t, err) require.Len(t, assignableClients, 1) assert.Equal(t, "client-1", assignableClients[0].ID) assert.Equal(t, int64(1), pagination.TotalItems) assignableAPIs, _, err := svc.ListAssignableAPIs(t.Context(), "client-2", "", utils.ListRequestOptions{}) require.NoError(t, err) require.Empty(t, assignableAPIs) assignableAPIs, _, err = svc.ListAssignableAPIs(t.Context(), "client-1", "", utils.ListRequestOptions{}) require.NoError(t, err) require.Len(t, assignableAPIs, 1) assert.Equal(t, orders.ID, assignableAPIs[0].ID) // An unknown API or client is rejected (surfaces as 404 at the HTTP layer). _, _, err = svc.ListAPIClients(t.Context(), "nope", "", utils.ListRequestOptions{}) require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) _, err = svc.SetAPIClientAccess(t.Context(), "nope", "client-1", APIClientGrant{}) require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) _, err = svc.SetAPIClientAccess(t.Context(), orders.ID, "nope", APIClientGrant{}) require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) require.True(t, apperror.IsCode(svc.RemoveAPIClientAccess(t.Context(), "nope", "client-1"), apperror.CodeNotFound)) _, err = svc.ListClientAPIs(t.Context(), "nope") require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) _, _, err = svc.ListAssignableClients(t.Context(), "nope", "", utils.ListRequestOptions{}) require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) _, _, err = svc.ListAssignableAPIs(t.Context(), "nope", "", utils.ListRequestOptions{}) require.True(t, apperror.IsCode(err, apperror.CodeNotFound)) } func findPermission(api API, key string) *Permission { for i := range api.Permissions { if api.Permissions[i].Key == key { return &api.Permissions[i] } } return nil }