refactor: pass transaction to resolveResource

This commit is contained in:
Elias Schneider
2026-07-07 11:13:14 +02:00
parent 5e2cc6f40e
commit 7667377c98
7 changed files with 29 additions and 25 deletions

View File

@@ -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

View File

@@ -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).

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}