Skip to content

Commit

Permalink
Merge pull request #549 from intuitem/feat/samlv2
Browse files Browse the repository at this point in the history
Feat/samlv2
  • Loading branch information
nas-tabchiche authored Jun 21, 2024
2 parents ff6974c + dde8fb7 commit 552fe62
Show file tree
Hide file tree
Showing 75 changed files with 2,108 additions and 319 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ The docker-compose.yml highlights a relevant configuration with a Caddy proxy in
Set DJANGO_DEBUG=False for security reason.

> [!NOTE]
> The frontend cannot infer the host automatically, so you need to either set the ORIGIN variable, or the HOST_HEADER and PROTOCOL_HEADER variables. Please see [the sveltekit doc](https://kit.svelte.dev/docs/adapter-node#environment-variables-origin-protocolheader-hostheader-and-port-header) on this tricky issue.
> The frontend cannot infer the host automatically, so you need to either set the ORIGIN variable, or the HOST_HEADER and PROTOCOL_HEADER variables. Please see [the sveltekit doc](https://kit.svelte.dev/docs/adapter-node#environment-variables-origin-protocolheader-hostheader-and-port-header) on this tricky issue. Beware that this approach does not work with "npm run dev", which should not be a worry for production.
> [!NOTE]
> Caddy needs to receive a SNI header. Therefore, for your public URL (the one declared in CISO_ASSISTANT_URL), you need to use a FQDN, not an IP address, as the SNI is not transmitted by a browser if the host is an IP address. Another tricky issue!
Expand Down
47 changes: 42 additions & 5 deletions backend/ciso_assistant/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
CORS are not managed by backend, so CORS library is not used
if "POSTGRES_NAME" environment variable defined, the database engine is posgresql
and the other env variables are POSGRES_USER, POSTGRES_PASSWORD, DB_HOST, DB_PORT
and the other env variables are POSTGRES_USER, POSTGRES_PASSWORD, DB_HOST, DB_PORT
else it is sqlite, and no env variable is required
"""
Expand Down Expand Up @@ -122,6 +122,7 @@ def set_ciso_assistant_url(_, __, event_dict):
"django_structlog",
"tailwind",
"iam",
"global_settings",
"core",
"cal",
"django_filters",
Expand All @@ -131,6 +132,11 @@ def set_ciso_assistant_url(_, __, event_dict):
"rest_framework",
"knox",
"drf_spectacular",
"allauth",
"allauth.account",
"allauth.headless",
"allauth.socialaccount",
"allauth.socialaccount.providers.saml",
]

MIDDLEWARE = [
Expand All @@ -143,13 +149,13 @@ def set_ciso_assistant_url(_, __, event_dict):
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_structlog.middlewares.RequestMiddleware",
# "debug_toolbar.middleware.DebugToolbarMiddleware",
# "pyinstrument.middleware.ProfilerMiddleware",
"allauth.account.middleware.AccountMiddleware",
]

ROOT_URLCONF = "ciso_assistant.urls"
LOGIN_REDIRECT_URL = "home"
LOGOUT_REDIRECT_URL = "login"
# we leave these for the API UI tools - even if Django templates and Admin are not used anymore
LOGIN_REDIRECT_URL = "/api"
LOGOUT_REDIRECT_URL = "/api"

AUTH_TOKEN_TTL = int(
os.environ.get("AUTH_TOKEN_TTL", default=60 * 60)
Expand Down Expand Up @@ -201,6 +207,9 @@ def set_ciso_assistant_url(_, __, event_dict):
"MIN_REFRESH_INTERVAL": 60,
}

# Empty outside of debug mode so that allauth middleware does not raise an error
STATIC_URL = ""

if DEBUG:
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"].append(
"rest_framework.renderers.BrowsableAPIRenderer"
Expand Down Expand Up @@ -333,3 +342,31 @@ def set_ciso_assistant_url(_, __, event_dict):
"SERVE_INCLUDE_SCHEMA": False,
# OTHER SETTINGS
}

# SSO with allauth

ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_USERNAME_REQUIRED = False
ACCOUNT_AUTHENTICATION_METHOD = "email"

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

ACCOUNT_ADAPTER = "iam.adapter.MyAccountAdapter"
SOCIALACCOUNT_ADAPTER = "iam.adapter.MySocialAccountAdapter"

SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True

HEADLESS_ONLY = True

HEADLESS_FRONTEND_URLS = {
"socialaccount_login_error": CISO_ASSISTANT_URL + "/login",
}

SOCIALACCOUNT_PROVIDERS = {
"saml": {
"EMAIL_AUTHENTICATION": True,
"VERIFIED_EMAIL": True,
},
}
54 changes: 54 additions & 0 deletions backend/core/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import os
from django.core.management import call_command

from structlog import get_logger

logger = get_logger(__name__)

READER_PERMISSIONS_LIST = [
"view_project",
Expand Down Expand Up @@ -245,6 +248,8 @@
"delete_loadedlibrary",
"backup",
"restore",
"view_globalsettings",
"change_globalsettings",
]


Expand All @@ -255,7 +260,9 @@ def startup(sender: AppConfig, **kwargs):
Create superuser if CISO_ASSISTANT_SUPERUSER_EMAIL defined
"""
from django.contrib.auth.models import Permission
from allauth.socialaccount.providers.saml.provider import SAMLProvider
from iam.models import Folder, Role, RoleAssignment, User, UserGroup
from global_settings.models import GlobalSettings

print("startup handler: initialize database")

Expand Down Expand Up @@ -355,6 +362,53 @@ def startup(sender: AppConfig, **kwargs):
except Exception as e:
print(e) # NOTE: Add this exception in the logger

default_attribute_mapping = SAMLProvider.default_attribute_mapping

settings = {
"attribute_mapping": {
"uid": default_attribute_mapping["uid"],
"email_verified": default_attribute_mapping["email_verified"],
"email": default_attribute_mapping["email"],
},
"idp": {
"entity_id": "",
"metadata_url": "",
"sso_url": "",
"slo_url": "",
"x509cert": "",
},
"sp": {
"entity_id": "ciso-assistant",
},
"advanced": {
"allow_repeat_attribute_name": True,
"allow_single_label_domains": False,
"authn_request_signed": False,
"digest_algorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
"logout_request_signed": False,
"logout_response_signed": False,
"metadata_signed": False,
"name_id_encrypted": False,
"reject_deprecated_algorithm": True,
"reject_idp_initiated_sso": True,
"signature_algorithm": "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
"want_assertion_encrypted": False,
"want_assertion_signed": False,
"want_attribute_statement": True,
"want_message_signed": False,
"want_name_id": False,
"want_name_id_encrypted": False,
},
}

if not GlobalSettings.objects.filter(name=GlobalSettings.Names.SSO).exists():
logger.info("SSO settings not found, creating default settings")
sso_settings = GlobalSettings.objects.create(
name=GlobalSettings.Names.SSO,
value={"client_id": "0", "settings": settings},
)
logger.info("SSO settings created", settings=sso_settings.value)

call_command("storelibraries")


Expand Down
4 changes: 3 additions & 1 deletion backend/core/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ def has_object_permission(self, request: Request, view, obj):
if not perms:
return False
_codename = perms[0].split(".")[1]
if request.method in ["GET", "OPTIONS", "HEAD"] and obj.is_published:
if request.method in ["GET", "OPTIONS", "HEAD"] and getattr(
obj, "is_published", False
):
return True
perm = Permission.objects.get(codename=_codename)
# special case of risk acceptance approval
Expand Down
1 change: 1 addition & 0 deletions backend/core/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ class Meta:
"is_active",
"date_joined",
"user_groups",
"is_sso",
]


Expand Down
9 changes: 8 additions & 1 deletion backend/core/urls.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from iam.sso.views import SSOSettingsViewSet
from .views import *
from library.views import StoredLibraryViewSet, LoadedLibraryViewSet
from iam.sso.saml.views import FinishACSView


from django.urls import include, path
Expand Down Expand Up @@ -42,11 +44,11 @@
router.register(r"stored-libraries", StoredLibraryViewSet, basename="stored-libraries")
router.register(r"loaded-libraries", LoadedLibraryViewSet, basename="loaded-libraries")


urlpatterns = [
path("", include(router.urls)),
path("iam/", include("iam.urls")),
path("serdes/", include("serdes.urls")),
path("settings/", include("global_settings.urls")),
path("csrf/", get_csrf_token, name="get_csrf_token"),
path("build/", get_build, name="get_build"),
path("license/", license, name="license"),
Expand All @@ -55,6 +57,11 @@
path("agg_data/", get_agg_data, name="get_agg_data"),
path("composer_data/", get_composer_data, name="get_composer_data"),
path("i18n/", include("django.conf.urls.i18n")),
path(
"accounts/saml/", include("iam.sso.saml.urls")
), # NOTE: This has to be placed before the allauth urls, otherwise our ACS implementation will not be used
path("accounts/", include("allauth.urls")),
path("_allauth/", include("allauth.headless.urls")),
]

if DEBUG:
Expand Down
6 changes: 4 additions & 2 deletions backend/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ class BaseModelViewSet(viewsets.ModelViewSet):
search_fields = ["name", "description"]
model: models.Model

serializers_module = "core.serializers"

def get_queryset(self):
if not self.model:
return None
Expand Down Expand Up @@ -91,7 +93,7 @@ def get_serializer_class(self):
return super().get_serializer_class()

# Dynamically import the serializer module and get the serializer class
serializer_module = importlib.import_module("core.serializers")
serializer_module = importlib.import_module(self.serializers_module)
serializer_class = getattr(serializer_module, serializer_name)

return serializer_class
Expand Down Expand Up @@ -135,7 +137,7 @@ class Meta:
@action(detail=True, name="Get write data")
def object(self, request, pk):
serializer_name = f"{self.model.__name__}WriteSerializer"
serializer_module = importlib.import_module("core.serializers")
serializer_module = importlib.import_module(self.serializers_module)
serializer_class = getattr(serializer_module, serializer_name)

return Response(serializer_class(super().get_object()).data)
Expand Down
Empty file.
8 changes: 8 additions & 0 deletions backend/global_settings/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import os
from django.apps import AppConfig
from django.db.models.signals import post_migrate


class SettingsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "global_settings"
65 changes: 65 additions & 0 deletions backend/global_settings/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Generated by Django 5.0.6 on 2024-06-20 16:48

import django.db.models.deletion
import iam.models
import uuid
from django.db import migrations, models


class Migration(migrations.Migration):
initial = True

dependencies = [
("iam", "0003_alter_folder_updated_at_alter_role_updated_at_and_more"),
]

operations = [
migrations.CreateModel(
name="GlobalSettings",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created at"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="Updated at"),
),
(
"is_published",
models.BooleanField(default=False, verbose_name="published"),
),
(
"name",
models.CharField(
choices=[("general", "General"), ("sso", "SSO")],
default="general",
max_length=30,
unique=True,
),
),
("value", models.JSONField(default=dict)),
(
"folder",
models.ForeignKey(
default=iam.models.Folder.get_root_folder,
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_folder",
to="iam.folder",
),
),
],
options={
"abstract": False,
},
),
]
Empty file.
28 changes: 28 additions & 0 deletions backend/global_settings/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.db import models

from iam.models import FolderMixin
from core.base_models import AbstractBaseModel


class GlobalSettings(AbstractBaseModel, FolderMixin):
"""
Global settings for the application.
New setting categories should only be added through data migrations.
"""

class Names(models.TextChoices):
GENERAL = "general", "General"
SSO = "sso", "SSO"

# Name of the setting category.
name = models.CharField(
max_length=30,
unique=True,
choices=Names,
default=Names.GENERAL,
)
# Value of the setting.
value = models.JSONField(default=dict)

def __str__(self):
return self.name
27 changes: 27 additions & 0 deletions backend/global_settings/routers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from rest_framework.routers import Route, DynamicRoute, SimpleRouter


class DefaultSettingsRouter(SimpleRouter):
"""
A custom router for settings views.
"""

routes = [
Route(
url=r"^{prefix}{trailing_slash}$",
mapping={
"get": "retrieve",
"put": "update",
"patch": "partial_update",
},
name="{basename}-detail",
detail=True,
initkwargs={"suffix": "Instance"},
),
DynamicRoute(
url=r"^{prefix}/{url_path}{trailing_slash}$",
name="{basename}-{url_name}",
detail=True,
initkwargs={},
),
]
Loading

0 comments on commit 552fe62

Please sign in to comment.