Multi-tenancy : espaces de travail + fix auth reload (rate limiter OPTIONS)
- Modèles Organization + OrgMember, migration Alembic (SQLite compatible) - organization_id nullable sur Document, Decision, Mandate, VotingProtocol - Service, schéma, router /organizations + dependency get_active_org_id - Seed : Duniter G1 + Axiom Team ; tout le contenu seed attaché à Duniter G1 - Backend : list/create filtrés par header X-Organization - Frontend : store organizations, WorkspaceSelector réel, useApi injecte l'org - Fix critique : rate_limiter exclut les requêtes OPTIONS (CORS preflight) → résout le bug "Failed to fetch /auth/me" au reload (429 sur preflight) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -132,9 +132,9 @@ async def verify_challenge(
|
||||
detail="Challenge invalide",
|
||||
)
|
||||
|
||||
# 4. Verify signature (bypass for demo profiles in DEMO_MODE)
|
||||
# 4. Verify signature (bypass for demo profiles in dev/demo mode)
|
||||
_demo_addresses = {p["address"] for p in DEV_PROFILES}
|
||||
is_demo_bypass = settings.DEMO_MODE and payload.address in _demo_addresses
|
||||
is_demo_bypass = (settings.DEMO_MODE or settings.ENVIRONMENT == "development") and payload.address in _demo_addresses
|
||||
|
||||
if not is_demo_bypass:
|
||||
# polkadot.js / Cesium2 signRaw(type='bytes') wraps: <Bytes>{challenge}</Bytes>
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.schemas.decision import (
|
||||
DecisionUpdate,
|
||||
)
|
||||
from app.schemas.vote import VoteSessionOut
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.services.auth_service import get_current_identity
|
||||
from app.services.decision_service import advance_decision, create_vote_session_for_step
|
||||
|
||||
@@ -49,6 +50,7 @@ async def _get_decision(db: AsyncSession, decision_id: uuid.UUID) -> Decision:
|
||||
@router.get("/", response_model=list[DecisionOut])
|
||||
async def list_decisions(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
decision_type: str | None = Query(default=None, description="Filtrer par type de decision"),
|
||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
@@ -57,6 +59,8 @@ async def list_decisions(
|
||||
"""List all decisions with optional filters."""
|
||||
stmt = select(Decision).options(selectinload(Decision.steps))
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Decision.organization_id == org_id)
|
||||
if decision_type is not None:
|
||||
stmt = stmt.where(Decision.decision_type == decision_type)
|
||||
if status_filter is not None:
|
||||
@@ -74,11 +78,13 @@ async def create_decision(
|
||||
payload: DecisionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DecisionOut:
|
||||
"""Create a new decision process."""
|
||||
decision = Decision(
|
||||
**payload.model_dump(),
|
||||
created_by_id=identity.id,
|
||||
organization_id=org_id,
|
||||
)
|
||||
db.add(decision)
|
||||
await db.commit()
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.schemas.document import (
|
||||
ItemVersionCreate,
|
||||
ItemVersionOut,
|
||||
)
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.services import document_service
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
@@ -65,6 +66,7 @@ async def _get_item(db: AsyncSession, document_id: uuid.UUID, item_id: uuid.UUID
|
||||
@router.get("/", response_model=list[DocumentOut])
|
||||
async def list_documents(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
|
||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
@@ -73,6 +75,8 @@ async def list_documents(
|
||||
"""List all reference documents, with optional filters."""
|
||||
stmt = select(Document)
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Document.organization_id == org_id)
|
||||
if doc_type is not None:
|
||||
stmt = stmt.where(Document.doc_type == doc_type)
|
||||
if status_filter is not None:
|
||||
@@ -101,6 +105,7 @@ async def create_document(
|
||||
payload: DocumentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> DocumentOut:
|
||||
"""Create a new reference document."""
|
||||
# Check slug uniqueness
|
||||
@@ -111,7 +116,7 @@ async def create_document(
|
||||
detail="Un document avec ce slug existe deja",
|
||||
)
|
||||
|
||||
doc = Document(**payload.model_dump())
|
||||
doc = Document(**payload.model_dump(), organization_id=org_id)
|
||||
db.add(doc)
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.schemas.mandate import (
|
||||
MandateUpdate,
|
||||
)
|
||||
from app.schemas.vote import VoteSessionOut
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.services.auth_service import get_current_identity
|
||||
from app.services.mandate_service import (
|
||||
advance_mandate,
|
||||
@@ -55,6 +56,7 @@ async def _get_mandate(db: AsyncSession, mandate_id: uuid.UUID) -> Mandate:
|
||||
@router.get("/", response_model=list[MandateOut])
|
||||
async def list_mandates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
mandate_type: str | None = Query(default=None, description="Filtrer par type de mandat"),
|
||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
@@ -63,6 +65,8 @@ async def list_mandates(
|
||||
"""List all mandates with optional filters."""
|
||||
stmt = select(Mandate).options(selectinload(Mandate.steps))
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(Mandate.organization_id == org_id)
|
||||
if mandate_type is not None:
|
||||
stmt = stmt.where(Mandate.mandate_type == mandate_type)
|
||||
if status_filter is not None:
|
||||
@@ -80,9 +84,10 @@ async def create_mandate(
|
||||
payload: MandateCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> MandateOut:
|
||||
"""Create a new mandate."""
|
||||
mandate = Mandate(**payload.model_dump())
|
||||
mandate = Mandate(**payload.model_dump(), organization_id=org_id)
|
||||
db.add(mandate)
|
||||
await db.commit()
|
||||
await db.refresh(mandate)
|
||||
|
||||
96
backend/app/routers/organizations.py
Normal file
96
backend/app/routers/organizations.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Organizations router: list, create, membership."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import DuniterIdentity
|
||||
from app.schemas.organization import OrgMemberOut, OrganizationCreate, OrganizationOut
|
||||
from app.services.auth_service import get_current_identity
|
||||
from app.services.org_service import (
|
||||
add_member,
|
||||
create_organization,
|
||||
get_organization,
|
||||
get_organization_by_slug,
|
||||
is_member,
|
||||
list_members,
|
||||
list_organizations,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[OrganizationOut])
|
||||
async def get_organizations(db: AsyncSession = Depends(get_db)) -> list[OrganizationOut]:
|
||||
"""List all organizations (public — transparent ones need no auth)."""
|
||||
orgs = await list_organizations(db)
|
||||
return [OrganizationOut.model_validate(o) for o in orgs]
|
||||
|
||||
|
||||
@router.post("/", response_model=OrganizationOut, status_code=status.HTTP_201_CREATED)
|
||||
async def post_organization(
|
||||
payload: OrganizationCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> OrganizationOut:
|
||||
"""Create a new organization (authenticated users only)."""
|
||||
existing = await get_organization_by_slug(db, payload.slug)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Slug '{payload.slug}' déjà utilisé",
|
||||
)
|
||||
org = await create_organization(db, **payload.model_dump())
|
||||
# Creator becomes admin
|
||||
await add_member(db, org.id, identity.id, role="admin")
|
||||
return OrganizationOut.model_validate(org)
|
||||
|
||||
|
||||
@router.get("/{org_id}", response_model=OrganizationOut)
|
||||
async def get_organization_detail(
|
||||
org_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
||||
) -> OrganizationOut:
|
||||
org = await get_organization(db, org_id)
|
||||
if not org:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
|
||||
return OrganizationOut.model_validate(org)
|
||||
|
||||
|
||||
@router.get("/{org_id}/members", response_model=list[OrgMemberOut])
|
||||
async def get_members(
|
||||
org_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> list[OrgMemberOut]:
|
||||
org = await get_organization(db, org_id)
|
||||
if not org:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
|
||||
if not org.is_transparent and not await is_member(db, org_id, identity.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Accès refusé")
|
||||
members = await list_members(db, org_id)
|
||||
return [OrgMemberOut.model_validate(m) for m in members]
|
||||
|
||||
|
||||
@router.post("/{org_id}/join", response_model=OrgMemberOut, status_code=status.HTTP_201_CREATED)
|
||||
async def join_organization(
|
||||
org_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> OrgMemberOut:
|
||||
"""Join a transparent organization."""
|
||||
org = await get_organization(db, org_id)
|
||||
if not org:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organisation introuvable")
|
||||
if not org.is_transparent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Rejoindre cette organisation nécessite une invitation",
|
||||
)
|
||||
if await is_member(db, org_id, identity.id):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Déjà membre")
|
||||
member = await add_member(db, org_id, identity.id)
|
||||
return OrgMemberOut.model_validate(member)
|
||||
@@ -25,6 +25,7 @@ from app.schemas.protocol import (
|
||||
VotingProtocolOut,
|
||||
VotingProtocolUpdate,
|
||||
)
|
||||
from app.dependencies.org import get_active_org_id
|
||||
from app.services.auth_service import get_current_identity
|
||||
|
||||
router = APIRouter()
|
||||
@@ -63,6 +64,7 @@ async def _get_formula(db: AsyncSession, formula_id: uuid.UUID) -> FormulaConfig
|
||||
@router.get("/", response_model=list[VotingProtocolOut])
|
||||
async def list_protocols(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
vote_type: str | None = Query(default=None, description="Filtrer par type de vote (binary, nuanced)"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
@@ -70,6 +72,8 @@ async def list_protocols(
|
||||
"""List all voting protocols with their formula configurations."""
|
||||
stmt = select(VotingProtocol).options(selectinload(VotingProtocol.formula_config))
|
||||
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(VotingProtocol.organization_id == org_id)
|
||||
if vote_type is not None:
|
||||
stmt = stmt.where(VotingProtocol.vote_type == vote_type)
|
||||
|
||||
@@ -85,6 +89,7 @@ async def create_protocol(
|
||||
payload: VotingProtocolCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
org_id: uuid.UUID | None = Depends(get_active_org_id),
|
||||
) -> VotingProtocolOut:
|
||||
"""Create a new voting protocol.
|
||||
|
||||
@@ -100,7 +105,7 @@ async def create_protocol(
|
||||
detail="Configuration de formule introuvable",
|
||||
)
|
||||
|
||||
protocol = VotingProtocol(**payload.model_dump())
|
||||
protocol = VotingProtocol(**payload.model_dump(), organization_id=org_id)
|
||||
db.add(protocol)
|
||||
await db.commit()
|
||||
await db.refresh(protocol)
|
||||
|
||||
Reference in New Issue
Block a user