diff --git a/docs/pages/reference/infrastructure-as-code/operator-resources/resources-teleport-dev-scopedroleassignmentsv1.mdx b/docs/pages/reference/infrastructure-as-code/operator-resources/resources-teleport-dev-scopedroleassignmentsv1.mdx
new file mode 100644
index 00000000000..38dcdbb7d29
--- /dev/null
+++ b/docs/pages/reference/infrastructure-as-code/operator-resources/resources-teleport-dev-scopedroleassignmentsv1.mdx
@@ -0,0 +1,44 @@
+---
+title: TeleportScopedRoleAssignmentV1
+description: Provides a comprehensive list of fields in the TeleportScopedRoleAssignmentV1 resource available through the Teleport Kubernetes operator
+---
+
+{/*Auto-generated file. Do not edit.*/}
+{/*To regenerate, navigate to integrations/operator and run "make crd-docs".*/}
+
+{/* Disable the outdated name check since custom resource fields occasionally
+need to refer to these. */}
+{/* vale 3rd-party-products.former-names = NO */}
+
+This guide is a comprehensive reference to the fields in the `TeleportScopedRoleAssignmentV1`
+resource, which you can apply after installing the Teleport Kubernetes operator.
+
+
+## resources.teleport.dev/v1
+
+**apiVersion:** resources.teleport.dev/v1
+
+|Field|Type|Description|
+|---|---|---|
+|apiVersion|string|APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources|
+|kind|string|Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds|
+|metadata|object||
+|scope|string|Scope is the scope of the role assignment resource.|
+|spec|[object](#spec)|ScopedRoleAssignment resource definition v1 from Teleport|
+
+### spec
+
+|Field|Type|Description|
+|---|---|---|
+|assignments|[][object](#specassignments-items)|Assignments is a list of individual role @ scope assignments.|
+|bot_name|string|Name of the Bot to whom all contained assignments apply. Mutually exclusive with `user`.|
+|bot_scope|string|Scope of the Bot to whom all contained assignments apply. Required if `bot_name` is set. If specified, assignment scopes must be equal or descendent of this scope.|
+|user|string|User is the user to whom all contained assignments apply. Mutually exclusive with `bot_name`.|
+
+### spec.assignments items
+
+|Field|Type|Description|
+|---|---|---|
+|role|string|Roles is the name of the role that is assigned by this assignment.|
+|scope|string|Scope is the scope to which the role is assigned. This must be a member/child of the scope of the [ScopedRoleAssignment] in which this assignment is contained.|
+
diff --git a/examples/chart/teleport-cluster/charts/teleport-operator/operator-crds/resources.teleport.dev_scopedroleassignmentsv1.yaml b/examples/chart/teleport-cluster/charts/teleport-operator/operator-crds/resources.teleport.dev_scopedroleassignmentsv1.yaml
new file mode 100644
index 00000000000..c6fb072893c
--- /dev/null
+++ b/examples/chart/teleport-cluster/charts/teleport-operator/operator-crds/resources.teleport.dev_scopedroleassignmentsv1.yaml
@@ -0,0 +1,146 @@
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: teleportscopedroleassignmentsv1.resources.teleport.dev
+spec:
+ group: resources.teleport.dev
+ names:
+ kind: TeleportScopedRoleAssignmentV1
+ listKind: TeleportScopedRoleAssignmentV1List
+ plural: teleportscopedroleassignmentsv1
+ shortNames:
+ - scopedroleassignmentv1
+ - scopedroleassignmentsv1
+ singular: teleportscopedroleassignmentv1
+ scope: Namespaced
+ versions:
+ - name: v1
+ schema:
+ openAPIV3Schema:
+ description: ScopedRoleAssignmentV1 is the Schema for the scopedroleassignmentsv1
+ API
+ properties:
+ apiVersion:
+ description: 'APIVersion defines the versioned schema of this representation
+ of an object. Servers should convert recognized schemas to the latest
+ internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+ type: string
+ kind:
+ description: 'Kind is a string value representing the REST resource this
+ object represents. Servers may infer this from the endpoint the client
+ submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+ type: string
+ metadata:
+ type: object
+ scope:
+ description: Scope is the scope of the role assignment resource.
+ type: string
+ spec:
+ description: ScopedRoleAssignment resource definition v1 from Teleport
+ properties:
+ assignments:
+ description: Assignments is a list of individual role @ scope assignments.
+ items:
+ properties:
+ role:
+ description: Roles is the name of the role that is assigned
+ by this assignment.
+ type: string
+ scope:
+ description: Scope is the scope to which the role is assigned.
+ This must be a member/child of the scope of the [ScopedRoleAssignment]
+ in which this assignment is contained.
+ type: string
+ type: object
+ nullable: true
+ type: array
+ bot_name:
+ description: Name of the Bot to whom all contained assignments apply.
+ Mutually exclusive with `user`.
+ type: string
+ bot_scope:
+ description: Scope of the Bot to whom all contained assignments apply.
+ Required if `bot_name` is set. If specified, assignment scopes must
+ be equal or descendent of this scope.
+ type: string
+ user:
+ description: User is the user to whom all contained assignments apply.
+ Mutually exclusive with `bot_name`.
+ type: string
+ type: object
+ status:
+ description: Status defines the observed state of the Teleport resource
+ properties:
+ conditions:
+ description: Conditions represent the latest available observations
+ of an object's state
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ teleportResourceID:
+ format: int64
+ type: integer
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
+status:
+ acceptedNames:
+ kind: ""
+ plural: ""
+ conditions: null
+ storedVersions: null
diff --git a/examples/chart/teleport-cluster/charts/teleport-operator/templates/role.yaml b/examples/chart/teleport-cluster/charts/teleport-operator/templates/role.yaml
index fb5bc7bbb29..08e250dfda7 100644
--- a/examples/chart/teleport-cluster/charts/teleport-operator/templates/role.yaml
+++ b/examples/chart/teleport-cluster/charts/teleport-operator/templates/role.yaml
@@ -68,6 +68,8 @@ rules:
- teleportscopedtokensv1/status
- teleportscopedrolesv1
- teleportscopedrolesv1/status
+ - teleportscopedroleassignmentsv1
+ - teleportscopedroleassignmentsv1/status
verbs:
- get
- list
diff --git a/examples/chart/teleport-cluster/templates/auth/config.yaml b/examples/chart/teleport-cluster/templates/auth/config.yaml
index bedd59ecc12..2ca7bf928aa 100644
--- a/examples/chart/teleport-cluster/templates/auth/config.yaml
+++ b/examples/chart/teleport-cluster/templates/auth/config.yaml
@@ -260,6 +260,14 @@ data:
- readnosecrets
- update
- delete
+ - resources:
+ - scoped_role_assignment
+ verbs:
+ - list
+ - create
+ - read
+ - update
+ - delete
deny: {}
version: v7
---
diff --git a/integrations/operator/Makefile b/integrations/operator/Makefile
index 57dceb14841..2725ceeb944 100644
--- a/integrations/operator/Makefile
+++ b/integrations/operator/Makefile
@@ -96,7 +96,8 @@ PROTOS = \
teleport/summarizer/v1/summarizer.proto \
teleport/accessmonitoringrules/v1/access_monitoring_rules.proto \
teleport/scopes/joining/v1/token.proto \
- teleport/scopes/access/v1/role.proto
+ teleport/scopes/access/v1/role.proto \
+ teleport/scopes/access/v1/assignment.proto
.PHONY: crd-manifests
crd-manifests: crdgen ## Generates CRD manifests using crdgen.
diff --git a/integrations/operator/apis/resources/v1/scopedroleassignment_types.go b/integrations/operator/apis/resources/v1/scopedroleassignment_types.go
new file mode 100644
index 00000000000..9cd3786c2a8
--- /dev/null
+++ b/integrations/operator/apis/resources/v1/scopedroleassignment_types.go
@@ -0,0 +1,107 @@
+// 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 v1
+
+import (
+ "google.golang.org/protobuf/encoding/protojson"
+ "google.golang.org/protobuf/proto"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
+ accessv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/access/v1"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/integrations/operator/apis/resources/teleportcr"
+ "github.com/gravitational/teleport/lib/scopes/access"
+)
+
+func init() {
+ SchemeBuilder.Register(&TeleportScopedRoleAssignmentV1{})
+}
+
+//+kubebuilder:object:root=true
+//+kubebuilder:subresource:status
+
+// TeleportScopedRoleAssignmentV1 represents a Kubernetes custom resource for
+// Scoped Role Assignments.
+type TeleportScopedRoleAssignmentV1 struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata"`
+
+ Scope string `json:"scope"`
+ Spec *TeleportScopedRoleAssignmentV1Spec `json:"spec,omitempty"`
+ Status teleportcr.Status `json:"status"`
+}
+
+// TeleportScopedRoleAssignmentV1Spec defines the desired state of the Scoped Role Assignment.
+type TeleportScopedRoleAssignmentV1Spec accessv1.ScopedRoleAssignmentSpec
+
+//+kubebuilder:object:root = true
+
+// TeleportScopedRoleAssignmentV1List contains a list of [TeleportScopedRoleAssignmentV1]
+// objects.
+type TeleportScopedRoleAssignmentV1List struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata"`
+ Items []TeleportScopedRoleAssignmentV1 `json:"items"`
+}
+
+// ToTeleport returns a Teleport representation of this Kubernetes resource.
+func (m *TeleportScopedRoleAssignmentV1) ToTeleport() *accessv1.ScopedRoleAssignment {
+ resource := &accessv1.ScopedRoleAssignment{
+ Kind: access.KindScopedRoleAssignment,
+ SubKind: access.SubKindDynamic,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: m.Name,
+ Description: m.Annotations[teleportcr.DescriptionKey],
+ Labels: m.Labels,
+ },
+ Scope: m.Scope,
+ Spec: (*accessv1.ScopedRoleAssignmentSpec)(m.Spec),
+ }
+ return resource
+}
+
+// StatusConditions returns a pointer to Status.Conditions slice. This is used
+// by the Teleport resource controller to report conditions back to resource.
+func (m *TeleportScopedRoleAssignmentV1) StatusConditions() *[]metav1.Condition {
+ return &m.Status.Conditions
+}
+
+// UnmarshalJSON delegates unmarshaling of the TeleportScopedRoleAssignmentV1Spec to
+// protojson, which is necessary for Proto RFD153 resources to be unmarshaled
+// correctly from the unstructured object.
+func (spec *TeleportScopedRoleAssignmentV1Spec) UnmarshalJSON(data []byte) error {
+ return protojson.UnmarshalOptions{
+ DiscardUnknown: true,
+ }.Unmarshal(data, (*accessv1.ScopedRoleAssignmentSpec)(spec))
+}
+
+// MarshalJSON delegates marshaling of the TeleportScopedRoleAssignmentV1Spec to
+// protojson, which is necessary for Proto RFD153 resources to be marshaled
+// correctly into an unstructured object.
+func (spec *TeleportScopedRoleAssignmentV1Spec) MarshalJSON() ([]byte, error) {
+ return protojson.MarshalOptions{
+ UseProtoNames: true,
+ }.Marshal((*accessv1.ScopedRoleAssignmentSpec)(spec))
+}
+
+// DeepCopyInto deep-copies one TeleportScopedRoleAssignmentV1Spec into another.
+// Required to satisfy runtime.Object interface.
+func (spec *TeleportScopedRoleAssignmentV1Spec) DeepCopyInto(out *TeleportScopedRoleAssignmentV1Spec) {
+ proto.Reset((*accessv1.ScopedRoleAssignmentSpec)(out))
+ proto.Merge((*accessv1.ScopedRoleAssignmentSpec)(out), (*accessv1.ScopedRoleAssignmentSpec)(spec))
+}
diff --git a/integrations/operator/apis/resources/v1/zz_generated.deepcopy.go b/integrations/operator/apis/resources/v1/zz_generated.deepcopy.go
index 86590709e60..2e6e447fcbc 100644
--- a/integrations/operator/apis/resources/v1/zz_generated.deepcopy.go
+++ b/integrations/operator/apis/resources/v1/zz_generated.deepcopy.go
@@ -1431,6 +1431,70 @@ func (in *TeleportSAMLIdPServiceProviderV1Spec) DeepCopy() *TeleportSAMLIdPServi
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *TeleportScopedRoleAssignmentV1) DeepCopyInto(out *TeleportScopedRoleAssignmentV1) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ if in.Spec != nil {
+ in, out := &in.Spec, &out.Spec
+ *out = (*in).DeepCopy()
+ }
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TeleportScopedRoleAssignmentV1.
+func (in *TeleportScopedRoleAssignmentV1) DeepCopy() *TeleportScopedRoleAssignmentV1 {
+ if in == nil {
+ return nil
+ }
+ out := new(TeleportScopedRoleAssignmentV1)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *TeleportScopedRoleAssignmentV1) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *TeleportScopedRoleAssignmentV1List) DeepCopyInto(out *TeleportScopedRoleAssignmentV1List) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]TeleportScopedRoleAssignmentV1, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TeleportScopedRoleAssignmentV1List.
+func (in *TeleportScopedRoleAssignmentV1List) DeepCopy() *TeleportScopedRoleAssignmentV1List {
+ if in == nil {
+ return nil
+ }
+ out := new(TeleportScopedRoleAssignmentV1List)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TeleportScopedRoleAssignmentV1Spec.
+func (in *TeleportScopedRoleAssignmentV1Spec) DeepCopy() *TeleportScopedRoleAssignmentV1Spec {
+ if in == nil {
+ return nil
+ }
+ out := new(TeleportScopedRoleAssignmentV1Spec)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TeleportScopedRoleV1) DeepCopyInto(out *TeleportScopedRoleV1) {
*out = *in
diff --git a/integrations/operator/config/crd/bases/resources.teleport.dev_scopedroleassignmentsv1.yaml b/integrations/operator/config/crd/bases/resources.teleport.dev_scopedroleassignmentsv1.yaml
new file mode 100644
index 00000000000..c6fb072893c
--- /dev/null
+++ b/integrations/operator/config/crd/bases/resources.teleport.dev_scopedroleassignmentsv1.yaml
@@ -0,0 +1,146 @@
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ name: teleportscopedroleassignmentsv1.resources.teleport.dev
+spec:
+ group: resources.teleport.dev
+ names:
+ kind: TeleportScopedRoleAssignmentV1
+ listKind: TeleportScopedRoleAssignmentV1List
+ plural: teleportscopedroleassignmentsv1
+ shortNames:
+ - scopedroleassignmentv1
+ - scopedroleassignmentsv1
+ singular: teleportscopedroleassignmentv1
+ scope: Namespaced
+ versions:
+ - name: v1
+ schema:
+ openAPIV3Schema:
+ description: ScopedRoleAssignmentV1 is the Schema for the scopedroleassignmentsv1
+ API
+ properties:
+ apiVersion:
+ description: 'APIVersion defines the versioned schema of this representation
+ of an object. Servers should convert recognized schemas to the latest
+ internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+ type: string
+ kind:
+ description: 'Kind is a string value representing the REST resource this
+ object represents. Servers may infer this from the endpoint the client
+ submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+ type: string
+ metadata:
+ type: object
+ scope:
+ description: Scope is the scope of the role assignment resource.
+ type: string
+ spec:
+ description: ScopedRoleAssignment resource definition v1 from Teleport
+ properties:
+ assignments:
+ description: Assignments is a list of individual role @ scope assignments.
+ items:
+ properties:
+ role:
+ description: Roles is the name of the role that is assigned
+ by this assignment.
+ type: string
+ scope:
+ description: Scope is the scope to which the role is assigned.
+ This must be a member/child of the scope of the [ScopedRoleAssignment]
+ in which this assignment is contained.
+ type: string
+ type: object
+ nullable: true
+ type: array
+ bot_name:
+ description: Name of the Bot to whom all contained assignments apply.
+ Mutually exclusive with `user`.
+ type: string
+ bot_scope:
+ description: Scope of the Bot to whom all contained assignments apply.
+ Required if `bot_name` is set. If specified, assignment scopes must
+ be equal or descendent of this scope.
+ type: string
+ user:
+ description: User is the user to whom all contained assignments apply.
+ Mutually exclusive with `bot_name`.
+ type: string
+ type: object
+ status:
+ description: Status defines the observed state of the Teleport resource
+ properties:
+ conditions:
+ description: Conditions represent the latest available observations
+ of an object's state
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ teleportResourceID:
+ format: int64
+ type: integer
+ type: object
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
+status:
+ acceptedNames:
+ kind: ""
+ plural: ""
+ conditions: null
+ storedVersions: null
diff --git a/integrations/operator/controllers/resources/scopedroleassignment_controller.go b/integrations/operator/controllers/resources/scopedroleassignment_controller.go
new file mode 100644
index 00000000000..f1630bf9b17
--- /dev/null
+++ b/integrations/operator/controllers/resources/scopedroleassignment_controller.go
@@ -0,0 +1,80 @@
+// 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 resources
+
+import (
+ "context"
+
+ "github.com/gravitational/trace"
+ kclient "sigs.k8s.io/controller-runtime/pkg/client"
+
+ "github.com/gravitational/teleport/api/client"
+ accessv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/access/v1"
+ resourcesv1 "github.com/gravitational/teleport/integrations/operator/apis/resources/v1"
+ "github.com/gravitational/teleport/integrations/operator/controllers"
+ "github.com/gravitational/teleport/integrations/operator/controllers/reconcilers"
+ scopedaccess "github.com/gravitational/teleport/lib/scopes/access"
+)
+
+type scopedRoleAssignmentClient struct {
+ teleportClient *client.Client
+}
+
+func (s *scopedRoleAssignmentClient) Create(ctx context.Context, assignment *accessv1.ScopedRoleAssignment) error {
+ _, err := s.teleportClient.ScopedAccessServiceClient().CreateScopedRoleAssignment(ctx, &accessv1.CreateScopedRoleAssignmentRequest{
+ Assignment: assignment,
+ })
+ return trace.Wrap(err)
+}
+
+func (s *scopedRoleAssignmentClient) Delete(ctx context.Context, name string) error {
+ _, err := s.teleportClient.ScopedAccessServiceClient().DeleteScopedRoleAssignment(ctx, &accessv1.DeleteScopedRoleAssignmentRequest{
+ Name: name,
+ SubKind: scopedaccess.SubKindDynamic,
+ })
+ if err != nil && trace.IsCompareFailed(err) {
+ return trace.NotFound("scoped role assignment %q not found: %v", name, err)
+ }
+ return trace.Wrap(err)
+}
+
+func (s *scopedRoleAssignmentClient) Get(ctx context.Context, name string) (*accessv1.ScopedRoleAssignment, error) {
+ resp, err := s.teleportClient.ScopedAccessServiceClient().GetScopedRoleAssignment(ctx, &accessv1.GetScopedRoleAssignmentRequest{
+ Name: name,
+ SubKind: scopedaccess.SubKindDynamic,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return resp.GetAssignment(), nil
+}
+
+func (s *scopedRoleAssignmentClient) Update(ctx context.Context, assignment *accessv1.ScopedRoleAssignment) error {
+ _, err := s.teleportClient.ScopedAccessServiceClient().UpdateScopedRoleAssignment(ctx, &accessv1.UpdateScopedRoleAssignmentRequest{
+ Assignment: assignment,
+ })
+ return trace.Wrap(err)
+}
+
+func NewScopedRoleAssignmentV1Reconciler(client kclient.Client, tClient *client.Client) (controllers.Reconciler, error) {
+ return reconcilers.NewTeleportResource153Reconciler[*accessv1.ScopedRoleAssignment, *resourcesv1.TeleportScopedRoleAssignmentV1](
+ client,
+ &scopedRoleAssignmentClient{
+ teleportClient: tClient,
+ },
+ )
+}
diff --git a/integrations/operator/controllers/resources/scopedroleassignment_controller_test.go b/integrations/operator/controllers/resources/scopedroleassignment_controller_test.go
new file mode 100644
index 00000000000..8c9119ce4bd
--- /dev/null
+++ b/integrations/operator/controllers/resources/scopedroleassignment_controller_test.go
@@ -0,0 +1,175 @@
+/*
+ * 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 resources_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/uuid"
+ "github.com/gravitational/trace"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ kclient "sigs.k8s.io/controller-runtime/pkg/client"
+
+ headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
+ accessv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/scopes/access/v1"
+ "github.com/gravitational/teleport/api/types"
+ resourcesv1 "github.com/gravitational/teleport/integrations/operator/apis/resources/v1"
+ "github.com/gravitational/teleport/integrations/operator/controllers/reconcilers"
+ "github.com/gravitational/teleport/integrations/operator/controllers/resources"
+ "github.com/gravitational/teleport/integrations/operator/controllers/resources/testlib"
+ "github.com/gravitational/teleport/lib/scopes/access"
+)
+
+var scopedRoleAssignmentSpec = &accessv1.ScopedRoleAssignmentSpec{
+ User: "test-user",
+ Assignments: []*accessv1.Assignment{
+ {
+ Role: "test-role",
+ Scope: "/staging",
+ },
+ },
+}
+
+type scopedRoleAssignmentTestingPrimitives struct {
+ setup *testSetup
+ reconcilers.Resource153Adapter[*accessv1.ScopedRoleAssignment]
+}
+
+func (g *scopedRoleAssignmentTestingPrimitives) Init(setup *testSetup) {
+ g.setup = setup
+}
+
+func (g *scopedRoleAssignmentTestingPrimitives) SetupTeleportFixtures(ctx context.Context) error {
+ return nil
+}
+
+func (g *scopedRoleAssignmentTestingPrimitives) CreateTeleportResource(ctx context.Context, name string) error {
+ assignment := &accessv1.ScopedRoleAssignment{
+ Kind: access.KindScopedRoleAssignment,
+ Version: types.V1,
+ SubKind: access.SubKindDynamic,
+ Metadata: &headerv1.Metadata{
+ Name: name,
+ Labels: map[string]string{
+ types.OriginLabel: types.OriginKubernetes,
+ },
+ },
+ Scope: "/staging",
+ Spec: scopedRoleAssignmentSpec,
+ }
+ _, err := g.setup.TeleportClient.ScopedAccessServiceClient().CreateScopedRoleAssignment(ctx, &accessv1.CreateScopedRoleAssignmentRequest{
+ Assignment: assignment,
+ })
+ return trace.Wrap(err)
+}
+
+func (g *scopedRoleAssignmentTestingPrimitives) GetTeleportResource(ctx context.Context, name string) (*accessv1.ScopedRoleAssignment, error) {
+ resp, err := g.setup.TeleportClient.ScopedAccessServiceClient().GetScopedRoleAssignment(ctx, &accessv1.GetScopedRoleAssignmentRequest{
+ Name: name,
+ SubKind: access.SubKindDynamic,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return resp.GetAssignment(), nil
+}
+
+func (g *scopedRoleAssignmentTestingPrimitives) DeleteTeleportResource(ctx context.Context, name string) error {
+ _, err := g.setup.TeleportClient.ScopedAccessServiceClient().DeleteScopedRoleAssignment(ctx, &accessv1.DeleteScopedRoleAssignmentRequest{
+ Name: name,
+ SubKind: access.SubKindDynamic,
+ })
+ return trace.Wrap(err)
+}
+
+func (g *scopedRoleAssignmentTestingPrimitives) CreateKubernetesResource(ctx context.Context, name string) error {
+ assignment := &resourcesv1.TeleportScopedRoleAssignmentV1{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: g.setup.Namespace.Name,
+ },
+ Scope: "/staging",
+ Spec: (*resourcesv1.TeleportScopedRoleAssignmentV1Spec)(scopedRoleAssignmentSpec),
+ }
+ return trace.Wrap(g.setup.K8sClient.Create(ctx, assignment))
+}
+
+func (g *scopedRoleAssignmentTestingPrimitives) DeleteKubernetesResource(ctx context.Context, name string) error {
+ assignment := &resourcesv1.TeleportScopedRoleAssignmentV1{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: g.setup.Namespace.Name,
+ },
+ }
+ return trace.Wrap(g.setup.K8sClient.Delete(ctx, assignment))
+}
+
+func (g *scopedRoleAssignmentTestingPrimitives) GetKubernetesResource(ctx context.Context, name string) (*resourcesv1.TeleportScopedRoleAssignmentV1, error) {
+ assignment := &resourcesv1.TeleportScopedRoleAssignmentV1{}
+ obj := kclient.ObjectKey{
+ Name: name,
+ Namespace: g.setup.Namespace.Name,
+ }
+ err := g.setup.K8sClient.Get(ctx, obj, assignment)
+ return assignment, trace.Wrap(err)
+}
+
+func (g *scopedRoleAssignmentTestingPrimitives) ModifyKubernetesResource(ctx context.Context, name string) error {
+ assignment, err := g.GetKubernetesResource(ctx, name)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ assignment.Spec.Assignments = []*accessv1.Assignment{
+ {
+ Role: "test-role",
+ Scope: "/staging/aa",
+ },
+ }
+ return trace.Wrap(g.setup.K8sClient.Update(ctx, assignment))
+}
+
+func (g *scopedRoleAssignmentTestingPrimitives) CompareTeleportAndKubernetesResource(
+ tResource *accessv1.ScopedRoleAssignment, kubeResource *resourcesv1.TeleportScopedRoleAssignmentV1) (bool, string) {
+ diff := cmp.Diff(
+ tResource,
+ kubeResource.ToTeleport(),
+ testlib.ProtoCompareOptions()...,
+ )
+ return diff == "", diff
+}
+
+func TestScopedRoleAssignmentCreation(t *testing.T) {
+ t.Setenv("TELEPORT_UNSTABLE_SCOPES", "yes")
+ test := &scopedRoleAssignmentTestingPrimitives{}
+ testlib.ResourceCreationSynchronousTest(t, resources.NewScopedRoleAssignmentV1Reconciler, test, testlib.WithResourceName(uuid.New().String()))
+}
+
+func TestScopedRoleAssignmentDeletionDrift(t *testing.T) {
+ t.Setenv("TELEPORT_UNSTABLE_SCOPES", "yes")
+ test := &scopedRoleAssignmentTestingPrimitives{}
+ testlib.ResourceDeletionDriftSynchronousTest(t, resources.NewScopedRoleAssignmentV1Reconciler, test, testlib.WithResourceName(uuid.New().String()))
+}
+
+func TestScopedRoleAssignmentUpdate(t *testing.T) {
+ t.Setenv("TELEPORT_UNSTABLE_SCOPES", "yes")
+ test := &scopedRoleAssignmentTestingPrimitives{}
+ testlib.ResourceUpdateTestSynchronous(t, resources.NewScopedRoleAssignmentV1Reconciler, test, testlib.WithResourceName(uuid.New().String()))
+}
diff --git a/integrations/operator/controllers/resources/setup.go b/integrations/operator/controllers/resources/setup.go
index 43cb80b0688..8e3a2ae0e0b 100644
--- a/integrations/operator/controllers/resources/setup.go
+++ b/integrations/operator/controllers/resources/setup.go
@@ -67,6 +67,7 @@ func SetupAllControllers(log logr.Logger, mgr manager.Manager, teleportClient *c
{"TeleportSAMLIdPServiceProviderV1", NewSAMLIdPServiceProviderV1Reconciler},
{"TeleportScopedTokenV1", NewScopedTokenV1Reconciler},
{"TeleportScopedRoleV1", NewScopedRoleV1Reconciler},
+ {"TeleportScopedRoleAssignmentV1", NewScopedRoleAssignmentV1Reconciler},
}
oidc := modules.GetProtoEntitlement(features, entitlements.OIDC)
diff --git a/integrations/operator/controllers/resources/testlib/env.go b/integrations/operator/controllers/resources/testlib/env.go
index 4e94f0c9bc4..118f1aaead8 100644
--- a/integrations/operator/controllers/resources/testlib/env.go
+++ b/integrations/operator/controllers/resources/testlib/env.go
@@ -142,6 +142,7 @@ func defaultTeleportServiceConfig(t *testing.T, insecureMode bool) (*helpers.Tel
types.NewRule(types.KindSAMLIdPServiceProvider, unrestricted),
types.NewRule(types.KindScopedToken, unrestricted),
types.NewRule(access.KindScopedRole, unrestricted),
+ types.NewRule(access.KindScopedRoleAssignment, unrestricted),
},
},
})
diff --git a/integrations/operator/crdgen/handlerequest.go b/integrations/operator/crdgen/handlerequest.go
index 14dc7fed2c7..f3e06af922a 100644
--- a/integrations/operator/crdgen/handlerequest.go
+++ b/integrations/operator/crdgen/handlerequest.go
@@ -281,6 +281,13 @@ func generateSchema(file *File, groupName string, format crdFormatFunc, resp *go
withAdditionalRootFields([]string{"scope"}),
},
},
+ {
+ name: "ScopedRoleAssignment",
+ opts: []resourceSchemaOption{
+ withVersionOverride(types.V1),
+ withAdditionalRootFields([]string{"scope"}),
+ },
+ },
}
for _, resource := range resources {
diff --git a/integrations/operator/crdgen/test/fixtures/scoped_role_assignment.yaml b/integrations/operator/crdgen/test/fixtures/scoped_role_assignment.yaml
new file mode 100644
index 00000000000..40308894ad8
--- /dev/null
+++ b/integrations/operator/crdgen/test/fixtures/scoped_role_assignment.yaml
@@ -0,0 +1,10 @@
+apiVersion: resources.teleport.dev/v1
+kind: TeleportScopedRoleAssignmentV1
+scope: /foo
+sub_kind: dynamic
+spec:
+ user: "test"
+ assignments:
+ - role: some-role
+ scope: /foo/bar
+version: v1
\ No newline at end of file
diff --git a/integrations/operator/hack/fixture-operator-role.yaml b/integrations/operator/hack/fixture-operator-role.yaml
index 2f7058d0df0..0155330ddf9 100644
--- a/integrations/operator/hack/fixture-operator-role.yaml
+++ b/integrations/operator/hack/fixture-operator-role.yaml
@@ -197,6 +197,14 @@ spec:
- readnosecrets
- update
- delete
+ - resources:
+ - scoped_role_assignment
+ verbs:
+ - list
+ - create
+ - read
+ - update
+ - delete
deny: {}
version: v7