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 support #21

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.12`.

```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
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 @@ -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),
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,14 @@ variable "chart_name" {
type = string
}

variable "account_expiration_days" {
description = "Days a self-registered account remains active before expiring."
type = number
}

variable "approved_domains" {
description = "Approved email domains for user self registration"
type = list(string)
}

variable "coupons" {
description = "Valid coupons for user self registration"
type = list(string)
description = "Coupon configuration for user self registration"
type = map(object({
account_expiration_days = number
approved_domains = list(string)
registration_groups = list(string)
}))
default = {}
}

variable "create_namespace" {
Expand Down Expand Up @@ -46,11 +41,6 @@ variable "realm_id" {
type = string
}

variable "registration_group" {
description = "Name of Keycloak group to add registering users"
type = string
}

variable "registration_message" {
description = "Custom message to display to registering users"
type = string
Expand Down Expand Up @@ -84,7 +74,6 @@ variable "affinity" {
}
}


variable "cloud_provider" {
type = string
}
Expand Down
Loading
Loading