diff --git a/backend/internal/api/module.go b/backend/internal/api/module.go index 82e3b177..17154208 100644 --- a/backend/internal/api/module.go +++ b/backend/internal/api/module.go @@ -34,8 +34,8 @@ func (m *Module) ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID stri } // AllowedScopesForAudience implements the OIDC module's APIAccessProvider interface -func (m *Module) AllowedScopesForAudience(ctx context.Context, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) { - return m.service.AllowedScopesForAudience(ctx, clientID, audience, subjectType) +func (m *Module) AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) { + return m.service.AllowedScopesForAudience(ctx, tx, clientID, audience, subjectType) } // DescribePermissions implements the OIDC module's APIAccessProvider interface diff --git a/backend/internal/api/service.go b/backend/internal/api/service.go index c1015173..05b18d3d 100644 --- a/backend/internal/api/service.go +++ b/backend/internal/api/service.go @@ -379,9 +379,13 @@ func (s *Service) ClientAPIScopesAndAudiences(ctx context.Context, tx *gorm.DB, } // AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, plus whether such an API exists -func (s *Service) AllowedScopesForAudience(ctx context.Context, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) { +func (s *Service) AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) { + if tx == nil { + tx = s.db + } + var api API - err = s.db.WithContext(ctx). + err = tx.WithContext(ctx). Select("id"). Where("audience = ?", audience). First(&api). @@ -393,7 +397,7 @@ func (s *Service) AllowedScopesForAudience(ctx context.Context, clientID, audien return nil, false, err } - err = s.db.WithContext(ctx). + err = tx.WithContext(ctx). Table("api_permissions"). Select("api_permissions.key"). Joins("JOIN oidc_clients_allowed_api_permissions g ON g.api_permission_id = api_permissions.id AND g.oidc_client_id = ? AND g.subject_type = ?", clientID, subjectType). diff --git a/backend/internal/api/service_test.go b/backend/internal/api/service_test.go index cec48c88..2ba31c85 100644 --- a/backend/internal/api/service_test.go +++ b/backend/internal/api/service_test.go @@ -148,12 +148,12 @@ func TestAllowedScopesForAudienceFiltersBySubjectType(t *testing.T) { }) require.NoError(t, err) - userScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), "client-1", "https://api.orders.example.com", oidc.SubjectTypeUser) + userScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeUser) require.NoError(t, err) require.True(t, exists) assert.ElementsMatch(t, []string{"read:orders"}, userScopes) - clientScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), "client-1", "https://api.orders.example.com", oidc.SubjectTypeClient) + clientScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeClient) require.NoError(t, err) require.True(t, exists) assert.ElementsMatch(t, []string{"write:orders"}, clientScopes) diff --git a/backend/internal/oidc/api_resource.go b/backend/internal/oidc/api_resource.go index 64324026..d6078e25 100644 --- a/backend/internal/oidc/api_resource.go +++ b/backend/internal/oidc/api_resource.go @@ -37,7 +37,7 @@ type APIAccessProvider interface { // ClientAPIScopes returns the custom-API permission keys and the distinct API audiences a client is allowed to request across all subject types ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) // AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, and whether such an API exists - AllowedScopesForAudience(ctx context.Context, clientID, audience string, subjectType SubjectType) (scopes []string, apiExists bool, err error) + AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType SubjectType) (scopes []string, apiExists bool, err error) // DescribePermissions returns the display information for the given permission keys of the API identified by audience // Unknown keys are omitted DescribePermissions(ctx context.Context, audience string, keys []string) ([]PermissionInfo, error) @@ -46,7 +46,7 @@ type APIAccessProvider interface { // resolveResource maps an RFC 8707 resource, which may be empty, to the audience to stamp on the issued token and the subset of requestedScopes that may be granted // An empty resource is a plain login token bound to the requesting client and yields only identity scopes // The subject type selects which of the client's grants apply: user-delegated flows only see user grants, the client credentials grant only sees client grants -func resolveResource(ctx context.Context, provider APIAccessProvider, clientID, resource string, requestedScopes []string, subjectType SubjectType) (audience string, grantedScopes []string, err error) { +func resolveResource(ctx context.Context, tx *gorm.DB, provider APIAccessProvider, clientID, resource string, requestedScopes []string, subjectType SubjectType) (audience string, grantedScopes []string, err error) { grantable := make(map[string]struct{}, len(standardScopes)) for _, scope := range standardScopes { grantable[scope] = struct{}{} @@ -60,7 +60,7 @@ func resolveResource(ctx context.Context, provider APIAccessProvider, clientID, return "", nil, fosite.ErrInvalidTarget.WithHintf("The requested resource '%s' is invalid, missing, unknown, or malformed.", resource) } - allowedScopes, apiExists, err := provider.AllowedScopesForAudience(ctx, clientID, resource, subjectType) + allowedScopes, apiExists, err := provider.AllowedScopesForAudience(ctx, tx, clientID, resource, subjectType) if err != nil { return "", nil, err } diff --git a/backend/internal/oidc/api_resource_test.go b/backend/internal/oidc/api_resource_test.go index a28b6014..07baf32f 100644 --- a/backend/internal/oidc/api_resource_test.go +++ b/backend/internal/oidc/api_resource_test.go @@ -42,7 +42,7 @@ func (f fakeAPIAccess) ClientAPIScopes(_ context.Context, _ *gorm.DB, _ string) return scopes, audiences, nil } -func (f fakeAPIAccess) AllowedScopesForAudience(_ context.Context, _ string, audience string, subjectType SubjectType) ([]string, bool, error) { +func (f fakeAPIAccess) AllowedScopesForAudience(_ context.Context, _ *gorm.DB, _ string, audience string, subjectType SubjectType) ([]string, bool, error) { bySubject, exists := f.allowed[audience] if !exists { return nil, false, nil @@ -69,7 +69,7 @@ func (f fakeAPIAccess) DescribePermissions(_ context.Context, audience string, k func TestResolveResourceDefaultIsLoginToken(t *testing.T) { // With no resource the token is a plain login token audienced to the requesting client - audience, granted, err := resolveResource(t.Context(), nil, "client-1", "", []string{"openid", "profile"}, SubjectTypeUser) + audience, granted, err := resolveResource(t.Context(), nil, nil, "client-1", "", []string{"openid", "profile"}, SubjectTypeUser) require.NoError(t, err) assert.Equal(t, "client-1", audience) assert.Equal(t, []string{"openid", "profile"}, granted) @@ -77,7 +77,7 @@ func TestResolveResourceDefaultIsLoginToken(t *testing.T) { func TestResolveResourceRejectsCustomScopeWithoutResource(t *testing.T) { // Requesting a custom scope without targeting its API must be rejected, not dropped. - _, _, err := resolveResource(t.Context(), nil, "client-1", "", []string{"openid", "read:orders"}, SubjectTypeUser) + _, _, err := resolveResource(t.Context(), nil, nil, "client-1", "", []string{"openid", "read:orders"}, SubjectTypeUser) require.Error(t, err) } @@ -86,7 +86,7 @@ func TestResolveResourceCustomAPIGrantsValidScopes(t *testing.T) { "https://api.orders.example.com": {"read:orders", "write:orders"}, }) - audience, granted, err := resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"openid", "read:orders"}, SubjectTypeUser) + audience, granted, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"openid", "read:orders"}, SubjectTypeUser) require.NoError(t, err) assert.Equal(t, "https://api.orders.example.com", audience) // openid stays (identity, for the ID token); read:orders is allowed for this API. @@ -98,13 +98,13 @@ func TestResolveResourceRejectsScopeFromAnotherAPI(t *testing.T) { "https://api.orders.example.com": {"read:orders", "write:orders"}, }) // write:billing belongs to a different API than the one targeted -> rejected. - _, _, err := resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"openid", "write:billing"}, SubjectTypeUser) + _, _, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"openid", "write:billing"}, SubjectTypeUser) require.Error(t, err) } func TestResolveResourceUnknownIsRejected(t *testing.T) { provider := userAccess(map[string][]string{}) - _, _, err := resolveResource(t.Context(), provider, "client-1", "https://api.unknown.example.com", []string{"read"}, SubjectTypeUser) + _, _, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.unknown.example.com", []string{"read"}, SubjectTypeUser) require.Error(t, err) } @@ -113,7 +113,7 @@ func TestResolveResourceUnauthorizedClientIsRejected(t *testing.T) { provider := userAccess(map[string][]string{ "https://api.orders.example.com": {}, }) - _, _, err := resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeUser) + _, _, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeUser) require.Error(t, err) } @@ -129,23 +129,23 @@ func TestResolveResourceSubjectTypesAreIndependent(t *testing.T) { }} // The user-delegated grant works for user flows... - audience, granted, err := resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeUser) + audience, granted, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeUser) require.NoError(t, err) assert.Equal(t, "https://api.orders.example.com", audience) assert.ElementsMatch(t, []string{"read:orders"}, granted) // ...but not for the client itself. - _, _, err = resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeClient) + _, _, err = resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeClient) require.Error(t, err) // The client grant works machine-to-machine... - audience, granted, err = resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"write:orders"}, SubjectTypeClient) + audience, granted, err = resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"write:orders"}, SubjectTypeClient) require.NoError(t, err) assert.Equal(t, "https://api.orders.example.com", audience) assert.ElementsMatch(t, []string{"write:orders"}, granted) // ...but users cannot be asked to delegate it. - _, _, err = resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", []string{"write:orders"}, SubjectTypeUser) + _, _, err = resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"write:orders"}, SubjectTypeUser) require.Error(t, err) } @@ -158,7 +158,7 @@ func TestResolveResourceClientWithoutClientGrantsIsDenied(t *testing.T) { }, }} - _, _, err := resolveResource(t.Context(), provider, "client-1", "https://api.orders.example.com", nil, SubjectTypeClient) + _, _, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", nil, SubjectTypeClient) require.Error(t, err) } diff --git a/backend/internal/oidc/authorization_service.go b/backend/internal/oidc/authorization_service.go index a7cfac51..a2f88a72 100644 --- a/backend/internal/oidc/authorization_service.go +++ b/backend/internal/oidc/authorization_service.go @@ -41,7 +41,7 @@ type authorizationService struct { // resolveGrant resolves the RFC 8707 resource of a request into the token audience, the scopes that may actually be granted, and the audience-qualified keys used to record and check consent // It always resolves against the client's user-delegated grants because every flow that passes through here acts on behalf of a user func (s *authorizationService) resolveGrant(ctx context.Context, clientID, resource string, requestedScopes []string) (audience string, grantedScopes []string, consentKeys []string, err error) { - audience, grantedScopes, err = resolveResource(ctx, s.apiAccess, clientID, resource, requestedScopes, SubjectTypeUser) + audience, grantedScopes, err = resolveResource(ctx, dbFromContext(ctx, s.db), s.apiAccess, clientID, resource, requestedScopes, SubjectTypeUser) if err != nil { return "", nil, nil, err } diff --git a/backend/internal/oidc/token_handler.go b/backend/internal/oidc/token_handler.go index 6dd170ba..c9a57dce 100644 --- a/backend/internal/oidc/token_handler.go +++ b/backend/internal/oidc/token_handler.go @@ -69,7 +69,7 @@ func (h *tokenHandler) token(c *gin.Context) { h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err) return } - audience, grantedScopes, err := resolveResource(ctx, h.apiAccess, client.GetID(), resource, accessRequest.GetRequestedScopes(), SubjectTypeClient) + audience, grantedScopes, err := resolveResource(ctx, nil, h.apiAccess, client.GetID(), resource, accessRequest.GetRequestedScopes(), SubjectTypeClient) if err != nil { h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err) return @@ -127,7 +127,7 @@ func (h *tokenHandler) validateRefreshAPIGrant(ctx context.Context, client Clien return err } - _, _, err = resolveResource(ctx, h.apiAccess, client.GetID(), resource, accessRequest.GetGrantedScopes(), SubjectTypeUser) + _, _, err = resolveResource(ctx, nil, h.apiAccess, client.GetID(), resource, accessRequest.GetGrantedScopes(), SubjectTypeUser) return err }