diff --git a/lib/scopes/cursor.go b/lib/scopes/cursor.go
new file mode 100644
index 00000000000..7d700fe759d
--- /dev/null
+++ b/lib/scopes/cursor.go
@@ -0,0 +1,110 @@
+// Teleport
+// Copyright (C) 2026 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package scopes
+
+import (
+ "strings"
+
+ "github.com/gravitational/trace"
+)
+
+// ResourceCursorPrefix prefixes cursors for scoped resources in a
+// logical resource stream.
+//
+// The prefix starts with '~', which is not allowed in backend-safe resource
+// names and sorts after all backend-safe name bytes, preserving historical
+// name-only cursors for unscoped resources while ordering scoped resources
+// after unscoped resources.
+const ResourceCursorPrefix = "~scoped/"
+
+// ResourceCursorScopedStart returns the first cursor in the scoped portion of
+// the logical resource stream.
+func ResourceCursorScopedStart() string {
+ return ResourceCursorPrefix
+}
+
+// IsScopedResourceCursor returns true if cursor is in the scoped portion of the
+// logical resource stream.
+func IsScopedResourceCursor(cursor string) bool {
+ return strings.HasPrefix(cursor, ResourceCursorPrefix)
+}
+
+// MakeResourceCursor returns the cursor for a scoped or unscoped resource in a
+// logical, lexicographically ordered resource stream.
+//
+// Resource cursors are intended for pagination tokens, range bounds, and
+// in-memory cache indexes. They are not backend storage keys and must not be
+// used to construct backend keys.
+//
+// Unscoped resource cursors preserve the historical name-only format:
+//
+//
+//
+// Scoped resource cursors use a synthetic prefix that cannot appear in
+// backend-safe resource names and sorts after all backend-safe name bytes:
+//
+// ~scoped//
+//
+// The scope component is encoded with EncodeForKey so that scoped cursors
+// preserve scope ordering and can safely use '/' as the cursor separator.
+func MakeResourceCursor(scope, name string) (string, error) {
+ if scope == "" {
+ return name, nil
+ }
+
+ encodedScope, err := EncodeForKey(scope)
+ if err != nil {
+ return "", trace.Wrap(err)
+ }
+
+ return ResourceCursorPrefix + encodedScope + separator + name, nil
+}
+
+// ParseResourceCursor parses a resource cursor produced by [MakeResourceCursor]
+// into its scope and name components.
+//
+// Unscoped cursors are interpreted as historical name-only cursors. Scoped
+// cursors must use the scoped cursor format:
+//
+// ~scoped//
+func ParseResourceCursor(cursor string) (QualifiedName, error) {
+ encodedScopeAndName, ok := strings.CutPrefix(cursor, ResourceCursorPrefix)
+ if !ok {
+ return QualifiedName{Name: cursor}, nil
+ }
+
+ encodedScope, name, ok := strings.Cut(encodedScopeAndName, separator)
+ if !ok {
+ return QualifiedName{}, trace.BadParameter("scoped resource cursor %q missing name separator", cursor)
+ }
+ if encodedScope == "" {
+ return QualifiedName{}, trace.BadParameter("scoped resource cursor %q has empty encoded scope", cursor)
+ }
+ if name == "" {
+ return QualifiedName{}, trace.BadParameter("scoped resource cursor %q has empty name", cursor)
+ }
+ if strings.Contains(name, separator) {
+ return QualifiedName{}, trace.BadParameter("scoped resource cursor %q has invalid name", cursor)
+ }
+
+ scope, err := DecodeFromKey(encodedScope)
+ if err != nil {
+ return QualifiedName{}, trace.Wrap(err)
+ }
+
+ return QualifiedName{Scope: scope, Name: name}, nil
+}
diff --git a/lib/scopes/cursor_test.go b/lib/scopes/cursor_test.go
new file mode 100644
index 00000000000..e8b53168bda
--- /dev/null
+++ b/lib/scopes/cursor_test.go
@@ -0,0 +1,113 @@
+// Teleport
+// Copyright (C) 2026 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package scopes
+
+import (
+ "slices"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestResourceCursor(t *testing.T) {
+ t.Parallel()
+
+ t.Run("unscoped", func(t *testing.T) {
+ cursor, err := MakeResourceCursor("", "name")
+ require.NoError(t, err)
+ require.Equal(t, "name", cursor)
+ require.False(t, IsScopedResourceCursor(cursor))
+
+ parsed, err := ParseResourceCursor(cursor)
+ require.NoError(t, err)
+ require.Equal(t, QualifiedName{Name: "name"}, parsed)
+ })
+
+ t.Run("scoped", func(t *testing.T) {
+ cursor, err := MakeResourceCursor("/aa/bb", "name")
+ require.NoError(t, err)
+ require.True(t, IsScopedResourceCursor(cursor))
+ require.Contains(t, cursor, ResourceCursorPrefix)
+
+ parsed, err := ParseResourceCursor(cursor)
+ require.NoError(t, err)
+ require.Equal(t, QualifiedName{Scope: "/aa/bb", Name: "name"}, parsed)
+ })
+
+ t.Run("scoped start", func(t *testing.T) {
+ require.Equal(t, ResourceCursorPrefix, ResourceCursorScopedStart())
+ require.True(t, IsScopedResourceCursor(ResourceCursorScopedStart()))
+ })
+}
+
+func TestParseResourceCursorErrors(t *testing.T) {
+ t.Parallel()
+
+ for _, cursor := range []string{
+ ResourceCursorPrefix,
+ ResourceCursorPrefix + "/name",
+ ResourceCursorPrefix + "++/",
+ ResourceCursorPrefix + "++/nested/name",
+ ResourceCursorPrefix + "invalid/name",
+ } {
+ t.Run(cursor, func(t *testing.T) {
+ _, err := ParseResourceCursor(cursor)
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestResourceCursorSort(t *testing.T) {
+ t.Parallel()
+
+ resources := []QualifiedName{
+ {Scope: "/bb", Name: "aaa"},
+ {Scope: "", Name: "zzz"},
+ {Scope: "/aa", Name: "bbb"},
+ {Scope: "/aa/bb", Name: "aaa"},
+ {Scope: "", Name: "aaa"},
+ {Scope: "/aa", Name: "aaa"},
+ }
+
+ cursors := make([]string, 0, len(resources))
+ for _, resource := range resources {
+ cursor, err := MakeResourceCursor(resource.Scope, resource.Name)
+ require.NoError(t, err)
+ cursors = append(cursors, cursor)
+ }
+
+ slices.Sort(cursors)
+
+ var got []QualifiedName
+ for _, cursor := range cursors {
+ resource, err := ParseResourceCursor(cursor)
+ require.NoError(t, err)
+ got = append(got, resource)
+ }
+
+ require.Equal(t, []QualifiedName{
+ // Unscoped cursors preserve historical name-only ordering and sort before
+ // all scoped cursors.
+ {Scope: "", Name: "aaa"},
+ {Scope: "", Name: "zzz"},
+ // Scoped cursors sort by encoded scope, then name within the same scope.
+ {Scope: "/aa", Name: "aaa"},
+ {Scope: "/aa", Name: "bbb"},
+ {Scope: "/aa/bb", Name: "aaa"},
+ {Scope: "/bb", Name: "aaa"},
+ }, got)
+}
diff --git a/lib/scopes/qualified.go b/lib/scopes/qualified.go
index 7162e188455..2905859ade1 100644
--- a/lib/scopes/qualified.go
+++ b/lib/scopes/qualified.go
@@ -25,7 +25,7 @@ import (
)
// QualifiedNameSeparator is the separator between scope and name in a
-// scope-qualified name. This separator must never appear in scope segements.
+// scope-qualified name. This separator must never appear in scope segments.
const QualifiedNameSeparator = "::"
// QualifiedName pairs a scope with a resource name to uniquely identify a scoped
@@ -35,6 +35,10 @@ const QualifiedNameSeparator = "::"
// is necessary to fully specify the unique identifier of a scoped resource. Internally,
// teleport APIs should generally continue to use separate scope and name fields, as
// should structured logs/events.
+//
+// A QualifiedName may be used in APIs that need to refer to a resource that
+// may be scoped or unscoped. In these cases the Scope field may be empty, and
+// WeakValidate and StrongValidate will return an error.
type QualifiedName struct {
// Scope is the resource's scope path, e.g. "/staging/west".
Scope string
@@ -42,8 +46,12 @@ type QualifiedName struct {
Name string
}
-// String returns encodes the string representation of the QualifiedName.
+// String returns the string representation of the QualifiedName.
+// If the Scope is empty, the Name is returned verbatim.
func (q QualifiedName) String() string {
+ if q.Scope == "" {
+ return q.Name
+ }
return q.Scope + QualifiedNameSeparator + q.Name
}
diff --git a/lib/services/local/generic/generic.go b/lib/services/local/generic/generic.go
index e4380bc4034..f7545ec2b80 100644
--- a/lib/services/local/generic/generic.go
+++ b/lib/services/local/generic/generic.go
@@ -252,17 +252,19 @@ func (s *Service[T]) Resources(ctx context.Context, startKey, endKey string) ite
return resource, true
}
- return stream.TakeWhile(
- stream.FilterMap(s.backend.Items(ctx, params), mapFn),
- func(r T) bool {
- // We promise the consumers of this function that the returned
- // results an exclusive of the endkey but the underlying Items
- // method returns us a stream that's inclusive of the end key - so
- // if the user has provided us an end-key, we manually filter them
- // out to convert from inclusive to exclusive.
- return endKey == "" || r.GetName() < endKey
- },
- )
+ items := s.backend.Items(ctx, params)
+ if endKey != "" {
+ exclusiveEndKey := s.backendPrefix.AppendKey(backend.KeyFromString(endKey))
+ items = stream.TakeWhile(items, func(item backend.Item) bool {
+ // We promise consumers that the returned results are exclusive of
+ // endKey, but the underlying Items method returns an inclusive end
+ // key. Compare the full backend key so composite relative keys such
+ // as "/" are handled correctly.
+ return item.Key.Compare(exclusiveEndKey) < 0
+ })
+ }
+
+ return stream.FilterMap(items, mapFn)
}
// ListResources returns a paginated list of resources.
diff --git a/lib/services/local/generic/generic_test.go b/lib/services/local/generic/generic_test.go
index 28bfa5fb55d..9ce62730cab 100644
--- a/lib/services/local/generic/generic_test.go
+++ b/lib/services/local/generic/generic_test.go
@@ -45,7 +45,12 @@ import (
// testResource for testing the generic service.
type testResource struct {
types.ResourceHeader
- Spec testResourceSpec
+ Spec testResourceSpec
+ Scope string
+}
+
+func (r *testResource) GetScope() string {
+ return r.Scope
}
func newTestResource(name string) *testResource {
diff --git a/lib/services/local/generic/scopeaware.go b/lib/services/local/generic/scopeaware.go
new file mode 100644
index 00000000000..65c9c0a52bf
--- /dev/null
+++ b/lib/services/local/generic/scopeaware.go
@@ -0,0 +1,324 @@
+// Teleport
+// Copyright (C) 2026 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package generic
+
+import (
+ "context"
+ "iter"
+ "strings"
+ "time"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/backend"
+ "github.com/gravitational/teleport/lib/itertools/stream"
+ "github.com/gravitational/teleport/lib/scopes"
+)
+
+// ScopedResource is a resource type that has a scope. The scope may be empty
+// on any individual resource of the type, that resource may be referred to as
+// "unscoped" despite the type being scoped.
+type ScopedResource interface {
+ Resource
+ // GetScope returns the scope of the resource.
+ GetScope() string
+}
+
+// ScopeAwareService is a generic service for interacting with namespaced
+// scoped resources in the backend. Scoped resources will be stored in a
+// separate key range from resources with an empty scope, and namespaced by
+// their scope. The ScopeAwareService transparently handles listing all scoped
+// and unscoped resources, as well as creating and querying individual resources
+// from the correct key range.
+type ScopeAwareService[T ScopedResource] struct {
+ // UnscopedService is the underlying service for resources with an empty scope.
+ // Resources will be keyed at /
+ UnscopedService *Service[T]
+ // ScopedService is the underlying service for resources with a scope.
+ // Resources will be keyed at ///
+ ScopedService *Service[T]
+}
+
+// ScopeAwareServiceConfig holds configuration options for ScopeAwareService.
+type ScopeAwareServiceConfig[T ScopedResource] struct {
+ // Backend used to persist the resource.
+ Backend backend.Backend
+ // ResourceKind is the friendly name of the resource.
+ ResourceKind string
+ // UnscopedBackendPrefix used when constructing the [backend.Item.Key] for unscoped resources.
+ UnscopedBackendPrefix backend.Key
+ // ScopedBackendPrefix used when constructing the [backend.Item.Key] for scoped resources.
+ ScopedBackendPrefix backend.Key
+ // PageLimit
+ PageLimit uint
+ // MarshlFunc converts the resource to bytes for persistence.
+ MarshalFunc MarshalFunc[T]
+ // UnmarshalFunc converts the bytes read from the backend to the resource.
+ UnmarshalFunc UnmarshalFunc[T]
+ // ValidateFunc optionally validates the resource prior to persisting it. Any errors
+ // returned from the validation function will prevent writes to the backend.
+ ValidateFunc func(T) error
+ // RunWhileLockedRetryInterval is the interval to retry the RunWhileLocked function.
+ // If set to 0, the default interval of 250ms will be used.
+ // WARNING: If set to a negative value, the RunWhileLocked function will retry immediately.
+ RunWhileLockedRetryInterval time.Duration
+}
+
+// NewScopeAwareService returns a new scope-aware service.
+func NewScopeAwareService[T ScopedResource](cfg *ScopeAwareServiceConfig[T]) (*ScopeAwareService[T], error) {
+ unscopedService, err := NewService(&ServiceConfig[T]{
+ Backend: cfg.Backend,
+ ResourceKind: cfg.ResourceKind,
+ PageLimit: cfg.PageLimit,
+ BackendPrefix: cfg.UnscopedBackendPrefix,
+ MarshalFunc: cfg.MarshalFunc,
+ UnmarshalFunc: cfg.UnmarshalFunc,
+ ValidateFunc: cfg.ValidateFunc,
+ RunWhileLockedRetryInterval: cfg.RunWhileLockedRetryInterval,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ scopedService, err := NewService(&ServiceConfig[T]{
+ Backend: cfg.Backend,
+ ResourceKind: cfg.ResourceKind,
+ PageLimit: cfg.PageLimit,
+ BackendPrefix: cfg.ScopedBackendPrefix,
+ MarshalFunc: cfg.MarshalFunc,
+ UnmarshalFunc: cfg.UnmarshalFunc,
+ ValidateFunc: cfg.ValidateFunc,
+ RunWhileLockedRetryInterval: cfg.RunWhileLockedRetryInterval,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return &ScopeAwareService[T]{
+ UnscopedService: unscopedService,
+ ScopedService: scopedService,
+ }, nil
+}
+
+// Resources returns a stream of resources within the unified scope-aware range
+// [startKey, endKey). Unscoped resources are ordered before scoped resources.
+//
+// The startKey and endKey values are resource cursors as defined by
+// [scopes.MakeResourceCursor]. An unscoped cursor is the resource name. A scoped
+// cursor uses [scopes.ResourceCursorPrefix] followed by the scoped backend
+// service's relative key. [scopes.ResourceCursorScopedStart] is the boundary
+// between unscoped and scoped resources.
+func (s *ScopeAwareService[T]) Resources(ctx context.Context, startKey, endKey string) iter.Seq2[T, error] {
+ var streams []stream.Stream[T]
+
+ if !scopes.IsScopedResourceCursor(startKey) {
+ unscopedEndKey := endKey
+ if scopes.IsScopedResourceCursor(endKey) {
+ unscopedEndKey = ""
+ }
+ streams = append(streams, s.UnscopedService.Resources(ctx, startKey, unscopedEndKey))
+ }
+
+ if endKey != "" && !scopes.IsScopedResourceCursor(endKey) {
+ return stream.Chain(streams...)
+ }
+ if endKey == scopes.ResourceCursorScopedStart() {
+ return stream.Chain(streams...)
+ }
+
+ scopedStartKey := ""
+ if scopes.IsScopedResourceCursor(startKey) {
+ scopedStartKey = strings.TrimPrefix(startKey, scopes.ResourceCursorPrefix)
+ }
+ scopedEndKey := strings.TrimPrefix(endKey, scopes.ResourceCursorPrefix)
+ streams = append(streams, s.ScopedService.Resources(ctx, scopedStartKey, scopedEndKey))
+
+ return stream.Chain(streams...)
+}
+
+// GetResources returns all unscoped and scoped resources.
+func (s *ScopeAwareService[T]) GetResources(ctx context.Context) ([]T, error) {
+ return stream.Collect(s.Resources(ctx, "", ""))
+}
+
+// ListResources returns a page of resources over the unified scoped
+// and unscoped collection. It always returns all unscoped resources before
+// matching scoped resources.
+func (s *ScopeAwareService[T]) ListResources(ctx context.Context, pageSize int, nextToken string) ([]T, string, error) {
+ return s.ListResourcesWithFilter(ctx, pageSize, nextToken, func(T) bool { return true })
+}
+
+// ListResourcesWithFilter returns a page of matching resources over the
+// unified scoped and unscoped collection. It always returns all matching
+// unscoped resources before matching scoped resources.
+func (s *ScopeAwareService[T]) ListResourcesWithFilter(ctx context.Context, pageSize int, nextToken string, matcher func(T) bool) ([]T, string, error) {
+ if pageSize <= 0 || pageSize > int(s.UnscopedService.pageLimit) {
+ pageSize = int(s.UnscopedService.pageLimit)
+ }
+
+ // Check if the token was scoped, if so the caller has already paged over
+ // all unscoped resources and we should return the next page of scoped
+ // resources.
+ if scopes.IsScopedResourceCursor(nextToken) {
+ nextToken = strings.TrimPrefix(nextToken, scopes.ResourceCursorPrefix)
+ resources, nextToken, err := s.ScopedService.ListResourcesWithFilter(ctx, pageSize, nextToken, matcher)
+ if nextToken != "" {
+ nextToken = scopes.ResourceCursorPrefix + nextToken
+ }
+ return resources, nextToken, trace.Wrap(err)
+ }
+
+ // Fetch the next page of matching unscoped resources.
+ resources, nextToken, err := s.UnscopedService.ListResourcesWithFilter(ctx, pageSize, nextToken, matcher)
+ if err != nil {
+ return nil, "", trace.Wrap(err)
+ }
+
+ if nextToken != "" {
+ // There are remaining unscoped resources, return this page.
+ return resources, nextToken, nil
+ }
+ if len(resources) >= pageSize {
+ // The page is full but nextToken is empty indicating there are no more
+ // unscoped resources. Return with scopedPageTokenPrefix so the next
+ // page begins with scoped resources.
+ return resources, scopes.ResourceCursorScopedStart(), nil
+ }
+
+ // Reached the end of unscoped resources within pageSize, try to fill in
+ // the page with scoped resources.
+ remainingPageSize := pageSize - len(resources)
+ scopedResources, nextToken, err := s.ScopedService.ListResourcesWithFilter(ctx, remainingPageSize, "", matcher)
+ if nextToken != "" {
+ nextToken = scopes.ResourceCursorPrefix + nextToken
+ }
+ return append(resources, scopedResources...), nextToken, trace.Wrap(err)
+}
+
+// GetResource returns a resource, if it exists, for the given scope-qualified name.
+// If the scope is empty, it returns an unscoped resource from the unscoped key range.
+// If the scope is non-empty, it returns a scoped resource from the scoped key range.
+func (s *ScopeAwareService[T]) GetResource(ctx context.Context, scopedName scopes.QualifiedName) (T, error) {
+ svc, err := s.WithScopePrefix(scopedName.Scope)
+ if err != nil {
+ var nul T
+ return nul, trace.Wrap(err)
+ }
+ return svc.GetResource(ctx, scopedName.Name)
+}
+
+// DeleteResource deletes a resource for the given scope-qualified name.
+// If the scope is empty, it deletes an unscoped resource from the unscoped key range.
+// If the scope is non-empty, it deletes a scoped resource from the scoped key range.
+func (s *ScopeAwareService[T]) DeleteResource(ctx context.Context, scopedName scopes.QualifiedName) error {
+ svc, err := s.WithScopePrefix(scopedName.Scope)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ return svc.DeleteResource(ctx, scopedName.Name)
+}
+
+// DeleteAllResources deletes all scoped and unscoped resources.
+func (s *ScopeAwareService[T]) DeleteAllResources(ctx context.Context) error {
+ return trace.NewAggregate(
+ s.UnscopedService.DeleteAllResources(ctx),
+ s.ScopedService.DeleteAllResources(ctx),
+ )
+}
+
+// CreateResource creates the given scoped resource if it doesn't already
+// exist. If the scope is empty, it will be inserted in the unscoped key range,
+// else it will be inserted in the scoped key range.
+func (s *ScopeAwareService[T]) CreateResource(ctx context.Context, resource T) (T, error) {
+ svc, err := s.WithScopePrefix(resource.GetScope())
+ if err != nil {
+ var nul T
+ return nul, trace.Wrap(err)
+ }
+ return svc.CreateResource(ctx, resource)
+}
+
+// UpsertResource upserts the given scoped resource. If the scope is empty, it
+// will be inserted in the unscoped key range, else it will be inserted in the
+// scoped key range.
+func (s *ScopeAwareService[T]) UpsertResource(ctx context.Context, resource T) (T, error) {
+ svc, err := s.WithScopePrefix(resource.GetScope())
+ if err != nil {
+ var nul T
+ return nul, trace.Wrap(err)
+ }
+ return svc.UpsertResource(ctx, resource)
+}
+
+// UpdateResource updates the given scoped resource. If the scope is empty, it
+// will be updated in the unscoped key range, else it will be updated in the
+// scoped key range.
+func (s *ScopeAwareService[T]) UpdateResource(ctx context.Context, resource T) (T, error) {
+ svc, err := s.WithScopePrefix(resource.GetScope())
+ if err != nil {
+ var nul T
+ return nul, trace.Wrap(err)
+ }
+ return svc.UpdateResource(ctx, resource)
+}
+
+// ConditionalUpdateResource updates the given scoped resource if the revision
+// matches. If the scope is empty, it will be updated in the unscoped key
+// range, else it will be updated in the scoped key range.
+func (s *ScopeAwareService[T]) ConditionalUpdateResource(ctx context.Context, resource T) (T, error) {
+ svc, err := s.WithScopePrefix(resource.GetScope())
+ if err != nil {
+ var nul T
+ return nul, trace.Wrap(err)
+ }
+ return svc.ConditionalUpdateResource(ctx, resource)
+}
+
+// WithScopePrefix returns the unscoped service when scope is empty, otherwise
+// returns the scoped service with the encoded scope appended to its backend prefix.
+func (s *ScopeAwareService[T]) WithScopePrefix(scope string) (*Service[T], error) {
+ if scope == "" {
+ return s.UnscopedService, nil
+ }
+ encodedScope, err := scopes.EncodeForKey(scope)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return s.ScopedService.WithPrefix(encodedScope), nil
+}
+
+// WithScopedResourcePrefix returns a [*Service] with a prefix for the given
+// scope-qualified name appended to the backend prefix.
+//
+// If the given scope is empty, it will return the UnscopedService with the
+// given name as an added prefix.
+//
+// If the given scope is non-empty, it will return the ScopedService with the
+// encoded scope and the name as an added prefix.
+//
+// This may be appropriate for dependent resources keyed by a unique scoped
+// resource, i.e. members of a scoped access list.
+func (s *ScopeAwareService[T]) WithScopedResourcePrefix(scopedName scopes.QualifiedName) (*Service[T], error) {
+ if scopedName.Scope == "" {
+ return s.UnscopedService.WithPrefix(scopedName.Name), nil
+ }
+ encodedScope, err := scopes.EncodeForKey(scopedName.Scope)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return s.ScopedService.WithPrefix(encodedScope, scopedName.Name), nil
+}
diff --git a/lib/services/local/generic/scopeaware_test.go b/lib/services/local/generic/scopeaware_test.go
new file mode 100644
index 00000000000..929454b0b94
--- /dev/null
+++ b/lib/services/local/generic/scopeaware_test.go
@@ -0,0 +1,265 @@
+// Teleport
+// Copyright (C) 2026 Gravitational, Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package generic
+
+import (
+ "context"
+ "fmt"
+ "iter"
+ "maps"
+ "strings"
+ "testing"
+
+ "github.com/jonboulle/clockwork"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/api/utils/clientutils"
+ "github.com/gravitational/teleport/lib/backend"
+ "github.com/gravitational/teleport/lib/backend/memory"
+ "github.com/gravitational/teleport/lib/scopes"
+)
+
+func newScopedTestResource(sqn scopes.QualifiedName) *testResource {
+ r := newTestResource(sqn.Name)
+ r.Scope = sqn.Scope
+ return r
+}
+
+func TestScopeAwareService(t *testing.T) {
+ memBackend, err := memory.New(memory.Config{
+ Context: t.Context(),
+ Clock: clockwork.NewFakeClock(),
+ })
+ require.NoError(t, err)
+
+ service, err := NewScopeAwareService(&ScopeAwareServiceConfig[*testResource]{
+ Backend: memBackend,
+ ResourceKind: "generic resource",
+ UnscopedBackendPrefix: backend.NewKey("generic_prefix"),
+ ScopedBackendPrefix: backend.NewKey("scoped", "generic_prefix"),
+ PageLimit: 200,
+ UnmarshalFunc: unmarshalResource,
+ MarshalFunc: marshalResource,
+ })
+ require.NoError(t, err)
+
+ var resources []*testResource
+
+ // Create some unscoped resources.
+ const numUnscopedResources = 10
+ for nameIndex := range numUnscopedResources {
+ resources = append(resources, newTestResource(fmt.Sprintf("name%d", nameIndex)))
+ }
+
+ // Add a bunch of scoped resources at various scopes.
+ for baseScopeIndex := range 10 {
+ // Add ten at this base scope.
+ for nameIndex := range 10 {
+ resources = append(resources, newScopedTestResource(scopes.QualifiedName{
+ Scope: fmt.Sprintf("/base%d", baseScopeIndex),
+ Name: fmt.Sprintf("name%d", nameIndex),
+ }))
+ }
+
+ // Add ten more at each of 10 sub scopes.
+ for subScopeIndex := range 10 {
+ for nameIndex := range 10 {
+ resources = append(resources, newScopedTestResource(scopes.QualifiedName{
+ Scope: fmt.Sprintf("/base%d/sub%d", baseScopeIndex, subScopeIndex),
+ Name: fmt.Sprintf("name%d", nameIndex),
+ }))
+ }
+ }
+ }
+
+ expectedResourceNames := make(map[scopes.QualifiedName]struct{}, len(resources))
+ for _, resource := range resources {
+ expectedResourceNames[scopes.QualifiedName{
+ Scope: resource.GetScope(),
+ Name: resource.GetName(),
+ }] = struct{}{}
+ }
+
+ checkExpectedResources := func(t *testing.T, expectedResourceNames map[scopes.QualifiedName]struct{}, resources iter.Seq2[*testResource, error]) {
+ t.Helper()
+
+ expected := maps.Clone(expectedResourceNames)
+
+ for resource, err := range resources {
+ require.NoError(t, err)
+
+ key := scopes.QualifiedName{
+ Scope: resource.GetScope(),
+ Name: resource.GetName(),
+ }
+
+ assert.Contains(t, expected, key, "found unexpected resource %v", key)
+ delete(expected, key)
+ }
+ assert.Empty(t, expected, "did not find expected resources")
+ }
+
+ expectedResourcesInCursorRange := func(t *testing.T, startKey, endKey string) map[scopes.QualifiedName]struct{} {
+ t.Helper()
+
+ expected := make(map[scopes.QualifiedName]struct{})
+ for _, resource := range resources {
+ cursor, err := scopes.MakeResourceCursor(resource.GetScope(), resource.GetName())
+ require.NoError(t, err)
+ if startKey != "" && cursor < startKey {
+ continue
+ }
+ if endKey != "" && cursor >= endKey {
+ continue
+ }
+ expected[scopes.QualifiedName{Scope: resource.GetScope(), Name: resource.GetName()}] = struct{}{}
+ }
+ return expected
+ }
+
+ // Create all the resources.
+ for _, resource := range resources {
+ _, err := service.CreateResource(t.Context(), resource)
+ require.NoError(t, err)
+ }
+
+ t.Run("Resources", func(t *testing.T) {
+ checkExpectedResources(t, expectedResourceNames, service.Resources(t.Context(), "", ""))
+
+ foundScoped := false
+ for resource, err := range service.Resources(t.Context(), "", "") {
+ require.NoError(t, err)
+ if foundScoped && resource.GetScope() == "" {
+ t.Fatal("found unscoped resource after scoped resource")
+ }
+ foundScoped = foundScoped || resource.GetScope() != ""
+ }
+
+ countResources := func(resources iter.Seq2[*testResource, error]) int {
+ t.Helper()
+ var count int
+ for _, err := range resources {
+ require.NoError(t, err)
+ count++
+ }
+ return count
+ }
+
+ // The scoped start cursor is the boundary between unscoped resources and
+ // scoped resources in the unified logical resource stream.
+ require.Equal(t, numUnscopedResources,
+ countResources(service.Resources(t.Context(), "", scopes.ResourceCursorScopedStart())))
+ require.Equal(t, len(resources)-numUnscopedResources,
+ countResources(service.Resources(t.Context(), scopes.ResourceCursorScopedStart(), "")))
+
+ // Verify a range that starts in unscoped resources and ends in scoped
+ // resources, forcing Resources to concatenate both underlying services.
+ scopedEnd, err := scopes.MakeResourceCursor("/base1", "name0")
+ require.NoError(t, err)
+ checkExpectedResources(t,
+ expectedResourcesInCursorRange(t, "name5", scopedEnd),
+ service.Resources(t.Context(), "name5", scopedEnd),
+ )
+ })
+
+ // Check all resources can be listed, at various page sizes.
+ for _, pageSize := range []int{0, 1, 3, 5, 10, 11, 99, 100, 101} {
+ t.Run(fmt.Sprintf("pageSize=%d", pageSize), func(t *testing.T) {
+ t.Run("unfiltered", func(t *testing.T) {
+ resourceIter := clientutils.ResourcesWithPageSize(t.Context(), service.ListResources, pageSize)
+ checkExpectedResources(t, expectedResourceNames, resourceIter)
+ })
+
+ t.Run("filtered", func(t *testing.T) {
+ iter := clientutils.ResourcesWithPageSize(t.Context(), func(ctx context.Context, pageSize int, nextPageToken string) ([]*testResource, string, error) {
+ return service.ListResourcesWithFilter(ctx, pageSize, nextPageToken, func(resource *testResource) bool {
+ return strings.HasPrefix(resource.GetScope(), "/base7/")
+ })
+ }, pageSize)
+ count := 0
+ for resource, err := range iter {
+ require.NoError(t, err)
+ require.True(t, strings.HasPrefix(resource.GetScope(), "/base7/"))
+ count++
+ }
+ require.Equal(t, 100, count)
+
+ })
+ })
+
+ }
+
+ // Check that unscoped resources sort before scoped resources.
+ foundScoped := false
+ for resource, err := range clientutils.Resources(t.Context(), service.ListResources) {
+ require.NoError(t, err)
+ scope := resource.GetScope()
+ if foundScoped && scope == "" {
+ t.Fatal("found unscoped resource after scoped resource")
+ }
+ foundScoped = foundScoped || scope != ""
+ }
+
+ for resourceName := range expectedResourceNames {
+ // Get should work.
+ resource, err := service.GetResource(t.Context(), resourceName)
+ require.NoError(t, err)
+
+ // Update should work.
+ resource.Spec.PropA = "updated"
+ resource, err = service.UpdateResource(t.Context(), resource)
+ require.NoError(t, err)
+ require.Equal(t, "updated", resource.Spec.PropA)
+
+ // Try ConditionalUpdate with incorrect revision.
+ rev := resource.Metadata.Revision
+ resource.Metadata.Revision = ""
+ _, err = service.ConditionalUpdateResource(t.Context(), resource)
+ require.Error(t, err)
+
+ // ConditionalUpdate with correct revision.
+ resource.Metadata.Revision = rev
+ resource.Spec.PropA = "conditional_updated"
+ resource, err = service.ConditionalUpdateResource(t.Context(), resource)
+ require.NoError(t, err)
+ require.Equal(t, "conditional_updated", resource.Spec.PropA)
+
+ // Delete the resource and Get should fail.
+ err = service.DeleteResource(t.Context(), resourceName)
+ require.NoError(t, err)
+ _, err = service.GetResource(t.Context(), resourceName)
+ require.Error(t, err)
+
+ // Upsert the resource.
+ resource.Spec.PropA = "upserted"
+ resource, err = service.UpsertResource(t.Context(), resource)
+ require.NoError(t, err)
+ require.Equal(t, "upserted", resource.Spec.PropA)
+ }
+
+ // Make sure all the expected resources are still there.
+ resourceIter := clientutils.Resources(t.Context(), service.ListResources)
+ checkExpectedResources(t, expectedResourceNames, resourceIter)
+
+ // Delete all resources and make sure they're gone.
+ err = service.DeleteAllResources(t.Context())
+ require.NoError(t, err)
+ page, _, err := service.ListResources(t.Context(), 1, "")
+ require.NoError(t, err)
+ require.Empty(t, page)
+}