Skip to content

Commit

Permalink
feat(bff): authorize endpoints based on kubeflow-userid and kubeflow-…
Browse files Browse the repository at this point in the history
…groups header

Signed-off-by: Eder Ignatowicz <[email protected]>
  • Loading branch information
ederign committed Dec 17, 2024
1 parent ef7f988 commit 2fedf12
Show file tree
Hide file tree
Showing 19 changed files with 574 additions and 156 deletions.
85 changes: 63 additions & 22 deletions clients/ui/bff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ make docker-build
|----------------------------------------------------------------------------------------------|----------------------------------------------|-------------------------------------------------------------|
| GET /v1/healthcheck | HealthcheckHandler | Show application information. |
| GET /v1/user | UserHandler | Show "kubeflow-user-id" from header information. |
| GET /v1/namespaces | NamespacesHandler | Get all user namespaces. |
| GET /v1/namespaces | NamespacesHandler | Get all user namespaces. (only enabled in devmode) |
| GET /v1/model_registry | ModelRegistryHandler | Get all model registries, |
| GET /v1/model_registry/{model_registry_id}/registered_models | GetAllRegisteredModelsHandler | Gets a list of all RegisteredModel entities. |
| POST /v1/model_registry/{model_registry_id}/registered_models | CreateRegisteredModelHandler | Create a RegisteredModel entity. |
Expand All @@ -72,32 +72,51 @@ make docker-build
| GET /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts | GetAllModelArtifactsByModelVersionHandler | Get all ModelArtifact entities by ModelVersion ID |
| POST /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts | CreateModelArtifactByModelVersion | Create a ModelArtifact entity for a specific ModelVersion |

Note: Most API paths require the namespace parameter to be passed as a query parameter.
The only exceptions are the health check (/v1/healthcheck) and user (/v1/user) paths, which do not require the namespace parameter.

### Sample local calls

You will need to inject your requests with a kubeflow-userid header for authorization purposes. When running the service with the mocked Kubernetes client (MOCK_K8S_CLIENT=true), the user [email protected] is preconfigured with the necessary RBAC permissions to perform these actions.
You will need to inject your requests with a `kubeflow-userid` header and namespace for authorization purposes.

When running the service with the mocked Kubernetes client (MOCK_K8S_CLIENT=true), the user `[email protected]` is preconfigured with the necessary RBAC permissions to perform these actions.
```
# GET /v1/healthcheck
curl -i -H "kubeflow-userid: [email protected]" localhost:4000/api/v1/healthcheck
curl -i -H "kubeflow-userid: [email protected]" "localhost:4000/api/v1/healthcheck"
```
```
# GET /v1/user
curl -i -H "kubeflow-userid: [email protected]" localhost:4000/api/v1/user
curl -i -H "kubeflow-userid: [email protected]" "localhost:4000/api/v1/user"
```
```
# GET /v1/namespaces
curl -i -H "kubeflow-userid: [email protected]" localhost:4000/api/v1/namespaces
# GET /v1/namespaces (only works when DEV_MODE=true)
curl -i -H "kubeflow-userid: [email protected]" "localhost:4000/api/v1/namespaces"
```
```
# GET /v1/model_registry
curl -i -H "kubeflow-userid: [email protected]" localhost:4000/api/v1/model_registry
curl -i -H "kubeflow-userid: [email protected]" "localhost:4000/api/v1/model_registry?namespace=kubeflow"
```
```
# GET /v1/model_registry using groups permissions
curl -i \
-H "kubeflow-userid: [email protected]" \
-H "kubeflow-groups: dora-namespace-group ,group2,group3" \
"http://localhost:4000/api/v1/model_registry?namespace=dora-namespace"
```
```
# GET /v1/model_registry/{model_registry_id}/registered_models
curl -i -H "kubeflow-userid: [email protected]" localhost:4000/api/v1/model_registry/model-registry/registered_models
curl -i -H "kubeflow-userid: [email protected]" "localhost:4000/api/v1/model_registry/model-registry/registered_models?namespace=kubeflow"
```
```
# GET /v1/model_registry/{model_registry_id}/registered_models using group permissions
curl -i \
-H "kubeflow-userid: [email protected]" \
-H "kubeflow-groups: dora-namespace-group ,dora-service-group,group3" \
"http://localhost:4000/api/v1/model_registry/model-registry-dora/registered_models?namespace=dora-namespace"
```
```
#POST /v1/model_registry/{model_registry_id}/registered_models
curl -i -H "kubeflow-userid: [email protected]" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/registered_models" \
curl -i -H "kubeflow-userid: [email protected]" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/registered_models?namespace=kubeflow" \
-H "Content-Type: application/json" \
-d '{ "data": {
"customProperties": {
Expand All @@ -115,23 +134,23 @@ curl -i -H "kubeflow-userid: [email protected]" -X POST "http://localhost:4000/ap
```
```
# GET /v1/model_registry/{model_registry_id}/registered_models/{registered_model_id}
curl -i -H "kubeflow-userid: [email protected]" localhost:4000/api/v1/model_registry/model-registry/registered_models/1
curl -i -H "kubeflow-userid: [email protected]" "localhost:4000/api/v1/model_registry/model-registry/registered_models/1?namespace=kubeflow"
```
```
# PATCH /v1/model_registry/{model_registry_id}/registered_models/{registered_model_id}
curl -i -H "kubeflow-userid: [email protected]" -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/registered_models/1" \
curl -i -H "kubeflow-userid: [email protected]" -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/registered_models/1?namespace=kubeflow" \
-H "Content-Type: application/json" \
-d '{ "data": {
"description": "New description"
}}'
```
```
# GET /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}
curl -i -H "kubeflow-userid: [email protected]" http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1
curl -i -H "kubeflow-userid: [email protected]" "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1?namespace=kubeflow"
```
```
# POST /api/v1/model_registry/{model_registry_id}/model_versions
curl -i -H "kubeflow-userid: [email protected]" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/model_versions" \
curl -i -H "kubeflow-userid: [email protected]" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/model_versions?namespace=kubeflow" \
-H "Content-Type: application/json" \
-d '{ "data": {
"customProperties": {
Expand All @@ -150,19 +169,19 @@ curl -i -H "kubeflow-userid: [email protected]" -X POST "http://localhost:4000/ap
```
```
# PATCH /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}
curl -i -H "kubeflow-userid: [email protected]" -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1" \
curl -i -H "kubeflow-userid: [email protected]" -X PATCH "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1?namespace=kubeflow" \
-H "Content-Type: application/json" \
-d '{ "data": {
"description": "New description 2"
}}'
```
```
# GET /v1/model_registry/{model_registry_id}/registered_models/{registered_model_id}/versions
curl -i -H "kubeflow-userid: [email protected]" localhost:4000/api/v1/model_registry/model-registry/registered_models/1/versions
curl -i -H "kubeflow-userid: [email protected]" "localhost:4000/api/v1/model_registry/model-registry/registered_models/1/versions?namespace=kubeflow"
```
```
# POST /v1/model_registry/{model_registry_id}/registered_models/{registered_model_id}/versions
curl -i -H "kubeflow-userid: [email protected]" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/registered_models/1/versions" \
curl -i -H "kubeflow-userid: [email protected]" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/registered_models/1/versions?namespace=kubeflow" \
-H "Content-Type: application/json" \
-d '{ "data": {
"customProperties": {
Expand All @@ -176,16 +195,16 @@ curl -i -H "kubeflow-userid: [email protected]" -X POST "http://localhost:4000/ap
"name": "ModelVersion One",
"state": "LIVE",
"author": "alex",
"registeredModelId: "1"
"registeredModelId": "1"
}}'
```
```
# GET /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts
curl -i -H "kubeflow-userid: [email protected]" http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1/artifacts
# GET /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts
curl -i -H "kubeflow-userid: [email protected]" "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1/artifacts?namespace=kubeflow"
```
```
# POST /api/v1/model_registry/{model_registry_id}/model_versions/{model_version_id}/artifacts
curl -i -H "kubeflow-userid: [email protected]" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1/artifacts" \
curl -i -H "kubeflow-userid: [email protected]" -X POST "http://localhost:4000/api/v1/model_registry/model-registry/model_versions/1/artifacts?namespace=kubeflow" \
-H "Content-Type: application/json" \
-d '{ "data": {
"customProperties": {
Expand Down Expand Up @@ -246,13 +265,35 @@ The mock Kubernetes environment is activated when the environment variable `MOCK
- **Namespaces**:
- `kubeflow`
- `dora-namespace`
- `bella-namespace`

- **Users**:
- `[email protected]` (has `cluster-admin` privileges)
- `[email protected]` (restricted to the `dora-namespace`)
- `[email protected]` (restricted to the `bella-namespace`)

- **Groups**:
- `dora-service-group` (has access to `model-registry-dora` inside `dora-namespace`)
- `dora-namespace-group` (has access to the `dora-namespace`)

- **Services (Model Registries)**:
- `model-registry`: resides in the `kubeflow` namespace with the label `component: model-registry`.
- `model-registry-dora`: resides in the `dora-namespace` namespace with the label `component: model-registry`.
- `model-registry-bella`: resides in the `kubeflow` namespace with the label `component: model-registry`.
- `model-registry-one`: resides in the `kubeflow` namespace with the label `component: model-registry`.
- `non-model-registry`: resides in the `kubeflow` namespace *without* the label `component: model-registry`.
- `model-registry-dora`: resides in the `dora-namespace` namespace with the label `component: model-registry`.

#### 3. How BFF authorization works for kubeflow-userid and kubeflow-groups?

Authorization is performed using Kubernetes SubjectAccessReview (SAR), which validates user access to resources.

- `kubeflow-userid`: Required header that specifies the user’s email. Access is checked directly for the user via SAR.
- `kubeflow-groups`: Optional header with a comma-separated list of groups. If the user does not have access, SAR checks group permissions using OR logic. If any group has access, the request is authorized.


Access to Model Registry List:
- To list all model registries (/v1/model_registry), we perform a SAR check for get and list verbs on services within the specified namespace.
- If the user or any group has permission to get and list services in the namespace, the request is authorized.

Access to Specific Model Registry Endpoints:
- For other endpoints (e.g., /v1/model_registry/{model_registry_id}/...), we perform a SAR check for get and list verbs on the specific service (identified by model_registry_id) within the namespace.
- If the user or any group has permission to get or list the specific service, the request is authorized.
47 changes: 22 additions & 25 deletions clients/ui/bff/internal/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import (
)

const (
Version = "1.0.0"
Version = "1.0.0"

PathPrefix = "/api/v1"
ModelRegistryId = "model_registry_id"
RegisteredModelId = "registered_model_id"
Expand Down Expand Up @@ -89,33 +90,29 @@ func (app *App) Routes() http.Handler {
router.NotFound = http.HandlerFunc(app.notFoundResponse)
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)

// HTTP client routes
// HTTP client routes (requests that we forward to Model Registry API)
// on those, we perform SAR on Specific Service on a given namespace
router.GET(HealthCheckPath, app.HealthcheckHandler)
router.GET(RegisteredModelListPath, app.AttachRESTClient(app.GetAllRegisteredModelsHandler))
router.GET(RegisteredModelPath, app.AttachRESTClient(app.GetRegisteredModelHandler))
router.POST(RegisteredModelListPath, app.AttachRESTClient(app.CreateRegisteredModelHandler))
router.PATCH(RegisteredModelPath, app.AttachRESTClient(app.UpdateRegisteredModelHandler))
router.GET(RegisteredModelVersionsPath, app.AttachRESTClient(app.GetAllModelVersionsForRegisteredModelHandler))
router.POST(RegisteredModelVersionsPath, app.AttachRESTClient(app.CreateModelVersionForRegisteredModelHandler))
router.GET(ModelVersionPath, app.AttachRESTClient(app.GetModelVersionHandler))
router.POST(ModelVersionListPath, app.AttachRESTClient(app.CreateModelVersionHandler))
router.PATCH(ModelVersionPath, app.AttachRESTClient(app.UpdateModelVersionHandler))
router.GET(ModelVersionArtifactListPath, app.AttachRESTClient(app.GetAllModelArtifactsByModelVersionHandler))
router.POST(ModelVersionArtifactListPath, app.AttachRESTClient(app.CreateModelArtifactByModelVersionHandler))
router.PATCH(ModelRegistryPath, app.AttachRESTClient(app.UpdateModelVersionHandler))

// Kubernetes client routes
router.GET(RegisteredModelListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllRegisteredModelsHandler))))
router.GET(RegisteredModelPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetRegisteredModelHandler))))
router.POST(RegisteredModelListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateRegisteredModelHandler))))
router.PATCH(RegisteredModelPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateRegisteredModelHandler))))
router.GET(RegisteredModelVersionsPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllModelVersionsForRegisteredModelHandler))))
router.POST(RegisteredModelVersionsPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelVersionForRegisteredModelHandler))))
router.GET(ModelVersionPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient((app.GetModelVersionHandler)))))
router.POST(ModelVersionListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelVersionHandler))))
router.PATCH(ModelVersionPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateModelVersionHandler))))
router.GET(ModelVersionArtifactListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.GetAllModelArtifactsByModelVersionHandler))))
router.POST(ModelVersionArtifactListPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.CreateModelArtifactByModelVersionHandler))))
router.PATCH(ModelRegistryPath, app.AttachNamespace(app.PerformSARonSpecificService(app.AttachRESTClient(app.UpdateModelVersionHandler))))

// Kubernetes routes
router.GET(UserPath, app.UserHandler)
router.GET(ModelRegistryListPath, app.ModelRegistryHandler)
// Perform SAR to Get List Services by Namspace
router.GET(ModelRegistryListPath, app.AttachNamespace(app.PerformSARonGetListServicesByNamespace(app.ModelRegistryHandler)))
if app.config.DevMode {
router.GET(NamespaceListPath, app.GetNamespacesHandler)
}

accessControlExemptPaths := map[string]struct{}{
HealthCheckPath: {},
UserPath: {},
NamespaceListPath: {},
router.GET(NamespaceListPath, app.AttachNamespace(app.GetNamespacesHandler))
}

return app.RecoverPanic(app.enableCORS(app.RequireAccessControl(app.InjectUserHeaders(router), accessControlExemptPaths)))
return app.RecoverPanic(app.enableCORS(app.InjectUserHeaders(router)))
}
Loading

0 comments on commit 2fedf12

Please sign in to comment.