feat: add organization and member management APIs for admin and internal use
This commit is contained in:
@@ -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`
|
||||
|
||||
265
backend/app/api/admin_entities.py
Normal file
265
backend/app/api/admin_entities.py
Normal file
@@ -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"}
|
||||
100
backend/app/api/internal_entities.py
Normal file
100
backend/app/api/internal_entities.py
Normal file
@@ -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])
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
23
backend/app/models/member_organization.py
Normal file
23
backend/app/models/member_organization.py
Normal file
@@ -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)
|
||||
23
backend/app/models/organization.py
Normal file
23
backend/app/models/organization.py
Normal file
@@ -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
|
||||
)
|
||||
43
backend/app/repositories/member_organizations_repo.py
Normal file
43
backend/app/repositories/member_organizations_repo.py
Normal file
@@ -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)
|
||||
67
backend/app/repositories/organizations_repo.py
Normal file
67
backend/app/repositories/organizations_repo.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
37
backend/app/schemas/members.py
Normal file
37
backend/app/schemas/members.py
Normal file
@@ -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]
|
||||
29
backend/app/schemas/organizations.py
Normal file
29
backend/app/schemas/organizations.py
Normal file
@@ -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
|
||||
@@ -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;
|
||||
|
||||
27
backend/scripts/migrate_add_org_member_tables.sql
Normal file
27
backend/scripts/migrate_add_org_member_tables.sql
Normal file
@@ -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;
|
||||
@@ -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:<hex>` 與 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
|
||||
|
||||
@@ -2,13 +2,21 @@
|
||||
|
||||
Base URL:`https://memberapi.ose.tw`
|
||||
|
||||
## 0. 帳號密碼登入
|
||||
### POST `/auth/login`
|
||||
## 0. OIDC 登入(目前主流程)
|
||||
### GET `/auth/oidc/url?redirect_uri=<frontend-callback-url>`
|
||||
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: <client_key>`
|
||||
- `X-API-Key: <plain_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: <client_key>`
|
||||
- `X-API-Key: <plain_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: <internal_shared_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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user