Skip to content

[BE][API] MVP2-0 Issue 0-3: organization member/invitation API 추가 #74

Description

@yoonki1207

목적

MVP 2-0 Organization Membership / Invitation Foundation의 API 계층을 추가한다. organization_memberships를 기준으로 current user의 organization 목록, 현재 organization, organization member 목록, 기존 user 초대, 초대 수락, member 상태/auth 변경, member 제거 API를 제공한다.

이 이슈는 organization_memberships 모델/migration과 permission helper 전환 이후 진행한다. 기존 MVP1 team/resource permission API request/response shape는 깨지지 않는다.

선행 조건

Blocked by:

  • MBA-52: [BE][RBAC] MVP2-0 Issue 0-2: organization membership 기반 permission helper 전환

Transitive dependency:

  • MBA-51: [DB][RBAC] MVP2-0 Issue 0-1: organization_memberships 모델과 migration 추가

전제:

  • OrganizationMembership 모델과 membership/auth constant가 존재한다.
  • has_organization_manager_permission()organization_memberships.organization_auth_state='manager'와 legacy organization.created_by/managed_by fallback을 함께 반영한다.
  • workflow/LLM permission helper는 active organization membership이 없으면 fail-closed 한다.

문서 기준 및 정합성 확인

Source of truth:

  • docs/implementation-plan/mvp-2-0-organization-membership-plan.md
  • docs/api/organization-rbac.md
  • docs/data-model/physical-data-model.md
  • docs/data-model/rbac-permission-policy.md

정합성 확인:

  • 계획서 Issue 0-3의 범위는 organization member service, list/invite/accept/update/remove endpoint, schema, audit, self-removal/last-manager guard다.
  • 계획서 13.1~13.8 API 계약의 endpoint와 state transition을 따른다.
  • 계획서 14. Schema 작업의 schema 후보를 추가한다.
  • 계획서 15. Audit 설계의 organization membership audit action과 cleanup metadata를 기록한다.
  • 이메일만 존재하는 미가입 사용자 초대, invitation token, email 발송, signup completion flow는 MVP 2-0 범위 밖이다.
  • manager-side 강제 accept는 MVP 2-0에서 제외한다. 초대 수락은 invited user 본인만 가능하다.
  • 기존 Team/permission API shape는 유지한다. Team add와 user direct permission grant의 grantee 검증 기준 변경은 Issue 0-4 범위다.

현재 코드 기준

현재 확인된 코드 상태:

  • apps/gateway/api/api.py에는 /organizations router가 아직 등록되어 있지 않다.
  • apps/gateway/api/v1/endpoints/teams.py/teams, /permissions 중심이다.
  • apps/gateway/services/team_service.py에는 organization member service가 없다.
  • apps/shared/schemas/team.py는 team/permission schema만 가진다.
  • apps/shared/audit/actions.py에는 organization.invite, organization.member.accept, organization.member.update, organization.member.remove action이 없다.
  • User 모델에는 email, name, deactivated_at이 있어 member response와 invite validation에 사용할 수 있다.

구현 범위

1. Schema 추가

신규 파일 후보:

  • apps/shared/schemas/organization_membership.py

필요 schema:

OrganizationMemberInviteRequest
OrganizationMemberUpdateRequest
OrganizationMemberResponse
OrganizationSummaryResponse
OrganizationCurrentResponse
OrganizationMemberRemoveResponse
RevokedUserPermissionCounts

OrganizationMemberInviteRequest

필드:

  • user_id: UUID
  • organization_auth_state: Literal["member", "manager"] = "member"

Validation:

  • organization_auth_statemember, manager만 허용한다.
  • 기본값은 member다.

OrganizationMemberUpdateRequest

필드:

  • membership_state: Literal["active", "suspended"] | None = None
  • organization_auth_state: Literal["member", "manager"] | None = None

Validation:

  • removed는 PATCH로 받지 않는다. 제거는 DELETE endpoint만 사용한다.
  • invited member를 active로 바꾸는 manager-side 강제 accept는 허용하지 않는다.
  • 둘 다 None이면 400 처리한다.

OrganizationMemberResponse

필드:

  • id: UUID
  • organization_id: UUID
  • user_id: UUID
  • user_email: str
  • user_name: str
  • membership_state: str
  • organization_auth_state: str
  • invited_by: UUID | None
  • invited_at: datetime | None
  • accepted_at: datetime | None
  • removed_at: datetime | None = None
  • created_at: datetime
  • updated_at: datetime

주의:

  • email/name은 API response에 포함한다.
  • audit metadata에는 email을 저장하지 않는다.

OrganizationSummaryResponse

필드:

  • id: UUID
  • name: str
  • membership_state: str
  • organization_auth_state: str
  • is_active: bool

OrganizationCurrentResponse

필드는 OrganizationSummaryResponse와 동일하게 시작해도 된다.

OrganizationMemberRemoveResponse

필드:

  • status: Literal["removed"]
  • removed_team_memberships: int
  • revoked_user_permissions: RevokedUserPermissionCounts

RevokedUserPermissionCounts 필드:

  • workflow: int = 0
  • llm_credential: int = 0
  • knowledge_base: int = 0
  • audit: int = 0

MVP 2-0에서는 실제 cleanup 대상이 user_workflow_permissions, user_llm_permissions, team_memberships다. knowledge_base, audit은 아직 테이블이 없으면 0으로 반환한다.

2. Organization member service 추가

신규 파일 후보:

  • apps/gateway/services/organization_member_service.py

주요 public method:

list_organizations(db, current_user) -> list[OrganizationSummaryResponse]
get_current_organization(db, current_user) -> OrganizationCurrentResponse
list_members(db, current_user, organization_id, state=None) -> list[OrganizationMemberResponse]
invite_member(db, current_user, organization_id, request) -> OrganizationMemberResponse
accept_invitation(db, current_user, organization_id) -> OrganizationMemberResponse
update_member(db, current_user, organization_id, user_id, request) -> OrganizationMemberResponse
remove_member(db, current_user, organization_id, user_id) -> OrganizationMemberRemoveResponse

공통 helper:

_ensure_organization_manager(db, current_user, organization_id)
_get_active_organization(db, organization_id)
_get_user_or_404(db, user_id)
_get_membership(db, organization_id, user_id)
_count_active_managers(db, organization_id, exclude_user_id=None)
_ensure_not_last_manager(db, organization_id, target_user_id)
_serialize_member(membership, user)
_record_organization_membership_audit(...)

권한:

  • organization list/current: authenticated user
  • member list/invite/update/remove: organization manager
  • accept invitation: invited user 본인

3. API endpoint 추가

신규 파일 후보:

  • apps/gateway/api/v1/endpoints/organizations.py

apps/gateway/api/api.py에 router 등록:

api_router.include_router(organizations.router, prefix="/organizations", tags=["organizations"])

필수 endpoint:

GET /api/v1/organizations

Permission:

  • authenticated

동작:

  • current user의 organization_memberships 기준으로 조회한다.
  • membership_state in ('active', 'invited')만 반환한다.
  • organization이 inactive이면 제외한다.
  • invited organization은 response에 포함하되 resource 화면 진입 가능 여부는 FE/permission helper가 별도로 판단한다.

Response:

[
  {
    "id": "uuid",
    "name": "Acme",
    "membership_state": "active",
    "organization_auth_state": "manager",
    "is_active": true
  }
]

GET /api/v1/organizations/current

Permission:

  • authenticated

동작:

  • 우선 active organization membership 기준으로 current/primary organization을 반환한다.
  • 정렬 기준은 가능하면 accepted_at asc nulls last, created_at asc를 따른다.
  • active membership이 없으면 404 또는 기존 ensure_user_default_organization() 흐름과의 연결을 검토한다. 이 endpoint 자체에서 default organization 생성을 강제할지는 Issue 0-2/0-3 구현 시 기존 UX 회귀를 보고 결정하되, 문서상 organization membership 기준을 우선한다.

Response:

{
  "id": "uuid",
  "name": "Acme",
  "membership_state": "active",
  "organization_auth_state": "member",
  "is_active": true
}

GET /api/v1/organizations/{organization_id}/members

Permission:

  • organization manager

Query:

  • state=active|invited|suspended|removed optional

동작:

  • manager만 조회 가능하다.
  • state가 없으면 기본적으로 active, invited, suspended를 반환한다. removed 포함 여부는 query로 명시했을 때만 반환하는 것을 권장한다.
  • user email/name을 join해서 response에 포함한다.

POST /api/v1/organizations/{organization_id}/members/invitations

Permission:

  • organization manager

Request:

{
  "user_id": "uuid",
  "organization_auth_state": "member"
}

Rules:

  • 초대 대상 user가 존재해야 한다. 없으면 404.
  • deactivated user는 초대할 수 없다. 400 또는 409.
  • 자기 자신 초대는 MVP2-0 권장값에 따라 400으로 막는다.
  • 이미 active member이면 idempotent하게 기존 membership을 반환한다.
  • 이미 invited member이면 idempotent하게 기존 invitation을 반환하되 invited_at은 갱신하지 않는다.
  • removed row가 있으면 같은 row를 invited로 재활성화한다. 이때 invited_by=current_user.id, invited_at=now, accepted_at=None, removed_at=None로 갱신한다.
  • suspended member이면 409를 반환한다. 재활성화는 PATCH만 사용한다.
  • organization_auth_state='manager' 초대는 organization manager가 요청하는 경우 허용한다.
  • invitation 생성/재초대 성공 시 audit action organization.invite를 기록한다.

Response:

{
  "id": "membership_uuid",
  "membership_state": "invited",
  "organization_auth_state": "member"
}

POST /api/v1/organizations/{organization_id}/members/me/accept

Permission:

  • invited user 본인

Rules:

  • current user의 membership row를 조회한다.
  • membership_state가 invited이면 active로 변경하고 accepted_at=now를 기록한다.
  • 이미 active이면 idempotent하게 active membership을 반환한다.
  • suspended/removed 상태는 accept할 수 없고 409를 반환한다.
  • organization manager가 다른 user를 강제 activate하는 것은 MVP2-0에서 허용하지 않는다.
  • accept 성공 시 audit action organization.member.accept를 actor=current user로 기록한다.

Response:

{
  "id": "membership_uuid",
  "membership_state": "active"
}

선택 endpoint:

  • POST /api/v1/organizations/{organization_id}/members/{user_id}/accept는 구현하지 않아도 된다.
  • 구현한다면 current_user.id == user_id를 강제한다.

PATCH /api/v1/organizations/{organization_id}/members/{user_id}

Permission:

  • organization manager

Request:

{
  "membership_state": "suspended",
  "organization_auth_state": "manager"
}

Rules:

  • membership_stateactive, suspended만 직접 변경 가능하다.
  • removed는 DELETE endpoint만 사용한다.
  • invited member를 active로 바꾸는 manager-side 강제 accept는 허용하지 않는다.
  • active/suspended 전환만 처리한다.
  • suspended 전환 시 team/direct permission row는 삭제하지 않는다. Issue 0-2 helper가 fail-closed 처리한다.
  • suspended member를 active로 되돌리면 기존 team/direct permission row가 다시 효력을 가진다.
  • 마지막 active manager를 member로 낮추거나 suspend하는 것은 금지한다.
  • 자기 자신의 manager 권한을 낮추는 것은 MVP2-0에서 금지한다.
  • update 성공 시 audit action organization.member.update를 before/after metadata와 함께 기록한다.

DELETE /api/v1/organizations/{organization_id}/members/{user_id}

Permission:

  • organization manager

Rules:

  • 마지막 manager 제거 금지.
  • 자기 자신 제거 금지.
  • membership_state를 removed로 변경한다.
  • removed_at=now를 기록한다.
  • invited 상태의 user도 remove할 수 있다.
  • removed 상태에 대해 다시 DELETE가 오면 idempotent하게 status='removed'를 반환한다.
  • 제거 transaction 안에서 해당 organization의 cleanup을 수행한다.
  • cleanup 대상:
    • team_memberships where grantee_organization_id=organization_id and user_id=target_user_id
    • user_workflow_permissions where grantee_organization_id=organization_id and user_id=target_user_id
    • user_llm_permissions where grantee_organization_id=organization_id and user_id=target_user_id
    • MVP2 user_knowledge_permissions가 아직 없으면 count 0
    • MVP3 user_audit_permissions가 아직 없으면 count 0
  • cleanup count를 response와 audit metadata에 남긴다.
  • 중간 실패 시 제거와 cleanup이 일부만 반영되면 안 된다.

Response:

{
  "status": "removed",
  "removed_team_memberships": 3,
  "revoked_user_permissions": {
    "workflow": 2,
    "llm_credential": 1,
    "knowledge_base": 0,
    "audit": 0
  }
}

정합성 메모:

  • 계획서 Issue 0-4에도 member removal cleanup이 언급되어 있지만, API 계약 13.7 Remove member가 cleanup count response를 요구한다. 따라서 이 이슈에서 remove endpoint의 최소 cleanup transaction을 구현한다.
  • Issue 0-4는 team add와 user direct permission grant 생성 시점의 grantee 검증 기준 변경을 담당한다.

4. State transition 구현

허용 transition:

현재 상태 Action 다음 상태 주체
없음 invite invited organization manager
invited accept active invited user 본인
invited remove removed organization manager
active suspend suspended organization manager
active remove removed organization manager
suspended reactivate active organization manager
suspended remove removed organization manager
removed invite invited organization manager

불허:

  • manager가 invited member를 PATCH로 active 처리
  • suspended/removed member의 accept
  • self remove
  • self demote from manager
  • last manager demote/suspend/remove
  • email-only invite

5. Last manager guard

Guard 대상:

  • DELETE member
  • PATCH membership_state suspended
  • PATCH organization_auth_state member
  • 두 변경이 동시에 들어와 manager 권한이 사라지는 경우

판정 기준:

  • active organization_membershipsorganization_auth_state='manager' row count를 기준으로 한다.
  • target user를 제외했을 때 active manager가 0명이 되면 400 또는 409를 반환한다.
  • migration 누락을 대비한 legacy fallback은 permission helper에는 남기되, target state guard는 active manager membership row를 기준으로 삼는다. 단, 구현 중 기존 데이터 회귀가 우려되면 created_by/managed_by fallback을 보조적으로 고려한다.

6. Audit action 추가

파일:

  • apps/shared/audit/actions.py

추가 action:

ORGANIZATION_INVITE = "organization.invite"
ORGANIZATION_MEMBER_ACCEPT = "organization.member.accept"
ORGANIZATION_MEMBER_UPDATE = "organization.member.update"
ORGANIZATION_MEMBER_REMOVE = "organization.member.remove"

기존 action 재사용:

  • PERMISSION_REVOKE = "permission.revoke"는 cleanup aggregate 기록에 재사용 가능하다.

Audit metadata 원칙:

  • email은 저장하지 않는다.
  • user id, organization id, membership id만 저장한다.
  • update/remove는 before/after state를 저장한다.
  • remove는 cleanup count를 저장한다.

예시:

{
  "organization_id": "uuid",
  "membership_id": "uuid",
  "target_user_id": "uuid",
  "previous_membership_state": "active",
  "next_membership_state": "removed",
  "previous_organization_auth_state": "member",
  "next_organization_auth_state": "member",
  "reason": "organization.member.remove",
  "cleanup": {
    "team_memberships": 2,
    "user_workflow_permissions": 1,
    "user_llm_permissions": 1,
    "user_knowledge_permissions": 0,
    "user_audit_permissions": 0
  }
}

Audit timing:

  • invite/update/remove 성공 후 같은 transaction 안에서 audit row를 생성한다.
  • accept는 target user 본인의 action으로 기록한다.
  • cleanup으로 삭제되는 개별 permission row마다 audit row를 남기지 않아도 된다. MVP2-0 권장은 aggregate row 하나다.

7. 테스트 추가

파일 후보:

  • apps/gateway/tests/services/test_organization_member_service.py
  • apps/gateway/tests/api/test_organization_members_api.py
  • apps/shared/tests/test_audit_actions.py

Service test:

  1. manager가 기존 active user를 invited member로 초대할 수 있다.
  2. 초대 대상 user가 없으면 404.
  3. deactivated user 초대는 400/409.
  4. 자기 자신 초대는 400.
  5. active member 재초대는 idempotent하게 기존 row 반환.
  6. invited member 재초대는 invited_at 갱신 없이 기존 row 반환.
  7. removed member 재초대는 same row를 invited로 되돌리고 removed_at, accepted_at을 초기화한다.
  8. suspended member invite는 409.
  9. invited user 본인은 accept할 수 있고 accepted_at이 설정된다.
  10. active user accept는 idempotent하다.
  11. suspended/removed user accept는 409.
  12. manager는 active member를 suspended로 바꿀 수 있다.
  13. manager는 suspended member를 active로 바꿀 수 있다.
  14. manager는 member를 manager로 승격할 수 있다.
  15. 마지막 manager demote/suspend/remove는 차단된다.
  16. self remove와 self demote는 차단된다.
  17. remove member는 membership을 removed로 바꾸고 team/direct permission rows를 cleanup한다.
  18. remove member response에 cleanup count가 정확히 담긴다.
  19. remove member는 audit metadata에 cleanup count를 남긴다.

API test:

  1. GET /api/v1/organizations는 current user의 active/invited organizations만 반환한다.
  2. GET /api/v1/organizations/current는 active membership 기준 current organization을 반환한다.
  3. manager만 GET /organizations/{id}/members를 호출할 수 있다.
  4. manager만 invite/update/remove를 호출할 수 있다.
  5. invited user는 POST /members/me/accept로 직접 accept할 수 있다.
  6. 다른 user의 accept는 허용되지 않는다.
  7. invited user는 accept 전 resource 접근이 불가능하다. 이 검증은 Issue 0-2 permission helper와 통합해서 확인한다.
  8. remove 후 user는 workflow/LLM permission row가 남지 않거나 cleanup되어 접근할 수 없다.

Audit action test:

  • AuditAction.ORGANIZATION_INVITE == "organization.invite"
  • AuditAction.ORGANIZATION_MEMBER_ACCEPT == "organization.member.accept"
  • AuditAction.ORGANIZATION_MEMBER_UPDATE == "organization.member.update"
  • AuditAction.ORGANIZATION_MEMBER_REMOVE == "organization.member.remove"

완료 조건

  • apps/shared/schemas/organization_membership.py가 추가된다.
  • organization summary/current/member/invite/update/remove response schema가 추가된다.
  • apps/gateway/services/organization_member_service.py가 추가된다.
  • /api/v1/organizations router가 추가되고 api.py에 등록된다.
  • GET /organizations가 active/invited organization membership 기준으로 동작한다.
  • GET /organizations/current가 active organization membership 기준으로 동작한다.
  • manager가 member list를 조회할 수 있다.
  • manager가 기존 가입 user를 invite할 수 있다.
  • email-only invite는 지원하지 않는다.
  • deactivated user invite가 차단된다.
  • invited user는 본인이 직접 accept할 수 있다.
  • manager-side forced accept는 허용하지 않는다.
  • manager가 active member를 suspend하고 suspended member를 reactivate할 수 있다.
  • manager가 member/manager auth_state를 변경할 수 있다.
  • 마지막 manager demote/suspend/remove가 차단된다.
  • self remove와 self demote가 차단된다.
  • manager가 member를 remove할 수 있다.
  • remove 시 team_memberships, user_workflow_permissions, user_llm_permissions cleanup이 같은 transaction에서 수행된다.
  • remove response에 cleanup count가 포함된다.
  • invite/accept/update/remove audit action이 기록된다.
  • audit metadata에 email 같은 민감 표시 정보를 넣지 않는다.
  • organization member service/API test가 추가되고 통과한다.
  • 기존 MVP1 /teams, /permissions endpoint request/response shape는 변경하지 않는다.

명시적 제외 범위

이 이슈에서 하지 않는다.

  • organization_memberships model/migration 추가: MBA-51
  • permission helper fail-closed 전환: MBA-52
  • team add 시 active organization membership 검증: Issue 0-4
  • user direct permission grant 시 active organization membership 검증: Issue 0-4
  • _ensure_grantee_user_membership 제거/의미 변경: Issue 0-4
  • FE Members tab, invite modal, picker filtering: Issue 0-5
  • email-only invite, invitation token, email 발송, signup completion flow
  • SSO/OIDC 자동 organization join
  • active organization switcher/header/session 계약 확정
  • MVP2 user_knowledge_permissions migration 및 knowledge base use enforcement

후속 연결

이 이슈가 끝나면 Issue 0-4에서 기존 team/direct permission mutation 경로를 active organization membership 기준으로 전환한다. 그 뒤 Issue 0-5에서 Members UI와 picker filtering을 붙인다.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions