Files

650 lines
31 KiB
Go

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
}