mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-19 19:36:24 +00:00
650 lines
31 KiB
Go
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
|
|
}
|