Skip to content

BE handoff — Organization·Workspace IAM 계약

정본

Leyve의 고객 테넌트는 Organization, 실제 업무·장부 범위는 Workspace다. Supabase 프로토타입도 이 명칭을 물리 스키마, RPC, Edge 응답, Auth metadata까지 동일하게 사용한다.

  • baseline: supabase/migrations/20260822000000_organization_workspace_canonical_baseline.sql
  • SignIn Edge Function: supabase/functions/iam-sign-in/index.ts
  • 문맥 선택 Edge Function: supabase/functions/iam-context/index.ts
  • 검증: supabase/tests/organization_workspace_canonical_core_smoke.sql, supabase/tests/organization_workspace_catalog_zero_smoke.sql

기존 명칭을 위한 table, view, RPC, payload alias는 제공하지 않는다. canonical baseline 자체가 최종 Prototype DB blueprint이며, 전환용 forward migration 없이 이 baseline에서 DB를 새로 만든다.

범위 모델

  • organizations: 계약·결제 주체이자 고객 테넌트의 단일 본체다. 표시명, slug, 사업자등록번호, 상태, authorization epoch를 소유한다.
  • workspaces: Organization에 속한 업무·장부 범위다. 코드, 불변 순번, edition, 주소, 상태, authorization epoch를 소유한다.
  • organization_affiliations: 조직별 프로필·좌석 수명주기이며 접근권을 만들지 않는다.
  • organization_memberships, workspace_memberships: 실제 접근 관계다. Workspace-only 사용자는 Organization Affiliation을 가지되 Organization Membership이 없어도 된다.
  • organization_role_assignments, workspace_role_assignments: 역할을 Principal이 아니라 특정 Membership 세대에 바인딩한다.
  • 모든 Workspace 종속 데이터는 organization_id, workspace_id를 사용한다. 서로 다른 Organization의 Workspace를 연결하지 못하도록 복합 FK를 둔다.

organizations/workspaces와 별도의 표시용 scope adapter는 두지 않는다. 고객 계정과 IAM scope는 동일 UUID와 동일 행을 사용한다.

Canonical Role 계약

Scopehierarchy
Organizationorganization.owner > organization.admin > organization.manager > organization.viewer
Workspaceworkspace.owner > workspace.admin > workspace.manager > workspace.viewer
  • 두 scope 모두 Owner는 Admin, Admin은 Manager, Manager는 Viewer Permission을 포함하는 누적 계층이다.
  • Owner는 소유권·Owner 부여·회수·이전·종료, Admin은 설정·Membership·Owner 아래 Role, Manager는 일상 업무 데이터·비민감 운영 상태, Viewer는 조회만 담당한다. Organization의 계약·결제는 Organization Owner에게만 허용한다.
  • Viewer 조회는 명시된 일반 Workspace 데이터로 한정한다. 타인의 근태 이벤트·스케줄·예외는 Manager 이상, 법적 고용계약은 Owner/Admin 또는 exact-session 자기 주체 계약으로만 읽는다.
  • Organization Role은 산하 모든 Workspace에 같은 등급으로 투영한다. owner→owner, admin→admin, manager→manager, viewer→viewer다. 별도 opt-in flag나 Workspace Membership을 요구하지 않는다.
  • Workspace 직접 Role은 Organization 투영 Role보다 엄격히 높을 때만 해당 Workspace에 추가한다. 같거나 낮은 부여는 workspace_direct_role_must_elevate로 거부하며 Workspace Role은 Organization으로 역상속하지 않는다. 효과 Role 계산은 방어적으로 더 높은 등급을 사용한다. Organization Role 승격으로 기존 직접 Role이 같거나 낮아지면 같은 잠금과 transaction에서 Assignment와 불필요해진 Workspace username을 만료하며 자동 복원하지 않는다.
  • operator | auditor | contract_admin | accountant는 초기 카탈로그에 두지 않는다. 고위험 승인·반출·마감과 직무별 차이는 도메인 helper와 contextual policy로 제한한다.
  • 기본 access Role은 테넌트 업무 권한의 상한선이다. 초기 모델에는 사용자별 Functional Permission grant를 두지 않는다. subject-owned self-service와 trusted worker는 별도 인가 계약으로 판정한다.
  • organization-memberworkspace-member는 로그인 응답의 접근 문맥 분기값이지 Role이 아니다. 권한은 Membership에 바인딩된 dotted Role key로만 판정한다.

로그인 API

문맥식별자입력권리 확인
Organization OwnerEMAIL이메일 + 패스워드Affiliation + Organization Membership + organization.owner
Organization 최고관리자/중간관리자/조회자ORGANIZATION_USERNAMEOrganization Code + organizationUsername + 패스워드Affiliation + Organization Membership + 해당 Role
WorkspaceWORKSPACE_USERNAMEWorkspace Code + workspaceUsername + 패스워드Affiliation + Workspace Membership
  • Code는 각각 org-{10 lower Crockford Base32}, ws-{10 lower Crockford Base32}다.
  • Organization Owner를 non-owner Role로 변경할 때 active Organization username이 없으면 organization_username_required_before_owner_demotion으로 transaction 전체를 거부한다. Owner 승격은 Organization username을 만료하고 verified EMAIL identifier를 요구한다.
  • 서버가 {username}@{namespace_code}를 조립하며 클라이언트 조립 문자열을 신뢰하지 않는다.
  • 요청은 organizationCode + organizationUsername 또는 workspaceCode + workspaceUsername으로 분리하며 범용 code·signInId를 받지 않는다.
  • 인증 실패는 Code·Identifier·Principal 존재 여부를 응답 코드, 본문, 지연에서 구분하지 않는다.
  • iam-sign-in 응답은 organizationId, organizationSlug, organization, workspaceIds, workspaces, workspaceNumber만 사용한다.
  • Organization principal kind는 organization-member, Workspace principal kind는 workspace-member다.
  • 이메일은 Role key가 아닌 인증 방법이지만, 제품 정책상 active organization.owner에게만 Organization 이메일 로그인을 허용한다. 인증 성공만으로 Owner Role을 파생하지 않는다.
  • Leysys 본사 staff는 고객 Role 카탈로그와 분리된 ops-center surface·issuer· 승인 흐름을 사용한다. platform/superadmin은 고객 Role key가 아니다.

SignUp·세션 metadata

  • create_account_kind: organization_owner
  • organization_name
  • sales_provisioned_organization_owner
  • 상태: choose-organization, no-workspaces

클라이언트가 보낸 Organization ID나 Auth metadata만으로 기존 Organization 권한을 만들지 않는다. 가입·초대·프로비저닝 RPC가 서버에서 Membership과 역할을 생성하고 다시 검증한다.

권한과 보안

  • 공개 IAM table은 모두 RLS를 활성화하고 anon table privilege를 부여하지 않는다.
  • iam-sign-in은 pre-authentication endpoint라 verify_jwt=false이지만, secret key로 필요한 table의 최소 service_role 권한만 사용한다.
  • 로그인 서버에는 Organization/Workspace와 Membership 조회, session 원장 쓰기, authorization audit append 권한만 부여한다.
  • 프로비저닝 RPC는 PUBLIC, anon, authenticated 실행 권한을 회수하고 service_role만 실행한다.
  • terminal Membership은 되살리지 않고 새 generation을 만든다.
  • 권한 변경은 관련 identity·membership·role·policy·product epoch를 같은 트랜잭션에서 증가시킨다.
  • 브라우저 저장소의 principal은 표시 캐시다. access token 변경 시 서버에서 session과 Membership을 다시 읽는다.
  • Viewer Role 자체에 mutation Permission을 넣지 않고, 모든 mutation endpoint는 효과 Role과 도메인별 Permission·hard deny를 함께 검증한다.

Workspace 0 불변식

  • 새 Organization에는 같은 트랜잭션에서 정확히 하나의 workspace_number = 0 Workspace를 만든다.
  • (organization_id, workspace_number)는 unique이고 순번은 0 이상이며 변경·재사용하지 않는다.
  • Workspace 코드와 소속 Organization도 생성 후 변경할 수 없다.
  • Workspace 0 직접 삭제는 거부하고 Organization 삭제에 따른 cascade만 허용한다.
  • 새 Workspace는 Organization별 transaction lock 안에서 max(workspace_number) + 1을 배정한다.

검증 게이트

로컬에서 supabase db reset 후 SQL smoke를 실행한다. catalog gate는 public/private의 relation, column, constraint, index, function name·argument·body, view output, policy, trigger, comment에서 폐기된 scope 식별자가 남지 않았는지 확인한다. 또한 아래를 검사한다.

  • Organization-only / Workspace-only RLS 분리
  • cross-Organization Workspace FK 거부
  • terminal Membership 재활성화 거부
  • workspace_module_entitlements 접근 경계
  • canonical named argument RPC 호출
  • 가입·Sales 프로비저닝 결과의 Organization/Workspace payload
  • 두 번째 reset 뒤 schema diff 0