8000 feat: use sub instead of id to check admin rights by XavierJp · Pull Request #41 · datagouv/d-roles · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

feat: use sub instead of id to check admin rights #41

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions db/migrations/20250604.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
\set schema_name :DB_SCHEMA

BEGIN;

ALTER TABLE :schema_name.users
RENAME COLUMN is_email_confirmed TO is_verified;

COMMIT;
9 changes: 9 additions & 0 deletions db/migrations/20250605.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
\set schema_name :DB_SCHEMA

BEGIN;

CREATE UNIQUE INDEX users_sub_pro_connect_unique_idx
ON :schema_name.users (sub_pro_connect)
WHERE sub_pro_connect IS NOT NULL;

COMMIT;
2 changes: 1 addition & 1 deletion db/seed/seed.sql
10000
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ INSERT INTO :schema_name.groups (id, name, orga_id)
VALUES (1, 'stack technique', 1) ON CONFLICT (id) DO NOTHING;

-- Create users with different emails
INSERT INTO :schema_name.users (id, email, is_email_confirmed)
INSERT INTO :schema_name.users (id, email, is_verified)
VALUES
(1, 'user@yopmail.com', false),
(2, 'xavier.jouppe@beta.gouv.fr', false),
Expand Down
45 changes: 34 additions & 11 deletions src/models.py → src/model.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,51 @@
from typing import Annotated, NewType
from typing import Annotated
from xmlrpc.client import boolean

from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
from fastapi import HTTPException, status
from pydantic import BaseModel, BeforeValidator, ConfigDict, EmailStr, Field

# Create a NewType for type hints
Siren = NewType("Siren", str)


# Validator function
def validate_siren(v: str) -> str:
if not isinstance(v, str) or not v.isdigit() or len(v) != 9:
raise ValueError("Siren must be a 9-digit string")
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Siren must be a 9-digit string"
)

if v == "356000000":
# La poste
return v

# Luhn algorithm
total = 0
for i, char in enumerate(reversed(v)):
digit = int(char)
if i % 2 == 1: # Every second digit from right
digit *= 2
if digit > 9:
digit = digit // 10 + digit % 10
total += digit

if total % 10 != 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid SIREN: fails Luhn checksum",
)

return v


# Type annotation with validation
SirenType = Annotated[str, field_validator("validate_siren")]
Siren = Annotated[
str,
BeforeValidator(validate_siren),
Field(description="A valid Siren"),
]


# --- Organisation ---
class OrganisationBase(BaseModel):
name: str | None = None
siren: Siren # type: ignore # Optional for group creation
siren: Siren


class OrganisationCreate(OrganisationBase):
Expand All @@ -39,8 +63,6 @@ class OrganisationResponse(OrganisationBase):

class UserBase(BaseModel):
email: EmailStr
sub_pro_connect: str | None = None
is_email_confirmed: bool = False


class UserCreate(UserBase):
Expand All @@ -49,6 +71,7 @@ class UserCreate(UserBase):

class UserResponse(UserBase):
id: int
is_verified: bool

model_config = ConfigDict(from_attributes=True)

Expand Down
Empty file added src/models/__init__.py
Empty file.
2 changes: 1 addition & 1 deletion src/repositories/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# ------- REPOSITORY FILE -------


from src.models import ServiceAccountResponse
from src.model import ServiceAccountResponse


class AuthRepository:
Expand Down
2 changes: 1 addition & 1 deletion src/repositories/groups.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ------- REPOSITORY FILE -------
from ..models import GroupCreate, GroupResponse, GroupWithUsersAndScopesResponse
from ..model import GroupCreate, GroupResponse, GroupWithUsersAndScopesResponse


class GroupsRepository:
Expand Down
2 changes: 1 addition & 1 deletion src/repositories/organisations.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ------- REPOSITORY FILE -------
from ..models import OrganisationCreate, OrganisationResponse
from ..model import OrganisationCreate, OrganisationResponse


class OrganisationsRepository:
Expand Down
2 changes: 1 addition & 1 deletion src/repositories/roles.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ------- REPOSITORY FILE -------
from ..models import RoleResponse
from ..model import RoleResponse


class RolesRepository:
Expand Down
2 changes: 1 addition & 1 deletion src/repositories/scopes.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ------- REPOSITORY FILE -------
from src.models import ScopeResponse
from src.model import ScopeResponse


class ScopesRepository:
Expand Down
2 changes: 1 addition & 1 deletion src/repositories/service_providers.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from src.models import ServiceProviderResponse
from src.model import ServiceProviderResponse


class ServiceProvidersRepository:
Expand Down
32 changes: 26 additions & 6 deletions src/repositories/users.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,49 @@
# ------- REPOSITORY FILE -------
from ..models import UserCreate, UserResponse, UserWithRoleResponse
from pydantic import UUID4

from ..model import UserCreate, UserResponse, UserWithRoleResponse


class UsersRepository:
def __init__(self, db_session):
self.db_session = db_session

async def verify_user(self, user_email: str, user_sub: UUID4):
async with self.db_session.transaction():
query = """
UPDATE users SET sub_pro_connect = :sub_pro_connect, is_verified = TRUE
WHERE email = :email
"""
values = {"email": user_email, "sub_pro_connect": user_sub}
await self.db_session.execute(query, values)

async def get_user_by_email(self, email: str) -> UserResponse:
async with self.db_session.transaction():
query = """
SELECT * FROM users as U WHERE U.email = :email
SELECT U.id, U.email, U.is_verified FROM users as U WHERE U.email = :email
"""
return await self.db_session.fetch_one(query, {"email": email})

async def get_user_by_id(self, user_id: int) -> UserResponse:
async with self.db_session.transaction():
query = """
SELECT * FROM users as U WHERE U.id = :id
SELECT U.id, U.email, U.is_verified FROM users as U WHERE U.id = :id
"""
return await self.db_session.fetch_one(query, {"id": user_id})

async def get_user_by_sub(self, user_sub: UUID4) -> UserResponse:
async with self.db_session.transaction():
query = """
SELECT U.id, U.email, U.is_verified FROM users as U WHERE U.sub_pro_connect = :sub_pro_connect
"""
return await self.db_session.fetch_one(
query, {"sub_pro_connect": str(user_sub)}
)

async def get_users_by_group_id(self, group_id: int) -> list[UserWithRoleResponse]:
async with self.db_session.transaction():
query = """
SELECT U.id, U.email, U.sub_pro_connect, U.created_at, R.role_name, R.is_admin
SELECT U.id, U.email, U.is_verified, U.created_at, R.role_name, R.is_admin
FROM users as U
INNER JOIN group_user_relations as TUR ON TUR.user_id = U.id
INNER JOIN roles as R ON TUR.role_id = R.id
Expand All @@ -33,6 +53,6 @@ async def get_users_by_group_id(self, group_id: int) -> list[UserWithRoleRespons

async def add_user(self, user: UserCreate) -> UserResponse:
async with self.db_session.transaction():
query = "INSERT INTO users (email, is_email_confirmed) VALUES (:email, :is_email_confirmed) RETURNING *"
values = {"email": user.email, "is_email_confirmed": False}
query = "INSERT INTO users (email, is_verified) VALUES (:email, :is_verified) RETURNING *"
values = {"email": user.email, "is_verified": False}
return await self.db_session.fetch_one(query, values)
12 changes: 6 additions & 6 deletions src/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from src.auth import ACCESS_TOKEN_EXPIRE_MINUTES, create_access_token
from src.dependencies import get_auth_service
from src.models import Token
from src.model import Token
from src.services.auth import AuthService

router = APIRouter(
Expand All @@ -25,13 +25,13 @@ async def get_token(
auth_service: AuthService = Depends(get_auth_service),
):
"""
OAuth2 token endpoint for Client Credentials flow.
OAuth2 pour un flow “Client Credentials.

Client authentication can be provided through:
1. HTTP Basic Authentication in the Authorization header
2. Request body with client_id and client_secret fields
L’authentication peut être effectuée de deux manières :
1. HTTP Basic Authentication dans le header Authorization
2. `client_id` et `client_secret` dans le body de la requête (form data)

The grant_type must be 'client_credentials'.
Le `grant_type` est `client_credentials`.
"""
# Verify grant type
if grant_type != "client_credentials":
Expand Down
39 changes: 25 additions & 14 deletions src/routers/groups.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# ------- USER ROUTER FILE -------
from fastapi import APIRouter, Depends, Path, Query
from pydantic import EmailStr
from pydantic import UUID4, EmailStr

from src.auth import decode_access_token
from src.dependencies import get_groups_service
from src.dependencies import get_groups_service, get_users_service
from src.services.users import UsersService

from ..models import GroupCreate, GroupResponse, GroupWithUsersAndScopesResponse
from ..model import GroupCreate, GroupResponse, GroupWithUsersAndScopesResponse
from ..services.groups import GroupsService

router = APIRouter(
Expand All @@ -21,43 +22,53 @@ async def list_groups(
group_service: GroupsService = Depends(get_groups_service),
):
"""
List every available groups for your service provider
Liste les équipes disponibles pour votre fournisseur de services.
"""
return await group_service.list_groups()


@router.get("/search", response_model=list[GroupWithUsersAndScopesResponse])
async def search_groups_by_user(
email: EmailStr = Query(
..., description="The email of the user to filter groups by"
async def search(
email: EmailStr = Query(..., description="Mail de l’utilisateur"),
acting_user_sub: UUID4 | None = Query(
None, description="Sub de l’utilisateur (facultatif)"
),
users_service: UsersService = Depends(get_users_service),
group_service: GroupsService = Depends(get_groups_service),
):
"""
Search for groups by user email.
Recherche les équipes d’un utilisateur vérifié, avec son adresse e-mail.

Si l'utilisateur n'est pas encore vérifié, l’appel échouera et vous devrez vérifier l'utilisateur (cf. `/users/verify`).

NB : il est possible de vérifier automatiquement l’utilisateur en passant en argument `acting_user_sub`.
"""

if acting_user_sub:
await users_service.verify_user(user_sub=acting_user_sub, user_email=email)

return await group_service.search_groups(email=email)


@router.get("/{group_id}", response_model=GroupWithUsersAndScopesResponse)
async def get_group(
group_id: int = Path(..., description="The ID of the group to retrieve"),
async def by_id(
group_id: int = Path(..., description="ID du groupe à récupérer"),
group_service: GroupsService = Depends(get_groups_service),
):
"""
Get a group by ID, including all users that belong to it.
Récupère une équipe par son ID. Inclut les utilisateurs, leurs rôles et les droits du groupes sur le fournisseur de service.
"""
return await group_service.get_group_with_users_and_scopes(group_id)


@router.post("/", response_model=GroupResponse, status_code=201)
async def create_group(
async def create(
group: GroupCreate,
groups_service: GroupsService = Depends(get_groups_service),
):
"""
Create a new group.
Crée une nouvelle équipe.

If the organization doesn't exist, it will be created automatically.
Si l’organisation n’existe pas encore, elle est créée automatiquement.
"""
return await groups_service.create_group(group)
Loading
0