Skip to content

Commit

Permalink
✨[#114] add setup configuration
Browse files Browse the repository at this point in the history
* Add setup configuraiton model and step
* Add OIDCSetupConfigForm with only required fields
* Create common create_missing_groups util
  • Loading branch information
Coperh committed Nov 22, 2024
1 parent 4496f50 commit 5715b80
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 16 deletions.
19 changes: 5 additions & 14 deletions mozilla_django_oidc_db/backends.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import fnmatch
import logging
from collections.abc import Collection
from typing import Any, TypeAlias, cast
Expand All @@ -25,7 +24,7 @@
from .jwt import verify_and_decode_token
from .models import OpenIDConnectConfigBase, UserInformationClaimsSources
from .typing import ClaimPath, JSONObject
from .utils import extract_content_type, obfuscate_claims
from .utils import create_missing_groups, extract_content_type, obfuscate_claims

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -385,18 +384,10 @@ def _set_user_groups(
return

# Create missing groups if required
existing_groups = set(Group.objects.filter(name__in=desired_group_names))
existing_group_names = {group.name for group in existing_groups}
filtered_names = fnmatch.filter(
set(desired_group_names) - existing_group_names, sync_groups_glob
)
groups_to_create = (
[Group(name=name) for name in filtered_names] if sync_missing_groups else []
)
if groups_to_create:
# postgres sets the PK after bulk_create
Group.objects.bulk_create(groups_to_create)
existing_groups |= set(groups_to_create)
if sync_missing_groups:
existing_groups = create_missing_groups(desired_group_names, sync_groups_glob)
else:
existing_groups = set(Group.objects.filter(name__in=desired_group_names))

# at this point, existing_groups is the full collection of groups that should be
# set on the user model, because:
Expand Down
4 changes: 3 additions & 1 deletion mozilla_django_oidc_db/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ def __init__(self, *bits: str):
self.bits = list(bits)

def __eq__(self, other) -> bool:
return self.bits == other.bits
if isinstance(other, ClaimFieldDefault):
return self.bits == other.bits
return False

def __call__(self) -> list[str]:
return self.bits
Expand Down
14 changes: 14 additions & 0 deletions mozilla_django_oidc_db/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,17 @@ def clean(self):
self.add_error(field, _("This field is required."))

return cleaned_data


class OIDCSetupConfigForm(OpenIDConnectConfigForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

if self.fields:
self.fields["oidc_rp_sign_algo"].required = False
self.fields["oidc_nonce_size"].required = False
self.fields["oidc_state_size"].required = False
self.fields["userinfo_claims_source"].required = False
self.fields["username_claim"].required = False
self.fields["claim_mapping"].required = False
self.fields["sync_groups_glob_pattern"].required = False
Empty file.
92 changes: 92 additions & 0 deletions mozilla_django_oidc_db/setup_configuration/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from typing import Optional, Union

from django_setup_configuration.fields import DjangoModelRef
from django_setup_configuration.models import ConfigurationModel
from pydantic import AnyUrl, Discriminator, Tag
from typing_extensions import Annotated

from mozilla_django_oidc_db.models import OpenIDConnectConfig


class OIDCFullEndpointConfig(ConfigurationModel):
oidc_op_authorization_endpoint: AnyUrl = DjangoModelRef(
OpenIDConnectConfig, "oidc_op_authorization_endpoint"
)
oidc_op_token_endpoint: AnyUrl = DjangoModelRef(
OpenIDConnectConfig, "oidc_op_token_endpoint"
)
oidc_op_user_endpoint: AnyUrl = DjangoModelRef(
OpenIDConnectConfig, "oidc_op_user_endpoint"
)


class OIDCDiscoveryEndpoint(ConfigurationModel):
oidc_op_discovery_endpoint: AnyUrl = DjangoModelRef(
OpenIDConnectConfig, "oidc_op_discovery_endpoint", default=None
)


def get_endpoint_endpoint_model(endpoint_data):

if isinstance(endpoint_data, dict):
discovery_endpoint = endpoint_data.get("oidc_op_discovery_endpoint")
else:
discovery_endpoint = getattr(endpoint_data, "oidc_op_discovery_endpoint", None)
if discovery_endpoint:
return "discovery"
return "all"


EndpointConfigUnion = Annotated[
Union[
Annotated[OIDCFullEndpointConfig, Tag("all")],
Annotated[OIDCDiscoveryEndpoint, Tag("discovery")],
],
Discriminator(get_endpoint_endpoint_model),
]


class AdminOIDCConfigurationModel(ConfigurationModel):

# Json
claim_mapping: Optional[dict] = DjangoModelRef(OpenIDConnectConfig, "claim_mapping")

# Arrays are overridden to make the typing simpler (the underlying Django field is an ArrayField, which is non-standard)
username_claim: Optional[list[str]] = DjangoModelRef(
OpenIDConnectConfig, "username_claim"
)
groups_claim: Optional[list[str]] = DjangoModelRef(
OpenIDConnectConfig, "groups_claim"
)
superuser_group_names: Optional[list[str]] = DjangoModelRef(
OpenIDConnectConfig, "superuser_group_names"
)
default_groups: Optional[list[str]] = DjangoModelRef(
OpenIDConnectConfig, "superuser_group_names"
)
oidc_rp_scopes_list: Optional[list[str]] = DjangoModelRef(
OpenIDConnectConfig, "oidc_rp_scopes_list"
)

endpoint_config: EndpointConfigUnion

class Meta:
django_model_refs = {
OpenIDConnectConfig: [
"oidc_rp_client_id",
"oidc_rp_client_secret",
"oidc_token_use_basic_auth",
"oidc_rp_sign_algo",
"oidc_rp_idp_sign_key",
"oidc_op_logout_endpoint",
"oidc_op_jwks_endpoint",
"oidc_use_nonce",
"oidc_nonce_size",
"oidc_state_size",
"oidc_keycloak_idp_hint",
"userinfo_claims_source",
"sync_groups",
"sync_groups_glob_pattern",
"make_users_staff",
]
}
53 changes: 53 additions & 0 deletions mozilla_django_oidc_db/setup_configuration/steps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from django_setup_configuration.configuration import BaseConfigurationStep
from django_setup_configuration.exceptions import ConfigurationRunFailed

from mozilla_django_oidc_db.forms import OIDCSetupConfigForm
from mozilla_django_oidc_db.models import OpenIDConnectConfig
from mozilla_django_oidc_db.setup_configuration.models import (
AdminOIDCConfigurationModel,
)
from mozilla_django_oidc_db.utils import create_missing_groups


class AdminOIDCConfigurationStep(BaseConfigurationStep[AdminOIDCConfigurationModel]):
"""
Configure admin login via OpenID Connect
"""

verbose_name = "Configuration for admin login via OpenID Connect"
config_model = AdminOIDCConfigurationModel
namespace = "ADMIN_OIDC"
enable_setting = "ADMIN_OIDC_CONFIG_ENABLE"

def execute(self, model: AdminOIDCConfigurationModel) -> None:

config = OpenIDConnectConfig.get_solo()

base_model_data = model.model_dump()
endpoint_config_data = base_model_data.pop("endpoint_config")

all_settings = {
"sync_groups": config.sync_groups,
"oidc_use_nonce": config.oidc_use_nonce,
"enabled": True,
"claim_mapping": config.claim_mapping, # JSONFormField widget cannot handle blank values with object schema
"sync_groups_glob_pattern": config.sync_groups_glob_pattern,
**base_model_data,
**endpoint_config_data,
}

if groups := all_settings.get("default_groups"):
all_settings["default_groups"] = create_missing_groups(
groups, all_settings["sync_groups_glob_pattern"]
)

form = OIDCSetupConfigForm(
instance=config,
data=all_settings,
)
if not form.is_valid():
raise ConfigurationRunFailed(
"Admin OIDC configuration field validation failed",
form.errors.as_json(),
)
form.save()
24 changes: 23 additions & 1 deletion mozilla_django_oidc_db/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import fnmatch
import logging
from collections.abc import Collection
from collections.abc import Collection, Iterable
from copy import deepcopy

from django.contrib.auth.models import Group

import requests
from glom import Path, PathAccessError, assign, glom
from requests.utils import _parse_content_type_header # type: ignore
Expand Down Expand Up @@ -89,3 +92,22 @@ def do_op_logout(config: OpenIDConnectConfigBase, id_token: str) -> None:
"status_code": response.status_code,
},
)


def create_missing_groups(
group_names: Iterable[str], sync_groups_glob: str = "*"
) -> set[Group]:

existing_groups = set(Group.objects.filter(name__in=group_names))
existing_group_names = {group.name for group in existing_groups}

filtered_names = fnmatch.filter(
set(group_names) - existing_group_names, sync_groups_glob
)

groups_to_create = [Group(name=name) for name in filtered_names]
if groups_to_create:
# postgres sets the PK after bulk_create
Group.objects.bulk_create(groups_to_create)
existing_groups |= set(groups_to_create)
return existing_groups

0 comments on commit 5715b80

Please sign in to comment.