Skip to content

Commit 6511da1

Browse files
committed
feat: Permissions CRUD UI and OIDC auth integration in UI
Signed-off-by: ntkathole <nikhilkathole2683@gmail.com>
1 parent 70a9751 commit 6511da1

28 files changed

Lines changed: 2331 additions & 354 deletions

sdk/python/feast/api/registry/rest/permissions.py

Lines changed: 154 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
from typing import Dict, List, Optional
2+
13
from fastapi import APIRouter, Depends, Query
4+
from fastapi.responses import JSONResponse
5+
from pydantic import BaseModel
26

37
from feast.api.registry.rest.rest_utils import (
48
create_grpc_pagination_params,
@@ -9,7 +13,107 @@
913
get_sorting_params,
1014
grpc_call,
1115
)
12-
from feast.registry_server import RegistryServer_pb2
16+
from feast.protos.feast.core.Permission_pb2 import Permission as PermissionProto
17+
from feast.protos.feast.core.Permission_pb2 import PermissionSpec as PermissionSpecProto
18+
from feast.protos.feast.core.Policy_pb2 import (
19+
CombinedGroupNamespacePolicy as CombinedGroupNamespacePolicyProto,
20+
)
21+
from feast.protos.feast.core.Policy_pb2 import GroupBasedPolicy as GroupBasedPolicyProto
22+
from feast.protos.feast.core.Policy_pb2 import (
23+
NamespaceBasedPolicy as NamespaceBasedPolicyProto,
24+
)
25+
from feast.protos.feast.core.Policy_pb2 import Policy as PolicyProto
26+
from feast.protos.feast.core.Policy_pb2 import RoleBasedPolicy as RoleBasedPolicyProto
27+
from feast.protos.feast.registry import RegistryServer_pb2
28+
29+
30+
class RoleBasedPolicyModel(BaseModel):
31+
roles: List[str]
32+
33+
34+
class GroupBasedPolicyModel(BaseModel):
35+
groups: List[str]
36+
37+
38+
class NamespaceBasedPolicyModel(BaseModel):
39+
namespaces: List[str]
40+
41+
42+
class CombinedGroupNamespacePolicyModel(BaseModel):
43+
groups: List[str]
44+
namespaces: List[str]
45+
46+
47+
class PolicyModel(BaseModel):
48+
role_based_policy: Optional[RoleBasedPolicyModel] = None
49+
group_based_policy: Optional[GroupBasedPolicyModel] = None
50+
namespace_based_policy: Optional[NamespaceBasedPolicyModel] = None
51+
combined_group_namespace_policy: Optional[CombinedGroupNamespacePolicyModel] = None
52+
53+
54+
class ApplyPermissionRequestBody(BaseModel):
55+
name: str
56+
project: str
57+
types: List[str] = []
58+
name_patterns: List[str] = []
59+
actions: List[str] = []
60+
policy: PolicyModel
61+
tags: Optional[Dict[str, str]] = {}
62+
required_tags: Optional[Dict[str, str]] = {}
63+
64+
65+
def _build_policy_proto(policy: PolicyModel) -> PolicyProto:
66+
if policy.role_based_policy:
67+
return PolicyProto(
68+
role_based_policy=RoleBasedPolicyProto(roles=policy.role_based_policy.roles)
69+
)
70+
elif policy.group_based_policy:
71+
return PolicyProto(
72+
group_based_policy=GroupBasedPolicyProto(
73+
groups=policy.group_based_policy.groups
74+
)
75+
)
76+
elif policy.namespace_based_policy:
77+
return PolicyProto(
78+
namespace_based_policy=NamespaceBasedPolicyProto(
79+
namespaces=policy.namespace_based_policy.namespaces
80+
)
81+
)
82+
elif policy.combined_group_namespace_policy:
83+
return PolicyProto(
84+
combined_group_namespace_policy=CombinedGroupNamespacePolicyProto(
85+
groups=policy.combined_group_namespace_policy.groups,
86+
namespaces=policy.combined_group_namespace_policy.namespaces,
87+
)
88+
)
89+
return PolicyProto()
90+
91+
92+
_TYPE_NAME_TO_ENUM = {
93+
"FEATURE_VIEW": PermissionSpecProto.Type.FEATURE_VIEW,
94+
"ON_DEMAND_FEATURE_VIEW": PermissionSpecProto.Type.ON_DEMAND_FEATURE_VIEW,
95+
"BATCH_FEATURE_VIEW": PermissionSpecProto.Type.BATCH_FEATURE_VIEW,
96+
"STREAM_FEATURE_VIEW": PermissionSpecProto.Type.STREAM_FEATURE_VIEW,
97+
"ENTITY": PermissionSpecProto.Type.ENTITY,
98+
"FEATURE_SERVICE": PermissionSpecProto.Type.FEATURE_SERVICE,
99+
"DATA_SOURCE": PermissionSpecProto.Type.DATA_SOURCE,
100+
"VALIDATION_REFERENCE": PermissionSpecProto.Type.VALIDATION_REFERENCE,
101+
"SAVED_DATASET": PermissionSpecProto.Type.SAVED_DATASET,
102+
"PERMISSION": PermissionSpecProto.Type.PERMISSION,
103+
"PROJECT": PermissionSpecProto.Type.PROJECT,
104+
"LABEL_VIEW": PermissionSpecProto.Type.LABEL_VIEW,
105+
}
106+
107+
_ACTION_NAME_TO_ENUM = {
108+
"CREATE": PermissionSpecProto.AuthzedAction.CREATE,
109+
"DESCRIBE": PermissionSpecProto.AuthzedAction.DESCRIBE,
110+
"UPDATE": PermissionSpecProto.AuthzedAction.UPDATE,
111+
"DELETE": PermissionSpecProto.AuthzedAction.DELETE,
112+
"READ_ONLINE": PermissionSpecProto.AuthzedAction.READ_ONLINE,
113+
"READ_OFFLINE": PermissionSpecProto.AuthzedAction.READ_OFFLINE,
114+
"WRITE_ONLINE": PermissionSpecProto.AuthzedAction.WRITE_ONLINE,
115+
"WRITE_OFFLINE": PermissionSpecProto.AuthzedAction.WRITE_OFFLINE,
116+
}
13117

14118

15119
def get_permission_router(grpc_handler) -> APIRouter:
@@ -33,8 +137,6 @@ def get_permission(
33137

34138
result = permission
35139

36-
# Note: permissions may not have relationships in the traditional sense
37-
# but we include the functionality for consistency
38140
if include_relationships:
39141
relationships = get_object_relationships(
40142
grpc_handler, "permission", name, project, allow_cache
@@ -75,4 +177,53 @@ def list_permissions(
75177

76178
return result
77179

180+
@router.post("/permissions", status_code=201)
181+
def apply_permission(body: ApplyPermissionRequestBody):
182+
types = [_TYPE_NAME_TO_ENUM[t] for t in body.types if t in _TYPE_NAME_TO_ENUM]
183+
actions = [
184+
_ACTION_NAME_TO_ENUM[a] for a in body.actions if a in _ACTION_NAME_TO_ENUM
185+
]
186+
policy_proto = _build_policy_proto(body.policy)
187+
188+
permission_spec = PermissionSpecProto(
189+
name=body.name,
190+
types=types,
191+
name_patterns=body.name_patterns,
192+
actions=actions,
193+
policy=policy_proto,
194+
tags=body.tags or {},
195+
required_tags=body.required_tags or {},
196+
)
197+
permission_proto = PermissionProto(spec=permission_spec)
198+
199+
req = RegistryServer_pb2.ApplyPermissionRequest(
200+
permission=permission_proto,
201+
project=body.project,
202+
commit=True,
203+
)
204+
grpc_call(grpc_handler.ApplyPermission, req)
205+
206+
return JSONResponse(
207+
status_code=201,
208+
content={
209+
"name": body.name,
210+
"project": body.project,
211+
"status": "applied",
212+
},
213+
)
214+
215+
@router.delete("/permissions/{name}")
216+
def delete_permission(
217+
name: str,
218+
project: str = Query(...),
219+
):
220+
req = RegistryServer_pb2.DeletePermissionRequest(
221+
name=name,
222+
project=project,
223+
commit=True,
224+
)
225+
grpc_call(grpc_handler.DeletePermission, req)
226+
227+
return {"name": name, "project": project, "status": "deleted"}
228+
78229
return router

sdk/python/feast/permissions/auth_model.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class AuthConfig(FeastConfigBaseModel):
3737
class OidcAuthConfig(AuthConfig):
3838
auth_discovery_url: str
3939
client_id: Optional[str] = None
40+
ui_client_id: Optional[str] = None
4041
verify_ssl: bool = True
4142
ca_cert_path: str = ""
4243

sdk/python/feast/ui_server.py

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,30 @@ def _safe_error_response(
2828
)
2929

3030

31+
def _build_auth_config_json(store: "feast.FeatureStore") -> str:
32+
"""Build a JSON string with auth config from feature_store.yaml for the UI."""
33+
from feast.permissions.auth_model import AuthConfig, OidcAuthConfig
34+
35+
auth_cfg = getattr(store.config, "auth_config", None)
36+
if not isinstance(auth_cfg, AuthConfig):
37+
return json.dumps({"auth_type": "no_auth"})
38+
39+
auth_type = auth_cfg.type if auth_cfg else "no_auth"
40+
41+
config: Dict[str, str] = {"auth_type": auth_type}
42+
if auth_type == "oidc" and isinstance(auth_cfg, OidcAuthConfig):
43+
discovery_url = auth_cfg.auth_discovery_url
44+
if "/realms/" in discovery_url:
45+
base = discovery_url.split("/realms/")[0]
46+
realm = discovery_url.split("/realms/")[1].split("/")[0]
47+
config["url"] = base
48+
config["realm"] = realm
49+
config["auth_discovery_url"] = discovery_url
50+
config["client_id"] = auth_cfg.ui_client_id or auth_cfg.client_id or ""
51+
52+
return json.dumps(config)
53+
54+
3155
def _build_projects_list(
3256
store: "feast.FeatureStore",
3357
project_id: str,
@@ -75,12 +99,63 @@ def _build_projects_list(
7599

76100
def _setup_rest_mode(app: FastAPI, store: "feast.FeatureStore"):
77101
"""Mount the REST registry API routes on the UI server under /api/v1."""
102+
from fastapi import Depends, Request
103+
from fastapi.responses import JSONResponse
104+
78105
from feast.api.registry.rest import register_all_routes
106+
from feast.errors import FeastObjectNotFoundException, FeastPermissionError
107+
from feast.permissions.server.utils import (
108+
ServerType,
109+
init_auth_manager,
110+
init_security_manager,
111+
str_to_auth_manager_type,
112+
)
79113
from feast.registry_server import RegistryServer
80114

81115
grpc_handler = RegistryServer(store.registry, store=store)
82116

83-
rest_app = FastAPI(root_path="/api/v1")
117+
auth_cfg = store.config.auth_config
118+
dependencies = []
119+
if auth_cfg and auth_cfg.type != "no_auth":
120+
from feast.permissions.server.rest import inject_user_details
121+
122+
auth_type = str_to_auth_manager_type(auth_cfg.type)
123+
init_security_manager(auth_type=auth_type, fs=store)
124+
init_auth_manager(
125+
auth_type=auth_type,
126+
server_type=ServerType.REST,
127+
auth_config=auth_cfg,
128+
)
129+
dependencies.append(Depends(inject_user_details))
130+
131+
rest_app = FastAPI(root_path="/api/v1", dependencies=dependencies)
132+
133+
@rest_app.exception_handler(FeastPermissionError)
134+
async def feast_permission_error_handler(
135+
request: Request, exc: FeastPermissionError
136+
):
137+
return JSONResponse(
138+
status_code=403,
139+
content={
140+
"status_code": 403,
141+
"detail": str(exc),
142+
"error_type": "FeastPermissionError",
143+
},
144+
)
145+
146+
@rest_app.exception_handler(FeastObjectNotFoundException)
147+
async def feast_object_not_found_handler(
148+
request: Request, exc: FeastObjectNotFoundException
149+
):
150+
return JSONResponse(
151+
status_code=404,
152+
content={
153+
"status_code": 404,
154+
"detail": str(exc),
155+
"error_type": "FeastObjectNotFoundException",
156+
},
157+
)
158+
84159
register_all_routes(rest_app, grpc_handler, store=store)
85160

86161
class PushRequest(BaseModel):
@@ -1086,14 +1161,25 @@ def get_mlflow_feature_models():
10861161
"error": "Failed to fetch model data",
10871162
}
10881163

1089-
# For all other paths (such as paths that would otherwise be handled by react router), pass to React
1090-
@app.api_route("/p/{path_name:path}", methods=["GET"])
1091-
def catch_all():
1164+
auth_config_json = _build_auth_config_json(store)
1165+
1166+
def _serve_index():
10921167
filename = ui_dir.joinpath("index.html")
10931168
with open(filename) as f:
10941169
content = f.read()
1170+
if auth_config_json:
1171+
tag = f'<script id="feast-auth-config" type="application/json">{auth_config_json}</script>'
1172+
content = content.replace("</head>", f"{tag}\n</head>", 1)
10951173
return Response(content, media_type="text/html")
10961174

1175+
@app.get("/")
1176+
def serve_root():
1177+
return _serve_index()
1178+
1179+
@app.api_route("/p/{path_name:path}", methods=["GET"])
1180+
def catch_all():
1181+
return _serve_index()
1182+
10971183
app.mount(
10981184
"/",
10991185
StaticFiles(directory=ui_dir, html=True),

ui/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const transformNodeModules = [
44
"msw",
55
"until-async",
66
"chroma-js",
7+
"keycloak-js",
78
];
89

910
module.exports = {

ui/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@types/styled-components": "^5.1.34",
3333
"dagre": "^0.8.5",
3434
"inter-ui": "^3.19.3",
35+
"keycloak-js": "^26.2.4",
3536
"long": "^5.2.3",
3637
"moment": "^2.29.1",
3738
"protobufjs": "^7.1.1",

0 commit comments

Comments
 (0)