From d1b32e62c5be4aadabd72ee9e625bfa02b2ddf37 Mon Sep 17 00:00:00 2001 From: williamong-tel Date: Wed, 22 Apr 2026 12:51:46 -0700 Subject: [PATCH] add scoped role assignments to k8s operator (#65623) * add scoped role assignments for k8s Fix tests and retrieving the scoped role assignments by adding subkind Add additional catch to treat comparefailed error as not found for scoped role assignments * fix merge conflict --- ...s-teleport-dev-scopedroleassignmentsv1.mdx | 44 +++++ ....teleport.dev_scopedroleassignmentsv1.yaml | 146 +++++++++++++++ .../teleport-operator/templates/role.yaml | 2 + .../templates/auth/config.yaml | 8 + integrations/operator/Makefile | 3 +- .../v1/scopedroleassignment_types.go | 107 +++++++++++ .../resources/v1/zz_generated.deepcopy.go | 64 +++++++ ....teleport.dev_scopedroleassignmentsv1.yaml | 146 +++++++++++++++ .../scopedroleassignment_controller.go | 80 ++++++++ .../scopedroleassignment_controller_test.go | 175 ++++++++++++++++++ .../operator/controllers/resources/setup.go | 1 + .../controllers/resources/testlib/env.go | 1 + integrations/operator/crdgen/handlerequest.go | 7 + .../test/fixtures/scoped_role_assignment.yaml | 10 + .../operator/hack/fixture-operator-role.yaml | 8 + 15 files changed, 801 insertions(+), 1 deletion(-) create mode 100644 docs/pages/reference/infrastructure-as-code/operator-resources/resources-teleport-dev-scopedroleassignmentsv1.mdx create mode 100644 examples/chart/teleport-cluster/charts/teleport-operator/operator-crds/resources.teleport.dev_scopedroleassignmentsv1.yaml create mode 100644 integrations/operator/apis/resources/v1/scopedroleassignment_types.go create mode 100644 integrations/operator/config/crd/bases/resources.teleport.dev_scopedroleassignmentsv1.yaml create mode 100644 integrations/operator/controllers/resources/scopedroleassignment_controller.go create mode 100644 integrations/operator/controllers/resources/scopedroleassignment_controller_test.go create mode 100644 integrations/operator/crdgen/test/fixtures/scoped_role_assignment.yaml 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