mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 12:31:54 +00:00
fix(saml): restore SAML, deactivate urls, enable idp-initiate (#8175)
This commit is contained in:
committed by
GitHub
parent
b38207507a
commit
cd97e57521
@@ -4,6 +4,9 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [v1.10.0] (Prowler UNRELEASED)
|
||||
|
||||
### Added
|
||||
- SSO with SAML support [(#8175)](https://github.com/prowler-cloud/prowler/pull/8175)
|
||||
|
||||
---
|
||||
|
||||
## [v1.9.1] (Prowler v5.8.1)
|
||||
|
||||
@@ -17,8 +17,8 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
def pre_social_login(self, request, sociallogin):
|
||||
# Link existing accounts with the same email address
|
||||
email = sociallogin.account.extra_data.get("email")
|
||||
# if sociallogin.account.provider == "saml":
|
||||
# email = sociallogin.user.email
|
||||
if sociallogin.provider.id == "saml":
|
||||
email = sociallogin.user.email
|
||||
if email:
|
||||
existing_user = self.get_user_by_email(email)
|
||||
if existing_user:
|
||||
@@ -31,98 +31,39 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
"""
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
user = super().save_user(request, sociallogin, form)
|
||||
provider = sociallogin.provider.id
|
||||
extra = sociallogin.account.extra_data
|
||||
|
||||
# if provider == "saml":
|
||||
# # Handle SAML-specific logic
|
||||
# user.first_name = (
|
||||
# extra.get("firstName", [""])[0] if extra.get("firstName") else ""
|
||||
# )
|
||||
# user.last_name = (
|
||||
# extra.get("lastName", [""])[0] if extra.get("lastName") else ""
|
||||
# )
|
||||
# user.company_name = (
|
||||
# extra.get("organization", [""])[0]
|
||||
# if extra.get("organization")
|
||||
# else ""
|
||||
# )
|
||||
# user.name = f"{user.first_name} {user.last_name}".strip()
|
||||
# if user.name == "":
|
||||
# user.name = "N/A"
|
||||
# user.save(using=MainRouter.admin_db)
|
||||
|
||||
# email_domain = user.email.split("@")[-1]
|
||||
# tenant = (
|
||||
# SAMLConfiguration.objects.using(MainRouter.admin_db)
|
||||
# .get(email_domain=email_domain)
|
||||
# .tenant
|
||||
# )
|
||||
|
||||
# with rls_transaction(str(tenant.id)):
|
||||
# role_name = (
|
||||
# extra.get("userType", ["saml_default_role"])[0].strip()
|
||||
# if extra.get("userType")
|
||||
# else "saml_default_role"
|
||||
# )
|
||||
|
||||
# try:
|
||||
# role = Role.objects.using(MainRouter.admin_db).get(
|
||||
# name=role_name, tenant_id=tenant.id
|
||||
# )
|
||||
# except Role.DoesNotExist:
|
||||
# role = Role.objects.using(MainRouter.admin_db).create(
|
||||
# name=role_name,
|
||||
# tenant_id=tenant.id,
|
||||
# manage_users=False,
|
||||
# manage_account=False,
|
||||
# manage_billing=False,
|
||||
# manage_providers=False,
|
||||
# manage_integrations=False,
|
||||
# manage_scans=False,
|
||||
# unlimited_visibility=False,
|
||||
# )
|
||||
|
||||
# Membership.objects.using(MainRouter.admin_db).create(
|
||||
# user=user,
|
||||
# tenant=tenant,
|
||||
# role=Membership.RoleChoices.MEMBER,
|
||||
# )
|
||||
|
||||
# UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
# user=user,
|
||||
# role=role,
|
||||
# tenant_id=tenant.id,
|
||||
# )
|
||||
|
||||
# Handle other providers (e.g., GitHub, Google)
|
||||
user.save(using=MainRouter.admin_db)
|
||||
social_account_name = extra.get("name")
|
||||
if social_account_name:
|
||||
user.name = social_account_name
|
||||
if provider != "saml":
|
||||
# Handle other providers (e.g., GitHub, Google)
|
||||
user.save(using=MainRouter.admin_db)
|
||||
social_account_name = extra.get("name")
|
||||
if social_account_name:
|
||||
user.name = social_account_name
|
||||
user.save(using=MainRouter.admin_db)
|
||||
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(
|
||||
name=f"{user.email.split('@')[0]} default tenant"
|
||||
)
|
||||
with rls_transaction(str(tenant.id)):
|
||||
Membership.objects.using(MainRouter.admin_db).create(
|
||||
user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant_id=tenant.id,
|
||||
manage_users=True,
|
||||
manage_account=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(
|
||||
name=f"{user.email.split('@')[0]} default tenant"
|
||||
)
|
||||
with rls_transaction(str(tenant.id)):
|
||||
Membership.objects.using(MainRouter.admin_db).create(
|
||||
user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant_id=tenant.id,
|
||||
manage_users=True,
|
||||
manage_account=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Generated by Django 5.1.10 on 2025-07-02 15:47
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import api.db_utils
|
||||
import api.rls
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0031_scan_disable_on_cascade_periodic_tasks"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="integration",
|
||||
name="integration_type",
|
||||
field=api.db_utils.IntegrationTypeEnumField(
|
||||
choices=[
|
||||
("amazon_s3", "Amazon S3"),
|
||||
("aws_security_hub", "AWS Security Hub"),
|
||||
("jira", "JIRA"),
|
||||
("slack", "Slack"),
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SAMLToken",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("inserted_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("expires_at", models.DateTimeField(editable=False)),
|
||||
("token", models.JSONField(unique=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "saml_tokens",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SAMLConfiguration",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"email_domain",
|
||||
models.CharField(
|
||||
help_text="Email domain used to identify the tenant, e.g. prowlerdemo.com",
|
||||
max_length=254,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"metadata_xml",
|
||||
models.TextField(
|
||||
help_text="Raw IdP metadata XML to configure SingleSignOnService, certificates, etc."
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "saml_configurations",
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="samlconfiguration",
|
||||
constraint=api.rls.RowLevelSecurityConstraint(
|
||||
"tenant_id",
|
||||
name="rls_on_samlconfiguration",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="samlconfiguration",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("tenant",), name="unique_samlconfig_per_tenant"
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SAMLDomainIndex",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("email_domain", models.CharField(max_length=254, unique=True)),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "saml_domain_index",
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="samldomainindex",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("email_domain", "tenant"),
|
||||
name="unique_resources_by_email_domain",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="samldomainindex",
|
||||
constraint=api.rls.BaseSecurityConstraint(
|
||||
name="statements_on_samldomainindex",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
),
|
||||
]
|
||||
+207
-198
@@ -1,15 +1,21 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from config.custom_logging import BackendLogger
|
||||
from config.settings.social_login import SOCIALACCOUNT_PROVIDERS
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.contrib.postgres.search import SearchVector, SearchVectorField
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
@@ -21,6 +27,7 @@ from psqlextra.models import PostgresPartitionedModel
|
||||
from psqlextra.types import PostgresPartitioningMethod
|
||||
from uuid6 import uuid7
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import (
|
||||
CustomUserManager,
|
||||
FindingDeltaEnumField,
|
||||
@@ -1364,242 +1371,244 @@ class IntegrationProviderRelationship(RowLevelSecurityProtectedModel):
|
||||
]
|
||||
|
||||
|
||||
# class SAMLToken(models.Model):
|
||||
# id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
# inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
# updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
# expires_at = models.DateTimeField(editable=False)
|
||||
# token = models.JSONField(unique=True)
|
||||
# user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
class SAMLToken(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
expires_at = models.DateTimeField(editable=False)
|
||||
token = models.JSONField(unique=True)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
# class Meta:
|
||||
# db_table = "saml_tokens"
|
||||
class Meta:
|
||||
db_table = "saml_tokens"
|
||||
|
||||
# def save(self, *args, **kwargs):
|
||||
# if not self.expires_at:
|
||||
# self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=15)
|
||||
# super().save(*args, **kwargs)
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.expires_at:
|
||||
self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=15)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# def is_expired(self) -> bool:
|
||||
# return datetime.now(timezone.utc) >= self.expires_at
|
||||
def is_expired(self) -> bool:
|
||||
return datetime.now(timezone.utc) >= self.expires_at
|
||||
|
||||
|
||||
# class SAMLDomainIndex(models.Model):
|
||||
# """
|
||||
# Public index of SAML domains. No RLS. Used for fast lookup in SAML login flow.
|
||||
# """
|
||||
class SAMLDomainIndex(models.Model):
|
||||
"""
|
||||
Public index of SAML domains. No RLS. Used for fast lookup in SAML login flow.
|
||||
"""
|
||||
|
||||
# email_domain = models.CharField(max_length=254, unique=True)
|
||||
# tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE)
|
||||
email_domain = models.CharField(max_length=254, unique=True)
|
||||
tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE)
|
||||
|
||||
# class Meta:
|
||||
# db_table = "saml_domain_index"
|
||||
class Meta:
|
||||
db_table = "saml_domain_index"
|
||||
|
||||
# constraints = [
|
||||
# models.UniqueConstraint(
|
||||
# fields=("email_domain", "tenant"),
|
||||
# name="unique_resources_by_email_domain",
|
||||
# ),
|
||||
# BaseSecurityConstraint(
|
||||
# name="statements_on_%(class)s",
|
||||
# statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
# ),
|
||||
# ]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("email_domain", "tenant"),
|
||||
name="unique_resources_by_email_domain",
|
||||
),
|
||||
BaseSecurityConstraint(
|
||||
name="statements_on_%(class)s",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# class SAMLConfiguration(RowLevelSecurityProtectedModel):
|
||||
# """
|
||||
# Stores per-tenant SAML settings, including email domain and IdP metadata.
|
||||
# Automatically syncs to a SocialApp instance on save.
|
||||
class SAMLConfiguration(RowLevelSecurityProtectedModel):
|
||||
"""
|
||||
Stores per-tenant SAML settings, including email domain and IdP metadata.
|
||||
Automatically syncs to a SocialApp instance on save.
|
||||
|
||||
# Note:
|
||||
# This model exists to provide a tenant-aware abstraction over SAML configuration.
|
||||
# It supports row-level security, custom validation, and metadata parsing, enabling
|
||||
# Prowler to expose a clean API and admin interface for managing SAML integrations.
|
||||
Note:
|
||||
This model exists to provide a tenant-aware abstraction over SAML configuration.
|
||||
It supports row-level security, custom validation, and metadata parsing, enabling
|
||||
Prowler to expose a clean API and admin interface for managing SAML integrations.
|
||||
|
||||
# Although Django Allauth uses the SocialApp model to store provider configuration,
|
||||
# it is not designed for multi-tenant use. SocialApp lacks support for tenant scoping,
|
||||
# email domain mapping, and structured metadata handling.
|
||||
Although Django Allauth uses the SocialApp model to store provider configuration,
|
||||
it is not designed for multi-tenant use. SocialApp lacks support for tenant scoping,
|
||||
email domain mapping, and structured metadata handling.
|
||||
|
||||
# By managing SAMLConfiguration separately, we ensure:
|
||||
# - Strong isolation between tenants via RLS.
|
||||
# - Ownership of raw IdP metadata and its validation.
|
||||
# - An explicit link between SAML config and business-level identifiers (e.g. email domain).
|
||||
# - Programmatic transformation into the SocialApp format used by Allauth.
|
||||
By managing SAMLConfiguration separately, we ensure:
|
||||
- Strong isolation between tenants via RLS.
|
||||
- Ownership of raw IdP metadata and its validation.
|
||||
- An explicit link between SAML config and business-level identifiers (e.g. email domain).
|
||||
- Programmatic transformation into the SocialApp format used by Allauth.
|
||||
|
||||
# In short, this model acts as a secure and user-friendly layer over Allauth's lower-level primitives.
|
||||
# """
|
||||
In short, this model acts as a secure and user-friendly layer over Allauth's lower-level primitives.
|
||||
"""
|
||||
|
||||
# id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
# email_domain = models.CharField(
|
||||
# max_length=254,
|
||||
# unique=True,
|
||||
# help_text="Email domain used to identify the tenant, e.g. prowlerdemo.com",
|
||||
# )
|
||||
# metadata_xml = models.TextField(
|
||||
# help_text="Raw IdP metadata XML to configure SingleSignOnService, certificates, etc."
|
||||
# )
|
||||
# created_at = models.DateTimeField(auto_now_add=True)
|
||||
# updated_at = models.DateTimeField(auto_now=True)
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
email_domain = models.CharField(
|
||||
max_length=254,
|
||||
unique=True,
|
||||
help_text="Email domain used to identify the tenant, e.g. prowlerdemo.com",
|
||||
)
|
||||
metadata_xml = models.TextField(
|
||||
help_text="Raw IdP metadata XML to configure SingleSignOnService, certificates, etc."
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# class JSONAPIMeta:
|
||||
# resource_name = "saml-configurations"
|
||||
class JSONAPIMeta:
|
||||
resource_name = "saml-configurations"
|
||||
|
||||
# class Meta:
|
||||
# db_table = "saml_configurations"
|
||||
class Meta:
|
||||
db_table = "saml_configurations"
|
||||
|
||||
# constraints = [
|
||||
# RowLevelSecurityConstraint(
|
||||
# field="tenant_id",
|
||||
# name="rls_on_%(class)s",
|
||||
# statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
# ),
|
||||
# # 1 config per tenant
|
||||
# models.UniqueConstraint(
|
||||
# fields=["tenant"],
|
||||
# name="unique_samlconfig_per_tenant",
|
||||
# ),
|
||||
# ]
|
||||
constraints = [
|
||||
RowLevelSecurityConstraint(
|
||||
field="tenant_id",
|
||||
name="rls_on_%(class)s",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
# 1 config per tenant
|
||||
models.UniqueConstraint(
|
||||
fields=["tenant"],
|
||||
name="unique_samlconfig_per_tenant",
|
||||
),
|
||||
]
|
||||
|
||||
# def clean(self, old_email_domain=None):
|
||||
# # Domain must not contain @
|
||||
# if "@" in self.email_domain:
|
||||
# raise ValidationError({"email_domain": "Domain must not contain @"})
|
||||
def clean(self, old_email_domain=None):
|
||||
# Domain must not contain @
|
||||
if "@" in self.email_domain:
|
||||
raise ValidationError({"email_domain": "Domain must not contain @"})
|
||||
|
||||
# # Enforce at most one config per tenant
|
||||
# qs = SAMLConfiguration.objects.filter(tenant=self.tenant)
|
||||
# # Exclude ourselves in case of update
|
||||
# if self.pk:
|
||||
# qs = qs.exclude(pk=self.pk)
|
||||
# if qs.exists():
|
||||
# raise ValidationError(
|
||||
# {"tenant": "A SAML configuration already exists for this tenant."}
|
||||
# )
|
||||
# Enforce at most one config per tenant
|
||||
qs = SAMLConfiguration.objects.filter(tenant=self.tenant)
|
||||
# Exclude ourselves in case of update
|
||||
if self.pk:
|
||||
qs = qs.exclude(pk=self.pk)
|
||||
if qs.exists():
|
||||
raise ValidationError(
|
||||
{"tenant": "A SAML configuration already exists for this tenant."}
|
||||
)
|
||||
|
||||
# # The email domain must be unique in the entire system
|
||||
# qs = SAMLConfiguration.objects.using(MainRouter.admin_db).filter(
|
||||
# email_domain__iexact=self.email_domain
|
||||
# )
|
||||
# if qs.exists() and old_email_domain != self.email_domain:
|
||||
# raise ValidationError(
|
||||
# {"tenant": "There is a problem with your email domain."}
|
||||
# )
|
||||
# The email domain must be unique in the entire system
|
||||
qs = SAMLConfiguration.objects.using(MainRouter.admin_db).filter(
|
||||
email_domain__iexact=self.email_domain
|
||||
)
|
||||
if qs.exists() and old_email_domain != self.email_domain:
|
||||
raise ValidationError(
|
||||
{"tenant": "There is a problem with your email domain."}
|
||||
)
|
||||
|
||||
# def save(self, *args, **kwargs):
|
||||
# self.email_domain = self.email_domain.strip().lower()
|
||||
# is_create = not SAMLConfiguration.objects.filter(pk=self.pk).exists()
|
||||
def save(self, *args, **kwargs):
|
||||
self.email_domain = self.email_domain.strip().lower()
|
||||
is_create = not SAMLConfiguration.objects.filter(pk=self.pk).exists()
|
||||
|
||||
# if not is_create:
|
||||
# old = SAMLConfiguration.objects.get(pk=self.pk)
|
||||
# old_email_domain = old.email_domain
|
||||
# old_metadata_xml = old.metadata_xml
|
||||
# else:
|
||||
# old_email_domain = None
|
||||
# old_metadata_xml = None
|
||||
if not is_create:
|
||||
old = SAMLConfiguration.objects.get(pk=self.pk)
|
||||
old_email_domain = old.email_domain
|
||||
old_metadata_xml = old.metadata_xml
|
||||
else:
|
||||
old_email_domain = None
|
||||
old_metadata_xml = None
|
||||
|
||||
# self.clean(old_email_domain)
|
||||
# super().save(*args, **kwargs)
|
||||
self.clean(old_email_domain)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# if is_create or (
|
||||
# old_email_domain != self.email_domain
|
||||
# or old_metadata_xml != self.metadata_xml
|
||||
# ):
|
||||
# self._sync_social_app(old_email_domain)
|
||||
if is_create or (
|
||||
old_email_domain != self.email_domain
|
||||
or old_metadata_xml != self.metadata_xml
|
||||
):
|
||||
self._sync_social_app(old_email_domain)
|
||||
|
||||
# # Sync the public index
|
||||
# if not is_create and old_email_domain and old_email_domain != self.email_domain:
|
||||
# SAMLDomainIndex.objects.filter(email_domain=old_email_domain).delete()
|
||||
# Sync the public index
|
||||
if not is_create and old_email_domain and old_email_domain != self.email_domain:
|
||||
SAMLDomainIndex.objects.filter(email_domain=old_email_domain).delete()
|
||||
|
||||
# # Create/update the new domain index
|
||||
# SAMLDomainIndex.objects.update_or_create(
|
||||
# email_domain=self.email_domain, defaults={"tenant": self.tenant}
|
||||
# )
|
||||
# Create/update the new domain index
|
||||
SAMLDomainIndex.objects.update_or_create(
|
||||
email_domain=self.email_domain, defaults={"tenant": self.tenant}
|
||||
)
|
||||
|
||||
# def _parse_metadata(self):
|
||||
# """
|
||||
# Parse the raw IdP metadata XML and extract:
|
||||
# - entity_id
|
||||
# - sso_url
|
||||
# - slo_url (may be None)
|
||||
# - x509cert (required)
|
||||
# """
|
||||
# ns = {
|
||||
# "md": "urn:oasis:names:tc:SAML:2.0:metadata",
|
||||
# "ds": "http://www.w3.org/2000/09/xmldsig#",
|
||||
# }
|
||||
# try:
|
||||
# root = ET.fromstring(self.metadata_xml)
|
||||
# except ET.ParseError as e:
|
||||
# raise ValidationError({"metadata_xml": f"Invalid XML: {e}"})
|
||||
def _parse_metadata(self):
|
||||
"""
|
||||
Parse the raw IdP metadata XML and extract:
|
||||
- entity_id
|
||||
- sso_url
|
||||
- slo_url (may be None)
|
||||
- x509cert (required)
|
||||
"""
|
||||
ns = {
|
||||
"md": "urn:oasis:names:tc:SAML:2.0:metadata",
|
||||
"ds": "http://www.w3.org/2000/09/xmldsig#",
|
||||
}
|
||||
try:
|
||||
root = ET.fromstring(self.metadata_xml)
|
||||
except ET.ParseError as e:
|
||||
raise ValidationError({"metadata_xml": f"Invalid XML: {e}"})
|
||||
|
||||
# # Entity ID
|
||||
# entity_id = root.attrib.get("entityID")
|
||||
# Entity ID
|
||||
entity_id = root.attrib.get("entityID")
|
||||
|
||||
# # SSO endpoint (must exist)
|
||||
# sso = root.find(".//md:IDPSSODescriptor/md:SingleSignOnService", ns)
|
||||
# if sso is None or "Location" not in sso.attrib:
|
||||
# raise ValidationError(
|
||||
# {"metadata_xml": "Missing SingleSignOnService in metadata."}
|
||||
# )
|
||||
# sso_url = sso.attrib["Location"]
|
||||
# SSO endpoint (must exist)
|
||||
sso = root.find(".//md:IDPSSODescriptor/md:SingleSignOnService", ns)
|
||||
if sso is None or "Location" not in sso.attrib:
|
||||
raise ValidationError(
|
||||
{"metadata_xml": "Missing SingleSignOnService in metadata."}
|
||||
)
|
||||
sso_url = sso.attrib["Location"]
|
||||
|
||||
# # SLO endpoint (optional)
|
||||
# slo = root.find(".//md:IDPSSODescriptor/md:SingleLogoutService", ns)
|
||||
# slo_url = slo.attrib.get("Location") if slo is not None else None
|
||||
# SLO endpoint (optional)
|
||||
slo = root.find(".//md:IDPSSODescriptor/md:SingleLogoutService", ns)
|
||||
slo_url = slo.attrib.get("Location") if slo is not None else None
|
||||
|
||||
# # X.509 certificate (required)
|
||||
# cert = root.find(
|
||||
# './/md:KeyDescriptor[@use="signing"]/ds:KeyInfo/ds:X509Data/ds:X509Certificate',
|
||||
# ns,
|
||||
# )
|
||||
# if cert is None or not cert.text or not cert.text.strip():
|
||||
# raise ValidationError(
|
||||
# {
|
||||
# "metadata_xml": 'Metadata must include a <ds:X509Certificate> under <KeyDescriptor use="signing">.'
|
||||
# }
|
||||
# )
|
||||
# x509cert = cert.text.strip()
|
||||
# X.509 certificate (required)
|
||||
cert = root.find(
|
||||
'.//md:KeyDescriptor[@use="signing"]/ds:KeyInfo/ds:X509Data/ds:X509Certificate',
|
||||
ns,
|
||||
)
|
||||
if cert is None or not cert.text or not cert.text.strip():
|
||||
raise ValidationError(
|
||||
{
|
||||
"metadata_xml": 'Metadata must include a <ds:X509Certificate> under <KeyDescriptor use="signing">.'
|
||||
}
|
||||
)
|
||||
x509cert = cert.text.strip()
|
||||
|
||||
# return {
|
||||
# "entity_id": entity_id,
|
||||
# "sso_url": sso_url,
|
||||
# "slo_url": slo_url,
|
||||
# "x509cert": x509cert,
|
||||
# }
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"sso_url": sso_url,
|
||||
"slo_url": slo_url,
|
||||
"x509cert": x509cert,
|
||||
}
|
||||
|
||||
# def _sync_social_app(self, previous_email_domain=None):
|
||||
# """
|
||||
# Create or update the corresponding SocialApp based on email_domain.
|
||||
# If the domain changed, update the matching SocialApp.
|
||||
# """
|
||||
# idp_settings = self._parse_metadata()
|
||||
# settings_dict = SOCIALACCOUNT_PROVIDERS["saml"].copy()
|
||||
# settings_dict["idp"] = idp_settings
|
||||
def _sync_social_app(self, previous_email_domain=None):
|
||||
"""
|
||||
Create or update the corresponding SocialApp based on email_domain.
|
||||
If the domain changed, update the matching SocialApp.
|
||||
"""
|
||||
idp_settings = self._parse_metadata()
|
||||
settings_dict = SOCIALACCOUNT_PROVIDERS["saml"].copy()
|
||||
settings_dict["idp"] = idp_settings
|
||||
|
||||
# current_site = Site.objects.get(id=settings.SITE_ID)
|
||||
current_site = Site.objects.get(id=settings.SITE_ID)
|
||||
|
||||
# social_app_qs = SocialApp.objects.filter(
|
||||
# provider="saml", client_id=previous_email_domain or self.email_domain
|
||||
# )
|
||||
social_app_qs = SocialApp.objects.filter(
|
||||
provider="saml", client_id=previous_email_domain or self.email_domain
|
||||
)
|
||||
|
||||
# client_id = self.email_domain[:191]
|
||||
# name = f"SAML-{self.email_domain}"[:40]
|
||||
client_id = self.email_domain[:191]
|
||||
name = f"SAML-{self.email_domain}"[:40]
|
||||
|
||||
# if social_app_qs.exists():
|
||||
# social_app = social_app_qs.first()
|
||||
# social_app.client_id = client_id
|
||||
# social_app.name = name
|
||||
# social_app.settings = settings_dict
|
||||
# social_app.save()
|
||||
# social_app.sites.set([current_site])
|
||||
# else:
|
||||
# social_app = SocialApp.objects.create(
|
||||
# provider="saml",
|
||||
# client_id=client_id,
|
||||
# name=name,
|
||||
# settings=settings_dict,
|
||||
# )
|
||||
# social_app.sites.set([current_site])
|
||||
if social_app_qs.exists():
|
||||
social_app = social_app_qs.first()
|
||||
social_app.client_id = client_id
|
||||
social_app.name = name
|
||||
social_app.settings = settings_dict
|
||||
social_app.provider_id = idp_settings["entity_id"]
|
||||
social_app.save()
|
||||
social_app.sites.set([current_site])
|
||||
else:
|
||||
social_app = SocialApp.objects.create(
|
||||
provider="saml",
|
||||
client_id=client_id,
|
||||
name=name,
|
||||
settings=settings_dict,
|
||||
provider_id=idp_settings["entity_id"],
|
||||
)
|
||||
social_app.sites.set([current_site])
|
||||
|
||||
|
||||
class ResourceScanSummary(RowLevelSecurityProtectedModel):
|
||||
|
||||
@@ -5152,6 +5152,199 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: Relationship deleted successfully
|
||||
/api/v1/saml-config:
|
||||
get:
|
||||
operationId: saml_config_list
|
||||
description: Returns all the SAML-based SSO configurations associated with the
|
||||
current tenant.
|
||||
summary: List all SSO configurations
|
||||
parameters:
|
||||
- in: query
|
||||
name: fields[saml-configurations]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- email_domain
|
||||
- metadata_xml
|
||||
- created_at
|
||||
- updated_at
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
explode: false
|
||||
- name: filter[search]
|
||||
required: false
|
||||
in: query
|
||||
description: A search term.
|
||||
schema:
|
||||
type: string
|
||||
- name: page[number]
|
||||
required: false
|
||||
in: query
|
||||
description: A page number within the paginated result set.
|
||||
schema:
|
||||
type: integer
|
||||
- name: page[size]
|
||||
required: false
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
schema:
|
||||
type: integer
|
||||
- name: sort
|
||||
required: false
|
||||
in: query
|
||||
description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)'
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- id
|
||||
- -id
|
||||
- email_domain
|
||||
- -email_domain
|
||||
- metadata_xml
|
||||
- -metadata_xml
|
||||
- created_at
|
||||
- -created_at
|
||||
- updated_at
|
||||
- -updated_at
|
||||
explode: false
|
||||
tags:
|
||||
- SAML
|
||||
security:
|
||||
- jwtAuth: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedSAMLConfigurationList'
|
||||
description: ''
|
||||
post:
|
||||
operationId: saml_config_create
|
||||
description: Creates a new SAML SSO configuration for the current tenant, including
|
||||
email domain and metadata XML.
|
||||
summary: Create the SSO configuration
|
||||
tags:
|
||||
- SAML
|
||||
requestBody:
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SAMLConfigurationRequest'
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SAMLConfigurationRequest'
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SAMLConfigurationRequest'
|
||||
required: true
|
||||
security:
|
||||
- jwtAuth: []
|
||||
responses:
|
||||
'201':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SAMLConfigurationResponse'
|
||||
description: ''
|
||||
/api/v1/saml-config/{id}:
|
||||
get:
|
||||
operationId: saml_config_retrieve
|
||||
description: Returns the details of a specific SAML configuration belonging
|
||||
to the current tenant.
|
||||
summary: Retrieve SSO configuration details
|
||||
parameters:
|
||||
- in: query
|
||||
name: fields[saml-configurations]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- email_domain
|
||||
- metadata_xml
|
||||
- created_at
|
||||
- updated_at
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
explode: false
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this saml configuration.
|
||||
required: true
|
||||
tags:
|
||||
- SAML
|
||||
security:
|
||||
- jwtAuth: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SAMLConfigurationResponse'
|
||||
description: ''
|
||||
patch:
|
||||
operationId: saml_config_partial_update
|
||||
description: Partially updates an existing SAML SSO configuration. Supports
|
||||
changes to email domain and metadata XML.
|
||||
summary: Update the SSO configuration
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this saml configuration.
|
||||
required: true
|
||||
tags:
|
||||
- SAML
|
||||
requestBody:
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedSAMLConfigurationRequest'
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedSAMLConfigurationRequest'
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedSAMLConfigurationRequest'
|
||||
required: true
|
||||
security:
|
||||
- jwtAuth: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SAMLConfigurationResponse'
|
||||
description: ''
|
||||
delete:
|
||||
operationId: saml_config_destroy
|
||||
description: Deletes an existing SAML SSO configuration associated with the
|
||||
current tenant.
|
||||
summary: Delete the SSO configuration
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this saml configuration.
|
||||
required: true
|
||||
tags:
|
||||
- SAML
|
||||
security:
|
||||
- jwtAuth: []
|
||||
responses:
|
||||
'204':
|
||||
description: No response body
|
||||
/api/v1/scans:
|
||||
get:
|
||||
operationId: scans_list
|
||||
@@ -9158,6 +9351,15 @@ components:
|
||||
$ref: '#/components/schemas/Role'
|
||||
required:
|
||||
- data
|
||||
PaginatedSAMLConfigurationList:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SAMLConfiguration'
|
||||
required:
|
||||
- data
|
||||
PaginatedScanList:
|
||||
type: object
|
||||
properties:
|
||||
@@ -10042,6 +10244,52 @@ components:
|
||||
readOnly: true
|
||||
required:
|
||||
- data
|
||||
PatchedSAMLConfigurationRequest:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common attributes
|
||||
and relationships.
|
||||
enum:
|
||||
- saml-configurations
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
email_domain:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Email domain used to identify the tenant, e.g. prowlerdemo.com
|
||||
maxLength: 254
|
||||
metadata_xml:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Raw IdP metadata XML to configure SingleSignOnService,
|
||||
certificates, etc.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- email_domain
|
||||
- metadata_xml
|
||||
required:
|
||||
- data
|
||||
PatchedScanUpdateRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -12155,6 +12403,97 @@ components:
|
||||
$ref: '#/components/schemas/Role'
|
||||
required:
|
||||
- data
|
||||
SAMLConfiguration:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SAMLConfigurationTypeEnum'
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common attributes
|
||||
and relationships.
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
email_domain:
|
||||
type: string
|
||||
description: Email domain used to identify the tenant, e.g. prowlerdemo.com
|
||||
maxLength: 254
|
||||
metadata_xml:
|
||||
type: string
|
||||
description: Raw IdP metadata XML to configure SingleSignOnService,
|
||||
certificates, etc.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- email_domain
|
||||
- metadata_xml
|
||||
SAMLConfigurationRequest:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common attributes
|
||||
and relationships.
|
||||
enum:
|
||||
- saml-configurations
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
email_domain:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Email domain used to identify the tenant, e.g. prowlerdemo.com
|
||||
maxLength: 254
|
||||
metadata_xml:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Raw IdP metadata XML to configure SingleSignOnService,
|
||||
certificates, etc.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- email_domain
|
||||
- metadata_xml
|
||||
required:
|
||||
- data
|
||||
SAMLConfigurationResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SAMLConfiguration'
|
||||
required:
|
||||
- data
|
||||
SAMLConfigurationTypeEnum:
|
||||
type: string
|
||||
enum:
|
||||
- saml-configurations
|
||||
Scan:
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -20,69 +20,37 @@ class TestProwlerSocialAccountAdapter:
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
assert adapter.get_user_by_email("notfound@example.com") is None
|
||||
|
||||
# def test_pre_social_login_links_existing_user(self, create_test_user, rf):
|
||||
# adapter = ProwlerSocialAccountAdapter()
|
||||
def test_pre_social_login_links_existing_user(self, create_test_user, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
# sociallogin = MagicMock(spec=SocialLogin)
|
||||
# sociallogin.account = MagicMock()
|
||||
# sociallogin.account.provider = "saml"
|
||||
# sociallogin.account.extra_data = {}
|
||||
# sociallogin.user = create_test_user
|
||||
# sociallogin.connect = MagicMock()
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = "saml"
|
||||
sociallogin.account.extra_data = {}
|
||||
sociallogin.user = create_test_user
|
||||
sociallogin.connect = MagicMock()
|
||||
|
||||
# adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
|
||||
# call_args = sociallogin.connect.call_args
|
||||
# assert call_args is not None
|
||||
call_args = sociallogin.connect.call_args
|
||||
assert call_args is not None
|
||||
|
||||
# called_request, called_user = call_args[0]
|
||||
# assert called_request.path == "/"
|
||||
# assert called_user.email == create_test_user.email
|
||||
called_request, called_user = call_args[0]
|
||||
assert called_request.path == "/"
|
||||
assert called_user.email == create_test_user.email
|
||||
|
||||
def test_pre_social_login_no_link_if_email_missing(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.provider = "github"
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.user = MagicMock()
|
||||
sociallogin.provider.id = "saml"
|
||||
sociallogin.account.extra_data = {}
|
||||
sociallogin.connect = MagicMock()
|
||||
|
||||
adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
# def test_save_user_saml_flow(
|
||||
# self,
|
||||
# rf,
|
||||
# saml_setup,
|
||||
# saml_sociallogin,
|
||||
# ):
|
||||
# adapter = ProwlerSocialAccountAdapter()
|
||||
# request = rf.get("/")
|
||||
# saml_sociallogin.user.email = saml_setup["email"]
|
||||
# saml_sociallogin.account.extra_data = {
|
||||
# "firstName": [],
|
||||
# "lastName": [],
|
||||
# "organization": [],
|
||||
# "userType": [],
|
||||
# }
|
||||
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).get(
|
||||
# id=saml_setup["tenant_id"]
|
||||
# )
|
||||
# saml_config = SAMLConfiguration.objects.using(MainRouter.admin_db).get(
|
||||
# tenant=tenant
|
||||
# )
|
||||
# assert saml_config.email_domain == saml_setup["domain"]
|
||||
|
||||
# user = adapter.save_user(request, saml_sociallogin)
|
||||
|
||||
# assert user.name == "N/A"
|
||||
# assert user.company_name == ""
|
||||
# assert user.email == saml_setup["email"]
|
||||
# assert (
|
||||
# Membership.objects.using(MainRouter.admin_db)
|
||||
# .filter(user=user, tenant=tenant)
|
||||
# .exists()
|
||||
# )
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import pytest
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from api.models import Resource, ResourceTag
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Resource, ResourceTag, SAMLConfiguration, Tenant
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -122,147 +125,147 @@ class TestResourceModel:
|
||||
# assert Finding.objects.filter(uid=long_uid).exists()
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# class TestSAMLConfigurationModel:
|
||||
# VALID_METADATA = """<?xml version='1.0' encoding='UTF-8'?>
|
||||
# <md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
# <md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
# <md:KeyDescriptor use='signing'>
|
||||
# <ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
# <ds:X509Data>
|
||||
# <ds:X509Certificate>FAKECERTDATA</ds:X509Certificate>
|
||||
# </ds:X509Data>
|
||||
# </ds:KeyInfo>
|
||||
# </md:KeyDescriptor>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://idp.test/sso'/>
|
||||
# </md:IDPSSODescriptor>
|
||||
# </md:EntityDescriptor>
|
||||
# """
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLConfigurationModel:
|
||||
VALID_METADATA = """<?xml version='1.0' encoding='UTF-8'?>
|
||||
<md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
<md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
<md:KeyDescriptor use='signing'>
|
||||
<ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>FAKECERTDATA</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://idp.test/sso'/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>
|
||||
"""
|
||||
|
||||
# def test_creates_valid_configuration(self):
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant A")
|
||||
# config = SAMLConfiguration.objects.using(MainRouter.admin_db).create(
|
||||
# email_domain="ssoexample.com",
|
||||
# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
# tenant=tenant,
|
||||
# )
|
||||
def test_creates_valid_configuration(self):
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant A")
|
||||
config = SAMLConfiguration.objects.using(MainRouter.admin_db).create(
|
||||
email_domain="ssoexample.com",
|
||||
metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
# assert config.email_domain == "ssoexample.com"
|
||||
# assert SocialApp.objects.filter(client_id="ssoexample.com").exists()
|
||||
assert config.email_domain == "ssoexample.com"
|
||||
assert SocialApp.objects.filter(client_id="ssoexample.com").exists()
|
||||
|
||||
# def test_email_domain_with_at_symbol_fails(self):
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant B")
|
||||
# config = SAMLConfiguration(
|
||||
# email_domain="invalid@domain.com",
|
||||
# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
# tenant=tenant,
|
||||
# )
|
||||
def test_email_domain_with_at_symbol_fails(self):
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant B")
|
||||
config = SAMLConfiguration(
|
||||
email_domain="invalid@domain.com",
|
||||
metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
# with pytest.raises(ValidationError) as exc_info:
|
||||
# config.clean()
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config.clean()
|
||||
|
||||
# errors = exc_info.value.message_dict
|
||||
# assert "email_domain" in errors
|
||||
# assert "Domain must not contain @" in errors["email_domain"][0]
|
||||
errors = exc_info.value.message_dict
|
||||
assert "email_domain" in errors
|
||||
assert "Domain must not contain @" in errors["email_domain"][0]
|
||||
|
||||
# def test_duplicate_email_domain_fails(self):
|
||||
# tenant1 = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant C1")
|
||||
# tenant2 = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant C2")
|
||||
def test_duplicate_email_domain_fails(self):
|
||||
tenant1 = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant C1")
|
||||
tenant2 = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant C2")
|
||||
|
||||
# SAMLConfiguration.objects.using(MainRouter.admin_db).create(
|
||||
# email_domain="duplicate.com",
|
||||
# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
# tenant=tenant1,
|
||||
# )
|
||||
SAMLConfiguration.objects.using(MainRouter.admin_db).create(
|
||||
email_domain="duplicate.com",
|
||||
metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
tenant=tenant1,
|
||||
)
|
||||
|
||||
# config = SAMLConfiguration(
|
||||
# email_domain="duplicate.com",
|
||||
# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
# tenant=tenant2,
|
||||
# )
|
||||
config = SAMLConfiguration(
|
||||
email_domain="duplicate.com",
|
||||
metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
tenant=tenant2,
|
||||
)
|
||||
|
||||
# with pytest.raises(ValidationError) as exc_info:
|
||||
# config.clean()
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config.clean()
|
||||
|
||||
# errors = exc_info.value.message_dict
|
||||
# assert "tenant" in errors
|
||||
# assert "There is a problem with your email domain." in errors["tenant"][0]
|
||||
errors = exc_info.value.message_dict
|
||||
assert "tenant" in errors
|
||||
assert "There is a problem with your email domain." in errors["tenant"][0]
|
||||
|
||||
# def test_duplicate_tenant_config_fails(self):
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant D")
|
||||
def test_duplicate_tenant_config_fails(self):
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant D")
|
||||
|
||||
# SAMLConfiguration.objects.using(MainRouter.admin_db).create(
|
||||
# email_domain="unique1.com",
|
||||
# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
# tenant=tenant,
|
||||
# )
|
||||
SAMLConfiguration.objects.using(MainRouter.admin_db).create(
|
||||
email_domain="unique1.com",
|
||||
metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
# config = SAMLConfiguration(
|
||||
# email_domain="unique2.com",
|
||||
# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
# tenant=tenant,
|
||||
# )
|
||||
config = SAMLConfiguration(
|
||||
email_domain="unique2.com",
|
||||
metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
# with pytest.raises(ValidationError) as exc_info:
|
||||
# config.clean()
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config.clean()
|
||||
|
||||
# errors = exc_info.value.message_dict
|
||||
# assert "tenant" in errors
|
||||
# assert (
|
||||
# "A SAML configuration already exists for this tenant."
|
||||
# in errors["tenant"][0]
|
||||
# )
|
||||
errors = exc_info.value.message_dict
|
||||
assert "tenant" in errors
|
||||
assert (
|
||||
"A SAML configuration already exists for this tenant."
|
||||
in errors["tenant"][0]
|
||||
)
|
||||
|
||||
# def test_invalid_metadata_xml_fails(self):
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant E")
|
||||
# config = SAMLConfiguration(
|
||||
# email_domain="brokenxml.com",
|
||||
# metadata_xml="<bad<xml>",
|
||||
# tenant=tenant,
|
||||
# )
|
||||
def test_invalid_metadata_xml_fails(self):
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant E")
|
||||
config = SAMLConfiguration(
|
||||
email_domain="brokenxml.com",
|
||||
metadata_xml="<bad<xml>",
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
# with pytest.raises(ValidationError) as exc_info:
|
||||
# config._parse_metadata()
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config._parse_metadata()
|
||||
|
||||
# errors = exc_info.value.message_dict
|
||||
# assert "metadata_xml" in errors
|
||||
# assert "Invalid XML" in errors["metadata_xml"][0]
|
||||
# assert "not well-formed" in errors["metadata_xml"][0]
|
||||
errors = exc_info.value.message_dict
|
||||
assert "metadata_xml" in errors
|
||||
assert "Invalid XML" in errors["metadata_xml"][0]
|
||||
assert "not well-formed" in errors["metadata_xml"][0]
|
||||
|
||||
# def test_metadata_missing_sso_fails(self):
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant F")
|
||||
# xml = """<md:EntityDescriptor entityID="x" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
|
||||
# <md:IDPSSODescriptor></md:IDPSSODescriptor>
|
||||
# </md:EntityDescriptor>"""
|
||||
# config = SAMLConfiguration(
|
||||
# email_domain="nosso.com",
|
||||
# metadata_xml=xml,
|
||||
# tenant=tenant,
|
||||
# )
|
||||
def test_metadata_missing_sso_fails(self):
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant F")
|
||||
xml = """<md:EntityDescriptor entityID="x" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
|
||||
<md:IDPSSODescriptor></md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>"""
|
||||
config = SAMLConfiguration(
|
||||
email_domain="nosso.com",
|
||||
metadata_xml=xml,
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
# with pytest.raises(ValidationError) as exc_info:
|
||||
# config._parse_metadata()
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config._parse_metadata()
|
||||
|
||||
# errors = exc_info.value.message_dict
|
||||
# assert "metadata_xml" in errors
|
||||
# assert "Missing SingleSignOnService" in errors["metadata_xml"][0]
|
||||
errors = exc_info.value.message_dict
|
||||
assert "metadata_xml" in errors
|
||||
assert "Missing SingleSignOnService" in errors["metadata_xml"][0]
|
||||
|
||||
# def test_metadata_missing_certificate_fails(self):
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant G")
|
||||
# xml = """<md:EntityDescriptor entityID="x" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
|
||||
# <md:IDPSSODescriptor>
|
||||
# <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://example.com/sso"/>
|
||||
# </md:IDPSSODescriptor>
|
||||
# </md:EntityDescriptor>"""
|
||||
# config = SAMLConfiguration(
|
||||
# email_domain="nocert.com",
|
||||
# metadata_xml=xml,
|
||||
# tenant=tenant,
|
||||
# )
|
||||
def test_metadata_missing_certificate_fails(self):
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant G")
|
||||
xml = """<md:EntityDescriptor entityID="x" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
|
||||
<md:IDPSSODescriptor>
|
||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://example.com/sso"/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>"""
|
||||
config = SAMLConfiguration(
|
||||
email_domain="nocert.com",
|
||||
metadata_xml=xml,
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
# with pytest.raises(ValidationError) as exc_info:
|
||||
# config._parse_metadata()
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config._parse_metadata()
|
||||
|
||||
# errors = exc_info.value.message_dict
|
||||
# assert "metadata_xml" in errors
|
||||
# assert "X509Certificate" in errors["metadata_xml"][0]
|
||||
errors = exc_info.value.message_dict
|
||||
assert "metadata_xml" in errors
|
||||
assert "X509Certificate" in errors["metadata_xml"][0]
|
||||
|
||||
@@ -5,19 +5,26 @@ import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from allauth.socialaccount.models import SocialAccount, SocialApp
|
||||
from botocore.exceptions import ClientError, NoCredentialsError
|
||||
from conftest import API_JSON_CONTENT_TYPE, TEST_PASSWORD, TEST_USER
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from django_celery_results.models import TaskResult
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.compliance import get_compliance_frameworks
|
||||
from api.db_router import MainRouter
|
||||
from api.models import (
|
||||
Integration,
|
||||
Invitation,
|
||||
@@ -28,6 +35,8 @@ from api.models import (
|
||||
ProviderSecret,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
SAMLConfiguration,
|
||||
SAMLToken,
|
||||
Scan,
|
||||
StateChoices,
|
||||
Task,
|
||||
@@ -35,7 +44,7 @@ from api.models import (
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.rls import Tenant
|
||||
from api.v1.views import ComplianceOverviewViewSet
|
||||
from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView
|
||||
|
||||
TODAY = str(datetime.today().date())
|
||||
|
||||
@@ -5658,334 +5667,342 @@ class TestIntegrationViewSet:
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# class TestSAMLTokenValidation:
|
||||
# def test_valid_token_returns_tokens(self, authenticated_client, create_test_user):
|
||||
# user = create_test_user
|
||||
# valid_token_data = {
|
||||
# "access": "mock_access_token",
|
||||
# "refresh": "mock_refresh_token",
|
||||
# }
|
||||
# saml_token = SAMLToken.objects.create(
|
||||
# token=valid_token_data,
|
||||
# user=user,
|
||||
# expires_at=datetime.now(timezone.utc) + timedelta(seconds=10),
|
||||
# )
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLTokenValidation:
|
||||
def test_valid_token_returns_tokens(self, authenticated_client, create_test_user):
|
||||
user = create_test_user
|
||||
valid_token_data = {
|
||||
"access": "mock_access_token",
|
||||
"refresh": "mock_refresh_token",
|
||||
}
|
||||
saml_token = SAMLToken.objects.create(
|
||||
token=valid_token_data,
|
||||
user=user,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(seconds=10),
|
||||
)
|
||||
|
||||
# url = reverse("token-saml")
|
||||
# response = authenticated_client.post(f"{url}?id={saml_token.id}")
|
||||
url = reverse("token-saml")
|
||||
response = authenticated_client.post(f"{url}?id={saml_token.id}")
|
||||
|
||||
# assert response.status_code == status.HTTP_200_OK
|
||||
# assert response.json() == {"data": valid_token_data}
|
||||
# assert not SAMLToken.objects.filter(id=saml_token.id).exists()
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {"data": valid_token_data}
|
||||
assert not SAMLToken.objects.filter(id=saml_token.id).exists()
|
||||
|
||||
# def test_invalid_token_id_returns_404(self, authenticated_client):
|
||||
# url = reverse("token-saml")
|
||||
# response = authenticated_client.post(f"{url}?id={str(uuid4())}")
|
||||
def test_invalid_token_id_returns_404(self, authenticated_client):
|
||||
url = reverse("token-saml")
|
||||
response = authenticated_client.post(f"{url}?id={str(uuid4())}")
|
||||
|
||||
# assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# assert response.json()["errors"]["detail"] == "Invalid token ID."
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.json()["errors"]["detail"] == "Invalid token ID."
|
||||
|
||||
# def test_expired_token_returns_400(self, authenticated_client, create_test_user):
|
||||
# user = create_test_user
|
||||
# expired_token_data = {
|
||||
# "access": "expired_access_token",
|
||||
# "refresh": "expired_refresh_token",
|
||||
# }
|
||||
# saml_token = SAMLToken.objects.create(
|
||||
# token=expired_token_data,
|
||||
# user=user,
|
||||
# expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
|
||||
# )
|
||||
def test_expired_token_returns_400(self, authenticated_client, create_test_user):
|
||||
user = create_test_user
|
||||
expired_token_data = {
|
||||
"access": "expired_access_token",
|
||||
"refresh": "expired_refresh_token",
|
||||
}
|
||||
saml_token = SAMLToken.objects.create(
|
||||
token=expired_token_data,
|
||||
user=user,
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
|
||||
)
|
||||
|
||||
# url = reverse("token-saml")
|
||||
# response = authenticated_client.post(f"{url}?id={saml_token.id}")
|
||||
url = reverse("token-saml")
|
||||
response = authenticated_client.post(f"{url}?id={saml_token.id}")
|
||||
|
||||
# assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
# assert response.json()["errors"]["detail"] == "Token expired."
|
||||
# assert SAMLToken.objects.filter(id=saml_token.id).exists()
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert response.json()["errors"]["detail"] == "Token expired."
|
||||
assert SAMLToken.objects.filter(id=saml_token.id).exists()
|
||||
|
||||
# def test_token_can_be_used_only_once(self, authenticated_client, create_test_user):
|
||||
# user = create_test_user
|
||||
# token_data = {
|
||||
# "access": "single_use_token",
|
||||
# "refresh": "single_use_refresh",
|
||||
# }
|
||||
# saml_token = SAMLToken.objects.create(
|
||||
# token=token_data,
|
||||
# user=user,
|
||||
# expires_at=datetime.now(timezone.utc) + timedelta(seconds=10),
|
||||
# )
|
||||
def test_token_can_be_used_only_once(self, authenticated_client, create_test_user):
|
||||
user = create_test_user
|
||||
token_data = {
|
||||
"access": "single_use_token",
|
||||
"refresh": "single_use_refresh",
|
||||
}
|
||||
saml_token = SAMLToken.objects.create(
|
||||
token=token_data,
|
||||
user=user,
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(seconds=10),
|
||||
)
|
||||
|
||||
# url = reverse("token-saml")
|
||||
url = reverse("token-saml")
|
||||
|
||||
# # First use: should succeed
|
||||
# response1 = authenticated_client.post(f"{url}?id={saml_token.id}")
|
||||
# assert response1.status_code == status.HTTP_200_OK
|
||||
# First use: should succeed
|
||||
response1 = authenticated_client.post(f"{url}?id={saml_token.id}")
|
||||
assert response1.status_code == status.HTTP_200_OK
|
||||
|
||||
# # Second use: should fail (already deleted)
|
||||
# response2 = authenticated_client.post(f"{url}?id={saml_token.id}")
|
||||
# assert response2.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Second use: should fail (already deleted)
|
||||
response2 = authenticated_client.post(f"{url}?id={saml_token.id}")
|
||||
assert response2.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# class TestSAMLInitiateAPIView:
|
||||
# def test_valid_email_domain_and_certificates(
|
||||
# self, authenticated_client, saml_setup, monkeypatch
|
||||
# ):
|
||||
# monkeypatch.setenv("SAML_PUBLIC_CERT", "fake_cert")
|
||||
# monkeypatch.setenv("SAML_PRIVATE_KEY", "fake_key")
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLInitiateAPIView:
|
||||
def test_valid_email_domain_and_certificates(
|
||||
self, authenticated_client, saml_setup, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("SAML_PUBLIC_CERT", "fake_cert")
|
||||
monkeypatch.setenv("SAML_PRIVATE_KEY", "fake_key")
|
||||
|
||||
# url = reverse("api_saml_initiate")
|
||||
# payload = {"email_domain": saml_setup["email"]}
|
||||
url = reverse("api_saml_initiate")
|
||||
payload = {"email_domain": saml_setup["email"]}
|
||||
|
||||
# response = authenticated_client.post(url, data=payload, format="json")
|
||||
response = authenticated_client.post(url, data=payload, format="json")
|
||||
|
||||
# assert response.status_code == status.HTTP_302_FOUND
|
||||
# assert (
|
||||
# reverse("saml_login", kwargs={"organization_slug": saml_setup["domain"]})
|
||||
# in response.url
|
||||
# )
|
||||
# assert "SAMLRequest" not in response.url
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
assert (
|
||||
reverse("saml_login", kwargs={"organization_slug": saml_setup["domain"]})
|
||||
in response.url
|
||||
)
|
||||
assert "SAMLRequest" not in response.url
|
||||
|
||||
# def test_invalid_email_domain(self, authenticated_client):
|
||||
# url = reverse("api_saml_initiate")
|
||||
# payload = {"email_domain": "user@unauthorized.com"}
|
||||
def test_invalid_email_domain(self, authenticated_client):
|
||||
url = reverse("api_saml_initiate")
|
||||
payload = {"email_domain": "user@unauthorized.com"}
|
||||
|
||||
# response = authenticated_client.post(url, data=payload, format="json")
|
||||
response = authenticated_client.post(url, data=payload, format="json")
|
||||
|
||||
# assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
# assert response.json()["errors"]["detail"] == "Unauthorized domain."
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json()["errors"]["detail"] == "Unauthorized domain."
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# class TestSAMLConfigurationViewSet:
|
||||
# def test_list_saml_configurations(self, authenticated_client, saml_setup):
|
||||
# config = SAMLConfiguration.objects.get(
|
||||
# email_domain=saml_setup["email"].split("@")[-1]
|
||||
# )
|
||||
# response = authenticated_client.get(reverse("saml-config-list"))
|
||||
# assert response.status_code == status.HTTP_200_OK
|
||||
# assert (
|
||||
# response.json()["data"][0]["attributes"]["email_domain"]
|
||||
# == config.email_domain
|
||||
# )
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLConfigurationViewSet:
|
||||
def test_list_saml_configurations(self, authenticated_client, saml_setup):
|
||||
config = SAMLConfiguration.objects.get(
|
||||
email_domain=saml_setup["email"].split("@")[-1]
|
||||
)
|
||||
response = authenticated_client.get(reverse("saml-config-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
response.json()["data"][0]["attributes"]["email_domain"]
|
||||
== config.email_domain
|
||||
)
|
||||
|
||||
# def test_retrieve_saml_configuration(self, authenticated_client, saml_setup):
|
||||
# config = SAMLConfiguration.objects.get(
|
||||
# email_domain=saml_setup["email"].split("@")[-1]
|
||||
# )
|
||||
# response = authenticated_client.get(
|
||||
# reverse("saml-config-detail", kwargs={"pk": config.id})
|
||||
# )
|
||||
# assert response.status_code == status.HTTP_200_OK
|
||||
# assert (
|
||||
# response.json()["data"]["attributes"]["metadata_xml"] == config.metadata_xml
|
||||
# )
|
||||
def test_retrieve_saml_configuration(self, authenticated_client, saml_setup):
|
||||
config = SAMLConfiguration.objects.get(
|
||||
email_domain=saml_setup["email"].split("@")[-1]
|
||||
)
|
||||
response = authenticated_client.get(
|
||||
reverse("saml-config-detail", kwargs={"pk": config.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
response.json()["data"]["attributes"]["metadata_xml"] == config.metadata_xml
|
||||
)
|
||||
|
||||
# def test_create_saml_configuration(self, authenticated_client, tenants_fixture):
|
||||
# payload = {
|
||||
# "email_domain": "newdomain.com",
|
||||
# "metadata_xml": """<?xml version='1.0' encoding='UTF-8'?>
|
||||
# <md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
# <md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
# <md:KeyDescriptor use='signing'>
|
||||
# <ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
# <ds:X509Data>
|
||||
# <ds:X509Certificate>TEST</ds:X509Certificate>
|
||||
# </ds:X509Data>
|
||||
# </ds:KeyInfo>
|
||||
# </md:KeyDescriptor>
|
||||
# <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
# </md:IDPSSODescriptor>
|
||||
# </md:EntityDescriptor>
|
||||
# """,
|
||||
# }
|
||||
# response = authenticated_client.post(
|
||||
# reverse("saml-config-list"), data=payload, format="json"
|
||||
# )
|
||||
# assert response.status_code == status.HTTP_201_CREATED
|
||||
# assert SAMLConfiguration.objects.filter(email_domain="newdomain.com").exists()
|
||||
def test_create_saml_configuration(self, authenticated_client, tenants_fixture):
|
||||
payload = {
|
||||
"email_domain": "newdomain.com",
|
||||
"metadata_xml": """<?xml version='1.0' encoding='UTF-8'?>
|
||||
<md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
<md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
<md:KeyDescriptor use='signing'>
|
||||
<ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>TEST</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>
|
||||
""",
|
||||
}
|
||||
response = authenticated_client.post(
|
||||
reverse("saml-config-list"), data=payload, format="json"
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert SAMLConfiguration.objects.filter(email_domain="newdomain.com").exists()
|
||||
|
||||
# def test_update_saml_configuration(self, authenticated_client, saml_setup):
|
||||
# config = SAMLConfiguration.objects.get(
|
||||
# email_domain=saml_setup["email"].split("@")[-1]
|
||||
# )
|
||||
# payload = {
|
||||
# "data": {
|
||||
# "type": "saml-configurations",
|
||||
# "id": str(config.id),
|
||||
# "attributes": {
|
||||
# "metadata_xml": """<?xml version='1.0' encoding='UTF-8'?>
|
||||
# <md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
# <md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
# <md:KeyDescriptor use='signing'>
|
||||
# <ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
# <ds:X509Data>
|
||||
# <ds:X509Certificate>TEST2</ds:X509Certificate>
|
||||
# </ds:X509Data>
|
||||
# </ds:KeyInfo>
|
||||
# </md:KeyDescriptor>
|
||||
# <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
# </md:IDPSSODescriptor>
|
||||
# </md:EntityDescriptor>
|
||||
# """
|
||||
# },
|
||||
# }
|
||||
# }
|
||||
# response = authenticated_client.patch(
|
||||
# reverse("saml-config-detail", kwargs={"pk": config.id}),
|
||||
# data=payload,
|
||||
# content_type="application/vnd.api+json",
|
||||
# )
|
||||
# assert response.status_code == status.HTTP_200_OK
|
||||
# config.refresh_from_db()
|
||||
# assert (
|
||||
# config.metadata_xml.strip()
|
||||
# == payload["data"]["attributes"]["metadata_xml"].strip()
|
||||
# )
|
||||
def test_update_saml_configuration(self, authenticated_client, saml_setup):
|
||||
config = SAMLConfiguration.objects.get(
|
||||
email_domain=saml_setup["email"].split("@")[-1]
|
||||
)
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "saml-configurations",
|
||||
"id": str(config.id),
|
||||
"attributes": {
|
||||
"metadata_xml": """<?xml version='1.0' encoding='UTF-8'?>
|
||||
<md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
<md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
<md:KeyDescriptor use='signing'>
|
||||
<ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>TEST2</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>
|
||||
"""
|
||||
},
|
||||
}
|
||||
}
|
||||
response = authenticated_client.patch(
|
||||
reverse("saml-config-detail", kwargs={"pk": config.id}),
|
||||
data=payload,
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
config.refresh_from_db()
|
||||
assert (
|
||||
config.metadata_xml.strip()
|
||||
== payload["data"]["attributes"]["metadata_xml"].strip()
|
||||
)
|
||||
|
||||
# def test_delete_saml_configuration(self, authenticated_client, saml_setup):
|
||||
# config = SAMLConfiguration.objects.get(
|
||||
# email_domain=saml_setup["email"].split("@")[-1]
|
||||
# )
|
||||
# response = authenticated_client.delete(
|
||||
# reverse("saml-config-detail", kwargs={"pk": config.id})
|
||||
# )
|
||||
# assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
# assert not SAMLConfiguration.objects.filter(id=config.id).exists()
|
||||
def test_delete_saml_configuration(self, authenticated_client, saml_setup):
|
||||
config = SAMLConfiguration.objects.get(
|
||||
email_domain=saml_setup["email"].split("@")[-1]
|
||||
)
|
||||
response = authenticated_client.delete(
|
||||
reverse("saml-config-detail", kwargs={"pk": config.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not SAMLConfiguration.objects.filter(id=config.id).exists()
|
||||
|
||||
|
||||
# @pytest.mark.django_db
|
||||
# class TestTenantFinishACSView:
|
||||
# def test_dispatch_skips_if_user_not_authenticated(self):
|
||||
# request = RequestFactory().get(
|
||||
# reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
# )
|
||||
# request.user = type("Anonymous", (), {"is_authenticated": False})()
|
||||
@pytest.mark.django_db
|
||||
class TestTenantFinishACSView:
|
||||
def test_dispatch_skips_if_user_not_authenticated(self):
|
||||
request = RequestFactory().get(
|
||||
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
)
|
||||
request.user = type("Anonymous", (), {"is_authenticated": False})()
|
||||
|
||||
# with patch(
|
||||
# "allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
# ) as mock_get_app:
|
||||
# mock_get_app.return_value = SocialApp(
|
||||
# provider="saml",
|
||||
# client_id="testtenant",
|
||||
# name="Test App",
|
||||
# settings={},
|
||||
# )
|
||||
with patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app:
|
||||
mock_get_app.return_value = SocialApp(
|
||||
provider="saml",
|
||||
client_id="testtenant",
|
||||
name="Test App",
|
||||
settings={},
|
||||
)
|
||||
|
||||
# view = TenantFinishACSView.as_view()
|
||||
# response = view(request, organization_slug="testtenant")
|
||||
view = TenantFinishACSView.as_view()
|
||||
response = view(request, organization_slug="testtenant")
|
||||
|
||||
# assert response.status_code in [200, 302]
|
||||
assert response.status_code in [200, 302]
|
||||
|
||||
# def test_dispatch_skips_if_social_app_not_found(self, users_fixture):
|
||||
# request = RequestFactory().get(
|
||||
# reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
# )
|
||||
# request.user = users_fixture[0]
|
||||
def test_dispatch_skips_if_social_app_not_found(self, users_fixture):
|
||||
request = RequestFactory().get(
|
||||
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
)
|
||||
request.user = users_fixture[0]
|
||||
|
||||
# with patch(
|
||||
# "allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
# ) as mock_get_app:
|
||||
# mock_get_app.return_value = SocialApp(
|
||||
# provider="saml",
|
||||
# client_id="testtenant",
|
||||
# name="Test App",
|
||||
# settings={},
|
||||
# )
|
||||
with patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app:
|
||||
mock_get_app.return_value = SocialApp(
|
||||
provider="saml",
|
||||
client_id="testtenant",
|
||||
name="Test App",
|
||||
settings={},
|
||||
)
|
||||
|
||||
# view = TenantFinishACSView.as_view()
|
||||
# response = view(request, organization_slug="testtenant")
|
||||
view = TenantFinishACSView.as_view()
|
||||
response = view(request, organization_slug="testtenant")
|
||||
|
||||
# assert isinstance(response, JsonResponse) or response.status_code in [200, 302]
|
||||
assert isinstance(response, JsonResponse) or response.status_code in [200, 302]
|
||||
|
||||
# def test_dispatch_sets_user_profile_and_assigns_role_and_creates_token(
|
||||
# self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
|
||||
# ):
|
||||
# monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
# user = create_test_user
|
||||
# original_email = user.email
|
||||
# original_name = user.name
|
||||
# original_company = user.company_name
|
||||
# user.email = f"doe@{saml_setup['email']}"
|
||||
def test_dispatch_sets_user_profile_and_assigns_role_and_creates_token(
|
||||
self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
original_name = user.name
|
||||
original_company = user.company_name
|
||||
user.company_name = "testing_company"
|
||||
user.is_authenticate = True
|
||||
|
||||
# social_account = SocialAccount(
|
||||
# user=user,
|
||||
# provider="saml",
|
||||
# extra_data={
|
||||
# "firstName": ["John"],
|
||||
# "lastName": ["Doe"],
|
||||
# "organization": ["TestOrg"],
|
||||
# "userType": ["saml_default_role"],
|
||||
# },
|
||||
# )
|
||||
social_account = SocialAccount(
|
||||
user=user,
|
||||
provider="saml",
|
||||
extra_data={
|
||||
"firstName": ["John"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["testing_company"],
|
||||
"userType": ["saml_default_role"],
|
||||
},
|
||||
)
|
||||
|
||||
# request = RequestFactory().get(
|
||||
# reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
# )
|
||||
# request.user = user
|
||||
request = RequestFactory().get(
|
||||
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
)
|
||||
request.user = user
|
||||
|
||||
# with (
|
||||
# patch(
|
||||
# "allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
# ) as mock_get_app_or_404,
|
||||
# patch("allauth.socialaccount.models.SocialApp.objects.get"),
|
||||
# patch(
|
||||
# "allauth.socialaccount.models.SocialAccount.objects.get"
|
||||
# ) as mock_sa_get,
|
||||
# ):
|
||||
# mock_get_app_or_404.return_value = MagicMock(
|
||||
# provider="saml", client_id="testtenant", name="Test App", settings={}
|
||||
# )
|
||||
# mock_sa_get.return_value = social_account
|
||||
with (
|
||||
patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app_or_404,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialApp.objects.get"
|
||||
) as mock_socialapp_get,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialAccount.objects.get"
|
||||
) as mock_sa_get,
|
||||
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
|
||||
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
|
||||
patch("api.models.User.objects.get") as mock_user_get,
|
||||
):
|
||||
mock_get_app_or_404.return_value = MagicMock(
|
||||
provider="saml", client_id="testtenant", name="Test App", settings={}
|
||||
)
|
||||
mock_sa_get.return_value = social_account
|
||||
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
|
||||
mock_saml_domain_get.return_value = SimpleNamespace(
|
||||
tenant_id=tenants_fixture[0].id
|
||||
)
|
||||
mock_saml_config_get.return_value = MagicMock()
|
||||
mock_user_get.return_value = user
|
||||
|
||||
# view = TenantFinishACSView.as_view()
|
||||
# response = view(request, organization_slug="testtenant")
|
||||
view = TenantFinishACSView.as_view()
|
||||
response = view(request, organization_slug="testtenant")
|
||||
|
||||
# assert response.status_code == 302
|
||||
assert response.status_code == 302
|
||||
|
||||
# expected_callback_host = "localhost"
|
||||
# parsed_url = urlparse(response.url)
|
||||
# assert parsed_url.netloc == expected_callback_host
|
||||
# query_params = parse_qs(parsed_url.query)
|
||||
# assert "id" in query_params
|
||||
expected_callback_host = "localhost"
|
||||
parsed_url = urlparse(response.url)
|
||||
assert parsed_url.netloc == expected_callback_host
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
assert "id" in query_params
|
||||
|
||||
# token_id = query_params["id"][0]
|
||||
# token_obj = SAMLToken.objects.get(id=token_id)
|
||||
# assert token_obj.user == user
|
||||
# assert not token_obj.is_expired()
|
||||
token_id = query_params["id"][0]
|
||||
token_obj = SAMLToken.objects.get(id=token_id)
|
||||
assert token_obj.user == user
|
||||
assert not token_obj.is_expired()
|
||||
|
||||
# user.refresh_from_db()
|
||||
# assert user.name == "John Doe"
|
||||
# assert user.company_name == "TestOrg"
|
||||
user.refresh_from_db()
|
||||
assert user.name == "John Doe"
|
||||
assert user.company_name == "testing_company"
|
||||
|
||||
# role = Role.objects.using(MainRouter.admin_db).get(name="saml_default_role")
|
||||
# assert role.tenant == tenants_fixture[0]
|
||||
role = Role.objects.using(MainRouter.admin_db).get(name="saml_default_role")
|
||||
assert role.tenant == tenants_fixture[0]
|
||||
|
||||
# assert (
|
||||
# UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
# .filter(user=user, tenant_id=tenants_fixture[0].id)
|
||||
# .exists()
|
||||
# )
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, tenant_id=tenants_fixture[0].id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
# # Membership should have been created with default role
|
||||
# membership = Membership.objects.using(MainRouter.admin_db).get(
|
||||
# user=user, tenant=tenants_fixture[0]
|
||||
# )
|
||||
# assert membership.role == Membership.RoleChoices.MEMBER
|
||||
# assert membership.user == user
|
||||
# assert membership.tenant == tenants_fixture[0]
|
||||
membership = Membership.objects.using(MainRouter.admin_db).get(
|
||||
user=user, tenant=tenants_fixture[0]
|
||||
)
|
||||
assert membership.role == Membership.RoleChoices.MEMBER
|
||||
assert membership.user == user
|
||||
assert membership.tenant == tenants_fixture[0]
|
||||
|
||||
# # Restore original user state
|
||||
# user.email = original_email
|
||||
# user.name = original_name
|
||||
# user.company_name = original_company
|
||||
# user.save()
|
||||
user.name = original_name
|
||||
user.company_name = original_company
|
||||
user.save()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -29,6 +29,7 @@ from api.models import (
|
||||
ResourceTag,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
SAMLConfiguration,
|
||||
Scan,
|
||||
StateChoices,
|
||||
StatusChoices,
|
||||
@@ -129,6 +130,12 @@ class TokenSerializer(BaseTokenSerializer):
|
||||
|
||||
class TokenSocialLoginSerializer(BaseTokenSerializer):
|
||||
email = serializers.EmailField(write_only=True)
|
||||
tenant_id = serializers.UUIDField(
|
||||
write_only=True,
|
||||
required=False,
|
||||
help_text="If not provided, the tenant ID of the first membership that was added"
|
||||
" to the user will be used.",
|
||||
)
|
||||
|
||||
# Output tokens
|
||||
refresh = serializers.CharField(read_only=True)
|
||||
@@ -2067,23 +2074,23 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
# SSO
|
||||
|
||||
|
||||
# class SamlInitiateSerializer(serializers.Serializer):
|
||||
# email_domain = serializers.CharField()
|
||||
class SamlInitiateSerializer(serializers.Serializer):
|
||||
email_domain = serializers.CharField()
|
||||
|
||||
# class JSONAPIMeta:
|
||||
# resource_name = "saml-initiate"
|
||||
class JSONAPIMeta:
|
||||
resource_name = "saml-initiate"
|
||||
|
||||
|
||||
# class SamlMetadataSerializer(serializers.Serializer):
|
||||
# class JSONAPIMeta:
|
||||
# resource_name = "saml-meta"
|
||||
class SamlMetadataSerializer(serializers.Serializer):
|
||||
class JSONAPIMeta:
|
||||
resource_name = "saml-meta"
|
||||
|
||||
|
||||
# class SAMLConfigurationSerializer(RLSSerializer):
|
||||
# class Meta:
|
||||
# model = SAMLConfiguration
|
||||
# fields = ["id", "email_domain", "metadata_xml", "created_at", "updated_at"]
|
||||
# read_only_fields = ["id", "created_at", "updated_at"]
|
||||
class SAMLConfigurationSerializer(RLSSerializer):
|
||||
class Meta:
|
||||
model = SAMLConfiguration
|
||||
fields = ["id", "email_domain", "metadata_xml", "created_at", "updated_at"]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class LighthouseConfigSerializer(RLSSerializer):
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from allauth.socialaccount.providers.saml.views import ACSView, MetadataView, SLSView
|
||||
from django.urls import include, path
|
||||
from drf_spectacular.views import SpectacularRedocView
|
||||
from rest_framework_nested import routers
|
||||
|
||||
from api.v1.views import (
|
||||
ComplianceOverviewViewSet,
|
||||
CustomSAMLLoginView,
|
||||
CustomTokenObtainView,
|
||||
CustomTokenRefreshView,
|
||||
CustomTokenSwitchTenantView,
|
||||
@@ -23,10 +25,14 @@ from api.v1.views import (
|
||||
ResourceViewSet,
|
||||
RoleProviderGroupRelationshipView,
|
||||
RoleViewSet,
|
||||
SAMLConfigurationViewSet,
|
||||
SAMLInitiateAPIView,
|
||||
SAMLTokenValidateView,
|
||||
ScanViewSet,
|
||||
ScheduleViewSet,
|
||||
SchemaView,
|
||||
TaskViewSet,
|
||||
TenantFinishACSView,
|
||||
TenantMembersViewSet,
|
||||
TenantViewSet,
|
||||
UserRoleRelationshipView,
|
||||
@@ -50,7 +56,7 @@ router.register(
|
||||
router.register(r"overviews", OverviewViewSet, basename="overview")
|
||||
router.register(r"schedules", ScheduleViewSet, basename="schedule")
|
||||
router.register(r"integrations", IntegrationViewSet, basename="integration")
|
||||
# router.register(r"saml-config", SAMLConfigurationViewSet, basename="saml-config")
|
||||
router.register(r"saml-config", SAMLConfigurationViewSet, basename="saml-config")
|
||||
router.register(
|
||||
r"lighthouse-configurations",
|
||||
LighthouseConfigViewSet,
|
||||
@@ -119,24 +125,36 @@ urlpatterns = [
|
||||
),
|
||||
name="provider_group-providers-relationship",
|
||||
),
|
||||
# API endpoint to start SAML SSO flow (WIP)
|
||||
# path(
|
||||
# "auth/saml/initiate/", SAMLInitiateAPIView.as_view(), name="api_saml_initiate"
|
||||
# ),
|
||||
# # Custom SAML endpoints (must come before allauth.urls) (WIP)
|
||||
# path(
|
||||
# "accounts/saml/<organization_slug>/login/",
|
||||
# CustomSAMLLoginView.as_view(),
|
||||
# name="saml_login",
|
||||
# ),
|
||||
# path(
|
||||
# "accounts/saml/<organization_slug>/acs/finish/",
|
||||
# TenantFinishACSView.as_view(),
|
||||
# name="saml_finish_acs",
|
||||
# ),
|
||||
# Allauth SAML endpoints for tenants (WIP)
|
||||
# path("accounts/", include("allauth.urls")),
|
||||
# path("tokens/saml", SAMLTokenValidateView.as_view(), name="token-saml"),
|
||||
# API endpoint to start SAML SSO flow
|
||||
path(
|
||||
"auth/saml/initiate/", SAMLInitiateAPIView.as_view(), name="api_saml_initiate"
|
||||
),
|
||||
path(
|
||||
"accounts/saml/<organization_slug>/login/",
|
||||
CustomSAMLLoginView.as_view(),
|
||||
name="saml_login",
|
||||
),
|
||||
path(
|
||||
"accounts/saml/<organization_slug>/acs/",
|
||||
ACSView.as_view(),
|
||||
name="saml_acs",
|
||||
),
|
||||
path(
|
||||
"accounts/saml/<organization_slug>/acs/finish/",
|
||||
TenantFinishACSView.as_view(),
|
||||
name="saml_finish_acs",
|
||||
),
|
||||
path(
|
||||
"accounts/saml/<organization_slug>/sls/",
|
||||
SLSView.as_view(),
|
||||
name="saml_sls",
|
||||
),
|
||||
path(
|
||||
"accounts/saml/<organization_slug>/metadata/",
|
||||
MetadataView.as_view(),
|
||||
name="saml_metadata",
|
||||
),
|
||||
path("tokens/saml", SAMLTokenValidateView.as_view(), name="token-saml"),
|
||||
path("tokens/google", GoogleSocialLoginView.as_view(), name="token-google"),
|
||||
path("tokens/github", GithubSocialLoginView.as_view(), name="token-github"),
|
||||
path("", include(router.urls)),
|
||||
|
||||
+225
-203
@@ -1,10 +1,13 @@
|
||||
import glob
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import sentry_sdk
|
||||
from allauth.socialaccount.models import SocialAccount, SocialApp
|
||||
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
|
||||
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
|
||||
from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView
|
||||
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
|
||||
from celery.result import AsyncResult
|
||||
from config.env import env
|
||||
@@ -20,6 +23,7 @@ from django.db import transaction
|
||||
from django.db.models import Count, Exists, F, OuterRef, Prefetch, Q, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.decorators import method_decorator
|
||||
@@ -64,6 +68,7 @@ from api.compliance import (
|
||||
get_compliance_frameworks,
|
||||
)
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.exceptions import TaskFailedException
|
||||
from api.filters import (
|
||||
ComplianceOverviewFilter,
|
||||
@@ -101,6 +106,9 @@ from api.models import (
|
||||
ResourceScanSummary,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
SAMLConfiguration,
|
||||
SAMLDomainIndex,
|
||||
SAMLToken,
|
||||
Scan,
|
||||
ScanSummary,
|
||||
SeverityChoices,
|
||||
@@ -157,6 +165,8 @@ from api.v1.serializers import (
|
||||
RoleProviderGroupRelationshipSerializer,
|
||||
RoleSerializer,
|
||||
RoleUpdateSerializer,
|
||||
SAMLConfigurationSerializer,
|
||||
SamlInitiateSerializer,
|
||||
ScanComplianceReportSerializer,
|
||||
ScanCreateSerializer,
|
||||
ScanReportSerializer,
|
||||
@@ -393,240 +403,252 @@ class GithubSocialLoginView(SocialLoginView):
|
||||
return original_response
|
||||
|
||||
|
||||
# @extend_schema(exclude=True)
|
||||
# class SAMLTokenValidateView(GenericAPIView):
|
||||
# resource_name = "tokens"
|
||||
# http_method_names = ["post"]
|
||||
@extend_schema(exclude=True)
|
||||
class SAMLTokenValidateView(GenericAPIView):
|
||||
resource_name = "tokens"
|
||||
http_method_names = ["post"]
|
||||
|
||||
# def post(self, request):
|
||||
# token_id = request.query_params.get("id", "invalid")
|
||||
# try:
|
||||
# saml_token = SAMLToken.objects.using(MainRouter.admin_db).get(id=token_id)
|
||||
# except SAMLToken.DoesNotExist:
|
||||
# return Response({"detail": "Invalid token ID."}, status=404)
|
||||
def post(self, request):
|
||||
token_id = request.query_params.get("id", "invalid")
|
||||
try:
|
||||
saml_token = SAMLToken.objects.using(MainRouter.admin_db).get(id=token_id)
|
||||
except SAMLToken.DoesNotExist:
|
||||
return Response({"detail": "Invalid token ID."}, status=404)
|
||||
|
||||
# if saml_token.is_expired():
|
||||
# return Response({"detail": "Token expired."}, status=400)
|
||||
if saml_token.is_expired():
|
||||
return Response({"detail": "Token expired."}, status=400)
|
||||
|
||||
# token_data = saml_token.token
|
||||
# # Currently we don't store the tokens in the database, so we delete the token after use
|
||||
# saml_token.delete()
|
||||
token_data = saml_token.token
|
||||
# Currently we don't store the tokens in the database, so we delete the token after use
|
||||
saml_token.delete()
|
||||
|
||||
# return Response(token_data, status=200)
|
||||
return Response(token_data, status=200)
|
||||
|
||||
|
||||
# @extend_schema(exclude=True)
|
||||
# class CustomSAMLLoginView(LoginView):
|
||||
# def dispatch(self, request, *args, **kwargs):
|
||||
# """
|
||||
# Convert GET requests to POST to bypass allauth's confirmation screen.
|
||||
@extend_schema(exclude=True)
|
||||
class CustomSAMLLoginView(LoginView):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""
|
||||
Convert GET requests to POST to bypass allauth's confirmation screen.
|
||||
|
||||
# Why this is necessary:
|
||||
# - django-allauth requires POST for social logins to prevent open redirect attacks
|
||||
# - SAML login links typically use GET requests (e.g., <a href="...">)
|
||||
# - This conversion allows seamless login without user-facing confirmation
|
||||
Why this is necessary:
|
||||
- django-allauth requires POST for social logins to prevent open redirect attacks
|
||||
- SAML login links typically use GET requests (e.g., <a href="...">)
|
||||
- This conversion allows seamless login without user-facing confirmation
|
||||
|
||||
# Security considerations:
|
||||
# 1. Preserves CSRF protection: Original POST handling remains intact
|
||||
# 2. Avoids global SOCIALACCOUNT_LOGIN_ON_GET=True which would:
|
||||
# - Enable GET logins for ALL providers (security risk)
|
||||
# - Potentially expose open redirect vulnerabilities
|
||||
# 3. SAML payloads remain signed/encrypted regardless of HTTP method
|
||||
# 4. No sensitive parameters are exposed in URLs (copied to POST body)
|
||||
Security considerations:
|
||||
1. Preserves CSRF protection: Original POST handling remains intact
|
||||
2. Avoids global SOCIALACCOUNT_LOGIN_ON_GET=True which would:
|
||||
- Enable GET logins for ALL providers (security risk)
|
||||
- Potentially expose open redirect vulnerabilities
|
||||
3. SAML payloads remain signed/encrypted regardless of HTTP method
|
||||
4. No sensitive parameters are exposed in URLs (copied to POST body)
|
||||
|
||||
# This approach maintains security while providing better UX.
|
||||
# """
|
||||
# if request.method == "GET":
|
||||
# # Convert GET to POST while preserving parameters
|
||||
# request.method = "POST"
|
||||
# # Safe because SAML validates signatures
|
||||
# request.POST = request.GET.copy()
|
||||
# return super().dispatch(request, *args, **kwargs)
|
||||
This approach maintains security while providing better UX.
|
||||
"""
|
||||
if request.method == "GET":
|
||||
# Convert GET to POST while preserving parameters
|
||||
request.method = "POST"
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
# @extend_schema(exclude=True)
|
||||
# class SAMLInitiateAPIView(GenericAPIView):
|
||||
# serializer_class = SamlInitiateSerializer
|
||||
# permission_classes = []
|
||||
@extend_schema(exclude=True)
|
||||
class SAMLInitiateAPIView(GenericAPIView):
|
||||
serializer_class = SamlInitiateSerializer
|
||||
permission_classes = []
|
||||
|
||||
# def post(self, request, *args, **kwargs):
|
||||
# # Validate the input payload and extract the domain
|
||||
# serializer = self.get_serializer(data=request.data)
|
||||
# serializer.is_valid(raise_exception=True)
|
||||
# email = serializer.validated_data["email_domain"]
|
||||
# domain = email.split("@", 1)[-1].lower()
|
||||
def post(self, request, *args, **kwargs):
|
||||
# Validate the input payload and extract the domain
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
email = serializer.validated_data["email_domain"]
|
||||
domain = email.split("@", 1)[-1].lower()
|
||||
|
||||
# # Retrieve the SAML configuration for the given email domain
|
||||
# try:
|
||||
# check = SAMLDomainIndex.objects.get(email_domain=domain)
|
||||
# with rls_transaction(str(check.tenant_id)):
|
||||
# config = SAMLConfiguration.objects.get(tenant_id=str(check.tenant_id))
|
||||
# except (SAMLDomainIndex.DoesNotExist, SAMLConfiguration.DoesNotExist):
|
||||
# return Response(
|
||||
# {"detail": "Unauthorized domain."}, status=status.HTTP_403_FORBIDDEN
|
||||
# )
|
||||
# Retrieve the SAML configuration for the given email domain
|
||||
try:
|
||||
check = SAMLDomainIndex.objects.get(email_domain=domain)
|
||||
with rls_transaction(str(check.tenant_id)):
|
||||
config = SAMLConfiguration.objects.get(tenant_id=str(check.tenant_id))
|
||||
except (SAMLDomainIndex.DoesNotExist, SAMLConfiguration.DoesNotExist):
|
||||
return Response(
|
||||
{"detail": "Unauthorized domain."}, status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# # Check certificates are not empty (TODO: Validate certificates)
|
||||
# # saml_public_cert = os.getenv("SAML_PUBLIC_CERT", "").strip()
|
||||
# # saml_private_key = os.getenv("SAML_PRIVATE_KEY", "").strip()
|
||||
# Check certificates are not empty (TODO: Validate certificates)
|
||||
# saml_public_cert = os.getenv("SAML_PUBLIC_CERT", "").strip()
|
||||
# saml_private_key = os.getenv("SAML_PRIVATE_KEY", "").strip()
|
||||
|
||||
# # if not saml_public_cert or not saml_private_key:
|
||||
# # return Response(
|
||||
# # {"detail": "SAML configuration is invalid: missing certificates."},
|
||||
# # status=status.HTTP_403_FORBIDDEN,
|
||||
# # )
|
||||
# if not saml_public_cert or not saml_private_key:
|
||||
# return Response(
|
||||
# {"detail": "SAML configuration is invalid: missing certificates."},
|
||||
# status=status.HTTP_403_FORBIDDEN,
|
||||
# )
|
||||
|
||||
# # Build the SAML login URL using the configured API host
|
||||
# api_host = os.getenv("API_BASE_URL")
|
||||
# login_path = reverse(
|
||||
# "saml_login", kwargs={"organization_slug": config.email_domain}
|
||||
# )
|
||||
# login_url = urljoin(api_host, login_path)
|
||||
# Build the SAML login URL using the configured API host
|
||||
api_host = os.getenv("API_BASE_URL")
|
||||
login_path = reverse(
|
||||
"saml_login", kwargs={"organization_slug": config.email_domain}
|
||||
)
|
||||
login_url = urljoin(api_host, login_path)
|
||||
|
||||
# return redirect(login_url)
|
||||
return redirect(login_url)
|
||||
|
||||
|
||||
# @extend_schema_view(
|
||||
# list=extend_schema(
|
||||
# tags=["SAML"],
|
||||
# summary="List all SSO configurations",
|
||||
# description="Returns all the SAML-based SSO configurations associated with the current tenant.",
|
||||
# ),
|
||||
# retrieve=extend_schema(
|
||||
# tags=["SAML"],
|
||||
# summary="Retrieve SSO configuration details",
|
||||
# description="Returns the details of a specific SAML configuration belonging to the current tenant.",
|
||||
# ),
|
||||
# create=extend_schema(
|
||||
# tags=["SAML"],
|
||||
# summary="Create the SSO configuration",
|
||||
# description="Creates a new SAML SSO configuration for the current tenant, including email domain and metadata XML.",
|
||||
# ),
|
||||
# partial_update=extend_schema(
|
||||
# tags=["SAML"],
|
||||
# summary="Update the SSO configuration",
|
||||
# description="Partially updates an existing SAML SSO configuration. Supports changes to email domain and metadata XML.",
|
||||
# ),
|
||||
# destroy=extend_schema(
|
||||
# tags=["SAML"],
|
||||
# summary="Delete the SSO configuration",
|
||||
# description="Deletes an existing SAML SSO configuration associated with the current tenant.",
|
||||
# ),
|
||||
# )
|
||||
# @method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
# @method_decorator(CACHE_DECORATOR, name="list")
|
||||
# class SAMLConfigurationViewSet(BaseRLSViewSet):
|
||||
# """
|
||||
# ViewSet for managing SAML SSO configurations per tenant.
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
tags=["SAML"],
|
||||
summary="List all SSO configurations",
|
||||
description="Returns all the SAML-based SSO configurations associated with the current tenant.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["SAML"],
|
||||
summary="Retrieve SSO configuration details",
|
||||
description="Returns the details of a specific SAML configuration belonging to the current tenant.",
|
||||
),
|
||||
create=extend_schema(
|
||||
tags=["SAML"],
|
||||
summary="Create the SSO configuration",
|
||||
description="Creates a new SAML SSO configuration for the current tenant, including email domain and metadata XML.",
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
tags=["SAML"],
|
||||
summary="Update the SSO configuration",
|
||||
description="Partially updates an existing SAML SSO configuration. Supports changes to email domain and metadata XML.",
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["SAML"],
|
||||
summary="Delete the SSO configuration",
|
||||
description="Deletes an existing SAML SSO configuration associated with the current tenant.",
|
||||
),
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
class SAMLConfigurationViewSet(BaseRLSViewSet):
|
||||
"""
|
||||
ViewSet for managing SAML SSO configurations per tenant.
|
||||
|
||||
# This endpoint allows authorized users to perform CRUD operations on SAMLConfiguration,
|
||||
# which define how a tenant integrates with an external SAML Identity Provider (IdP).
|
||||
This endpoint allows authorized users to perform CRUD operations on SAMLConfiguration,
|
||||
which define how a tenant integrates with an external SAML Identity Provider (IdP).
|
||||
|
||||
# Typical use cases include:
|
||||
# - Listing all existing configurations for auditing or UI display.
|
||||
# - Retrieving a single configuration to show setup details.
|
||||
# - Creating or updating a configuration to onboard or modify SAML integration.
|
||||
# - Deleting a configuration when deactivating SAML for a tenant.
|
||||
# """
|
||||
Typical use cases include:
|
||||
- Listing all existing configurations for auditing or UI display.
|
||||
- Retrieving a single configuration to show setup details.
|
||||
- Creating or updating a configuration to onboard or modify SAML integration.
|
||||
- Deleting a configuration when deactivating SAML for a tenant.
|
||||
"""
|
||||
|
||||
# serializer_class = SAMLConfigurationSerializer
|
||||
# required_permissions = [Permissions.MANAGE_INTEGRATIONS]
|
||||
# queryset = SAMLConfiguration.objects.all()
|
||||
serializer_class = SAMLConfigurationSerializer
|
||||
required_permissions = [Permissions.MANAGE_INTEGRATIONS]
|
||||
queryset = SAMLConfiguration.objects.all()
|
||||
|
||||
# def get_queryset(self):
|
||||
# # If called during schema generation, return an empty queryset
|
||||
# if getattr(self, "swagger_fake_view", False):
|
||||
# return SAMLConfiguration.objects.none()
|
||||
# return SAMLConfiguration.objects.filter(tenant=self.request.tenant_id)
|
||||
def get_queryset(self):
|
||||
# If called during schema generation, return an empty queryset
|
||||
if getattr(self, "swagger_fake_view", False):
|
||||
return SAMLConfiguration.objects.none()
|
||||
return SAMLConfiguration.objects.filter(tenant=self.request.tenant_id)
|
||||
|
||||
|
||||
# class TenantFinishACSView(FinishACSView):
|
||||
# def dispatch(self, request, organization_slug):
|
||||
# response = super().dispatch(request, organization_slug)
|
||||
# user = getattr(request, "user", None)
|
||||
# if not user or not user.is_authenticated:
|
||||
# return response
|
||||
class TenantFinishACSView(FinishACSView):
|
||||
def dispatch(self, request, organization_slug):
|
||||
response = super().dispatch(request, organization_slug)
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return response
|
||||
|
||||
# try:
|
||||
# social_app = SocialApp.objects.get(
|
||||
# provider="saml", client_id=organization_slug
|
||||
# )
|
||||
# social_account = SocialAccount.objects.get(
|
||||
# user=user, provider=social_app.provider
|
||||
# )
|
||||
# except (SocialApp.DoesNotExist, SocialAccount.DoesNotExist):
|
||||
# return response
|
||||
# Defensive check to avoid edge case failures due to inconsistent or incomplete data in the database
|
||||
# This handles scenarios like partially deleted or missing related objects
|
||||
try:
|
||||
check = SAMLDomainIndex.objects.get(email_domain=organization_slug)
|
||||
with rls_transaction(str(check.tenant_id)):
|
||||
SAMLConfiguration.objects.get(tenant_id=str(check.tenant_id))
|
||||
social_app = SocialApp.objects.get(
|
||||
provider="saml", client_id=organization_slug
|
||||
)
|
||||
user_id = User.objects.get(email=str(user)).id
|
||||
social_account = SocialAccount.objects.get(
|
||||
user=str(user_id), provider=social_app.provider_id
|
||||
)
|
||||
except (
|
||||
SAMLDomainIndex.DoesNotExist,
|
||||
SAMLConfiguration.DoesNotExist,
|
||||
SocialApp.DoesNotExist,
|
||||
SocialAccount.DoesNotExist,
|
||||
User.DoesNotExist,
|
||||
):
|
||||
return response
|
||||
|
||||
# extra = social_account.extra_data
|
||||
# user.first_name = (
|
||||
# extra.get("firstName", [""])[0] if extra.get("firstName") else ""
|
||||
# )
|
||||
# user.last_name = extra.get("lastName", [""])[0] if extra.get("lastName") else ""
|
||||
# user.company_name = (
|
||||
# extra.get("organization", [""])[0] if extra.get("organization") else ""
|
||||
# )
|
||||
# user.name = f"{user.first_name} {user.last_name}".strip()
|
||||
# if user.name == "":
|
||||
# user.name = "N/A"
|
||||
# user.save()
|
||||
extra = social_account.extra_data
|
||||
user.first_name = (
|
||||
extra.get("firstName", [""])[0] if extra.get("firstName") else ""
|
||||
)
|
||||
user.last_name = extra.get("lastName", [""])[0] if extra.get("lastName") else ""
|
||||
user.company_name = (
|
||||
extra.get("organization", [""])[0] if extra.get("organization") else ""
|
||||
)
|
||||
user.name = f"{user.first_name} {user.last_name}".strip()
|
||||
if user.name == "":
|
||||
user.name = "N/A"
|
||||
user.save()
|
||||
|
||||
# email_domain = user.email.split("@")[-1]
|
||||
# tenant = (
|
||||
# SAMLConfiguration.objects.using(MainRouter.admin_db)
|
||||
# .get(email_domain=email_domain)
|
||||
# .tenant
|
||||
# )
|
||||
# role_name = (
|
||||
# extra.get("userType", ["saml_default_role"])[0].strip()
|
||||
# if extra.get("userType")
|
||||
# else "saml_default_role"
|
||||
# )
|
||||
# try:
|
||||
# role = Role.objects.using(MainRouter.admin_db).get(
|
||||
# name=role_name, tenant=tenant
|
||||
# )
|
||||
# except Role.DoesNotExist:
|
||||
# role = Role.objects.using(MainRouter.admin_db).create(
|
||||
# name=role_name,
|
||||
# tenant=tenant,
|
||||
# manage_users=False,
|
||||
# manage_account=False,
|
||||
# manage_billing=False,
|
||||
# manage_providers=False,
|
||||
# manage_integrations=False,
|
||||
# manage_scans=False,
|
||||
# unlimited_visibility=False,
|
||||
# )
|
||||
# UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
|
||||
# user=user,
|
||||
# tenant_id=tenant.id,
|
||||
# ).delete()
|
||||
# UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
# user=user,
|
||||
# role=role,
|
||||
# tenant_id=tenant.id,
|
||||
# )
|
||||
# membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create(
|
||||
# user=user,
|
||||
# tenant=tenant,
|
||||
# defaults={
|
||||
# "user": user,
|
||||
# "tenant": tenant,
|
||||
# "role": Membership.RoleChoices.MEMBER,
|
||||
# },
|
||||
# )
|
||||
email_domain = user.email.split("@")[-1]
|
||||
tenant = (
|
||||
SAMLConfiguration.objects.using(MainRouter.admin_db)
|
||||
.get(email_domain=email_domain)
|
||||
.tenant
|
||||
)
|
||||
role_name = (
|
||||
extra.get("userType", ["saml_default_role"])[0].strip()
|
||||
if extra.get("userType")
|
||||
else "saml_default_role"
|
||||
)
|
||||
try:
|
||||
role = Role.objects.using(MainRouter.admin_db).get(
|
||||
name=role_name, tenant=tenant
|
||||
)
|
||||
except Role.DoesNotExist:
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name=role_name,
|
||||
tenant=tenant,
|
||||
manage_users=False,
|
||||
manage_account=False,
|
||||
manage_billing=False,
|
||||
manage_providers=False,
|
||||
manage_integrations=False,
|
||||
manage_scans=False,
|
||||
unlimited_visibility=False,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
|
||||
user=user,
|
||||
tenant_id=tenant.id,
|
||||
).delete()
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create(
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
defaults={
|
||||
"user": user,
|
||||
"tenant": tenant,
|
||||
"role": Membership.RoleChoices.MEMBER,
|
||||
},
|
||||
)
|
||||
|
||||
# serializer = TokenSocialLoginSerializer(data={"email": user.email})
|
||||
# serializer.is_valid(raise_exception=True)
|
||||
serializer = TokenSocialLoginSerializer(
|
||||
data={"email": user.email, "tenant_id": str(tenant.id)}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# token_data = serializer.validated_data
|
||||
# saml_token = SAMLToken.objects.using(MainRouter.admin_db).create(
|
||||
# token=token_data, user=user
|
||||
# )
|
||||
# callback_url = env.str("SAML_SSO_CALLBACK_URL")
|
||||
# redirect_url = f"{callback_url}?id={saml_token.id}"
|
||||
token_data = serializer.validated_data
|
||||
saml_token = SAMLToken.objects.using(MainRouter.admin_db).create(
|
||||
token=token_data, user=user
|
||||
)
|
||||
callback_url = env.str("SAML_SSO_CALLBACK_URL")
|
||||
redirect_url = f"{callback_url}?id={saml_token.id}"
|
||||
|
||||
# return redirect(redirect_url)
|
||||
return redirect(redirect_url)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
|
||||
@@ -11,6 +11,7 @@ SECRET_KEY = env("SECRET_KEY", default="secret")
|
||||
DEBUG = env.bool("DJANGO_DEBUG", default=False)
|
||||
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
USE_X_FORWARDED_HOST = True
|
||||
|
||||
# Application definition
|
||||
|
||||
@@ -248,3 +249,7 @@ X_FRAME_OPTIONS = "DENY"
|
||||
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
||||
|
||||
DJANGO_DELETION_BATCH_SIZE = env.int("DJANGO_DELETION_BATCH_SIZE", 5000)
|
||||
|
||||
# SAML requirement
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
@@ -25,9 +25,18 @@ SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
|
||||
SOCIALACCOUNT_ADAPTER = "api.adapters.ProwlerSocialAccountAdapter"
|
||||
|
||||
# SAML keys (TODO: Validate certificates)
|
||||
# SAML_PUBLIC_CERT = env("SAML_PUBLIC_CERT", default="")
|
||||
# SAML_PRIVATE_KEY = env("SAML_PRIVATE_KEY", default="")
|
||||
|
||||
# def inline(pem: str) -> str:
|
||||
# return "".join(
|
||||
# line.strip()
|
||||
# for line in pem.splitlines()
|
||||
# if "CERTIFICATE" not in line and "KEY" not in line
|
||||
# )
|
||||
|
||||
|
||||
# # SAML keys (TODO: Validate certificates)
|
||||
# SAML_PUBLIC_CERT = inline(env("SAML_PUBLIC_CERT", default=""))
|
||||
# SAML_PRIVATE_KEY = inline(env("SAML_PRIVATE_KEY", default=""))
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"google": {
|
||||
@@ -60,17 +69,14 @@ SOCIALACCOUNT_PROVIDERS = {
|
||||
"entity_id": "urn:prowler.com:sp",
|
||||
},
|
||||
"advanced": {
|
||||
# TODO: Validate certificates
|
||||
# "x509cert": SAML_PUBLIC_CERT,
|
||||
# "private_key": SAML_PRIVATE_KEY,
|
||||
# "authn_request_signed": True,
|
||||
# "want_assertion_signed": True,
|
||||
# "want_message_signed": True,
|
||||
# "want_assertion_signed": True,
|
||||
"reject_idp_initiated_sso": False,
|
||||
"name_id_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
||||
"authn_request_signed": False,
|
||||
"logout_request_signed": False,
|
||||
"logout_response_signed": False,
|
||||
"want_assertion_encrypted": False,
|
||||
"want_name_id_encrypted": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+52
-49
@@ -1,8 +1,9 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from allauth.socialaccount.models import SocialLogin
|
||||
from django.conf import settings
|
||||
from django.db import connection as django_connection
|
||||
from django.db import connections as django_connections
|
||||
@@ -28,6 +29,8 @@ from api.models import (
|
||||
Resource,
|
||||
ResourceTag,
|
||||
Role,
|
||||
SAMLConfiguration,
|
||||
SAMLDomainIndex,
|
||||
Scan,
|
||||
ScanSummary,
|
||||
StateChoices,
|
||||
@@ -1118,62 +1121,62 @@ def latest_scan_finding(authenticated_client, providers_fixture, resources_fixtu
|
||||
return finding
|
||||
|
||||
|
||||
# @pytest.fixture
|
||||
# def saml_setup(tenants_fixture):
|
||||
# tenant_id = tenants_fixture[0].id
|
||||
# domain = "example.com"
|
||||
@pytest.fixture
|
||||
def saml_setup(tenants_fixture):
|
||||
tenant_id = tenants_fixture[0].id
|
||||
domain = "prowler.com"
|
||||
|
||||
# SAMLDomainIndex.objects.create(email_domain=domain, tenant_id=tenant_id)
|
||||
SAMLDomainIndex.objects.create(email_domain=domain, tenant_id=tenant_id)
|
||||
|
||||
# metadata_xml = """<?xml version='1.0' encoding='UTF-8'?>
|
||||
# <md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
# <md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
# <md:KeyDescriptor use='signing'>
|
||||
# <ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
# <ds:X509Data>
|
||||
# <ds:X509Certificate>TEST</ds:X509Certificate>
|
||||
# </ds:X509Data>
|
||||
# </ds:KeyInfo>
|
||||
# </md:KeyDescriptor>
|
||||
# <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
# </md:IDPSSODescriptor>
|
||||
# </md:EntityDescriptor>
|
||||
# """
|
||||
# SAMLConfiguration.objects.create(
|
||||
# tenant_id=str(tenant_id),
|
||||
# email_domain=domain,
|
||||
# metadata_xml=metadata_xml,
|
||||
# )
|
||||
metadata_xml = """<?xml version='1.0' encoding='UTF-8'?>
|
||||
<md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
<md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
<md:KeyDescriptor use='signing'>
|
||||
<ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>TEST</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>
|
||||
"""
|
||||
SAMLConfiguration.objects.create(
|
||||
tenant_id=str(tenant_id),
|
||||
email_domain=domain,
|
||||
metadata_xml=metadata_xml,
|
||||
)
|
||||
|
||||
# return {
|
||||
# "email": f"user@{domain}",
|
||||
# "domain": domain,
|
||||
# "tenant_id": tenant_id,
|
||||
# }
|
||||
return {
|
||||
"email": f"user@{domain}",
|
||||
"domain": domain,
|
||||
"tenant_id": tenant_id,
|
||||
}
|
||||
|
||||
|
||||
# @pytest.fixture
|
||||
# def saml_sociallogin(users_fixture):
|
||||
# user = users_fixture[0]
|
||||
# user.email = "samlsso@acme.com"
|
||||
# extra_data = {
|
||||
# "firstName": ["Test"],
|
||||
# "lastName": ["User"],
|
||||
# "organization": ["Prowler"],
|
||||
# "userType": ["member"],
|
||||
# }
|
||||
@pytest.fixture
|
||||
def saml_sociallogin(users_fixture):
|
||||
user = users_fixture[0]
|
||||
user.email = "samlsso@acme.com"
|
||||
extra_data = {
|
||||
"firstName": ["Test"],
|
||||
"lastName": ["User"],
|
||||
"organization": ["Prowler"],
|
||||
"userType": ["member"],
|
||||
}
|
||||
|
||||
# account = MagicMock()
|
||||
# account.provider = "saml"
|
||||
# account.extra_data = extra_data
|
||||
account = MagicMock()
|
||||
account.provider = "saml"
|
||||
account.extra_data = extra_data
|
||||
|
||||
# sociallogin = MagicMock(spec=SocialLogin)
|
||||
# sociallogin.account = account
|
||||
# sociallogin.user = user
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = account
|
||||
sociallogin.user = user
|
||||
|
||||
# return sociallogin
|
||||
return sociallogin
|
||||
|
||||
|
||||
def get_authorization_header(access_token: str) -> dict:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Configuring SAML Single Sign-On (SSO) in Prowler
|
||||
|
||||
This guide explains how to enable and test SAML SSO integration in Prowler. It includes environment setup, certificate configuration, API endpoints, and how to configure Okta as your Identity Provider (IdP).
|
||||
This guide explains how to enable and test SAML SSO integration in Prowler. It includes environment setup, API endpoints, and how to configure Okta as your Identity Provider (IdP).
|
||||
|
||||
---
|
||||
|
||||
@@ -20,26 +20,6 @@ Update this variable to specify which domains Django should accept incoming requ
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,prowler-api,mycompany.prowler
|
||||
```
|
||||
|
||||
# SAML Certificates
|
||||
|
||||
To enable SAML support, you must provide a public certificate and private key to allow Prowler to sign SAML requests and validate responses.
|
||||
|
||||
### Why is this necessary?
|
||||
|
||||
SAML relies on digital signatures to verify trust between the Identity Provider (IdP) and the Service Provider (SP). Prowler acts as the SP and must use a certificate to sign outbound authentication requests.
|
||||
|
||||
### Add to your .env file:
|
||||
|
||||
```env
|
||||
SAML_PUBLIC_CERT="-----BEGIN CERTIFICATE-----
|
||||
...your certificate here...
|
||||
-----END CERTIFICATE-----"
|
||||
|
||||
SAML_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
|
||||
...your private key here...
|
||||
-----END PRIVATE KEY-----"
|
||||
```
|
||||
|
||||
# SAML Configuration API
|
||||
|
||||
You can manage SAML settings via the API. Prowler provides full CRUD support for tenant-specific SAML configuration.
|
||||
@@ -60,7 +40,7 @@ You can manage SAML settings via the API. Prowler provides full CRUD support for
|
||||
|
||||
### Description
|
||||
|
||||
This endpoint receives an email and checks if there is an active SAML configuration for the associated domain (i.e., the part after the @). If a configuration exists and the required certificates are present, it responds with an HTTP 302 redirect to the appropriate saml_login endpoint for the organization.
|
||||
This endpoint receives an email and checks if there is an active SAML configuration for the associated domain (i.e., the part after the @). If a configuration exists it responds with an HTTP 302 redirect to the appropriate saml_login endpoint for the organization.
|
||||
|
||||
- POST /api/v1/accounts/saml/initiate/
|
||||
|
||||
@@ -78,7 +58,7 @@ This endpoint receives an email and checks if there is an active SAML configurat
|
||||
|
||||
• 302 FOUND: Redirects to the SAML login URL associated with the organization.
|
||||
|
||||
• 403 FORBIDDEN: The domain is not authorized or SAML certificates are missing from the configuration.
|
||||
• 403 FORBIDDEN: The domain is not authorized.
|
||||
|
||||
### Validation logic
|
||||
|
||||
@@ -86,8 +66,6 @@ This endpoint receives an email and checks if there is an active SAML configurat
|
||||
|
||||
• Retrieves the related SAMLConfiguration object via tenant_id.
|
||||
|
||||
• Verifies that SAML_PUBLIC_CERT and SAML_PRIVATE_KEY environment variables are set.
|
||||
|
||||
|
||||
# SAML Integration: Testing Guide
|
||||
|
||||
@@ -95,26 +73,7 @@ This document outlines the process for testing the SAML integration functionalit
|
||||
|
||||
---
|
||||
|
||||
## 1. Generate Self-Signed Certificate and Private Key
|
||||
|
||||
First, generate a self-signed certificate and corresponding private key using OpenSSL:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||
-keyout saml_private_key.pem \
|
||||
-out saml_public_cert.pem \
|
||||
-subj "/C=US/ST=Test/L=Test/O=Test/OU=Test/CN=localhost"
|
||||
```
|
||||
|
||||
## 2. Add Certificate Values to .env
|
||||
|
||||
Paste the generated values into your .env file:
|
||||
```
|
||||
SAML_PUBLIC_CERT=<paste certificate content here>
|
||||
SAML_PRIVATE_KEY=<paste private key content here>
|
||||
```
|
||||
|
||||
## 3. Start Ngrok and Update ALLOWED_HOSTS
|
||||
## 1. Start Ngrok and Update ALLOWED_HOSTS
|
||||
|
||||
Start ngrok on port 8080:
|
||||
```
|
||||
@@ -127,7 +86,7 @@ Then, copy the generated ngrok URL and include it in the ALLOWED_HOSTS setting.
|
||||
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"])
|
||||
```
|
||||
|
||||
## 4. Configure the Identity Provider (IdP)
|
||||
## 2. Configure the Identity Provider (IdP)
|
||||
|
||||
Start your environment and configure your IdP. You will need to download the IdP's metadata XML file.
|
||||
|
||||
@@ -137,7 +96,7 @@ Your Assertion Consumer Service (ACS) URL must follow this format:
|
||||
https://<PROXY_URL>/api/v1/accounts/saml/<CONFIGURED_DOMAIN>/acs/
|
||||
```
|
||||
|
||||
## 5. IdP Attribute Mapping
|
||||
## 3. IdP Attribute Mapping
|
||||
|
||||
The following fields are expected from the IdP:
|
||||
|
||||
@@ -151,7 +110,7 @@ The following fields are expected from the IdP:
|
||||
|
||||
These values are dynamic. If the values change in the IdP, they will be updated on the next login.
|
||||
|
||||
## 6. SAML Configuration API (POST)
|
||||
## 4. SAML Configuration API (POST)
|
||||
|
||||
SAML configuration is managed via a CRUD API. Use the following POST request to create a new configuration:
|
||||
|
||||
@@ -171,7 +130,7 @@ curl --location 'http://localhost:8080/api/v1/saml-config' \
|
||||
}'
|
||||
```
|
||||
|
||||
## 7. SAML SSO Callback Configuration
|
||||
## 5. SAML SSO Callback Configuration
|
||||
|
||||
### Environment Variable Configuration
|
||||
|
||||
@@ -201,7 +160,7 @@ AUTH_URL="<WEB_UI_URL>"
|
||||
- Both environment variables are required for proper SAML SSO functionality
|
||||
- Verify that the `NEXT_PUBLIC_API_BASE_URL` environment variable is properly configured to reference the correct API server base URL corresponding to your target deployment environment. This ensures proper routing of SAML callback requests to the appropriate backend services.
|
||||
|
||||
## 8. Start SAML Login Flow
|
||||
## 6. Start SAML Login Flow
|
||||
|
||||
Once everything is configured, start the SAML login process by visiting the following URL:
|
||||
|
||||
@@ -211,6 +170,6 @@ https://<PROXY_IP>/api/v1/accounts/saml/<CONFIGURED_DOMAIN>/login/?email=<USER_E
|
||||
|
||||
At the end you will get a valid access and refresh token
|
||||
|
||||
## 9. Notes on the initiate Endpoint
|
||||
## 7. Notes on the initiate Endpoint
|
||||
|
||||
The initiate endpoint is not strictly required. It was created to allow extra checks or behavior modifications (like enumeration mitigation). It also simplifies UI integration with SAML, but again, it's optional.
|
||||
|
||||
Reference in New Issue
Block a user