Skip to content

Commit 3e90d71

Browse files
committed
[pydantic issue] User token parsing fixed
Signed-off-by: jyejare <[email protected]>
1 parent 6ee5041 commit 3e90d71

File tree

5 files changed

+199
-58
lines changed

5 files changed

+199
-58
lines changed

infra/feast-operator/config/rbac/role.yaml

Lines changed: 11 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ rules:
1515
- list
1616
- update
1717
- watch
18+
- apiGroups:
19+
- authentication.k8s.io
20+
resources:
21+
- tokenreviews
22+
verbs:
23+
- create
1824
- apiGroups:
1925
- batch
2026
resources:
@@ -44,11 +50,13 @@ rules:
4450
- apiGroups:
4551
- ""
4652
resources:
53+
- namespaces
4754
- pods
4855
- secrets
4956
verbs:
5057
- get
5158
- list
59+
- watch
5260
- apiGroups:
5361
- ""
5462
resources:
@@ -84,8 +92,11 @@ rules:
8492
- apiGroups:
8593
- rbac.authorization.k8s.io
8694
resources:
95+
- clusterrolebindings
96+
- clusterroles
8797
- rolebindings
8898
- roles
99+
- subjectaccessreviews
89100
verbs:
90101
- create
91102
- delete
@@ -104,32 +115,3 @@ rules:
104115
- list
105116
- update
106117
- watch
107-
# Token Access Review permissions for Feast server RBAC creation
108-
- apiGroups:
109-
- authentication.k8s.io
110-
resources:
111-
- tokenreviews
112-
verbs:
113-
- create
114-
- apiGroups:
115-
- rbac.authorization.k8s.io
116-
resources:
117-
- subjectaccessreviews
118-
verbs:
119-
- create
120-
- apiGroups:
121-
- ""
122-
resources:
123-
- namespaces
124-
verbs:
125-
- get
126-
- list
127-
- watch
128-
- apiGroups:
129-
- rbac.authorization.k8s.io
130-
resources:
131-
- clusterroles
132-
- clusterrolebindings
133-
verbs:
134-
- get
135-
- list

infra/feast-operator/internal/controller/authz/authz.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,23 @@ func (authz *FeastAuthorization) deployKubernetesAuth() error {
3737
if authz.isKubernetesAuth() {
3838
authz.removeOrphanedRoles()
3939

40+
// Create namespace-scoped RBAC resources
4041
if err := authz.createFeastRole(); err != nil {
4142
return authz.setFeastKubernetesAuthCondition(err)
4243
}
4344
if err := authz.createFeastRoleBinding(); err != nil {
4445
return authz.setFeastKubernetesAuthCondition(err)
4546
}
4647

48+
// Create cluster-scoped RBAC resources (separate from namespace resources)
49+
if err := authz.createFeastClusterRole(); err != nil {
50+
return authz.setFeastKubernetesAuthCondition(err)
51+
}
52+
if err := authz.createFeastClusterRoleBinding(); err != nil {
53+
return authz.setFeastKubernetesAuthCondition(err)
54+
}
55+
56+
// Create custom auth roles
4757
for _, roleName := range authz.Handler.FeatureStore.Status.Applied.AuthzConfig.KubernetesAuthz.Roles {
4858
if err := authz.createAuthRole(roleName); err != nil {
4959
return authz.setFeastKubernetesAuthCondition(err)
@@ -89,6 +99,80 @@ func (authz *FeastAuthorization) createFeastRole() error {
8999
return nil
90100
}
91101

102+
func (authz *FeastAuthorization) createFeastClusterRole() error {
103+
logger := log.FromContext(authz.Handler.Context)
104+
clusterRole := authz.initFeastClusterRole()
105+
if op, err := controllerutil.CreateOrUpdate(authz.Handler.Context, authz.Handler.Client, clusterRole, controllerutil.MutateFn(func() error {
106+
return authz.setFeastClusterRole(clusterRole)
107+
})); err != nil {
108+
return err
109+
} else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated {
110+
logger.Info("Successfully reconciled", "ClusterRole", clusterRole.Name, "operation", op)
111+
}
112+
113+
return nil
114+
}
115+
116+
func (authz *FeastAuthorization) initFeastClusterRole() *rbacv1.ClusterRole {
117+
clusterRole := &rbacv1.ClusterRole{
118+
ObjectMeta: metav1.ObjectMeta{Name: authz.getFeastClusterRoleName()},
119+
}
120+
clusterRole.SetGroupVersionKind(rbacv1.SchemeGroupVersion.WithKind("ClusterRole"))
121+
return clusterRole
122+
}
123+
124+
func (authz *FeastAuthorization) setFeastClusterRole(clusterRole *rbacv1.ClusterRole) error {
125+
clusterRole.Labels = authz.getLabels()
126+
clusterRole.Rules = []rbacv1.PolicyRule{
127+
{
128+
APIGroups: []string{rbacv1.GroupName},
129+
Resources: []string{"rolebindings"},
130+
Verbs: []string{"list"},
131+
},
132+
}
133+
return nil
134+
}
135+
136+
func (authz *FeastAuthorization) initFeastClusterRoleBinding() *rbacv1.ClusterRoleBinding {
137+
clusterRoleBinding := &rbacv1.ClusterRoleBinding{
138+
ObjectMeta: metav1.ObjectMeta{Name: authz.getFeastClusterRoleBindingName()},
139+
}
140+
clusterRoleBinding.SetGroupVersionKind(rbacv1.SchemeGroupVersion.WithKind("ClusterRoleBinding"))
141+
return clusterRoleBinding
142+
}
143+
144+
func (authz *FeastAuthorization) setFeastClusterRoleBinding(clusterRoleBinding *rbacv1.ClusterRoleBinding) error {
145+
clusterRoleBinding.Labels = authz.getLabels()
146+
clusterRoleBinding.Subjects = []rbacv1.Subject{
147+
{
148+
Kind: "ServiceAccount",
149+
Name: authz.getFeastServiceAccountName(),
150+
Namespace: authz.Handler.FeatureStore.Namespace,
151+
},
152+
}
153+
clusterRoleBinding.RoleRef = rbacv1.RoleRef{
154+
APIGroup: rbacv1.GroupName,
155+
Kind: "ClusterRole",
156+
Name: authz.getFeastClusterRoleName(),
157+
}
158+
return nil
159+
}
160+
161+
// Create ClusterRoleBinding
162+
func (authz *FeastAuthorization) createFeastClusterRoleBinding() error {
163+
logger := log.FromContext(authz.Handler.Context)
164+
clusterRoleBinding := authz.initFeastClusterRoleBinding()
165+
if op, err := controllerutil.CreateOrUpdate(authz.Handler.Context, authz.Handler.Client, clusterRoleBinding, controllerutil.MutateFn(func() error {
166+
return authz.setFeastClusterRoleBinding(clusterRoleBinding)
167+
})); err != nil {
168+
return err
169+
} else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated {
170+
logger.Info("Successfully reconciled", "ClusterRoleBinding", clusterRoleBinding.Name, "operation", op)
171+
}
172+
173+
return nil
174+
}
175+
92176
func (authz *FeastAuthorization) initFeastRole() *rbacv1.Role {
93177
role := &rbacv1.Role{
94178
ObjectMeta: metav1.ObjectMeta{Name: authz.getFeastRoleName(), Namespace: authz.Handler.FeatureStore.Namespace},
@@ -230,3 +314,23 @@ func (authz *FeastAuthorization) getFeastRoleName() string {
230314
func GetFeastRoleName(featureStore *feastdevv1alpha1.FeatureStore) string {
231315
return services.GetFeastName(featureStore)
232316
}
317+
318+
func (authz *FeastAuthorization) getFeastClusterRoleName() string {
319+
return GetFeastClusterRoleName(authz.Handler.FeatureStore)
320+
}
321+
322+
func GetFeastClusterRoleName(featureStore *feastdevv1alpha1.FeatureStore) string {
323+
return services.GetFeastName(featureStore) + "-cluster"
324+
}
325+
326+
func (authz *FeastAuthorization) getFeastClusterRoleBindingName() string {
327+
return GetFeastClusterRoleBindingName(authz.Handler.FeatureStore)
328+
}
329+
330+
func GetFeastClusterRoleBindingName(featureStore *feastdevv1alpha1.FeatureStore) string {
331+
return services.GetFeastName(featureStore) + "-cluster-binding"
332+
}
333+
334+
func (authz *FeastAuthorization) getFeastServiceAccountName() string {
335+
return services.GetFeastName(authz.Handler.FeatureStore)
336+
}

infra/feast-operator/internal/controller/featurestore_controller.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,10 @@ type FeatureStoreReconciler struct {
5959
// +kubebuilder:rbac:groups=feast.dev,resources=featurestores/finalizers,verbs=update
6060
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;create;update;watch;delete
6161
// +kubebuilder:rbac:groups=core,resources=services;configmaps;persistentvolumeclaims;serviceaccounts,verbs=get;list;create;update;watch;delete
62-
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;create;update;watch;delete
63-
// +kubebuilder:rbac:groups=core,resources=secrets;pods,verbs=get;list
62+
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings;clusterroles;clusterrolebindings;subjectaccessreviews,verbs=get;list;create;update;watch;delete
63+
// +kubebuilder:rbac:groups=core,resources=secrets;pods;namespaces,verbs=get;list;watch
6464
// +kubebuilder:rbac:groups=core,resources=pods/exec,verbs=create
65+
// +kubebuilder:rbac:groups=authentication.k8s.io,resources=tokenreviews,verbs=create
6566
// +kubebuilder:rbac:groups=route.openshift.io,resources=routes,verbs=get;list;create;update;watch;delete
6667
// +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete
6768

sdk/python/feast/permissions/auth/kubernetes_token_parser.py

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,46 +32,71 @@ def __init__(self):
3232

3333
async def user_details_from_access_token(self, access_token: str) -> User:
3434
"""
35-
Extract the service account from the token and search the roles associated with it.
36-
Also extract groups and namespaces using Token Access Review.
35+
Extract user details from the token using Token Access Review.
36+
Handles both service account tokens (JWTs) and user tokens (opaque tokens).
3737
3838
Returns:
39-
User: Current user, with associated roles, groups, and namespaces. The `username` is the `:` separated concatenation of `namespace` and `service account name`.
39+
User: Current user, with associated roles, groups, and namespaces.
4040
4141
Raises:
4242
AuthenticationError if any error happens.
4343
"""
44-
sa_namespace, sa_name = _decode_token(access_token)
45-
current_user = f"{sa_namespace}:{sa_name}"
46-
logger.info(
47-
f"Request received from ServiceAccount: {sa_name} in namespace: {sa_namespace}"
44+
# First, try to extract user information using Token Access Review
45+
groups, namespaces = self._extract_groups_and_namespaces_from_token(
46+
access_token
4847
)
4948

50-
intra_communication_base64 = os.getenv("INTRA_COMMUNICATION_BASE64")
51-
if sa_name is not None and sa_name == intra_communication_base64:
52-
return User(username=sa_name, roles=[], groups=[], namespaces=[])
53-
else:
54-
current_namespace = self._read_namespace_from_file()
49+
# Try to determine if this is a service account or regular user
50+
try:
51+
# Attempt to decode as JWT (for service accounts)
52+
sa_namespace, sa_name = _decode_token(access_token)
53+
current_user = f"{sa_namespace}:{sa_name}"
5554
logger.info(
56-
f"Looking for ServiceAccount roles of {sa_namespace}:{sa_name} in {current_namespace}"
55+
f"Request received from ServiceAccount: {sa_name} in namespace: {sa_namespace}"
5756
)
5857

59-
# Get roles using existing method
60-
roles = self.get_roles(
61-
current_namespace=current_namespace,
62-
service_account_namespace=sa_namespace,
63-
service_account_name=sa_name,
64-
)
65-
logger.info(f"Roles: {roles}")
58+
intra_communication_base64 = os.getenv("INTRA_COMMUNICATION_BASE64")
59+
if sa_name is not None and sa_name == intra_communication_base64:
60+
return User(username=sa_name, roles=[], groups=[], namespaces=[])
61+
else:
62+
current_namespace = self._read_namespace_from_file()
63+
logger.info(
64+
f"Looking for ServiceAccount roles of {sa_namespace}:{sa_name} in {current_namespace}"
65+
)
6666

67-
# Extract groups and namespaces using Token Access Review
68-
groups, namespaces = self._extract_groups_and_namespaces_from_token(
69-
access_token
70-
)
71-
logger.info(f"Groups: {groups}, Namespaces: {namespaces}")
67+
# Get roles using existing method
68+
roles = self.get_roles(
69+
current_namespace=current_namespace,
70+
service_account_namespace=sa_namespace,
71+
service_account_name=sa_name,
72+
)
73+
logger.info(f"Roles: {roles}")
74+
75+
return User(
76+
username=current_user,
77+
roles=roles,
78+
groups=groups,
79+
namespaces=namespaces,
80+
)
81+
82+
except AuthenticationError as e:
83+
# If JWT decoding fails, this is likely a user token
84+
# Use Token Access Review to get user information
85+
logger.info(f"Token is not a JWT (likely a user token): {e}")
86+
87+
# Get username from Token Access Review
88+
username = self._get_username_from_token_review(access_token)
89+
if not username:
90+
raise AuthenticationError("Could not extract username from token")
91+
92+
logger.info(f"Request received from User: {username}")
93+
94+
# For user tokens, we don't have traditional roles, but we have groups and namespaces
95+
# You might want to map groups to roles or use a different role assignment strategy
96+
roles = [] # Users don't have traditional service account roles
7297

7398
return User(
74-
username=current_user, roles=roles, groups=groups, namespaces=namespaces
99+
username=username, roles=roles, groups=groups, namespaces=namespaces
75100
)
76101

77102
def _read_namespace_from_file(self):
@@ -294,6 +319,35 @@ def _extract_user_data_science_projects(self, username: str) -> list[str]:
294319

295320
return user_namespaces
296321

322+
def _get_username_from_token_review(self, access_token: str) -> str:
323+
"""
324+
Extract username from Token Access Review.
325+
326+
Args:
327+
access_token: The access token to review
328+
329+
Returns:
330+
str: The username from the token review, or empty string if not found
331+
"""
332+
try:
333+
token_review = client.V1TokenReview(
334+
spec=client.V1TokenReviewSpec(token=access_token)
335+
)
336+
337+
response = self.auth_v1.create_token_review(token_review)
338+
339+
if response.status.authenticated and response.status.user:
340+
username = getattr(response.status.user, "username", "") or ""
341+
logger.debug(f"Extracted username from Token Access Review: {username}")
342+
return username
343+
else:
344+
logger.warning(f"Token Access Review failed: {response.status.error}")
345+
return ""
346+
347+
except Exception as e:
348+
logger.error(f"Failed to get username from Token Access Review: {e}")
349+
return ""
350+
297351
def _cluster_role_binding_grants_namespace_access(
298352
self, cluster_role_binding, namespace: str
299353
) -> bool:

sdk/python/feast/permissions/auth_model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,4 @@ class KubernetesAuthConfig(AuthConfig):
7070
# Optional user token for users (not service accounts)
7171
user_token: Optional[str] = None
7272

73-
model_config = ConfigDict(extra="allow")
73+
model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow")

0 commit comments

Comments
 (0)