From 1735de8427c0d6840bd272550d351cb28344390e Mon Sep 17 00:00:00 2001 From: iizitounene Date: Tue, 12 Mar 2024 17:54:06 +0100 Subject: [PATCH] Update Readme --- README.md | 270 ++++++++++++++++++++++++- docs/images/Confidential-client.png | Bin 0 -> 48346 bytes docs/images/authentication-filter.png | Bin 0 -> 292411 bytes docs/images/authorization-provider.png | Bin 0 -> 183423 bytes 4 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 docs/images/Confidential-client.png create mode 100644 docs/images/authentication-filter.png create mode 100644 docs/images/authorization-provider.png diff --git a/README.md b/README.md index 13e0fdd..c85efdd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,270 @@ -# okdp-spark-auth-filter +![Build](https://github.com/okdp/okdp-spark-auth-filter/actions/workflows/ci.yml/badge.svg?branch=main) [![License Apache2](https://img.shields.io/hexpm/l/plug.svg)](http://www.apache.org/licenses/LICENSE-2.0) + +[Apache Spark](https://spark.apache.org/) extension filter to enable Oauth2/OpenID Connect based authentication for Spark UIs and Spark History. + +![Architecture](docs/images/authorization-provider.png) + +The project consists of two main components: + +1. `Authentication filter`: Authenticates the user against an Oauth2/OIDC provider by implementing the [Authorization Code grant flow](https://datatracker.ietf.org/doc/html/rfc6749). + The filter supports all the providers compliant with Oauth2 and OpenID standards. +3. `Authorization provider`: An additional optional layer, on top of the `Authentication filter`, authorizes Spark UI/History UI user access +by comparing the user email and/or groups and/or roles returned by the Oauth2/OIDC provider during the authentication phase with the configured [spark ACLs](https://spark.apache.org/docs/latest/security.html#authentication-and-authorization) + +The implementation architecture of the `Authorization Code` grant flow is described by the following schema: + +![Authorization Grant flow](docs/images/authentication-filter.png) + +For more details, please check the [Authorization Code grant flow](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1). + +# Authorization Grant support matrix + +| Authorization Grant | Support | Description | +|:---------------------------------------|--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Authorization Code` | :heavy_check_mark: | Confidential client - Authorization Code standard flow | +| `Authorization Code + PKCE extension` | :x: | The [feature](#Use-cases-limitation-and-future-work) will be implemented in a separate UI portal ([See challenges/JS and Browser local cache](https://www.ietf.org/archive/id/draft-ietf-oauth-security-topics-25.html) | +| `Implicit` | :x: | Deprecated | +| `Resource Owner Password Credentials` | :x: | Not suitable | +| `Client Credentials` | :x: | NA | + + +# Installation + +1. Using Docker + +```shell +ADD https://repo1.maven.org/maven2/io/okdp/okdp-spark-auth-filter/1.0.0/okdp-spark-auth-filter-1.0.0.jar ${SPARK_HOME}/jars +``` + +2. Using Maven + +```xml + + io.okdp + okdp-spark-auth-filter + 1.0.0 + +``` + +3. Spark on Yarn/Standalone mode + +Copy the jar https://repo1.maven.org/maven2/io/okdp/okdp-spark-auth-filter/1.0.0/okdp-spark-auth-filter-1.0.0.jar into `${SPARK_HOME}/jars/` in the different spark nodes + +# Configuration + +## Create an Oauth2/OIDC client + +Create an Oauth2/OIDC client with an `Authorization Code grant` flow (confidential client). + +Set the redirect URL to a valid spark UI or Spark History UI home page. + +For [keycloak](https://www.keycloak.org/docs/latest/server_admin/#_oidc_clients), +* Access Type: `Confidential` +* Standard Flow Enabled: `Enabled` +* Implicit Flow Enabled: `Disabled` +* Direct Access Grants Enabled: `Disabled` + +Once done, save the `client_id` and `client_secret` into your secret management vault. + +## Configure the authentication filter + +The filter relies on the spark [spark.ui.filters](https://spark.apache.org/docs/latest/configuration.html) configuration property. + +| Property | Equivalent env variable | Default | Description | +|:-----------------------------|--------------------------------|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| `issuer-uri` | `AUTH_ISSUER_URI` | - | OIDC Provider issuer URL
This is used to discover OIDC endpoints | +| `client-id` | `AUTH_CLIENT_ID` | - | The Oauth2/OIDC client Id | +| `client-secret` | `AUTH_CLIENT_SECRET` | - | The Oauth2/OIDC client secret | +| `redirect-uri` | `AUTH_REDIRECT_URI` | - | Spark UI/History home page
ex.: https://spark-history.example.com/home | +| `scope` | `AUTH_SCOPE` | - | The scope(s) requested the Authorization Request.
Example: `openid+profile+email+roles+offline_access` | +| `cookie-max-age-minutes` | `AUTH_COOKE_MAX_AGE_MINUTES` | 12 * 60 | The maximum spark-cookie cookie duration in minutes | +| `cookie-cipher-secret-key` | `AUTH_COOKIE_ENCRYPTION_KEY` | - | Cookie encryption key
Can be generated using: `openssl enc -aes-128-cbc -k -P -md sha1 -pbkdf2` | +| `cookie-is-secure` | `AUTH_COOKE_IS_SECURE` | true | When enabled, the cookie is transmitted over a secure connection only (HTTPS).
Disable the option if your run with a non secure connection (HTTP) | + +> [!NOTE] +> 1. `issuer-uri` property or `AUTH_ISSUER_URI` env variable +> +> Try to access the endpoint `/.well-known/openid-configuration` (public access) to check if the `issuer-uri` is valid. +> +> This should return the different authentication endpoints (authorization, access token, user info endpoints, supported scopes etc.). +> +> For keycloack, the default `issuer-uri` is at `https:///auth/realms/master/` and `https:///auth/realms/master/.well-known/openid-configuration` is the well known configuration endpoint. +> +> 2. `cookie-cipher-secret-key` property or `AUTH_COOKIE_ENCRYPTION_KEY` env variable +> +> Generate the cookie encryption key by issuing the command: +> +> ```shell +> openssl enc -aes-128-cbc -k -P -md sha1 -pbkdf2 +> ``` +> +> 3. `scope` property or `AUTH_SCOPE` env variable +> +> The minimum required scope to turn on authentication is: `openid+profile+email` +> +> Add offline scope `offline_access` to enable the refresh token +> +> Add the `roles` and/or `groups` scope to enable role/groups based authorization +> +> It is not necessary to add groups and/or roles `scope` if you only need the authentication, and you don't need the authorization. +> +> N.B.: Please, note that the `groups` scope is not supported by the most OIDC providers. You can check the supported scopes at `/.well-known/openid-configuration` url. +> +> N.B.: Keycloack supports returning the groups for a user by adding `Group Membership mapper` to your client +> +> 4. `cookie-is-secure` property or `AUTH_COOKE_IS_SECURE` env variable +> +> It's recommended to secure the connection to your spark UIs by enabling HTTPS. Although, the spark cookie is encrypted, it's recommended to send it over an encrypted connection. +> +> By default, the property is enabled. Disable the property if your connection is not secure otherwise the cookie will not be sent +> + +### Basic configuration + +The filter can be enabled either by setting the properties globally in the `spark-defaults.properties` + +```properties +spark.ui.filters=io.okdp.spark.authc.OidcAuthFilter +spark.io.okdp.spark.authc.OidcAuthFilter.param.issuer-uri= +spark.io.okdp.spark.authc.OidcAuthFilter.param.client-id= +spark.io.okdp.spark.authc.OidcAuthFilter.param.client-secret= +spark.io.okdp.spark.authc.OidcAuthFilter.param.redirect-uri= +spark.io.okdp.spark.authc.OidcAuthFilter.param.scope= +spark.io.okdp.spark.authc.OidcAuthFilter.param.cookie-max-age-minutes=480 +spark.io.okdp.spark.authc.OidcAuthFilter.param.cookie-cipher-secret-key= +spark.io.okdp.spark.authc.OidcAuthFilter.param.cookie-is-secure= +``` + +Or during the job submission like the following: + +```shell +spark-submit --conf spark.ui.filters=io.okdp.spark.authc.OidcAuthFilter \ +--conf spark.io.okdp.spark.authc.OidcAuthFilter.param.issuer-uri= \ +--conf spark.io.okdp.spark.authc.OidcAuthFilter.param.client-id= \ +--conf spark.io.okdp.spark.authc.OidcAuthFilter.param.client-secret= \ +--conf spark.io.okdp.spark.authc.OidcAuthFilter.param.redirect-uri= \ +--conf spark.io.okdp.spark.authc.OidcAuthFilter.param.scope= \ +--conf spark.io.okdp.spark.authc.OidcAuthFilter.param.cookie-max-age-minutes=480 \ +--conf spark.io.okdp.spark.authc.OidcAuthFilter.param.cookie-cipher-secret-key= \ +--conf spark.io.okdp.spark.authc.OidcAuthFilter.param.cookie-is-secure= \ +--class ... +``` + +### Kubernetes configuration + +The properties can also be passed by their equivalent env variables. + +You can save the client_id, client secret and the cookie encryption key in a kubernetes secret and reference it as an env variable like the following: + +```yaml +env: +- name: AUTH_ISSUER_URI + value: +- name: AUTH_CLIENT_ID + valueFrom: + secretKeyRef: +- name: AUTH_REDIRECT_URI + value: +- name: AUTH_SCOPE + value: openid+profile+email+roles+offline_access +- name: AUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: +- name: AUTH_COOKIE_ENCRYPTION_KEY + valueFrom: + secretKeyRef: +``` + + +## Configure the authorization provider (optional) +### Overview + +An example of a raw access token returned by the Oauth2/OIDC provider during a successful authentication is like the following: + +```json +{ + "access_token": "eyJhbGciOiJI6-auxZsE6...", + "token_type": "bearer", + "expires_in": 86399, + "refresh_token": "ChlvaWJmNXBuaG1rdWN0e...", + "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjBkZWEw..." +} +``` + +The token payload after [decoding](https://jwt.io/) the `access_token` is as follows: + +```json +{ + "iss": "", + "sub": "CgNib2ISBGxkYXA", + "aud": "", + "exp": 1708476719, + "iat": 1708390319, + "at_hash": "x_kKHrjGfnSfkjDwIGPPbg", + "email": "bob@example.org", + "email_verified": true, + "groups": ["admins", "team1", "/team2"], + "roles": ["role-team1", "admin-role"], + "name": "bob" +} +``` + +The `"email"`, `"groups"` and/or `"roles"` can be mapped in Spark ACLs to consequently grant or denies access. + +### Configuration + +A basic configuration properties to enable the provider globally, in `spark-defaults.conf`, are: + +```properties +spark.user.groups.mapping=io.okdp.spark.authz.OidcGroupMappingServiceProvider +spark.acls.enable=true +spark.history.ui.acls.enable=true +# Comma separated list of admin groups (view all applications) +spark.history.ui.admin.acls.groups=admins,team1 +``` + +These properties should be set before the spark history starts. + +You can also decide globally which users, roles and/or groups you grant access to your applications individually in spark history UI +or your spark ui by adding the properties in `spark-defaults.conf`: + +Select the properties to enable the authorization for: + +```properties +#Comma separated list of groups +spark.admin.acls.groups=admins,admin-role +spark.modify.acls.groups=team1 +spark.ui.view.acls.groups=/team2,role-team1 + +# Comma separated list of users +spark.admin.acls=bob@example.org +spark.modify.acls=bob@example.org +spark.ui.view.acls=bob@example.org,bill@example.org +``` + +Or at spark job submission time (select the properties to enable): + +```shell +spark-submit -conf spark.admin.acls.groups=admins,admin-role \ +--conf spark.modify.acls.groups=team1 \ +--conf spark.ui.view.acls.groups=/team2,role-team1 \ +--conf spark.admin.acls=bob@example.org \ +--conf spark.modify.acls=bob@example.org \ +--conf spark.ui.view.acls=bob@example.org,bill@example.org \ + ... +``` + +# Use cases, limitation and future work + +The filter is designed to address a basic use cases where you don't need to deploy extra components in order to secure your Spark UIs or Spark History. + +In real world kubernetes integration, each spark application submission creates its own ingress endpoint. With hundreds of running spark applications, it becomes very difficult to track all the endpoints and configure them. + +Another limitation, is that depending on the oidc provider, the number of redirect URIs per oidc client can be limited and the usage of URIs pattern can also be [prohibited](https://www.ietf.org/archive/id/draft-ietf-oauth-security-topics-25.html). + +A new central portal UI is under development to support more authorization grants (PKCE), simplify dynamic discovery, log/monitoring tracking and provides shortcuts to easily navigate and filter the spark applications. + + + + diff --git a/docs/images/Confidential-client.png b/docs/images/Confidential-client.png new file mode 100644 index 0000000000000000000000000000000000000000..0b5a3d15518ff6fd0d848a8d1e0cfa13e794e7f8 GIT binary patch literal 48346 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

5%Xk>Vc};v5N{g|-LXU4 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