Compare commits

...

4 Commits

Author SHA1 Message Date
Quentin BEY 9be461d4fb 👷(e2e) improve github workflow
Remove useless code and try to improve runtime.
2025-06-17 13:58:09 +02:00
Quentin BEY 39ee1ad957 🔧(git) set LF line endings for all text files
Windows users are by default using CRLF line endings,
which can cause issues with some tools and
environments. This commit sets the `.gitattributes`
file to enforce LF line endings for all text
files in the repository.

Based on the same commit on docs
2025-06-16 16:35:08 +02:00
Quentin BEY 527b12e815 🧑‍💻(docker) split frontend to another file
This commit aims at improving the user experience:
- Use a dedicated `Dockerfile` for the frontend
- Run the backend and frontend in "watch"/dev mode in Docker
- Do not start all Docker instances for small tasks
2025-06-16 14:59:33 +02:00
Quentin BEY 32b13d0bca (resource-server) add SCIM /Me endpoint
This provide a "self-care" SCIM endpoint, authenticated with OIDC token
introspection. This endpoint will be use by services to fetch the user's
team list.

We chose to use the SCIM format (even if this is not a SCIM context) to
make it easier to understand/maintain/plug.
2025-06-12 15:14:00 +02:00
32 changed files with 723 additions and 157 deletions
+24
View File
@@ -0,0 +1,24 @@
# Set the default behavior for all files
* text=auto eol=lf
# Binary files (should not be modified)
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.mov binary
*.mp4 binary
*.mp3 binary
*.flv binary
*.fla binary
*.swf binary
*.gz binary
*.zip binary
*.7z binary
*.ttf binary
*.woff binary
*.woff2 binary
*.eot binary
*.pdf binary
+2 -1
View File
@@ -40,7 +40,7 @@ jobs:
name: Run trivy scan (frontend)
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target frontend-production -f Dockerfile'
docker-build-args: '--target frontend-production -f src/frontend/Dockerfile'
docker-image-name: 'docker.io/lasuite/people-frontend:${{ github.sha }}'
build-and-push-backend:
@@ -105,6 +105,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
+5 -26
View File
@@ -123,7 +123,6 @@ jobs:
test-e2e:
runs-on: ubuntu-latest
needs: [dependencies, build-front]
timeout-minutes: 10
strategy:
fail-fast: false
@@ -139,34 +138,14 @@ jobs:
make create-env-files
cat env.d/development/common.e2e.dist >> env.d/development/common
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
fail-on-cache-miss: true
- name: Restore the frontend cache
uses: actions/cache@v4
id: front-node_modules
with:
path: 'src/frontend/**/node_modules'
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
- name: Restore the build cache
uses: actions/cache@v4
id: cache-build
with:
path: src/frontend/apps/desk/out/
key: build-front-${{ github.run_id }}
- name: Build and Start Docker Servers
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
run: |
docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
COMPOSE_BAKE=true docker compose build --pull --build-arg BUILDKIT_INLINE_CACHE=1
make update-keycloak-realm-app
make add-dev-rsa-private-key-to-env
make run
- name: Apply DRF migrations
@@ -177,9 +156,9 @@ jobs:
run: |
make demo FLUSH_ARGS='--no-input'
- name: Setup Dimail DB
- name: Install playwright
run: |
make dimail-setup-db
cd src/frontend/ && yarn install && yarn playwright install --with-deps
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
+9
View File
@@ -8,6 +8,15 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(resource-server) add SCIM /Me endpoint #895
- 🔧(git) set LF line endings for all text files #928
### Changed
- 🧑‍💻(docker) split frontend to another file #924
## [1.17.0] - 2025-06-11
### Added
-55
View File
@@ -10,61 +10,6 @@ RUN python -m pip install --upgrade pip setuptools
RUN apk update && \
apk upgrade
### ---- Front-end dependencies image ----
FROM node:20 AS frontend-deps
WORKDIR /deps
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/apps/desk/package.json ./apps/desk/package.json
COPY ./src/frontend/packages/i18n/package.json ./packages/i18n/package.json
COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint-config-people/package.json
RUN yarn --frozen-lockfile
### ---- Front-end builder dev image ----
FROM node:20 AS frontend-builder-dev
WORKDIR /builder
COPY --from=frontend-deps /deps/node_modules ./node_modules
COPY ./src/frontend .
WORKDIR ./apps/desk
### ---- Front-end builder image ----
FROM frontend-builder-dev AS frontend-builder
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.27-alpine AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=frontend-builder \
/builder/apps/desk/out \
/usr/share/nginx/html
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
# ---- Back-end builder image ----
FROM base AS back-builder
+24 -23
View File
@@ -41,11 +41,9 @@ DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN = $(COMPOSE) run --rm --no-deps
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
WAIT_KC_DB = $(COMPOSE_RUN) dockerize -wait tcp://kc_postgresql:5432 -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -78,19 +76,33 @@ create-env-files: \
env.d/development/kc_postgresql
.PHONY: create-env-files
add-dev-rsa-private-key-to-env: ## Add a generated RSA private key to the env file
@echo "Generating RSA private key PEM for development..."
@mkdir -p env.d/development/rsa
@openssl genrsa -out env.d/development/rsa/private.pem 2048
@echo -n "\nOAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY=\"" >> env.d/development/common
@openssl rsa -in env.d/development/rsa/private.pem -outform PEM >> env.d/development/common
@echo "\"" >> env.d/development/common
@rm -rf env.d/development/rsa
.PHONY: add-dev-rsa-private-key-to-env
update-keycloak-realm-app: ## Create the Keycloak realm for the project
@echo "$(BOLD)Creating Keycloak realm for 'app'$(RESET)"
@sed -i 's|http://app-dev:8000|http://app:8000|g' ./docker/auth/realm.json
.PHONY: update-keycloak-realm-app
bootstrap: ## Prepare Docker images for the project and install frontend dependencies
bootstrap: \
data/media \
data/static \
create-env-files \
build \
run \
run-dev \
migrate \
back-i18n-compile \
mails-install \
mails-build \
dimail-setup-db \
install-front-desk
dimail-setup-db
.PHONY: bootstrap
# -- Docker/compose
@@ -106,19 +118,14 @@ logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d nginx
@$(COMPOSE) up --force-recreate -d app-dev
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d celery-beat-dev
@$(COMPOSE) up --force-recreate -d flower-dev
@$(COMPOSE) up --force-recreate -d keycloak
@$(COMPOSE) up -d dimail
@echo "Wait for postgresql to be up..."
@$(WAIT_KC_DB)
@$(WAIT_DB)
run: ## start the wsgi (production) and servers with production Docker images
@$(COMPOSE) up --force-recreate --detach app frontend celery celery-beat nginx maildev
.PHONY: run
run-dev: ## start the servers in development mode (watch) Docker images
@$(COMPOSE) up --force-recreate --detach app-dev frontend-dev celery-dev celery-beat-dev nginx maildev
.PHONY: run-dev
status: ## an alias for "docker compose ps"
@$(COMPOSE) ps
.PHONY: status
@@ -187,22 +194,16 @@ test-coverage: ## compute, display and save test coverage
makemigrations: ## run django makemigrations for the people project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations $(ARGS)
.PHONY: makemigrations
migrate: ## run django migrations for the people project.
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) migrate $(ARGS)
.PHONY: migrate
showmigrations: ## run django showmigrations for the people project.
@echo "$(BOLD)Running showmigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) showmigrations $(ARGS)
.PHONY: showmigrations
+7 -1
View File
@@ -33,7 +33,7 @@ The easiest way to start working on the project is to use GNU Make:
$ make bootstrap
```
This command builds the `app` container, installs dependencies, performs
This command builds the `app-dev` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-related or migration-related issues.
@@ -46,6 +46,12 @@ Note that if you need to run them afterward, you can use the eponym Make rule:
$ make run
```
or if you want to run them in development mode (with live reloading):
```bash
$ make run-dev
```
You can check all available Make rules using:
```bash
+17
View File
@@ -64,6 +64,23 @@ function _dc_run() {
_docker_compose run --rm $user_args "$@"
}
# _dc_run_no_deps: wrap docker compose run command without dependencies
#
# usage: _dc_run_no_deps [options] [ARGS...]
#
# options: docker compose run command options
# ARGS : docker compose run command arguments
function _dc_run_no_deps() {
_set_user
user_args="--user=$USER_ID"
if [ -z $USER_ID ]; then
user_args=""
fi
_docker_compose run --no-deps --rm $user_args "$@"
}
# _dc_exec: wrap docker compose exec command
#
# usage: _dc_exec [options] [ARGS...]
+1 -1
View File
@@ -35,4 +35,4 @@ fi
# Fix docker vs local path when project sources are mounted as a volume
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
_dc_run_no_deps app-dev pylint "${paths[@]}" "${args[@]}"
+123 -39
View File
@@ -6,6 +6,11 @@ services:
- env.d/development/postgresql
ports:
- "15432:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
interval: 1s
timeout: 2s
retries: 300
redis:
image: redis:5
@@ -23,6 +28,7 @@ services:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-development
pull_policy: never
environment:
- PYLINTHOME=/app/.pylint.d
- DJANGO_CONFIGURATION=Development
@@ -37,11 +43,69 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- dimail
- postgresql
- maildev
- redis
postgresql:
condition: service_healthy
restart: true
dimail:
condition: service_started
maildev:
condition: service_started
redis:
condition: service_started
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-dev
image: people:frontend-development
pull_policy: never
volumes:
- ./src/frontend:/home/frontend
- /home/frontend/node_modules
- /home/frontend/apps/desk/node_modules
ports:
- "3000:3000"
app:
build:
context: .
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-production
environment:
- DJANGO_CONFIGURATION=Development
env_file:
- env.d/development/common
- env.d/development/france
- env.d/development/postgresql
ports:
- "8071:8000"
volumes:
- ./data/media:/data/media
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
frontend:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
image: people:frontend-production
pull_policy: build
ports:
- "3000:3000"
celery-dev:
user: ${DOCKER_USER:-1000}
image: people:backend-development
@@ -56,7 +120,11 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- app-dev
postgresql:
condition: service_healthy
restart: true
app-dev:
condition: service_started
celery-beat-dev:
user: ${DOCKER_USER:-1000}
@@ -72,26 +140,9 @@ services:
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
- app-dev
app:
build:
context: .
target: backend-production
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
image: people:backend-production
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
volumes:
- ./data/media:/data/media
depends_on:
- postgresql
- redis
postgresql:
condition: service_healthy
restart: true
celery:
user: ${DOCKER_USER:-1000}
@@ -103,7 +154,29 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
- app
postgresql:
condition: service_healthy
restart: true
app:
condition: service_started
celery-beat:
user: ${DOCKER_USER:-1000}
image: people:backend-production
command: ["celery", "-A", "people.celery_app", "beat", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Demo
env_file:
- env.d/development/common
- env.d/development/postgresql
volumes:
- ./src/backend:/app
- ./data/media:/data/media
- ./data/static:/data/static
depends_on:
postgresql:
condition: service_healthy
restart: true
nginx:
image: nginx:1.25
@@ -112,12 +185,9 @@ services:
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- app
- keycloak
dockerize:
image: jwilder/dockerize
platform: linux/x86_64
keycloak:
condition: service_healthy
restart: true
crowdin:
image: crowdin/cli:4.6.1
@@ -137,11 +207,16 @@ services:
- ".:/app"
kc_postgresql:
image: postgres:14.3
ports:
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
image: postgres:14.3
ports:
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
healthcheck:
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
interval: 1s
timeout: 2s
retries: 300
keycloak:
image: quay.io/keycloak/keycloak:20.0.1
@@ -158,6 +233,8 @@ services:
- --hostname-admin-url=http://localhost:8083/
- --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true
- --metrics-enabled=true
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
@@ -168,10 +245,17 @@ services:
KC_DB_USERNAME: people
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
healthcheck:
test: [ "CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready" ]
interval: 1s
timeout: 2s
retries: 300
ports:
- "8080:8080"
depends_on:
- kc_postgresql
kc_postgresql:
condition: service_healthy
restart: true
dimail:
entrypoint: /opt/dimail-api/start-dev.sh
+1 -1
View File
@@ -26,7 +26,7 @@
"oauth2DeviceCodeLifespan": 600,
"oauth2DevicePollingInterval": 5,
"enabled": true,
"sslRequired": "external",
"sslRequired": "none",
"registrationAllowed": true,
"registrationEmailAsUsername": false,
"rememberMe": true,
+1
View File
@@ -3,6 +3,7 @@ DJANGO_ALLOWED_HOSTS=*
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=people.settings
DJANGO_SUPERUSER_PASSWORD=admin
DJANGO_CORS_ALLOWED_ORIGINS=http://localhost:3000
# Python
PYTHONPATH=/app
+4 -2
View File
@@ -2,7 +2,9 @@
SUSTAINED_THROTTLE_RATES="200/hour"
BURST_THROTTLE_RATES="200/minute"
OAUTH2_PROVIDER_OIDC_ENABLED = True
OAUTH2_PROVIDER_VALIDATOR_CLASS: "mailbox_oauth2.validators.ProConnectValidator"
OIDC_ORGANIZATION_REGISTRATION_ID_FIELD="siret"
OAUTH2_PROVIDER_OIDC_ENABLED=True
OAUTH2_PROVIDER_VALIDATOR_CLASS="mailbox_oauth2.validators.ProConnectValidator"
INSTALLED_PLUGINS=plugins.la_suite
@@ -0,0 +1 @@
"""SCIM compliant API for resource server"""
@@ -0,0 +1,16 @@
"""Exceptions for SCIM API."""
from django.conf import settings
from core.api.resource_server.scim.response import ScimJsonResponse
def scim_exception_handler(exc, _context):
"""Handle SCIM exceptions and return them in the correct format."""
data = {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": str(exc.status_code),
"detail": str(exc.detail) if settings.DEBUG else "",
}
return ScimJsonResponse(data, status=exc.status_code)
@@ -0,0 +1,16 @@
"""SCIM API response classes."""
from rest_framework.response import Response
class ScimJsonResponse(Response):
"""
Custom JSON response class for SCIM API.
This class sets the content type to "application/json+scim" for SCIM
responses.
"""
def __init__(self, *args, **kwargs):
"""JSON response with enforced SCIM content type."""
super().__init__(*args, content_type="application/json+scim", **kwargs)
@@ -0,0 +1,90 @@
"""SCIM serializers for resource server API."""
from django.urls import reverse
from rest_framework import serializers
from core import models
class SCIMUserSerializer(serializers.ModelSerializer):
"""Serialize users in SCIM format."""
schemas = serializers.SerializerMethodField()
userName = serializers.CharField(source="sub")
displayName = serializers.CharField(source="name")
emails = serializers.SerializerMethodField()
meta = serializers.SerializerMethodField()
groups = serializers.SerializerMethodField()
active = serializers.BooleanField(source="is_active")
class Meta:
model = models.User
fields = [
"id",
"schemas",
"active",
"userName",
"displayName",
"emails",
"groups",
"meta",
]
read_only_fields = [
"id",
"schemas",
"active",
"userName",
"displayName",
"emails",
"groups",
"meta",
]
def get_schemas(self, _obj):
"""Return the SCIM schemas for the user."""
return ["urn:ietf:params:scim:schemas:core:2.0:User"]
def get_emails(self, obj):
"""Return the user's email as a list of email objects."""
if obj.email:
return [
{
"value": obj.email,
"primary": True,
"type": "work",
}
]
return []
def get_groups(self, obj):
"""
Return the groups the user belongs to.
WARNING: you need to prefetch the team accesses in the
viewset to avoid N+1 queries.
"""
return [
{
"value": str(team_access.team.external_id),
"display": team_access.team.name,
"type": "direct",
}
for team_access in obj.accesses.all()
]
def get_meta(self, obj):
"""Return metadata about the user."""
request = self.context.get("request")
location = (
f"{request.build_absolute_uri('/').rstrip('/')}{reverse('scim-me-list')}"
if request
else None
)
return {
"resourceType": "User",
"created": obj.created_at.isoformat(),
"lastModified": obj.updated_at.isoformat(),
"location": location,
}
@@ -0,0 +1,62 @@
"""Resource server SCIM API endpoints"""
from django.contrib.auth import get_user_model
from django.db.models import Prefetch, Q
from lasuite.oidc_resource_server.mixins import ResourceServerMixin
from rest_framework import (
viewsets,
)
from core.api import permissions
from core.models import TeamAccess
from . import serializers
from .exceptions import scim_exception_handler
from .response import ScimJsonResponse
User = get_user_model()
class MeViewSet(ResourceServerMixin, viewsets.ViewSet):
"""
SCIM-compliant ViewSet for the /Me endpoint.
This endpoint provides information about the currently authenticated user
in SCIM (System for Cross-domain Identity Management) format.
Features:
- Returns user details in SCIM format.
- Includes the user's teams, restricted to the audience.
Limitations:
- Does not currently support managing Team hierarchies.
Endpoint:
GET /resource-server/v1.0/scim/Me/
Retrieves the authenticated user's details and associated teams.
"""
permission_classes = [permissions.IsAuthenticated]
serializer_class = serializers.SCIMUserSerializer
def get_exception_handler(self):
"""Override the default exception handler to use SCIM-specific handling."""
return scim_exception_handler
def list(self, request, *args, **kwargs):
"""Return the current user's details in SCIM format."""
service_provider_audience = self._get_service_provider_audience()
user = User.objects.prefetch_related(
Prefetch(
"accesses",
queryset=TeamAccess.objects.select_related("team").filter(
Q(team__service_providers__audience_id=service_provider_audience)
| Q(team__is_visible_all_services=True)
),
)
).get(pk=request.user.pk)
serializer = self.serializer_class(user, context={"request": request})
return ScimJsonResponse(serializer.data)
@@ -0,0 +1,40 @@
# Generated by Django 5.2 on 2025-06-12 09:58
import uuid
from django.conf import settings
from django.db import migrations, models
def gen_team_uuid(apps, schema_editor):
Team = apps.get_model("core", "Team")
for row in Team.objects.all():
row.uuid = uuid.uuid4()
# Don't need to batch update here, as the table is likely small or empty
row.save(update_fields=["uuid"])
class Migration(migrations.Migration):
dependencies = [
('core', '0015_alter_accountservice_api_key'),
]
operations = [
migrations.AddField(
model_name='team',
name='external_id',
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=False, verbose_name='external_id'),
),
migrations.RunPython(gen_team_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name='team',
name='external_id',
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Team external UUID for synchronization with external systems', unique=True, verbose_name='external_id'),
),
#
migrations.AlterField(
model_name='team',
name='users',
field=models.ManyToManyField(related_name='teams', through='core.TeamAccess', through_fields=('team', 'user'), to=settings.AUTH_USER_MODEL),
),
]
+13
View File
@@ -742,6 +742,11 @@ class Team(MP_Node, BaseModel):
can see it.
When a team is created from a Service Provider this one is automatically set in the
Team `service_providers`.
The team `external_id` is used to synchronize the team with external systems, this
is the equivalent of the User `sub` field but for teams (note: `sub` is highly related
to the OIDC standard, while `external_id` is not). The `external_id` is NOT the same as
the `externalId` field in the SCIM standard when importing teams from SCIM.
"""
# Allow up to 80 nested teams with 62^5 (916_132_832) root nodes
@@ -752,6 +757,14 @@ class Team(MP_Node, BaseModel):
name = models.CharField(max_length=100)
external_id = models.UUIDField(
verbose_name=_("external_id"),
help_text=_("Team external UUID for synchronization with external systems"),
unique=True,
default=uuid.uuid4,
editable=False,
)
users = models.ManyToManyField(
User,
through="TeamAccess",
@@ -0,0 +1 @@
"""Tests for the resource server SCIM API endpoints."""
@@ -0,0 +1,186 @@
"""
Tests for the SCIM Me API endpoint in People's core app
"""
import pytest
from rest_framework.status import (
HTTP_200_OK,
HTTP_401_UNAUTHORIZED,
HTTP_405_METHOD_NOT_ALLOWED,
)
from core import factories
from core.models import RoleChoices
pytestmark = pytest.mark.django_db
def test_api_me_anonymous(client):
"""Anonymous users should not be allowed to access the Me endpoint."""
response = client.get("/resource-server/v1.0/scim/Me/")
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "401",
"detail": "",
}
def test_api_me_authenticated(
client, force_login_via_resource_server, django_assert_num_queries
):
"""
Authenticated users should be able to access their own information
in SCIM format.
"""
user = factories.UserFactory(name="Test User", email="test@example.com")
service_provider = factories.ServiceProviderFactory()
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
with django_assert_num_queries(2):
response = client.get(
"/resource-server/v1.0/scim/Me/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": str(user.pk),
"active": True,
"userName": user.sub,
"displayName": user.name,
"emails": [
{
"value": user.email,
"primary": True,
"type": "work",
}
],
"groups": [],
"meta": {
"resourceType": "User",
"created": user.created_at.isoformat(),
"lastModified": user.updated_at.isoformat(),
"location": "http://testserver/resource-server/v1.0/scim/Me/",
},
}
def test_api_me_authenticated_with_team_access(
client, force_login_via_resource_server, django_assert_num_queries
):
"""
Authenticated users with TeamAccess should see their team information
in SCIM format, but only for teams visible to the service provider.
"""
user = factories.UserFactory(name="Test User", email="test@example.com")
service_provider = factories.ServiceProviderFactory()
# Create teams with different visibility settings
team_visible = factories.TeamFactory(name="Visible Team")
team_visible.service_providers.add(service_provider)
team_all_services = factories.TeamFactory(
name="All Services Team", is_visible_all_services=True
)
team_not_visible = factories.TeamFactory(name="Not Visible Team")
# This team is not associated with the service provider
# Add user to all teams
factories.TeamAccessFactory(user=user, team=team_visible, role=RoleChoices.MEMBER)
factories.TeamAccessFactory(
user=user, team=team_all_services, role=RoleChoices.ADMIN
)
factories.TeamAccessFactory(
user=user, team=team_not_visible, role=RoleChoices.OWNER
)
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
with django_assert_num_queries(
2
): # User + TeamAccess (with select_related teams)
response = client.get(
"/resource-server/v1.0/scim/Me/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_200_OK
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": str(user.pk),
"active": True,
"userName": user.sub,
"displayName": user.name,
"emails": [
{
"value": user.email,
"primary": True,
"type": "work",
}
],
"groups": [
{
"value": str(team_visible.external_id),
"display": team_visible.name,
"type": "direct",
},
{
"value": str(team_all_services.external_id),
"display": team_all_services.name,
"type": "direct",
},
],
"meta": {
"resourceType": "User",
"created": user.created_at.isoformat(),
"lastModified": user.updated_at.isoformat(),
"location": "http://testserver/resource-server/v1.0/scim/Me/",
},
}
@pytest.mark.parametrize(
"http_method",
["post", "put", "patch", "delete"],
ids=["POST", "PUT", "PATCH", "DELETE"],
)
def test_api_me_method_not_allowed(
client, force_login_via_resource_server, http_method
):
"""Test that methods other than GET are not allowed for the Me endpoint."""
user = factories.UserFactory()
service_provider = factories.ServiceProviderFactory()
# Authenticate using the resource server, ie via the Authorization header
with force_login_via_resource_server(client, user, service_provider.audience_id):
client_method = getattr(client, http_method)
response = client_method(
"/resource-server/v1.0/scim/Me/",
format="json",
HTTP_AUTHORIZATION="Bearer b64untestedbearertoken",
)
assert response.status_code == HTTP_405_METHOD_NOT_ALLOWED
assert response.headers["Content-Type"] == "application/json+scim"
# Check the full response with the expected structure
assert response.json() == {
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": str(HTTP_405_METHOD_NOT_ALLOWED),
"detail": "",
}
Binary file not shown.
Binary file not shown.
+9 -3
View File
@@ -3,18 +3,23 @@
from django.urls import include, path
from lasuite.oidc_resource_server.urls import urlpatterns as resource_server_urls
from rest_framework.routers import DefaultRouter
from rest_framework.routers import SimpleRouter
from core.api.resource_server import viewsets
from core.api.resource_server.scim import viewsets as scim_viewsets
# - Main endpoints
# Contacts will be added later
# Users will be added later
router = DefaultRouter()
router = SimpleRouter()
router.register("teams", viewsets.TeamViewSet, basename="teams")
# - SCIM endpoints
scim_router = SimpleRouter()
scim_router.register("Me", scim_viewsets.MeViewSet, basename="scim-me")
# - Routes nested under a team
team_related_router = DefaultRouter()
team_related_router = SimpleRouter()
team_related_router.register(
"invitations",
viewsets.InvitationViewset,
@@ -33,6 +38,7 @@ urlpatterns = [
*router.urls,
*resource_server_urls,
path("teams/<uuid:team_id>/", include(team_related_router.urls)),
path("scim/", include(scim_router.urls)),
]
),
),
+1 -1
View File
@@ -34,6 +34,7 @@ dependencies = [
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-extensions==4.1",
"django-lasuite==0.0.9",
"django-oauth-toolkit==3.0.1",
"django-parler==2.3",
@@ -70,7 +71,6 @@ dependencies = [
[project.optional-dependencies]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2025.6.1",
"ipdb==0.13.13",
"ipython==9.3.0",
+63
View File
@@ -0,0 +1,63 @@
FROM node:22-alpine AS frontend-deps
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/apps/desk/package.json ./apps/desk/package.json
COPY ./src/frontend/packages/i18n/package.json ./packages/i18n/package.json
COPY ./src/frontend/packages/eslint-config-people/package.json ./packages/eslint-config-people/package.json
RUN yarn install --frozen-lockfile
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/.prettierrc.js ./.prettierrc.js
COPY ./src/frontend/packages/eslint-config-people ./packages/eslint-config-people
COPY ./src/frontend/apps/desk ./apps/desk
### ---- Front-end builder image ----
FROM frontend-deps AS frontend-base
WORKDIR /home/frontend/apps/desk
### ---- Front-end builder dev image ----
FROM frontend-deps AS frontend-dev
WORKDIR /home/frontend/apps/desk
EXPOSE 3000
CMD [ "yarn", "dev"]
FROM frontend-base AS frontend-builder
WORKDIR /home/frontend/apps/desk
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=frontend-builder \
/home/frontend/apps/desk/out \
/usr/share/nginx/html
COPY ./src/frontend/apps/desk/conf/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
+1
View File
@@ -1,4 +1,5 @@
server {
listen 3000;
listen 8080;
server_name localhost;
server_tokens off;
@@ -97,6 +97,7 @@ test.describe('Mail domain', () => {
});
await page.goto('/mail-domains/unknown-domain.fr');
await page.waitForURL('/404/');
await expect(
page.getByText(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
@@ -101,6 +101,8 @@ test.describe('Add Mail Domains', () => {
test('checks 404 on mail-domains/[slug] page', async ({ page }) => {
await page.goto('/mail-domains/unknown-domain');
await page.waitForURL('/404/');
await expect(
page.getByText(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
@@ -83,6 +83,7 @@ test.describe('Teams Create', () => {
test('checks 404 on teams/[id] page', async ({ page }) => {
await page.goto('/teams/some-unknown-team');
await page.waitForURL('/404/');
await expect(
page.getByText(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
+2 -4
View File
@@ -32,12 +32,10 @@ export default defineConfig({
},
webServer: {
command: `cd ../.. && yarn app:${
process.env.CI ? 'start -p ' : 'dev --port '
} ${PORT}`,
command: !process.env.CI ? `cd ../.. && yarn app:dev --port ${PORT}` : '',
url: baseURL,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
reuseExistingServer: true,
},
/* Configure projects for major browsers */