diff --git a/contrib/claude/authorization.md b/contrib/claude/authorization.md index 76e1ec68c..68ef71709 100644 --- a/contrib/claude/authorization.md +++ b/contrib/claude/authorization.md @@ -255,7 +255,7 @@ and `r.AuthorizeBatch` (MCP) — keep the returned scope and pass it down. | IAM role policies (`IAMPolicySet`) | `pkg/iam/iam_policies.go` | | Authorizer + `AuthorizationAttributer` | `pkg/iam/authorizer.go` | | PolicySet registration | `pkg/iam/policy_set.go` | -| OAuth2 scope mappings (`ScopeSet`) | `pkg/iam/scope_set.go` | +| OAuth2 scope registry (`oauth2scope.Registry`) | `pkg/iam/oauth2scope/registry.go` | | OAuth2 scope constants (per domain) | `pkg//oauth2_scopes.go` | | OAuth2 discovery + request context | `pkg/iam/oauth2/` | | GraphQL authz helper | `pkg/server/api/authz/authorization.go` | @@ -291,9 +291,9 @@ Scopes are namespace- or product-level only — no resource segments (e.g. `v1:p - Authorization server (RFC 8414): `scopes_supported` on `/.well-known/oauth-authorization-server` lists OIDC + all API scopes; `protected_resources` links to the resource metadata document - Protected resource (RFC 9728): `scopes_supported` on `/.well-known/oauth-protected-resource` lists `openid` plus API scopes -**Enforcement:** OAuth2 bearer-token requests carry the validated access token on the request context (`pkg/iam/oauth2/request_context.go`). Before IAM policy evaluation, `iam.Authorizer` checks registered `iam.ScopeSet` mappings via `ScopeSet.Allows` (`RegisterScopes`, same composition model as `RegisterPolicySet`). Each domain package exports an `OAuth2ScopeSet()` (or `IAMOAuth2ScopeSet()` in `pkg/iam`) and registers it at service startup. The check uses explicit scope→action lists — no `:read` / `:get` heuristics at enforcement time. Session, personal API key, and SCIM auth skip the check (no access token on context). Unmapped IAM actions **deny** OAuth requests (fail closed). Enforcement reads scopes from the access token directly. +**Enforcement:** OAuth2 bearer-token requests carry the validated access token on the request context (`pkg/iam/oauth2/request_context.go`). Before IAM policy evaluation, `iam.Authorizer` checks registered `oauth2scope.Registry` mappings via `Registry.Allows`. Each domain package exports `OAuth2ScopeMappings` in its `oauth2_scopes.go`; `probod` registers all domain mappings on the shared registry before `iam.NewService`. The check uses explicit scope→action lists — no `:read` / `:get` heuristics at enforcement time. Session, personal API key, and SCIM auth skip the check (no access token on context). Unmapped IAM actions **deny** OAuth requests (fail closed). Enforcement reads scopes from the access token directly. -Add new namespace-level scope constants in the owning package's `oauth2_scopes.go`, map their IAM actions in that package's `OAuth2ScopeSet()`, and register that set on the authorizer when the surface is ready for OAuth clients. Write scopes are registered only when their mutating IAM actions are mapped. +Add new namespace-level scope constants in the owning package's `oauth2_scopes.go`, map their IAM actions in that package's `OAuth2ScopeMappings`, and add the mapping to `probod` wiring alongside the other domain registrations. Write scopes are registered only when their mutating IAM actions are mapped. **Well-known Probo CLI client:** `iam_oauth2_clients` scopes for `AAAAAAAAAAAASwAAAAAAAAAAcHJiY2xp` must match `CLIClientScopes` in `pkg/cli/config/config.go` (requested by `prb auth login`). When adding API scopes, update the client migration, `CLIClientScopes`, and scope registration together. diff --git a/pkg/iam/authorizer.go b/pkg/iam/authorizer.go index e68075b20..ee1e3d8f2 100644 --- a/pkg/iam/authorizer.go +++ b/pkg/iam/authorizer.go @@ -28,8 +28,8 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam/oauth2" + "go.probo.inc/probo/pkg/iam/oauth2scope" "go.probo.inc/probo/pkg/iam/policy" - "go.probo.inc/probo/pkg/iam/scopeset" ) // AuthorizationAttributer is implemented by entities that can provide @@ -84,21 +84,21 @@ type AuthorizeMultiParams struct { // Authorizer evaluates authorization requests against registered policies. type Authorizer struct { - pg *pg.Client - evaluator *policy.Evaluator - policySet *PolicySet - oauth2ScopeSet *scopeset.ScopeSet - logger *log.Logger + pg *pg.Client + evaluator *policy.Evaluator + policySet *PolicySet + scopeRegistry *oauth2scope.Registry + logger *log.Logger } // NewAuthorizer creates a new Authorizer instance. -func NewAuthorizer(pgClient *pg.Client, logger *log.Logger, scopeSet *scopeset.ScopeSet) *Authorizer { +func NewAuthorizer(pgClient *pg.Client, logger *log.Logger, scopeRegistry *oauth2scope.Registry) *Authorizer { return &Authorizer{ - pg: pgClient, - evaluator: policy.NewEvaluator(), - policySet: NewPolicySet(), - oauth2ScopeSet: scopeSet, - logger: logger, + pg: pgClient, + evaluator: policy.NewEvaluator(), + policySet: NewPolicySet(), + scopeRegistry: scopeRegistry, + logger: logger, } } @@ -117,7 +117,7 @@ func (a *Authorizer) checkOAuth2Scope( return nil } - if a.oauth2ScopeSet == nil || !a.oauth2ScopeSet.Allows(accessToken.Scopes, action) { + if a.scopeRegistry == nil || !a.scopeRegistry.Allows(accessToken.Scopes, action) { return NewInsufficientOAuth2ScopeError(principal, action) } diff --git a/pkg/iam/authorizer_batch_test.go b/pkg/iam/authorizer_batch_test.go index 40e5e3a86..9c11fa50a 100644 --- a/pkg/iam/authorizer_batch_test.go +++ b/pkg/iam/authorizer_batch_test.go @@ -31,8 +31,8 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/oauth2scope" "go.probo.inc/probo/pkg/iam/policy" - "go.probo.inc/probo/pkg/iam/scopeset" "go.probo.inc/probo/pkg/mail" ) @@ -217,7 +217,7 @@ func TestAuthorizer_AuthorizeBatch(t *testing.T) { t.Run("empty input", func(t *testing.T) { t.Parallel() - authorizer := iam.NewAuthorizer(nil, log.NewLogger(log.WithOutput(io.Discard)), scopeset.New()) + authorizer := iam.NewAuthorizer(nil, log.NewLogger(log.WithOutput(io.Discard)), oauth2scope.NewRegistry()) _, err := authorizer.AuthorizeBatch( context.Background(), @@ -645,7 +645,7 @@ func TestAuthorizer_AuthorizeMulti(t *testing.T) { t.Run("rejects empty items", func(t *testing.T) { t.Parallel() - authorizer := iam.NewAuthorizer(nil, log.NewLogger(log.WithOutput(io.Discard)), scopeset.New()) + authorizer := iam.NewAuthorizer(nil, log.NewLogger(log.WithOutput(io.Discard)), oauth2scope.NewRegistry()) scope, decisions, err := authorizer.AuthorizeMulti( context.Background(), @@ -664,7 +664,7 @@ func TestAuthorizer_AuthorizeMulti(t *testing.T) { t.Run("rejects unsupported principal type", func(t *testing.T) { t.Parallel() - authorizer := iam.NewAuthorizer(nil, log.NewLogger(log.WithOutput(io.Discard)), scopeset.New()) + authorizer := iam.NewAuthorizer(nil, log.NewLogger(log.WithOutput(io.Discard)), oauth2scope.NewRegistry()) scope, decisions, err := authorizer.AuthorizeMulti( context.Background(), @@ -783,7 +783,7 @@ func newTestAuthorizer(client *pg.Client, action string, allowResourceID *gid.GI } func newTestAuthorizerWithStatements(client *pg.Client, statements ...policy.Statement) *iam.Authorizer { - authorizer := iam.NewAuthorizer(client, log.NewLogger(log.WithOutput(io.Discard)), scopeset.New()) + authorizer := iam.NewAuthorizer(client, log.NewLogger(log.WithOutput(io.Discard)), oauth2scope.NewRegistry()) authorizer.RegisterPolicySet( iam.NewPolicySet().AddRolePolicy( string(coredata.MembershipRoleOwner), @@ -795,7 +795,7 @@ func newTestAuthorizerWithStatements(client *pg.Client, statements ...policy.Sta } func newTestAuthorizerWithIdentityScopedStatements(client *pg.Client, statements ...policy.Statement) *iam.Authorizer { - authorizer := iam.NewAuthorizer(client, log.NewLogger(log.WithOutput(io.Discard)), scopeset.New()) + authorizer := iam.NewAuthorizer(client, log.NewLogger(log.WithOutput(io.Discard)), oauth2scope.NewRegistry()) authorizer.RegisterPolicySet( iam.NewPolicySet().AddIdentityScopedPolicy( policy.NewPolicy("batch-authorize-identity-test", "Batch Authorize Identity Test", statements...), diff --git a/pkg/iam/authorizer_decisionlog_test.go b/pkg/iam/authorizer_decisionlog_test.go index 640c8e2c8..4c270359a 100644 --- a/pkg/iam/authorizer_decisionlog_test.go +++ b/pkg/iam/authorizer_decisionlog_test.go @@ -29,8 +29,8 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/oauth2scope" "go.probo.inc/probo/pkg/iam/policy" - "go.probo.inc/probo/pkg/iam/scopeset" ) func TestAuthorizer_DecisionLogging(t *testing.T) { @@ -152,7 +152,7 @@ func newTestAuthorizerWithLogger( statements = append(statements, extraStatements...) - authorizer := iam.NewAuthorizer(client, log.NewLogger(log.WithOutput(logOutput)), scopeset.New()) + authorizer := iam.NewAuthorizer(client, log.NewLogger(log.WithOutput(logOutput)), oauth2scope.NewRegistry()) authorizer.RegisterPolicySet( iam.NewPolicySet().AddRolePolicy( string(coredata.MembershipRoleOwner), diff --git a/pkg/iam/authorizer_oauth2_scope_test.go b/pkg/iam/authorizer_oauth2_scope_test.go index b5a5556ef..813843350 100644 --- a/pkg/iam/authorizer_oauth2_scope_test.go +++ b/pkg/iam/authorizer_oauth2_scope_test.go @@ -24,7 +24,7 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam/oauth2" - "go.probo.inc/probo/pkg/iam/scopeset" + "go.probo.inc/probo/pkg/iam/oauth2scope" ) func TestAuthorizer_checkOAuth2Scope(t *testing.T) { @@ -66,7 +66,7 @@ func TestAuthorizer_checkOAuth2Scope(t *testing.T) { t.Run("allows when registered scopes authorize the action", func(t *testing.T) { t.Parallel() - scopeSet := scopeset.New().Register( + scopeSet := oauth2scope.NewRegistry().Register( map[coredata.OAuth2Scope][]string{ scopeV1OrgRead: {action}, }, diff --git a/pkg/iam/oauth2/cimd.go b/pkg/iam/oauth2/cimd.go index 1e6965bb2..b9c185a4c 100644 --- a/pkg/iam/oauth2/cimd.go +++ b/pkg/iam/oauth2/cimd.go @@ -400,15 +400,7 @@ func (s *Service) upsertCIMDClient( clientURI = &doc.ClientURI } - scopes := slices.Concat( - []coredata.OAuth2Scope{ - ScopeOpenID, - ScopeProfile, - ScopeEmail, - ScopeOfflineAccess, - }, - s.scopeSet.APIScopes(), - ) + scopes := coredata.OAuth2Scopes(authorizationServerScopes(s.registry.RegisteredScopes())) now := time.Now() diff --git a/pkg/iam/oauth2/metadata.go b/pkg/iam/oauth2/metadata.go index 91a5eb965..6ebc997ac 100644 --- a/pkg/iam/oauth2/metadata.go +++ b/pkg/iam/oauth2/metadata.go @@ -15,8 +15,6 @@ package oauth2 import ( - "slices" - "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/uri" ) @@ -61,7 +59,7 @@ type ( } ) -func NewMetadata(issuer uri.URI, endpoints Endpoints, apiScopes []coredata.OAuth2Scope) *ServerMetadata { +func NewMetadata(issuer uri.URI, endpoints Endpoints, registeredScopes []coredata.OAuth2Scope) *ServerMetadata { return &ServerMetadata{ Issuer: issuer, AuthorizationEndpoint: endpoints.Authorization, @@ -72,16 +70,8 @@ func NewMetadata(issuer uri.URI, endpoints Endpoints, apiScopes []coredata.OAuth IntrospectionEndpoint: endpoints.Introspection, RevocationEndpoint: endpoints.Revocation, DeviceAuthorizationEndpoint: endpoints.DeviceAuthorization, - ScopesSupported: slices.Concat( - []coredata.OAuth2Scope{ - ScopeOpenID, - ScopeProfile, - ScopeEmail, - ScopeOfflineAccess, - }, - apiScopes, - ), - ProtectedResources: []uri.URI{issuer}, + ScopesSupported: authorizationServerScopes(registeredScopes), + ProtectedResources: []uri.URI{issuer}, ResponseTypesSupported: []coredata.OAuth2ResponseType{ coredata.OAuth2ResponseTypeCode, }, diff --git a/pkg/iam/oauth2/metadata_test.go b/pkg/iam/oauth2/metadata_test.go index 2b470778b..ebb8970eb 100644 --- a/pkg/iam/oauth2/metadata_test.go +++ b/pkg/iam/oauth2/metadata_test.go @@ -22,6 +22,7 @@ import ( "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/iam/oauth2" + "go.probo.inc/probo/pkg/iam/oauth2scope" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/uri" ) @@ -29,7 +30,11 @@ import ( func TestNewMetadata(t *testing.T) { t.Parallel() - apiScopes := []coredata.OAuth2Scope{probo.ScopeV1DocumentRead} + reg := oauth2scope.NewRegistry().Register( + map[coredata.OAuth2Scope][]string{ + probo.ScopeV1DocumentRead: {"core:document:get"}, + }, + ) issuer := uri.URI("https://auth.example.com") endpoints := oauth2.Endpoints{ @@ -43,7 +48,7 @@ func TestNewMetadata(t *testing.T) { DeviceAuthorization: "https://auth.example.com/device", } - metadata := oauth2.NewMetadata(issuer, endpoints, apiScopes) + metadata := oauth2.NewMetadata(issuer, endpoints, reg.RegisteredScopes()) require.NotNil(t, metadata) t.Run( @@ -83,7 +88,7 @@ func TestNewMetadata(t *testing.T) { oauth2.ScopeEmail, oauth2.ScopeOfflineAccess, }, - apiScopes, + reg.RegisteredScopes(), ) assert.Equal(t, expectedScopes, metadata.ScopesSupported) diff --git a/pkg/iam/oauth2/protected_resource_metadata.go b/pkg/iam/oauth2/protected_resource_metadata.go index d897e50f0..5d9ffe507 100644 --- a/pkg/iam/oauth2/protected_resource_metadata.go +++ b/pkg/iam/oauth2/protected_resource_metadata.go @@ -15,8 +15,6 @@ package oauth2 import ( - "slices" - "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/uri" ) @@ -33,7 +31,7 @@ type ProtectedResourceMetadata struct { func NewProtectedResourceMetadata( resource uri.URI, authorizationServer uri.URI, - apiScopes []coredata.OAuth2Scope, + registeredScopes []coredata.OAuth2Scope, ) *ProtectedResourceMetadata { return &ProtectedResourceMetadata{ Resource: resource, @@ -41,9 +39,6 @@ func NewProtectedResourceMetadata( BearerMethodsSupported: []string{ "header", }, - ScopesSupported: slices.Concat( - []coredata.OAuth2Scope{ScopeOpenID}, - apiScopes, - ), + ScopesSupported: protectedResourceScopes(registeredScopes), } } diff --git a/pkg/iam/oauth2/protected_resource_metadata_test.go b/pkg/iam/oauth2/protected_resource_metadata_test.go index 4fb4fff5e..2d7c85d66 100644 --- a/pkg/iam/oauth2/protected_resource_metadata_test.go +++ b/pkg/iam/oauth2/protected_resource_metadata_test.go @@ -21,6 +21,7 @@ import ( "github.com/stretchr/testify/require" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/iam/oauth2" + "go.probo.inc/probo/pkg/iam/oauth2scope" "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/uri" ) @@ -28,12 +29,16 @@ import ( func TestNewProtectedResourceMetadata(t *testing.T) { t.Parallel() - apiScopes := []coredata.OAuth2Scope{probo.ScopeV1DocumentRead} + reg := oauth2scope.NewRegistry().Register( + map[coredata.OAuth2Scope][]string{ + probo.ScopeV1DocumentRead: {"core:document:get"}, + }, + ) resource := uri.URI("https://app.example.com") authorizationServer := uri.URI("https://app.example.com") - metadata := oauth2.NewProtectedResourceMetadata(resource, authorizationServer, apiScopes) + metadata := oauth2.NewProtectedResourceMetadata(resource, authorizationServer, reg.RegisteredScopes()) require.NotNil(t, metadata) assert.Equal(t, resource, metadata.Resource) diff --git a/pkg/iam/oauth2/scopes.go b/pkg/iam/oauth2/scopes.go new file mode 100644 index 000000000..1d24190df --- /dev/null +++ b/pkg/iam/oauth2/scopes.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package oauth2 + +import ( + "slices" + + "go.probo.inc/probo/pkg/coredata" +) + +func authorizationServerScopes(registeredScopes []coredata.OAuth2Scope) []coredata.OAuth2Scope { + return slices.Concat( + []coredata.OAuth2Scope{ + ScopeOpenID, + ScopeProfile, + ScopeEmail, + ScopeOfflineAccess, + }, + registeredScopes, + ) +} + +func protectedResourceScopes(registeredScopes []coredata.OAuth2Scope) []coredata.OAuth2Scope { + return slices.Concat( + []coredata.OAuth2Scope{ScopeOpenID}, + registeredScopes, + ) +} diff --git a/pkg/iam/oauth2/service.go b/pkg/iam/oauth2/service.go index f51b2b631..d7262d254 100644 --- a/pkg/iam/oauth2/service.go +++ b/pkg/iam/oauth2/service.go @@ -31,7 +31,7 @@ import ( "go.probo.inc/probo/pkg/crypto/jose" "go.probo.inc/probo/pkg/crypto/rand" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/iam/scopeset" + "go.probo.inc/probo/pkg/iam/oauth2scope" "go.probo.inc/probo/pkg/net" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/uri" @@ -63,7 +63,7 @@ type ( gc *GarbageCollector cimd *cimdFetcher cimdAllowedClientIDs []string - scopeSet *scopeset.ScopeSet + registry *oauth2scope.Registry accessTokenDuration time.Duration refreshTokenDuration time.Duration authorizationCodeDuration time.Duration @@ -128,11 +128,10 @@ type ( } CreateManualAccessTokenRequest struct { - IdentityID gid.GID - Name string - ExpiresAt time.Time - Scopes coredata.OAuth2Scopes - AllowedAPIScopes []coredata.OAuth2Scope + IdentityID gid.GID + Name string + ExpiresAt time.Time + Scopes coredata.OAuth2Scopes } ) @@ -160,9 +159,9 @@ func WithDeviceCodeDuration(d time.Duration) Option { } } -func WithScopeSet(scopeSet *scopeset.ScopeSet) Option { +func WithRegistry(registry *oauth2scope.Registry) Option { return func(s *Service) { - s.scopeSet = scopeSet + s.registry = registry } } @@ -1918,8 +1917,8 @@ func (s *Service) CreateManualAccessToken( return "", nil, NewError(ErrInvalidRequest, WithDescription("scopes are required")) } - if err := validateManualAccessTokenScopes(req.Scopes, req.AllowedAPIScopes); err != nil { - return "", nil, err + if err := s.registry.ValidateScopes(req.Scopes); err != nil { + return "", nil, NewError(ErrInvalidScope, WithDescription(err.Error())) } tokenValue := rand.MustHexString(tokenByteLength) @@ -1951,18 +1950,3 @@ func (s *Service) CreateManualAccessToken( return tokenValue, accessToken, nil } - -func validateManualAccessTokenScopes(scopes, allowedAPIScopes coredata.OAuth2Scopes) error { - allowed := make(map[coredata.OAuth2Scope]struct{}, len(allowedAPIScopes)) - for _, scope := range allowedAPIScopes { - allowed[scope] = struct{}{} - } - - for _, scope := range scopes { - if _, ok := allowed[scope]; !ok { - return NewError(ErrInvalidScope, WithDescription("invalid scope: "+string(scope))) - } - } - - return nil -} diff --git a/pkg/iam/oauth2_scope_registrations_test.go b/pkg/iam/oauth2_scope_registrations_test.go index 43ce1cee7..0cfad777f 100644 --- a/pkg/iam/oauth2_scope_registrations_test.go +++ b/pkg/iam/oauth2_scope_registrations_test.go @@ -22,37 +22,37 @@ import ( "go.probo.inc/probo/pkg/agentrun" "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/iam" - "go.probo.inc/probo/pkg/iam/scopeset" + "go.probo.inc/probo/pkg/iam/oauth2scope" "go.probo.inc/probo/pkg/probo" ) -func allRegisteredOAuth2ScopeSets() *scopeset.ScopeSet { - return scopeset.New(). +func allRegisteredOAuth2ScopeRegistries() *oauth2scope.Registry { + return oauth2scope.NewRegistry(). Register(iam.IAMOAuth2ScopeMappings). Register(probo.OAuth2ScopeMappings). Register(accessreview.OAuth2ScopeMappings). Register(agentrun.OAuth2ScopeMappings) } -func TestRegisteredOAuth2ScopeSets_OrganizationRead(t *testing.T) { +func TestRegisteredOAuth2ScopeRegistries_OrganizationRead(t *testing.T) { t.Parallel() - scopeSet := allRegisteredOAuth2ScopeSets() + reg := allRegisteredOAuth2ScopeRegistries() tokenScopes := coredata.OAuth2Scopes{probo.ScopeV1OrgRead} - assert.True(t, scopeSet.Allows(tokenScopes, probo.ActionOrganizationGet)) - assert.False(t, scopeSet.Allows(tokenScopes, probo.ActionOrganizationUpdate)) - assert.False(t, scopeSet.Allows(tokenScopes, probo.ActionThirdPartyList)) + assert.True(t, reg.Allows(tokenScopes, probo.ActionOrganizationGet)) + assert.False(t, reg.Allows(tokenScopes, probo.ActionOrganizationUpdate)) + assert.False(t, reg.Allows(tokenScopes, probo.ActionThirdPartyList)) } -func TestRegisteredOAuth2ScopeSets_UnmappedActionDenies(t *testing.T) { +func TestRegisteredOAuth2ScopeRegistries_UnmappedActionDenies(t *testing.T) { t.Parallel() - scopeSet := allRegisteredOAuth2ScopeSets() + reg := allRegisteredOAuth2ScopeRegistries() tokenScopes := coredata.OAuth2Scopes{ probo.ScopeV1OrgRead, probo.ScopeV1ThirdPartyRead, } - assert.False(t, scopeSet.Allows(tokenScopes, "core:unmapped:action")) + assert.False(t, reg.Allows(tokenScopes, "core:unmapped:action")) } diff --git a/pkg/iam/scopeset/scopeset.go b/pkg/iam/oauth2scope/registry.go similarity index 52% rename from pkg/iam/scopeset/scopeset.go rename to pkg/iam/oauth2scope/registry.go index 10cbc5788..3681cd15b 100644 --- a/pkg/iam/scopeset/scopeset.go +++ b/pkg/iam/oauth2scope/registry.go @@ -12,10 +12,11 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package scopeset +package oauth2scope import ( "cmp" + "fmt" "maps" "slices" "sync" @@ -23,47 +24,47 @@ import ( "go.probo.inc/probo/pkg/coredata" ) -type ScopeSet struct { - mu sync.RWMutex - scopeActions map[coredata.OAuth2Scope][]string - actionScopes map[string][]coredata.OAuth2Scope +type Registry struct { + mu sync.RWMutex + scopeActions map[coredata.OAuth2Scope][]string + invertedIndex map[string][]coredata.OAuth2Scope } -func New() *ScopeSet { - return &ScopeSet{ +func NewRegistry() *Registry { + return &Registry{ scopeActions: make(map[coredata.OAuth2Scope][]string), } } -func (s *ScopeSet) Register(mappings map[coredata.OAuth2Scope][]string) *ScopeSet { - s.mu.Lock() - defer s.mu.Unlock() +func (r *Registry) Register(mappings map[coredata.OAuth2Scope][]string) *Registry { + r.mu.Lock() + defer r.mu.Unlock() for scope, actions := range mappings { if len(actions) == 0 { continue } - s.scopeActions[scope] = append(s.scopeActions[scope], actions...) + r.scopeActions[scope] = append(r.scopeActions[scope], actions...) } - s.rebuildActionScopes() + r.rebuildInvertedIndex() - return s + return r } -func (s *ScopeSet) APIScopes() []coredata.OAuth2Scope { - s.mu.RLock() - defer s.mu.RUnlock() +func (r *Registry) RegisteredScopes() []coredata.OAuth2Scope { + r.mu.RLock() + defer r.mu.RUnlock() - return sortedScopes(slices.Collect(maps.Keys(s.scopeActions))) + return sortedScopes(slices.Collect(maps.Keys(r.scopeActions))) } -func (s *ScopeSet) Allows(tokenScopes coredata.OAuth2Scopes, action string) bool { - s.mu.RLock() - defer s.mu.RUnlock() +func (r *Registry) Allows(tokenScopes coredata.OAuth2Scopes, action string) bool { + r.mu.RLock() + defer r.mu.RUnlock() - grantingScopes, ok := s.actionScopes[action] + grantingScopes, ok := r.invertedIndex[action] if !ok { return false } @@ -71,16 +72,29 @@ func (s *ScopeSet) Allows(tokenScopes coredata.OAuth2Scopes, action string) bool return slices.ContainsFunc(grantingScopes, tokenScopes.Contains) } -func (s *ScopeSet) rebuildActionScopes() { - actionScopes := make(map[string][]coredata.OAuth2Scope, len(s.scopeActions)*4) +func (r *Registry) ValidateScopes(scopes coredata.OAuth2Scopes) error { + r.mu.RLock() + defer r.mu.RUnlock() - for scope, actions := range s.scopeActions { - for _, action := range actions { - actionScopes[action] = append(actionScopes[action], scope) + for _, scope := range scopes { + if _, ok := r.scopeActions[scope]; !ok { + return fmt.Errorf("invalid scope: %s", scope) } } - s.actionScopes = actionScopes + return nil +} + +func (r *Registry) rebuildInvertedIndex() { + invertedIndex := make(map[string][]coredata.OAuth2Scope, len(r.scopeActions)*4) + + for scope, actions := range r.scopeActions { + for _, action := range actions { + invertedIndex[action] = append(invertedIndex[action], scope) + } + } + + r.invertedIndex = invertedIndex } func sortedScopes(scopes []coredata.OAuth2Scope) []coredata.OAuth2Scope { diff --git a/pkg/iam/oauth2scope/registry_test.go b/pkg/iam/oauth2scope/registry_test.go new file mode 100644 index 000000000..c46f85a74 --- /dev/null +++ b/pkg/iam/oauth2scope/registry_test.go @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package oauth2scope_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/iam/oauth2scope" +) + +func TestRegistry_Allows(t *testing.T) { + t.Parallel() + + const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read") + + reg := oauth2scope.NewRegistry().Register( + map[coredata.OAuth2Scope][]string{ + scopeV1OrgRead: {"core:organization:get"}, + }, + ) + + tokenScopes := coredata.OAuth2Scopes{scopeV1OrgRead} + + assert.True(t, reg.Allows(tokenScopes, "core:organization:get")) + assert.False(t, reg.Allows(tokenScopes, "core:organization:update")) +} + +func TestRegistry_Register(t *testing.T) { + t.Parallel() + + const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read") + + reg := oauth2scope.NewRegistry(). + Register( + map[coredata.OAuth2Scope][]string{ + scopeV1OrgRead: {"core:organization:get"}, + }, + ). + Register( + map[coredata.OAuth2Scope][]string{ + scopeV1OrgRead: {"core:organization-context:get"}, + }, + ) + + tokenScopes := coredata.OAuth2Scopes{scopeV1OrgRead} + + assert.True(t, reg.Allows(tokenScopes, "core:organization:get")) + assert.True(t, reg.Allows(tokenScopes, "core:organization-context:get")) +} + +func TestRegistry_ValidateScopes(t *testing.T) { + t.Parallel() + + const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read") + + reg := oauth2scope.NewRegistry().Register( + map[coredata.OAuth2Scope][]string{ + scopeV1OrgRead: {"core:organization:get"}, + }, + ) + + require.NoError(t, reg.ValidateScopes(coredata.OAuth2Scopes{scopeV1OrgRead})) + + err := reg.ValidateScopes(coredata.OAuth2Scopes{"v1:unknown:read"}) + require.Error(t, err) + assert.EqualError(t, err, "invalid scope: v1:unknown:read") +} + +func TestRegistry_RegisteredScopes(t *testing.T) { + t.Parallel() + + const ( + scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read") + scopeV1OrgWrite = coredata.OAuth2Scope("v1:org") + ) + + reg := oauth2scope.NewRegistry(). + Register( + map[coredata.OAuth2Scope][]string{ + scopeV1OrgWrite: {"core:organization:update"}, + scopeV1OrgRead: {"core:organization:get"}, + }, + ) + + assert.Equal( + t, + []coredata.OAuth2Scope{scopeV1OrgWrite, scopeV1OrgRead}, + reg.RegisteredScopes(), + ) +} diff --git a/pkg/iam/scopeset/scopeset_test.go b/pkg/iam/scopeset/scopeset_test.go deleted file mode 100644 index c489707af..000000000 --- a/pkg/iam/scopeset/scopeset_test.go +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission to use, copy, modify, and/or distribute this software for any -// purpose with or without fee is hereby granted, provided that the above -// copyright notice and this permission notice appear in all copies. -// -// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -// PERFORMANCE OF THIS SOFTWARE. - -package scopeset_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/iam/scopeset" -) - -func TestScopeSet_Allows(t *testing.T) { - t.Parallel() - - const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read") - - scopeSet := scopeset.New().Register( - map[coredata.OAuth2Scope][]string{ - scopeV1OrgRead: {"core:organization:get"}, - }, - ) - - tokenScopes := coredata.OAuth2Scopes{scopeV1OrgRead} - - assert.True(t, scopeSet.Allows(tokenScopes, "core:organization:get")) - assert.False(t, scopeSet.Allows(tokenScopes, "core:organization:update")) -} - -func TestScopeSet_Register(t *testing.T) { - t.Parallel() - - const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read") - - scopeSet := scopeset.New(). - Register( - map[coredata.OAuth2Scope][]string{ - scopeV1OrgRead: {"core:organization:get"}, - }, - ). - Register( - map[coredata.OAuth2Scope][]string{ - scopeV1OrgRead: {"core:organization-context:get"}, - }, - ) - - tokenScopes := coredata.OAuth2Scopes{scopeV1OrgRead} - - assert.True(t, scopeSet.Allows(tokenScopes, "core:organization:get")) - assert.True(t, scopeSet.Allows(tokenScopes, "core:organization-context:get")) -} diff --git a/pkg/iam/service.go b/pkg/iam/service.go index 869189b81..81f9b6785 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -34,10 +34,10 @@ import ( "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam/oauth2" + "go.probo.inc/probo/pkg/iam/oauth2scope" "go.probo.inc/probo/pkg/iam/oidc" "go.probo.inc/probo/pkg/iam/saml" "go.probo.inc/probo/pkg/iam/scim" - "go.probo.inc/probo/pkg/iam/scopeset" "go.probo.inc/probo/pkg/uri" ) @@ -70,7 +70,7 @@ type ( APIKeyService *APIKeyService OAuth2ServerService *oauth2.Service Authorizer *Authorizer - OAuth2ScopeSet *scopeset.ScopeSet + OAuth2ScopeRegistry *oauth2scope.Registry samlDomainVerifier *SAMLDomainVerifier } @@ -99,6 +99,7 @@ type ( MicrosoftOIDC oidc.ProviderConfig OAuth2ServerSigningKeys oauth2.SigningKeys OAuth2ServerOptions []oauth2.Option + OAuth2ScopeRegistry *oauth2scope.Registry } ) @@ -134,6 +135,10 @@ func NewService( return nil, fmt.Errorf("encryption key is required") } + if cfg.OAuth2ScopeRegistry == nil { + return nil, fmt.Errorf("oauth2 scope registry is required") + } + svc := &Service{ pg: pgClient, fm: fm, @@ -159,13 +164,12 @@ func NewService( svc.AuthService = NewAuthService(svc) svc.APIKeyService = NewAPIKeyService(svc) - svc.OAuth2ScopeSet = scopeset.New() - svc.OAuth2ScopeSet.Register(IAMOAuth2ScopeMappings) + svc.OAuth2ScopeRegistry = cfg.OAuth2ScopeRegistry svc.Authorizer = NewAuthorizer( pgClient, cfg.Logger.Named("authorizer"), - svc.OAuth2ScopeSet, + svc.OAuth2ScopeRegistry, ) svc.Authorizer.RegisterPolicySet(IAMPolicySet()) @@ -206,7 +210,7 @@ func NewService( uri.URI(cfg.BaseURL.String()), cfg.Logger.Named("oauth2"), append( - []oauth2.Option{oauth2.WithScopeSet(svc.OAuth2ScopeSet)}, + []oauth2.Option{oauth2.WithRegistry(svc.OAuth2ScopeRegistry)}, cfg.OAuth2ServerOptions..., )..., ) @@ -224,12 +228,12 @@ func NewService( // OAuth2ServerMetadata returns the OIDC discovery document. func (s *Service) OAuth2ServerMetadata(endpoints oauth2.Endpoints) *oauth2.ServerMetadata { - return oauth2.NewMetadata(uri.URI(s.baseURL), endpoints, s.OAuth2ScopeSet.APIScopes()) + return oauth2.NewMetadata(uri.URI(s.baseURL), endpoints, s.OAuth2ScopeRegistry.RegisteredScopes()) } // OAuth2ProtectedResourceMetadata returns the RFC 9728 protected resource metadata document. func (s *Service) OAuth2ProtectedResourceMetadata(resource uri.URI) *oauth2.ProtectedResourceMetadata { - return oauth2.NewProtectedResourceMetadata(resource, resource, s.OAuth2ScopeSet.APIScopes()) + return oauth2.NewProtectedResourceMetadata(resource, resource, s.OAuth2ScopeRegistry.RegisteredScopes()) } func (s *Service) IsSignUpEnabled() bool { diff --git a/pkg/probo/service.go b/pkg/probo/service.go index a93084249..8a82f3a7d 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -149,7 +149,6 @@ func NewService( } iamService.Authorizer.RegisterPolicySet(ProboPolicySet()) - iamService.OAuth2ScopeSet.Register(OAuth2ScopeMappings) svc := &Service{ pg: pgClient, diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 308f5d93f..1aa95a3b5 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -61,6 +61,7 @@ import ( "go.probo.inc/probo/pkg/html2pdf" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam/oauth2" + "go.probo.inc/probo/pkg/iam/oauth2scope" "go.probo.inc/probo/pkg/iam/oidc" "go.probo.inc/probo/pkg/mailer" "go.probo.inc/probo/pkg/mailman" @@ -453,6 +454,12 @@ func (impl *Implm) Run( } } + oauth2ScopeRegistry := oauth2scope.NewRegistry(). + Register(iam.IAMOAuth2ScopeMappings). + Register(probo.OAuth2ScopeMappings). + Register(agentrun.OAuth2ScopeMappings). + Register(accessreview.OAuth2ScopeMappings) + iamService, err := iam.NewService( ctx, pgClient, @@ -490,6 +497,7 @@ func (impl *Implm) Run( }, OAuth2ServerSigningKeys: oauth2SigningKeys, OAuth2ServerOptions: oauth2ServerOptions(impl.cfg.Auth.OAuth2Server), + OAuth2ScopeRegistry: oauth2ScopeRegistry, }, ) if err != nil { @@ -601,8 +609,6 @@ func (impl *Implm) Run( iamService.Authorizer.RegisterPolicySet(agentrun.PolicySet()) iamService.Authorizer.RegisterPolicySet(accessreview.PolicySet()) - iamService.OAuth2ScopeSet.Register(agentrun.OAuth2ScopeMappings) - iamService.OAuth2ScopeSet.Register(accessreview.OAuth2ScopeMappings) thirdPartyService := thirdparty.NewService(pgClient, fileManagerService, thirdPartyVetter) riskManagementService := riskmanagement.NewService(pgClient) diff --git a/pkg/server/api/connect/v1/base_resolvers.go b/pkg/server/api/connect/v1/base_resolvers.go index 092999ca1..3644e8ba1 100644 --- a/pkg/server/api/connect/v1/base_resolvers.go +++ b/pkg/server/api/connect/v1/base_resolvers.go @@ -267,15 +267,8 @@ func (r *queryResolver) SignUpEnabled(ctx context.Context) (bool, error) { } // Oauth2ScopesSupported is the resolver for the oauth2ScopesSupported field. -func (r *queryResolver) Oauth2ScopesSupported(ctx context.Context) ([]string, error) { - apiScopes := r.iam.OAuth2ScopeSet.APIScopes() - - scopes := make([]string, len(apiScopes)) - for i, scope := range apiScopes { - scopes[i] = scope.String() - } - - return scopes, nil +func (r *queryResolver) Oauth2ScopesSupported(ctx context.Context) ([]coredata.OAuth2Scope, error) { + return r.scopeRegistry.RegisteredScopes(), nil } // Mutation returns schema.MutationResolver implementation. diff --git a/pkg/server/api/connect/v1/gqlgen.yaml b/pkg/server/api/connect/v1/gqlgen.yaml index a42f27ca2..06b3872bb 100644 --- a/pkg/server/api/connect/v1/gqlgen.yaml +++ b/pkg/server/api/connect/v1/gqlgen.yaml @@ -43,4 +43,7 @@ models: - "go.probo.inc/probo/pkg/server/gqlutils/types/bigint.BigIntScalar" EmailAddr: model: - - "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar" \ No newline at end of file + - "go.probo.inc/probo/pkg/server/gqlutils/types/mail.AddrScalar" + OAuth2Scope: + model: + - go.probo.inc/probo/pkg/server/gqlutils/types/oauth2scope.OAuth2ScopeScalar \ No newline at end of file diff --git a/pkg/server/api/connect/v1/graphql/base.graphql b/pkg/server/api/connect/v1/graphql/base.graphql index b4deb6afd..254a2ebaf 100644 --- a/pkg/server/api/connect/v1/graphql/base.graphql +++ b/pkg/server/api/connect/v1/graphql/base.graphql @@ -16,6 +16,7 @@ scalar CursorKey scalar Datetime scalar Upload scalar EmailAddr +scalar OAuth2Scope interface Node { id: ID! @@ -33,7 +34,7 @@ type Query { signUpEnabled: Boolean! @goField(forceResolver: true) @authentication(required: OPTIONAL) - oauth2ScopesSupported: [String!]! + oauth2ScopesSupported: [OAuth2Scope!]! @goField(forceResolver: true) @authentication(required: OPTIONAL) } diff --git a/pkg/server/api/connect/v1/graphql_handler.go b/pkg/server/api/connect/v1/graphql_handler.go index 778c67b8c..e27d43d8c 100644 --- a/pkg/server/api/connect/v1/graphql_handler.go +++ b/pkg/server/api/connect/v1/graphql_handler.go @@ -37,6 +37,7 @@ func NewGraphQLHandler(svc *iam.Service, logger *log.Logger, fileManagerSvc *fil batchAuthorize: authz.NewBatchAuthorizeFunc(svc, logger), logger: logger, iam: svc, + scopeRegistry: svc.OAuth2ScopeRegistry, fileManager: fileManagerSvc, baseURL: baseURL, sessionCookie: authn.NewCookie(&cookieConfig), diff --git a/pkg/server/api/connect/v1/oauth2_access_token_resolvers.go b/pkg/server/api/connect/v1/oauth2_access_token_resolvers.go index 1647367dc..f1c81970b 100644 --- a/pkg/server/api/connect/v1/oauth2_access_token_resolvers.go +++ b/pkg/server/api/connect/v1/oauth2_access_token_resolvers.go @@ -36,11 +36,10 @@ func (r *mutationResolver) CreateOAuth2AccessToken(ctx context.Context, input ty tokenValue, accessToken, err := r.iam.OAuth2ServerService.CreateManualAccessToken( ctx, &oauth2.CreateManualAccessTokenRequest{ - IdentityID: identity.ID, - Name: strings.TrimSpace(input.Name), - ExpiresAt: input.ExpiresAt, - Scopes: scopes, - AllowedAPIScopes: r.iam.OAuth2ScopeSet.APIScopes(), + IdentityID: identity.ID, + Name: strings.TrimSpace(input.Name), + ExpiresAt: input.ExpiresAt, + Scopes: scopes, }, ) if err != nil { diff --git a/pkg/server/api/connect/v1/resolver.go b/pkg/server/api/connect/v1/resolver.go index 7d1170abb..5e04aa587 100644 --- a/pkg/server/api/connect/v1/resolver.go +++ b/pkg/server/api/connect/v1/resolver.go @@ -40,6 +40,7 @@ import ( "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/iam/oauth2scope" "go.probo.inc/probo/pkg/saferedirect" "go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/server/api/authn" @@ -53,6 +54,7 @@ type ( batchAuthorize authz.BatchAuthorizeFunc logger *log.Logger iam *iam.Service + scopeRegistry *oauth2scope.Registry fileManager *filemanager.Service baseURL *baseurl.BaseURL sessionCookie *authn.Cookie diff --git a/pkg/iam/scope_set_test.go b/pkg/server/gqlutils/types/oauth2scope/scope.go similarity index 54% rename from pkg/iam/scope_set_test.go rename to pkg/server/gqlutils/types/oauth2scope/scope.go index 1a9595dcd..5444c6dbf 100644 --- a/pkg/iam/scope_set_test.go +++ b/pkg/server/gqlutils/types/oauth2scope/scope.go @@ -12,33 +12,32 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package iam +package oauth2scope import ( - "testing" + "fmt" + "io" + "strconv" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/99designs/gqlgen/graphql" "go.probo.inc/probo/pkg/coredata" - "go.probo.inc/probo/pkg/iam/scopeset" ) -func TestAuthorizer_UsesOAuth2ScopeSet(t *testing.T) { - t.Parallel() +type OAuth2ScopeScalar = coredata.OAuth2Scope - const scopeV1OrgRead = coredata.OAuth2Scope("v1:org:read") - - scopeSet := scopeset.New().Register( - map[coredata.OAuth2Scope][]string{ - scopeV1OrgRead: {"core:organization:get"}, +func MarshalOAuth2ScopeScalar(s OAuth2ScopeScalar) graphql.Marshaler { + return graphql.WriterFunc( + func(w io.Writer) { + _, _ = w.Write([]byte(strconv.Quote(s.String()))) }, ) - - authorizer := NewAuthorizer(nil, nil, scopeSet) - - require.NotNil(t, authorizer.oauth2ScopeSet) - - tokenScopes := coredata.OAuth2Scopes{scopeV1OrgRead} - assert.True(t, authorizer.oauth2ScopeSet.Allows(tokenScopes, "core:organization:get")) - assert.False(t, authorizer.oauth2ScopeSet.Allows(tokenScopes, "core:organization:update")) +} + +func UnmarshalOAuth2ScopeScalar(v any) (OAuth2ScopeScalar, error) { + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("oauth2 scope must be a string") + } + + return OAuth2ScopeScalar(s), nil } diff --git a/relay.config.json b/relay.config.json index 96788f962..9167b07ba 100644 --- a/relay.config.json +++ b/relay.config.json @@ -36,7 +36,8 @@ "CursorKey": "string", "Duration": "string", "BigInt": "number", - "EmailAddr": "string" + "EmailAddr": "string", + "OAuth2Scope": "string" } }, "trust": {