Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi coupon #24

Merged
merged 11 commits into from
Oct 28, 2024
30 changes: 19 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,10 @@ The configuration of your self registration app can be customized in several way

Configuration options include:


- **account_expiration_days (optional)**: Days an account remains active after the user registers. Defaults to 7. Note that the calculated end date is saved in Keycloak user attribute `account_expiration_date` and can be manually overridden by a Keycloak administrator.
- **approved_domains (required)**: List of approved email domains that can register accounts using the self registration service. (supports names like `gmail.com` and wildcards such as `*.edu` or even `*`)
- **coupons (required)**: List of coupon codes that can be used by individuals during the self registration process.
- **registration_group (required)**: Keycloak group where all registering users will be added. This group can then be used to assign user properties such as available JupyterLab instance types, app sharing permissions, etc.
- **coupons (required)**: Map of coupon codes and their configuration that can be used by individuals during the self registration process. The coupon configuration options are:
- **account_expiration_days (optional)**: Days an account remains active after the user registers. Defaults to 7. Note that the calculated end date is saved in Keycloak user attribute `account_expiration_date` and can be manually overridden by a Keycloak administrator.
- **approved_domains (required)**: List of approved email domains that can register accounts using the self registration service. (supports names like `gmail.com` and wildcards such as `*.edu` or even `*`)
- **registration_groups (optional)**: List of Keycloak group where all registering users will be added. This group can then be used to assign user properties such as available JupyterLab instance types, app sharing permissions, etc.
- **name (optional)**: Name for resources that this extension will deploy via Terraform and Helm. Defaults to `self-registration`
- **namespace (optional)**: Kubernetes namespace for this service. Defaults to Nebari's default namespace.
- **registration_message (optional)**: A custom message to display on the landing page `/registration`
Expand All @@ -42,6 +41,10 @@ Configuration options include:
> **NOTE:** The `registration_group` must have been created in the Nebari realm in Keycloak prior to deploying the extension.

#### Example Nebari Config File

> [!NOTE]
> The configuration options for the plugin were recently updated. Previously, `self_registration.coupons` accepted a list of coupon codes and there were shared options for all the specified coupons (e.g., `approved_domains`, `account_expiration_days`, etc...). Now, the field takes a map of coupon codes, where each coupon accepts individual configuration options (as outlined below). Please make sure to update the configuration values when updating to newer versions of the plugin after `0.0.14`.

```yaml
provider: aws
namespace: dev
Expand All @@ -53,12 +56,17 @@ project_name: my-project
self_registration:
namespace: self-registration
coupons:
- abcdefg
approved_domains:
- gmail.com
- '*.edu'
account_expiration_days: 30
registration_group: test-group
abcdefg:
approved_domains:
- gmail.com
- '*.edu'
account_expiration_days: 30
registration_groups: [test-group, developer]
hijklmn:
approved_domains:
- '*'
account_expiration_days: 7
registration_groups: [admin]
affinity:
enabled: true
selector:
Expand Down
37 changes: 19 additions & 18 deletions self-registration/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
from datetime import datetime, timedelta

import yaml
from fastapi import APIRouter, FastAPI, Form, Request
from fastapi import FastAPI, Form, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from keycloak import KeycloakAdmin, KeycloakConnectionError, KeycloakGetError

from theme import DEFAULT_THEME


Expand Down Expand Up @@ -36,8 +37,7 @@ class UserExistsException(Exception):
config = {}


def check_email_domain(email):
approved_domains = config.get("approved_domains", [])
def check_email_domain(email, approved_domains):
for domain in approved_domains:
# Replace wildcard with its regex equivalent
pattern = domain.replace("*", ".*")
Expand Down Expand Up @@ -96,7 +96,7 @@ def generate_random_password(length=12):


# Function to assign a user to a group
def assign_user_to_group(user, group_name):
def assign_user_to_groups(user, groups):
try:
keycloak_admin = KeycloakAdmin(
server_url=config["keycloak"]["server_url"],
Expand All @@ -109,16 +109,17 @@ def assign_user_to_group(user, group_name):
except KeycloakConnectionError:
return False

# Get group
try:
group = keycloak_admin.get_group_by_path(group_name)
except KeycloakGetError:
return False # Fail if Keycloak group throws exception finding group
if not group:
return False # Also fail if Keycloak admin doesn't throw exception but group is still missing
for group_name in groups:
# Get group
try:
group = keycloak_admin.get_group_by_path(group_name)
except KeycloakGetError:
return False # Fail if Keycloak group throws exception finding group
if not group:
return False # Also fail if Keycloak admin doesn't throw exception but group is still missing

# Assign the user to the group
keycloak_admin.group_user_add(user["id"], group["id"])
# Assign the user to the group
keycloak_admin.group_user_add(user["id"], group["id"])

return True

Expand Down Expand Up @@ -156,20 +157,20 @@ def read_root(request: Request):

@app.post(url_prefix + "/validate/")
async def validate_submission(request: Request, email: str = Form(...), coupon_code: str = Form(...)):
if coupon_code in config.get("coupons", []):
if check_email_domain(email):
if coupon_config := config.get("coupons", {}).get(coupon_code):
if check_email_domain(email, coupon_config.get("approved_domains", [])):

# Create the user in Keycloak
try:
user, temporary_password, expiration_date = create_keycloak_user(
email, config.get("account_expiration_days", None)
email, coupon_config.get("account_expiration_days", None)
)
except UserExistsException as e:
return templates.TemplateResponse("index.html", get_template_context(request, str(e)))

# Assign user to group
if user:
success = assign_user_to_group(user, config.get("registration_group", None))
success = assign_user_to_groups(user, coupon_config.get("registration_groups", []))

if success:
return templates.TemplateResponse(
Expand All @@ -189,7 +190,7 @@ async def validate_submission(request: Request, email: str = Form(...), coupon_c
"index.html",
get_template_context(
request,
"User created but could not be assigned to JupyterLab group. Please contact support for assistance.",
"User created but could not be assigned to one or more groups. Please contact support for assistance.",
),
)
else:
Expand Down
2 changes: 1 addition & 1 deletion src/nebari_plugin_self_registration/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.0.14"
__version__ = "0.0.15"
24 changes: 15 additions & 9 deletions src/nebari_plugin_self_registration/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,23 @@ class SelfRegistrationAffinitySelectorConfig(Base):
app: Optional[str] = ""
job: Optional[str] = ""


class SelfRegistrationAffinityConfig(Base):
enabled: Optional[bool] = True
selector: Union[SelfRegistrationAffinitySelectorConfig, str] = "general"


class SelfRegistrationCouponConfig(Base):
account_expiration_days: Optional[int] = 7
approved_domains: Optional[List[str]] = []
registration_groups: Optional[List[str]] = []


class SelfRegistrationConfig(Base):
name: Optional[str] = "self-registration"
namespace: Optional[str] = None
values: Optional[Dict[str, Any]] = {}
account_expiration_days: Optional[int] = 7
approved_domains: Optional[List[str]] = []
coupons: Optional[List[str]] = []
registration_group: Optional[str] = ""
coupons: Optional[Dict[str, SelfRegistrationCouponConfig]] = {}
registration_message: Optional[str] = ""
affinity: SelfRegistrationAffinityConfig = SelfRegistrationAffinityConfig()

Expand Down Expand Up @@ -141,12 +145,14 @@ def input_vars(self, stage_outputs: Dict[str, Dict[str, Any]]):
chart_ns = self.config.namespace
create_ns = False

try:
theme = self.config.theme.jupyterhub.dict()
except AttributeError:
theme = {}

return {
"chart_name": self.config.self_registration.name,
"account_expiration_days": self.config.self_registration.account_expiration_days,
"approved_domains": self.config.self_registration.approved_domains,
"coupons": self.config.self_registration.coupons,
"registration_group": self.config.self_registration.registration_group,
"coupons": self.config.self_registration.model_dump()["coupons"], # serialize nested objects using model_dump()
"registration_message": self.config.self_registration.registration_message,
"project_name": self.config.escaped_project_name,
"realm_id": keycloak_config["realm_id"],
Expand All @@ -168,7 +174,7 @@ def input_vars(self, stage_outputs: Dict[str, Dict[str, Any]]):
),
},
"cloud_provider": self.config.provider,
"theme": self.config.theme.jupyterhub.dict(),
"theme": theme,
}

def get_keycloak_config(self, stage_outputs: Dict[str, Dict[str, Any]]):
Expand Down
11 changes: 4 additions & 7 deletions src/nebari_plugin_self_registration/terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,19 @@ locals {
module "keycloak" {
source = "./modules/keycloak"

realm_id = var.realm_id
client_id = var.client_id
base_url = var.base_url
realm_id = var.realm_id
client_id = var.client_id
base_url = var.base_url
}

module "self-registration" {
source = "./modules/self-registration"

approved_domains = var.approved_domains
account_expiration_days = var.account_expiration_days
chart_name = var.chart_name
coupons = var.coupons
create_namespace = var.create_namespace
ingress_host = var.ingress_host
self_registration_sa_name = local.self_registration_sa_name
registration_group = var.registration_group
registration_message = var.registration_message
namespace = var.namespace
keycloak_base_url = var.external_url
Expand All @@ -30,4 +27,4 @@ module "self-registration" {
affinity = var.affinity
cloud_provider = var.cloud_provider
theme = var.theme
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ locals {
}

resource "keycloak_openid_client" "this" {
realm_id = var.realm_id
name = var.client_id
client_id = var.client_id
access_type = "CONFIDENTIAL"
base_url = var.base_url
enabled = true
service_accounts_enabled = true
realm_id = var.realm_id
name = var.client_id
client_id = var.client_id
access_type = "CONFIDENTIAL"
base_url = var.base_url
enabled = true
service_accounts_enabled = true
}

# Get manage-users role via data and assign it to registration client service account
Expand All @@ -26,7 +26,7 @@ data "keycloak_role" "manage_users" {
resource "keycloak_openid_client_service_account_role" "registration_service_account_role" {
realm_id = var.realm_id
service_account_user_id = keycloak_openid_client.this.service_account_user_id
# Need to source as data?
client_id = data.keycloak_openid_client.realm_management.id
role = data.keycloak_role.manage_users.name
# Need to source as data?
client_id = data.keycloak_openid_client.realm_management.id
role = data.keycloak_role.manage_users.name
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ variable "client_id" {
variable "base_url" {
description = "Default URL to use when the auth server needs to redirect or link back to the client"
type = string
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.0.16
version: 0.0.17

# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "0.0.14"
appVersion: "0.0.15"
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ image:
repository: quay.io/nebari/nebari-self-registration
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: "20241023-1753"
tag: "20241025-1410"

imagePullSecrets: []
nameOverride: ""
Expand Down Expand Up @@ -99,15 +99,11 @@ job:
securityContext: {}
resources: {}
affinity: {}


app_configuration:
account_expiration_days: 7
approved_domains: []
coupons: []
coupons: {}
keycloak:
server_url: "http://server.com/auth"
realm_name: "my-realm"
client_id: "self-registration"
client_secret: ""
registration_group: ""
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ locals {
{ for k in ["default", "app", "job"] : k => length(var.affinity.selector[k]) > 0 ? var.affinity.selector[k] : var.affinity.selector.default },
{
app = var.affinity.selector
job = var.affinity.selector
job = var.affinity.selector
},
)
} : {
Expand All @@ -14,8 +14,8 @@ locals {
}

affinity_selector_key = {
aws = "eks.amazonaws.com/nodegroup"
gcp = "cloud.google.com/gke-nodepool"
aws = "eks.amazonaws.com/nodegroup"
gcp = "cloud.google.com/gke-nodepool"
}
}

Expand Down Expand Up @@ -80,22 +80,19 @@ resource "helm_release" "self_registration" {
name = var.self_registration_sa_name
}
app_configuration = {
coupons = var.coupons
approved_domains = var.approved_domains
account_expiration_days = var.account_expiration_days
registration_group = var.registration_group
registration_message = var.registration_message
coupons = var.coupons
registration_message = var.registration_message
keycloak = {
server_url = var.keycloak_base_url
realm_name = var.realm_id
client_id = var.keycloak_config["client_id"]
client_secret = var.keycloak_config["client_secret"]
}
theme = var.theme
theme = var.theme
}
env = [
]
}),
yamlencode(var.overrides),
]
}
}
Loading
Loading