From f5848a360fdad5910d699edd50bdde391af67179 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 30 Mar 2026 01:23:02 +0800 Subject: [PATCH] feat: add organization and member management APIs for admin and internal use --- backend/README.md | 8 + backend/app/api/admin_entities.py | 265 ++++++++++++++++++ backend/app/api/internal_entities.py | 100 +++++++ backend/app/main.py | 4 + backend/app/models/__init__.py | 4 +- backend/app/models/member_organization.py | 23 ++ backend/app/models/organization.py | 23 ++ .../repositories/member_organizations_repo.py | 43 +++ .../app/repositories/organizations_repo.py | 67 +++++ backend/app/repositories/users_repo.py | 49 +++- backend/app/schemas/members.py | 37 +++ backend/app/schemas/organizations.py | 29 ++ backend/scripts/init_schema.sql | 24 ++ .../scripts/migrate_add_org_member_tables.sql | 27 ++ docs/BACKEND_ARCHITECTURE.md | 103 +++---- docs/FRONTEND_API_CONTRACT.md | 105 ++++++- docs/ORG_MEMBER_MANAGEMENT_PLAN.md | 15 +- 17 files changed, 861 insertions(+), 65 deletions(-) create mode 100644 backend/app/api/admin_entities.py create mode 100644 backend/app/api/internal_entities.py create mode 100644 backend/app/models/member_organization.py create mode 100644 backend/app/models/organization.py create mode 100644 backend/app/repositories/member_organizations_repo.py create mode 100644 backend/app/repositories/organizations_repo.py create mode 100644 backend/app/schemas/members.py create mode 100644 backend/app/schemas/organizations.py create mode 100644 backend/scripts/migrate_add_org_member_tables.sql diff --git a/backend/README.md b/backend/README.md index 39c486b..7052551 100644 --- a/backend/README.md +++ b/backend/README.md @@ -51,3 +51,11 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY' - `POST /internal/authentik/users/ensure` - `POST /admin/permissions/grant` - `POST /admin/permissions/revoke` +- `GET|POST|PATCH /admin/organizations...` +- `GET|POST|PATCH /admin/members...` +- `GET|POST|DELETE /admin/members/{member_id}/organizations...` +- `GET /internal/members` +- `GET /internal/members/by-sub/{authentik_sub}` +- `GET /internal/organizations` +- `GET /internal/organizations/by-code/{org_code}` +- `GET /internal/members/{member_id}/organizations` diff --git a/backend/app/api/admin_entities.py b/backend/app/api/admin_entities.py new file mode 100644 index 0000000..89c1531 --- /dev/null +++ b/backend/app/api/admin_entities.py @@ -0,0 +1,265 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.api_client import ApiClient +from app.repositories.member_organizations_repo import MemberOrganizationsRepository +from app.repositories.organizations_repo import OrganizationsRepository +from app.repositories.users_repo import UsersRepository +from app.schemas.members import ( + MemberCreateRequest, + MemberListResponse, + MemberOrganizationsResponse, + MemberSummary, + MemberUpdateRequest, +) +from app.schemas.organizations import ( + OrganizationCreateRequest, + OrganizationListResponse, + OrganizationSummary, + OrganizationUpdateRequest, +) +from app.security.api_client_auth import require_api_client + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +def _to_member_summary(member) -> MemberSummary: + return MemberSummary( + id=member.id, + authentik_sub=member.authentik_sub, + authentik_user_id=member.authentik_user_id, + email=member.email, + display_name=member.display_name, + is_active=member.is_active, + ) + + +def _to_org_summary(org) -> OrganizationSummary: + return OrganizationSummary( + id=org.id, + org_code=org.org_code, + name=org.name, + tax_id=org.tax_id, + status=org.status, + ) + + +@router.get("/organizations", response_model=OrganizationListResponse) +def list_organizations( + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), + keyword: str | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> OrganizationListResponse: + repo = OrganizationsRepository(db) + items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset) + return OrganizationListResponse(items=[_to_org_summary(i) for i in items], total=total, limit=limit, offset=offset) + + +@router.post("/organizations", response_model=OrganizationSummary) +def create_organization( + payload: OrganizationCreateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> OrganizationSummary: + repo = OrganizationsRepository(db) + existing = repo.get_by_code(payload.org_code) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="organization_code_already_exists") + org = repo.create(org_code=payload.org_code, name=payload.name, tax_id=payload.tax_id, status=payload.status) + return _to_org_summary(org) + + +@router.get("/organizations/{org_id}", response_model=OrganizationSummary) +def get_organization( + org_id: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> OrganizationSummary: + repo = OrganizationsRepository(db) + org = repo.get_by_id(org_id) + if not org: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found") + return _to_org_summary(org) + + +@router.patch("/organizations/{org_id}", response_model=OrganizationSummary) +def update_organization( + org_id: str, + payload: OrganizationUpdateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> OrganizationSummary: + repo = OrganizationsRepository(db) + org = repo.get_by_id(org_id) + if not org: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found") + updated = repo.update(org, name=payload.name, tax_id=payload.tax_id, status=payload.status) + return _to_org_summary(updated) + + +@router.post("/organizations/{org_id}/activate", response_model=OrganizationSummary) +def activate_organization( + org_id: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> OrganizationSummary: + repo = OrganizationsRepository(db) + org = repo.get_by_id(org_id) + if not org: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found") + return _to_org_summary(repo.update(org, status="active")) + + +@router.post("/organizations/{org_id}/deactivate", response_model=OrganizationSummary) +def deactivate_organization( + org_id: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> OrganizationSummary: + repo = OrganizationsRepository(db) + org = repo.get_by_id(org_id) + if not org: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found") + return _to_org_summary(repo.update(org, status="inactive")) + + +@router.get("/members", response_model=MemberListResponse) +def list_members( + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), + keyword: str | None = Query(default=None), + is_active: bool | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), +) -> MemberListResponse: + repo = UsersRepository(db) + items, total = repo.list(keyword=keyword, is_active=is_active, limit=limit, offset=offset) + return MemberListResponse(items=[_to_member_summary(i) for i in items], total=total, limit=limit, offset=offset) + + +@router.post("/members", response_model=MemberSummary) +def create_member( + payload: MemberCreateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> MemberSummary: + repo = UsersRepository(db) + existing = repo.get_by_sub(payload.authentik_sub) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="member_sub_already_exists") + member = repo.upsert_by_sub( + authentik_sub=payload.authentik_sub, + email=payload.email, + display_name=payload.display_name, + is_active=payload.is_active, + ) + return _to_member_summary(member) + + +@router.get("/members/{member_id}", response_model=MemberSummary) +def get_member( + member_id: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> MemberSummary: + repo = UsersRepository(db) + member = repo.get_by_id(member_id) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") + return _to_member_summary(member) + + +@router.patch("/members/{member_id}", response_model=MemberSummary) +def update_member( + member_id: str, + payload: MemberUpdateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> MemberSummary: + repo = UsersRepository(db) + member = repo.get_by_id(member_id) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") + updated = repo.update_member(member, email=payload.email, display_name=payload.display_name, is_active=payload.is_active) + return _to_member_summary(updated) + + +@router.post("/members/{member_id}/activate", response_model=MemberSummary) +def activate_member( + member_id: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> MemberSummary: + repo = UsersRepository(db) + member = repo.get_by_id(member_id) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") + return _to_member_summary(repo.update_member(member, is_active=True)) + + +@router.post("/members/{member_id}/deactivate", response_model=MemberSummary) +def deactivate_member( + member_id: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> MemberSummary: + repo = UsersRepository(db) + member = repo.get_by_id(member_id) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") + return _to_member_summary(repo.update_member(member, is_active=False)) + + +@router.get("/members/{member_id}/organizations", response_model=MemberOrganizationsResponse) +def list_member_organizations( + member_id: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> MemberOrganizationsResponse: + users_repo = UsersRepository(db) + link_repo = MemberOrganizationsRepository(db) + + member = users_repo.get_by_id(member_id) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") + + orgs = link_repo.list_organizations_by_member_id(member_id) + return MemberOrganizationsResponse(member=_to_member_summary(member), organizations=[_to_org_summary(o) for o in orgs]) + + +@router.post("/members/{member_id}/organizations/{org_id}") +def bind_member_organization( + member_id: str, + org_id: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> dict[str, str]: + users_repo = UsersRepository(db) + orgs_repo = OrganizationsRepository(db) + link_repo = MemberOrganizationsRepository(db) + + member = users_repo.get_by_id(member_id) + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member_not_found") + org = orgs_repo.get_by_id(org_id) + if not org: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="organization_not_found") + + relation = link_repo.add_if_not_exists(member_id=member_id, organization_id=org_id) + return {"relation_id": relation.id, "result": "bound"} + + +@router.delete("/members/{member_id}/organizations/{org_id}") +def unbind_member_organization( + member_id: str, + org_id: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> dict[str, int | str]: + link_repo = MemberOrganizationsRepository(db) + deleted = link_repo.remove(member_id=member_id, organization_id=org_id) + return {"deleted": deleted, "result": "unbound"} diff --git a/backend/app/api/internal_entities.py b/backend/app/api/internal_entities.py new file mode 100644 index 0000000..b728341 --- /dev/null +++ b/backend/app/api/internal_entities.py @@ -0,0 +1,100 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.internal import verify_internal_secret +from app.db.session import get_db +from app.repositories.member_organizations_repo import MemberOrganizationsRepository +from app.repositories.organizations_repo import OrganizationsRepository +from app.repositories.users_repo import UsersRepository +from app.schemas.members import MemberListResponse, MemberOrganizationsResponse, MemberSummary +from app.schemas.organizations import OrganizationListResponse, OrganizationSummary + +router = APIRouter(prefix="/internal", tags=["internal"]) + + +def _to_member_summary(member) -> MemberSummary: + return MemberSummary( + id=member.id, + authentik_sub=member.authentik_sub, + authentik_user_id=member.authentik_user_id, + email=member.email, + display_name=member.display_name, + is_active=member.is_active, + ) + + +def _to_org_summary(org) -> OrganizationSummary: + return OrganizationSummary( + id=org.id, + org_code=org.org_code, + name=org.name, + tax_id=org.tax_id, + status=org.status, + ) + + +@router.get("/members", response_model=MemberListResponse) +def internal_list_members( + _: None = Depends(verify_internal_secret), + db: Session = Depends(get_db), + keyword: str | None = Query(default=None), + is_active: bool | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + offset: int = Query(default=0, ge=0), +) -> MemberListResponse: + repo = UsersRepository(db) + items, total = repo.list(keyword=keyword, is_active=is_active, limit=limit, offset=offset) + return MemberListResponse(items=[_to_member_summary(i) for i in items], total=total, limit=limit, offset=offset) + + +@router.get("/members/by-sub/{authentik_sub}", response_model=MemberSummary | None) +def internal_get_member_by_sub( + authentik_sub: str, + _: None = Depends(verify_internal_secret), + db: Session = Depends(get_db), +) -> MemberSummary | None: + repo = UsersRepository(db) + member = repo.get_by_sub(authentik_sub) + return _to_member_summary(member) if member else None + + +@router.get("/organizations", response_model=OrganizationListResponse) +def internal_list_organizations( + _: None = Depends(verify_internal_secret), + db: Session = Depends(get_db), + keyword: str | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + limit: int = Query(default=200, ge=1, le=500), + offset: int = Query(default=0, ge=0), +) -> OrganizationListResponse: + repo = OrganizationsRepository(db) + items, total = repo.list(keyword=keyword, status=status_filter, limit=limit, offset=offset) + return OrganizationListResponse(items=[_to_org_summary(i) for i in items], total=total, limit=limit, offset=offset) + + +@router.get("/organizations/by-code/{org_code}", response_model=OrganizationSummary | None) +def internal_get_organization_by_code( + org_code: str, + _: None = Depends(verify_internal_secret), + db: Session = Depends(get_db), +) -> OrganizationSummary | None: + repo = OrganizationsRepository(db) + org = repo.get_by_code(org_code) + return _to_org_summary(org) if org else None + + +@router.get("/members/{member_id}/organizations", response_model=MemberOrganizationsResponse | None) +def internal_list_member_organizations( + member_id: str, + _: None = Depends(verify_internal_secret), + db: Session = Depends(get_db), +) -> MemberOrganizationsResponse | None: + users_repo = UsersRepository(db) + link_repo = MemberOrganizationsRepository(db) + + member = users_repo.get_by_id(member_id) + if not member: + return None + + orgs = link_repo.list_organizations_by_member_id(member_id) + return MemberOrganizationsResponse(member=_to_member_summary(member), organizations=[_to_org_summary(o) for o in orgs]) diff --git a/backend/app/main.py b/backend/app/main.py index 7d35d36..e8ef733 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,8 +2,10 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.admin import router as admin_router +from app.api.admin_entities import router as admin_entities_router from app.api.auth import router as auth_router from app.api.internal import router as internal_router +from app.api.internal_entities import router as internal_entities_router from app.api.me import router as me_router from app.core.config import get_settings @@ -25,6 +27,8 @@ def healthz() -> dict[str, str]: app.include_router(internal_router) +app.include_router(internal_entities_router) app.include_router(admin_router) +app.include_router(admin_entities_router) app.include_router(me_router) app.include_router(auth_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f4457bd..44a7f90 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,7 @@ from app.models.api_client import ApiClient +from app.models.member_organization import MemberOrganization +from app.models.organization import Organization from app.models.permission import Permission from app.models.user import User -__all__ = ["ApiClient", "Permission", "User"] +__all__ = ["ApiClient", "MemberOrganization", "Organization", "Permission", "User"] diff --git a/backend/app/models/member_organization.py b/backend/app/models/member_organization.py new file mode 100644 index 0000000..19ec119 --- /dev/null +++ b/backend/app/models/member_organization.py @@ -0,0 +1,23 @@ +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, ForeignKey, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class MemberOrganization(Base): + __tablename__ = "member_organizations" + __table_args__ = ( + UniqueConstraint("member_id", "organization_id", name="uq_member_organizations_member_org"), + ) + + id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) + member_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + organization_id: Mapped[str] = mapped_column( + UUID(as_uuid=False), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False + ) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/backend/app/models/organization.py b/backend/app/models/organization.py new file mode 100644 index 0000000..12ee320 --- /dev/null +++ b/backend/app/models/organization.py @@ -0,0 +1,23 @@ +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, String, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Organization(Base): + __tablename__ = "organizations" + + id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) + org_code: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + tax_id: Mapped[str | None] = mapped_column(String(32)) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="active") + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) diff --git a/backend/app/repositories/member_organizations_repo.py b/backend/app/repositories/member_organizations_repo.py new file mode 100644 index 0000000..d9345a0 --- /dev/null +++ b/backend/app/repositories/member_organizations_repo.py @@ -0,0 +1,43 @@ +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from app.models.member_organization import MemberOrganization +from app.models.organization import Organization + + +class MemberOrganizationsRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def list_organizations_by_member_id(self, member_id: str) -> list[Organization]: + stmt = ( + select(Organization) + .join(MemberOrganization, MemberOrganization.organization_id == Organization.id) + .where(MemberOrganization.member_id == member_id) + .order_by(Organization.name.asc()) + ) + return list(self.db.scalars(stmt).all()) + + def add_if_not_exists(self, member_id: str, organization_id: str) -> MemberOrganization: + stmt = select(MemberOrganization).where( + MemberOrganization.member_id == member_id, + MemberOrganization.organization_id == organization_id, + ) + existing = self.db.scalar(stmt) + if existing: + return existing + + relation = MemberOrganization(member_id=member_id, organization_id=organization_id) + self.db.add(relation) + self.db.commit() + self.db.refresh(relation) + return relation + + def remove(self, member_id: str, organization_id: str) -> int: + stmt = delete(MemberOrganization).where( + MemberOrganization.member_id == member_id, + MemberOrganization.organization_id == organization_id, + ) + result = self.db.execute(stmt) + self.db.commit() + return int(result.rowcount or 0) diff --git a/backend/app/repositories/organizations_repo.py b/backend/app/repositories/organizations_repo.py new file mode 100644 index 0000000..627d8cc --- /dev/null +++ b/backend/app/repositories/organizations_repo.py @@ -0,0 +1,67 @@ +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.models.organization import Organization + + +class OrganizationsRepository: + def __init__(self, db: Session) -> None: + self.db = db + + def list( + self, + keyword: str | None = None, + status: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[Organization], int]: + stmt = select(Organization) + count_stmt = select(func.count()).select_from(Organization) + + if keyword: + pattern = f"%{keyword}%" + cond = or_(Organization.org_code.ilike(pattern), Organization.name.ilike(pattern)) + stmt = stmt.where(cond) + count_stmt = count_stmt.where(cond) + + if status: + stmt = stmt.where(Organization.status == status) + count_stmt = count_stmt.where(Organization.status == status) + + stmt = stmt.order_by(Organization.created_at.desc()).limit(limit).offset(offset) + items = list(self.db.scalars(stmt).all()) + total = int(self.db.scalar(count_stmt) or 0) + return items, total + + def get_by_id(self, org_id: str) -> Organization | None: + stmt = select(Organization).where(Organization.id == org_id) + return self.db.scalar(stmt) + + def get_by_code(self, org_code: str) -> Organization | None: + stmt = select(Organization).where(Organization.org_code == org_code) + return self.db.scalar(stmt) + + def create(self, org_code: str, name: str, tax_id: str | None, status: str = "active") -> Organization: + org = Organization(org_code=org_code, name=name, tax_id=tax_id, status=status) + self.db.add(org) + self.db.commit() + self.db.refresh(org) + return org + + def update( + self, + org: Organization, + *, + name: str | None = None, + tax_id: str | None = None, + status: str | None = None, + ) -> Organization: + if name is not None: + org.name = name + if tax_id is not None: + org.tax_id = tax_id + if status is not None: + org.status = status + self.db.commit() + self.db.refresh(org) + return org diff --git a/backend/app/repositories/users_repo.py b/backend/app/repositories/users_repo.py index a590cd4..a45c690 100644 --- a/backend/app/repositories/users_repo.py +++ b/backend/app/repositories/users_repo.py @@ -1,4 +1,4 @@ -from sqlalchemy import select +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.models.user import User @@ -12,6 +12,35 @@ class UsersRepository: stmt = select(User).where(User.authentik_sub == authentik_sub) return self.db.scalar(stmt) + def get_by_id(self, user_id: str) -> User | None: + stmt = select(User).where(User.id == user_id) + return self.db.scalar(stmt) + + def list( + self, + keyword: str | None = None, + is_active: bool | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[User], int]: + stmt = select(User) + count_stmt = select(func.count()).select_from(User) + + if keyword: + pattern = f"%{keyword}%" + cond = or_(User.authentik_sub.ilike(pattern), User.email.ilike(pattern), User.display_name.ilike(pattern)) + stmt = stmt.where(cond) + count_stmt = count_stmt.where(cond) + + if is_active is not None: + stmt = stmt.where(User.is_active == is_active) + count_stmt = count_stmt.where(User.is_active == is_active) + + stmt = stmt.order_by(User.created_at.desc()).limit(limit).offset(offset) + items = list(self.db.scalars(stmt).all()) + total = int(self.db.scalar(count_stmt) or 0) + return items, total + def upsert_by_sub( self, authentik_sub: str, @@ -40,3 +69,21 @@ class UsersRepository: self.db.commit() self.db.refresh(user) return user + + def update_member( + self, + user: User, + *, + email: str | None = None, + display_name: str | None = None, + is_active: bool | None = None, + ) -> User: + if email is not None: + user.email = email + if display_name is not None: + user.display_name = display_name + if is_active is not None: + user.is_active = is_active + self.db.commit() + self.db.refresh(user) + return user diff --git a/backend/app/schemas/members.py b/backend/app/schemas/members.py new file mode 100644 index 0000000..c275ef3 --- /dev/null +++ b/backend/app/schemas/members.py @@ -0,0 +1,37 @@ +from pydantic import BaseModel + +from app.schemas.organizations import OrganizationSummary + + +class MemberCreateRequest(BaseModel): + authentik_sub: str + email: str | None = None + display_name: str | None = None + is_active: bool = True + + +class MemberUpdateRequest(BaseModel): + email: str | None = None + display_name: str | None = None + is_active: bool | None = None + + +class MemberSummary(BaseModel): + id: str + authentik_sub: str + authentik_user_id: int | None = None + email: str | None = None + display_name: str | None = None + is_active: bool + + +class MemberListResponse(BaseModel): + items: list[MemberSummary] + total: int + limit: int + offset: int + + +class MemberOrganizationsResponse(BaseModel): + member: MemberSummary + organizations: list[OrganizationSummary] diff --git a/backend/app/schemas/organizations.py b/backend/app/schemas/organizations.py new file mode 100644 index 0000000..019ac4c --- /dev/null +++ b/backend/app/schemas/organizations.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel + + +class OrganizationCreateRequest(BaseModel): + org_code: str + name: str + tax_id: str | None = None + status: str = "active" + + +class OrganizationUpdateRequest(BaseModel): + name: str | None = None + tax_id: str | None = None + status: str | None = None + + +class OrganizationSummary(BaseModel): + id: str + org_code: str + name: str + tax_id: str | None = None + status: str + + +class OrganizationListResponse(BaseModel): + items: list[OrganizationSummary] + total: int + limit: int + offset: int diff --git a/backend/scripts/init_schema.sql b/backend/scripts/init_schema.sql index 49e82ba..d87c6b4 100644 --- a/backend/scripts/init_schema.sql +++ b/backend/scripts/init_schema.sql @@ -28,4 +28,28 @@ CREATE TABLE IF NOT EXISTS permissions ( CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub); CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id); +CREATE TABLE IF NOT EXISTS organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_code VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + tax_id VARCHAR(32), + status VARCHAR(16) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_organizations_org_code ON organizations(org_code); +CREATE INDEX IF NOT EXISTS idx_organizations_status ON organizations(status); + +CREATE TABLE IF NOT EXISTS member_organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + member_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_member_organizations_member_org UNIQUE (member_id, organization_id) +); + +CREATE INDEX IF NOT EXISTS idx_member_organizations_member_id ON member_organizations(member_id); +CREATE INDEX IF NOT EXISTS idx_member_organizations_org_id ON member_organizations(organization_id); + COMMIT; diff --git a/backend/scripts/migrate_add_org_member_tables.sql b/backend/scripts/migrate_add_org_member_tables.sql new file mode 100644 index 0000000..4f076aa --- /dev/null +++ b/backend/scripts/migrate_add_org_member_tables.sql @@ -0,0 +1,27 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_code VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + tax_id VARCHAR(32), + status VARCHAR(16) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_organizations_org_code ON organizations(org_code); +CREATE INDEX IF NOT EXISTS idx_organizations_status ON organizations(status); + +CREATE TABLE IF NOT EXISTS member_organizations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + member_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_member_organizations_member_org UNIQUE (member_id, organization_id) +); + +CREATE INDEX IF NOT EXISTS idx_member_organizations_member_id ON member_organizations(member_id); +CREATE INDEX IF NOT EXISTS idx_member_organizations_org_id ON member_organizations(organization_id); + +COMMIT; diff --git a/docs/BACKEND_ARCHITECTURE.md b/docs/BACKEND_ARCHITECTURE.md index 10aaf67..94e6a87 100644 --- a/docs/BACKEND_ARCHITECTURE.md +++ b/docs/BACKEND_ARCHITECTURE.md @@ -2,14 +2,16 @@ ## 1. 目標與邊界 - 網域:`memberapi.ose.tw` -- 角色:會員中心後端真相來源(User + Permission) +- 角色:會員中心後端真相來源(Member + Organization + Permission) - 範圍: - - user upsert(以 `authentik_sub` 為跨系統主鍵) - - permission grant/revoke - - permission snapshot 提供給其他系統 + - user/member 管理(以 `authentik_sub` 為跨系統主鍵) + - organization 管理 + - member-organization 關聯管理 + - permission grant/revoke 與 snapshot + - internal 查詢 API 提供其他系統 - 不在本服務處理: - - Authentik OIDC 流程頁與 UI - - 前端互動邏輯 + - 前端 UI/互動流程 + - Authentik hosted login page 本身 ## 2. 技術棧 - Python 3.12 @@ -18,76 +20,79 @@ - PostgreSQL(psycopg) - Pydantic Settings -## 3. 後端目錄(已建立) +## 3. 後端目錄 - `backend/app/main.py` - `backend/app/api/` + - `auth.py` + - `me.py` + - `admin.py`(permission) + - `admin_entities.py`(member/org) - `internal.py` - - `admin.py` -- `backend/app/core/config.py` -- `backend/app/db/session.py` + - `internal_entities.py` - `backend/app/models/` - `user.py` - `permission.py` + - `organization.py` + - `member_organization.py` - `api_client.py` - `backend/app/repositories/` - `users_repo.py` - `permissions_repo.py` -- `backend/app/security/api_client_auth.py` -- `backend/scripts/init_schema.sql` -- `backend/.env.example` -- `backend/.env.production.example` + - `organizations_repo.py` + - `member_organizations_repo.py` ## 4. 資料模型 - `users` - - `id`, `authentik_sub`(unique), `email`, `display_name`, `is_active`, timestamps + - `id`, `authentik_sub`(unique), `authentik_user_id`, `email`, `display_name`, `is_active`, timestamps - `permissions` - `id`, `user_id`, `scope_type`, `scope_id`, `module`, `action`, `created_at` - - unique constraint: `(user_id, scope_type, scope_id, module, action)` + - unique: `(user_id, scope_type, scope_id, module, action)` +- `organizations` + - `id`, `org_code`(unique), `name`, `tax_id`, `status`, timestamps +- `member_organizations` + - `id`, `member_id`, `organization_id`, `created_at` + - unique: `(member_id, organization_id)` - `api_clients`(由 `docs/API_CLIENTS_SQL.sql` 建立) - `client_key`, `api_key_hash`, `status`, allowlist, expires/rate-limit 欄位 -## 5. API 設計(MVP) +## 5. API 設計 - 健康檢查 - `GET /healthz` -- 使用者路由(Bearer token) +- 登入 + - `GET /auth/oidc/url` + - `POST /auth/oidc/exchange` +- 使用者路由(Bearer) - `GET /me` - `GET /me/permissions/snapshot` - - Bearer token 由 Authentik JWT + JWKS 驗證,並以 `sub` 自動 upsert user -- 內部路由(系統對系統) +- 管理路由(`X-Client-Key` + `X-API-Key`) + - `POST /admin/permissions/grant` + - `POST /admin/permissions/revoke` + - `GET|POST|PATCH /admin/organizations...` + - `GET|POST|PATCH /admin/members...` + - `GET|POST|DELETE /admin/members/{member_id}/organizations...` +- 內部路由(`X-Internal-Secret`) - `POST /internal/users/upsert-by-sub` - `GET /internal/permissions/{authentik_sub}/snapshot` - `POST /internal/authentik/users/ensure` - - header: `X-Internal-Secret` -- 管理路由(後台/API client) - - `POST /admin/permissions/grant` - - `POST /admin/permissions/revoke` - - headers: `X-Client-Key`, `X-API-Key` + - `GET /internal/members` + - `GET /internal/members/by-sub/{authentik_sub}` + - `GET /internal/organizations` + - `GET /internal/organizations/by-code/{org_code}` + - `GET /internal/members/{member_id}/organizations` ## 6. 安全策略 -- `admin` 路由強制 API client 驗證: - - client 必須存在且 `status=active` - - `expires_at` 未過期 - - `api_key_hash` 驗證(支援 `sha256:` 與 bcrypt/argon2) - - allowlist 驗證(origin/ip/path) -- `internal` 路由使用 `X-Internal-Secret` 做服務間驗證 -- `me` 路由使用 Authentik Access Token 驗證: - - 使用 `AUTHENTIK_JWKS_URL` 或 `AUTHENTIK_ISSUER` 推導 JWKS - - 可選 `AUTHENTIK_AUDIENCE` 驗證 aud claim -- Authentik Admin 整合: - - 使用 `AUTHENTIK_BASE_URL + AUTHENTIK_ADMIN_TOKEN` - - 可透過 `/internal/authentik/users/ensure` 建立或更新 Authentik user -- 建議上線前: - - 將 `.env` 範本中的明文密碼改為部署平台 secret - - API key 全部改為 argon2/bcrypt hash +- `admin` 路由:API client 驗證(status/過期/hash/allowlist) +- `internal` 路由:`X-Internal-Secret` +- `me` 路由:Auth token + JWKS 驗簽 +- `/me` 補值:若 token 無 `email/name`,會呼叫 `userinfo` 補齊 ## 7. 與其他系統資料流 -1. mkt/admin 後端登入後,以 token `sub` 呼叫 `/internal/users/upsert-by-sub` -2. 權限調整走 `/admin/permissions/grant|revoke` -3. 需要授權判斷時,呼叫 `/internal/permissions/{sub}/snapshot` -4. mkt 系統可本地快取 snapshot,並做定時補償 +1. 其他系統登入後,以 token `sub` 呼叫 `/internal/users/upsert-by-sub` +2. 需要組織/會員資料時,走 `/internal/members*`、`/internal/organizations*` +3. 權限查詢走 `/internal/permissions/{sub}/snapshot` +4. 後台人員調整權限走 `/admin/permissions/grant|revoke` -## 8. 下一階段(建議) -- 加入 Alembic migration -- 為 permission/action 加 enum 與驗證規則 -- 增加 audit log(誰在何時授權/撤銷) -- 加入 rate-limit 與可觀測性(metrics + request id) +## 8. 下一階段建議 +- 導入 Alembic migration +- 加 audit log(誰在何時做了 member/org/permission 變更) +- 補上整合測試與 rate-limit metrics diff --git a/docs/FRONTEND_API_CONTRACT.md b/docs/FRONTEND_API_CONTRACT.md index ca2aa19..1b58ca2 100644 --- a/docs/FRONTEND_API_CONTRACT.md +++ b/docs/FRONTEND_API_CONTRACT.md @@ -2,13 +2,21 @@ Base URL:`https://memberapi.ose.tw` -## 0. 帳號密碼登入 -### POST `/auth/login` +## 0. OIDC 登入(目前主流程) +### GET `/auth/oidc/url?redirect_uri=` +200 Response: +```json +{ + "authorize_url": "https://auth.ose.tw/application/o/authorize/..." +} +``` + +### POST `/auth/oidc/exchange` Request: ```json { - "username": "your-authentik-username", - "password": "your-password" + "code": "authorization-code", + "redirect_uri": "http://localhost:5173/auth/callback" } ``` @@ -22,11 +30,6 @@ Request: } ``` -401 Response: -```json -{ "detail": "invalid_username_or_password" } -``` - ## 1. 使用者資訊 ### GET `/me` Headers: @@ -134,7 +137,89 @@ Request: { "status": "ok" } ``` -## 6. 常見錯誤碼 +## 6. 組織管理(admin) +### GET `/admin/organizations` +Headers: +- `X-Client-Key: ` +- `X-API-Key: ` + +Query: +- `keyword` (optional) +- `status` (optional: `active|inactive`) +- `limit` (default `50`) +- `offset` (default `0`) + +### POST `/admin/organizations` +```json +{ + "org_code": "ose-main", + "name": "Ose Main", + "tax_id": "12345678", + "status": "active" +} +``` + +### PATCH `/admin/organizations/{org_id}` +```json +{ + "name": "Ose Main Updated", + "status": "inactive" +} +``` + +### POST `/admin/organizations/{org_id}/activate` +### POST `/admin/organizations/{org_id}/deactivate` + +## 7. 會員管理(admin) +### GET `/admin/members` +Headers: +- `X-Client-Key: ` +- `X-API-Key: ` + +Query: +- `keyword` (optional) +- `is_active` (optional: `true|false`) +- `limit` (default `50`) +- `offset` (default `0`) + +### POST `/admin/members` +```json +{ + "authentik_sub": "authentik-sub-123", + "email": "user@example.com", + "display_name": "User Name", + "is_active": true +} +``` + +### PATCH `/admin/members/{member_id}` +```json +{ + "display_name": "New Name", + "is_active": false +} +``` + +### POST `/admin/members/{member_id}/activate` +### POST `/admin/members/{member_id}/deactivate` + +## 8. 會員/組織關聯(admin) +### GET `/admin/members/{member_id}/organizations` +### POST `/admin/members/{member_id}/organizations/{org_id}` +### DELETE `/admin/members/{member_id}/organizations/{org_id}` + +## 9. 系統對系統查詢(internal) +Headers: +- `X-Internal-Secret: ` + +Endpoints: +- `GET /internal/members` +- `GET /internal/members/by-sub/{authentik_sub}` +- `GET /internal/organizations` +- `GET /internal/organizations/by-code/{org_code}` +- `GET /internal/members/{member_id}/organizations` + +## 10. 常見錯誤碼 - `401 invalid_client` - `401 invalid_api_key` - `401 client_expired` diff --git a/docs/ORG_MEMBER_MANAGEMENT_PLAN.md b/docs/ORG_MEMBER_MANAGEMENT_PLAN.md index 0468051..c8586ad 100644 --- a/docs/ORG_MEMBER_MANAGEMENT_PLAN.md +++ b/docs/ORG_MEMBER_MANAGEMENT_PLAN.md @@ -17,9 +17,9 @@ - `會員管理`:會員清單、邀請/建立會員、編輯會員、停用會員、指派組織 - `權限管理`:保留現有 grant/revoke(可作為管理員進階頁) -## 3. 建議後端 API(v1) +## 3. 後端 API(v1,已開) -### Organization +### Organization(admin) - `GET /admin/organizations` - `POST /admin/organizations` - `GET /admin/organizations/{org_id}` @@ -27,7 +27,7 @@ - `POST /admin/organizations/{org_id}/activate` - `POST /admin/organizations/{org_id}/deactivate` -### Member +### Member(admin) - `GET /admin/members` - `POST /admin/members` - `GET /admin/members/{member_id}` @@ -35,11 +35,18 @@ - `POST /admin/members/{member_id}/activate` - `POST /admin/members/{member_id}/deactivate` -### Member x Organization +### Member x Organization(admin) - `GET /admin/members/{member_id}/organizations` - `POST /admin/members/{member_id}/organizations/{org_id}` - `DELETE /admin/members/{member_id}/organizations/{org_id}` +### Internal 查詢 API(給其他系統) +- `GET /internal/members` +- `GET /internal/members/by-sub/{authentik_sub}` +- `GET /internal/organizations` +- `GET /internal/organizations/by-code/{org_code}` +- `GET /internal/members/{member_id}/organizations` + ## 4. 建議資料表(最小可行) - `organizations` - `id` (uuid)