Skip to content

BE handoff — Organization·Workspace IAM 계약

목표 런타임 전제 (2026-07-25)

이 절의 정본은 docs/decisions/IAM-ARCHITECTURE-REVIEW-2026-07-25.md다. 실서비스는 AWS + RDS MariaDB이고 RLS를 전제하지 않는다. Supabase 구현은 목표 구조를 실행 검증하는 Prototype adapter이며 Production DDL의 근거가 아니다.

로그인 API 경계

Login Context입력서버 조회명권리 확인
organization / email검증된 이메일 + 패스워드email identifierOrganization Affiliation + Organization Membership
organization / scoped IDOrganization Code + Login ID + 패스워드{login_id}@{org-code}Organization Affiliation + Organization Membership
workspace / scoped IDWorkspace Code + Login ID + 패스워드{login_id}@{ws-code}Organization Affiliation + Workspace Membership
platform별도 운영자 IdP별도 issuer/client/audiencePlatform Role + 고객 데이터는 별도 JIT grant
  • Code는 org-{10 lower Crockford Base32} 또는 ws-{10 lower Crockford Base32}다. 전역 유일·불변·재사용 금지이며 login_namespaces가 정본이다.
  • login_id는 3~32자 제한 ASCII다. 클라이언트가 조립한 username@code를 신뢰하지 않고 서버가 구조화 입력으로 생성한다.
  • Workspace-only 사용자는 Organization Membership이 없어도 되지만 같은 Organization의 접근권 없는 Organization Affiliation을 반드시 가진다.
  • 로그인 방법은 권리를 만들지 않는다. API는 Membership/Role/Scope/Policy를 판정하고, audience·인증 강도·auth_time·step-up은 민감 작업의 추가 제약으로 평가한다.
  • 인증 실패는 Code·Identifier·Principal 존재 여부를 상태코드·본문·지연·복구 흐름에서 구분하지 않는다.
  • Organization/Workspace API는 서로 다른 client/audience와 private login_context를 사용한다. 동일 issuer를 허용하더라도 다른 audience 토큰을 수락하지 않는다.
  • Platform Administrator는 고객 로그인 요청에 platform=true 같은 토글로 승격하지 않는다. 별도 인증 영역과 시간 제한 platform_access_grants, 요청자≠승인자, 사유·티켓·step-up·감사를 요구한다.

MariaDB 권한 정본

  • principals: 불변 UUIDv7 주체. 테넌트 프로필·권한 없음.
  • organization_affiliations: 조직별 프로필·좌석·수명주기. 접근권 없음.
  • organization_memberships, workspace_memberships: 접근 관계. Role Assignment는 Principal이 아니라 특정 Membership ID 세대에 바인딩한다.
  • 모든 Workspace 종속 행은 (workspace_id, organization_id) 복합 FK를 사용한다.
  • 권한 판정은 단일 인가 커널을 통과하고, 데이터 접근 계층은 Organization/Workspace 술어 없는 질의를 거부한다.
  • 권한 변경은 관련 Principal/Affiliation/Membership/Role/Policy/Product epoch를 같은 트랜잭션에서 증가시킨다.
  • 상품 데이터 접근은 Product State × Capability 전체 행렬을 평가한다. 미분류 셀과 purged 상품 데이터 접근은 거부한다.

목표 API 예시

  • POST /iam/login/organization/email
  • POST /iam/login/organization/scoped
  • POST /iam/login/workspace/scoped
  • GET /iam/me/organizations
  • GET /iam/me/workspaces?organizationId=
  • POST /iam/sessions/context
  • POST /iam/platform-access-grants

응답 토큰은 안정적 Principal/Session/issuer/audience/인증 사실만 담고, 현재 권리는 서버가 관련 epoch와 상태로 판정한다.

Supabase IAM Prototype as-built (2026-07-25)

정본 migration은 아래 두 개이며 순서대로 적용한다.

  • 20260725052205_iam_identity_access_prototype.sql: canonical IAM 모델, backfill, 불변식, trigger, RLS
  • 20260725060811_iam_consolidate_rls_policies.sql: Organization/Workspace 문맥의 조회 의미는 유지하면서 permissive policy를 테이블별 하나로 통합

Canonical 모델

  • principals: 불변 주체. Prototype에서는 auth_user_id로 Supabase Auth와 연결하지만 권한·프로필을 담지 않는다.
  • login_namespaces: 전역 org-/ws- Code registry. Code·kind·Organization·Workspace 재할당과 삭제를 trigger로 거부한다.
  • principal_identifiers: EMAIL 또는 scoped ID의 구조화 컬럼과 저장 생성 login_name. retired를 포함해 login_name은 영구 unique다.
  • authenticators: 비밀을 저장하지 않고 supabase_auth provider reference만 기록한다. 패스워드 검증은 Edge Function 안에서 Supabase Auth에 위임한다.
  • organizations, workspaces: 업무 데이터 companies, offices와 동일 UUID를 사용하는 canonical IAM scope adapter다.
  • organization_affiliations: 조직별 프로필·계약/좌석 수명주기이며 접근권을 만들지 않는다.
  • organization_memberships, workspace_memberships: generation-bound 접근 관계. Workspace Membership은 Organization Membership FK를 갖지 않는다.
  • roles, 두 Role Assignment 테이블: 역할을 특정 Membership 세대에 묶는다. 종료된 Membership을 되살리는 대신 새 행을 만들어야 한다.
  • sessions: Supabase Auth session_idlogin_context, Organization/Workspace 복합 scope, 인증 시각·만료·identity epoch를 연결한다.
  • authorization_audit_events: UPDATE/DELETE trigger가 있는 append-only hash-chain Prototype이다.

모든 공개 IAM 테이블은 RLS를 활성화하고 anon table privilege를 주지 않는다. private.current_principal_id(), private.has_active_organization_membership(), private.has_active_workspace_membership()authenticated가 RLS 내부에서 실행할 수 있다. service_role은 오직 Edge Function 서버에서 사용한다.

기존 단일 계정과 fixture는 migration에서 backfill한다. 이후 auth.users, companies, offices, company_memberships, office_memberships, workforce_identities 변경 trigger가 canonical IAM projection을 동기화한다. 레거시 테이블은 업무 화면 표시 adapter일 뿐 인증·역할 판정의 정본이 아니다.

iam-login 공개 인증 경계

iam-loginverify_jwt=false인 pre-authentication endpoint다. 공개 함수라는 뜻이지 table access를 공개한다는 뜻은 아니다.

json
{
  "loginContext": "workspace",
  "identifierType": "WORKSPACE_LOGIN_ID",
  "code": "ws-4f8q2r7n5c",
  "loginId": "guard01",
  "password": "..."
}
  1. 입력 조합과 Code/Login ID 형식을 검증한다.
  2. 서버가 login_name을 조립하고 identifier의 구조화 namespace FK와 문맥을 함께 검증한다.
  3. identifier 미존재도 dummy Auth 이메일로 signInWithPassword()를 수행해 조회 단계 차이를 줄이고, 실패 응답은 항상 동일 401 문구를 사용한다.
  4. Supabase Auth 성공 후 auth_user_id와 Principal을 다시 일치시킨다.
  5. Organization은 유효 Affiliation + Organization Membership, Workspace는 유효 Affiliation + Workspace Membership을 검사한다. Workspace 경로는 Organization Membership을 조회하지 않는다.
  6. Role Assignment, 접근 가능한 scope, legacy 업무 표시값과 module activation을 조립한다.
  7. canonical sessions와 append-only 로그인 성공 audit event를 기록하고 Auth token을 반환한다.

Organization 이메일이 여러 Membership을 가지면 Auth session만 먼저 반환하고 contextSelectionRequired=true로 표시한다. 선택 후 인증된 iam-context가 현재 Principal의 Organization Membership을 다시 검사하고 동일 Auth session_id에 Organization 문맥을 기록한다. 클라이언트가 보낸 Organization ID만으로 세션을 만들지 않는다.

Edge Function SDK는 @supabase/supabase-js@2.110.2로 pin했다. 새 publishable/secret key 환경변수를 우선하고 legacy anon/service-role 변수는 Prototype 호환 fallback으로만 사용한다.

Supabase 한계와 Production 차이

  • Supabase가 발급한 JWT의 실제 aud는 프로젝트 Auth 설정을 따른다. Prototype의 organization-console/workspace-console audience는 canonical session record에 저장해 애플리케이션 계약을 검증한다. Production은 별도 OIDC client/audience를 토큰 검증 단계에서 강제해야 한다.
  • 기존 업무 RPC/RLS 전부를 새 Role/Permission kernel로 바꾸지는 않았다. 이번 slice는 인증·소속·세션 정본과 legacy Company/Office 읽기 bridge까지다.
  • 다단계 인증, 계층형 rate limiter, invite token 일회 소비, Platform JIT grant, Product State × Capability kernel은 목표 문서의 후속 단계다.

검증:

  • supabase/tests/iam_identity_access_prototype_smoke.sql
  • supabase/tests/iam_identity_access_prototype_transaction.sql
  • src/composables/__tests__/iamArchitectureMigration.spec.js
  • src/composables/__tests__/iamAuthRepo.spec.js
  • src/composables/__tests__/workforceAuthRepo.spec.js

smoke는 Workspace-only 사용자에게 Affiliation+Workspace Membership만 생기는지, Organization Membership이 생기지 않는지, 교차 Organization 복합 FK, terminal Membership, Code 불변, scoped login name, RLS를 모두 검사한다.

2026-07-25 Seoul 프로젝트(hzmfhlbzjfyxwloodpcj)에 두 migration과 iam-login, iam-context를 적용했다. 원격 transaction smoke와 잘못된 자격증명 일반화 401을 확인했고, 이번 IAM 테이블의 Supabase security/performance advisor 경고는 0건이다. 대체된 원격 workforce-login 함수는 제거했다. 기존 도메인의 advisor 경고는 이번 IAM slice의 범위 밖이며 별도 보안 정리 대상으로 남는다.

폐기된 Prototype adapter 기록

아래 workforce-login, Company Code discovery, workforce_identities 권한 정본 서술은 2026-07-25 이전 구현 이력이다. 현재 런타임은 iam-login과 canonical IAM 테이블을 사용한다. 신규 코드에서 아래 계약을 복원하지 않는다.

workforce-login 서비스 권한 불변식

workforce-login은 세션 발급 전에 Company와 직원의 Office 범위를 조회한다. 따라서 Edge Function의 service_role에는 아래 테이블의 SELECT 권한이 명시적으로 있어야 한다. BYPASSRLS는 테이블 권한을 대신하지 않는다.

  • companies
  • workforce_identities
  • office_memberships
  • offices
  • office_module_entitlements

정본은 20260713120000_workforce_login_service_role_grants.sql이다. 새 환경 복원 후에는 has_table_privilege('service_role', ..., 'select')가 다섯 테이블 모두 true인지 확인한다. 권한 누락 시 함수의 최초 Company 조회가 PostgREST 42501 permission denied로 실패하며, 사용자에게는 일반 로그인 오류만 보인다.

Auth 사용자를 프로젝트 간 SQL로 이관할 때 auth.users.instance_id는 호스팅 기본값 00000000-0000-0000-0000-000000000000을 유지해야 한다. NULL이면 SQL에는 사용자가 존재해도 GoTrue 사용자 목록과 패스워드 인증에서 제외되어 invalid credentials가 발생한다. 복구 정본은 20260713121000_repair_auth_user_instance_id.sql이다.

관리자 이메일 인증과 Company 선택 경계

관리자는 supabase.auth.signInWithPassword({ email, password })로만 인증한다. Company 코드는 관리자 인증 수단으로 사용하지 않는다. 인증 뒤 auth.getUser()로 검증한 사용자 ID와 사용자 JWT를 함께 사용해 company_memberships.user_id = authUser.id AND status = active인 행만 조회한다. RLS는 최종 방어선이지만 같은 Company 관리자에게 동료 멤버십 조회를 허용하는 정책도 존재할 수 있으므로, 현재 사용자 조건을 RLS에 암묵적으로 맡기면 안 된다.

  • 활성 Company 0개: 업무 데이터 진입을 막고 권한 문의 상태를 반환한다.
  • 활성 Company 1개: 해당 company_id로 Company·Office·계약 데이터를 조회한다.
  • 활성 Company 2개 이상: 목록을 모두 반환하고 사용자가 선택한 company_id를 후속 조회에 명시한다. 임의 limit(1) 또는 첫 행 fallback은 금지한다.
  • 선택한 Company 단건 조회도 company_id뿐 아니라 user_id = authUser.id, status = active를 동시에 적용한 뒤 maybeSingle()을 사용한다. 그렇지 않으면 같은 Company의 다른 관리자 멤버십이 보이는 RLS에서 중복 목록과 단건 조회 실패가 발생한다.
  • Company 선택은 인증이 아니라 업무 컨텍스트 선택이다. 선택을 바꿔도 Auth 세션은 유지하고, 모든 캐시·query key·감사 이벤트에 선택된 company_id를 포함한다.
  • 회사명 중복을 허용하므로 선택 UI의 준식별 정보는 사업자등록번호를 사용한다. 내부 slug/Company 코드는 목록의 주 식별자로 노출하지 않는다.

기존 company-login Edge Function은 관리자 Company 코드 로그인을 위해 존재했던 레거시 경계이며 2026-07-16 폐기했다. 20260716143000_company_login_service_role_grants.sql은 적용 이력으로만 남고 신규 코드가 이 함수나 관리자 코드 로그인을 참조해서는 안 된다. Company 코드는 직원 workforce-login의 tenant discovery에만 사용한다.

브라우저 세션 재수화 계약 (2026-07-17)

  • 보호 라우트 첫 진입과 access token 변경 시 브라우저 저장소의 principal만 신뢰하지 않는다. auth.getUser()로 Auth 서버가 검증한 사용자를 얻은 뒤 활성 권한을 다시 조회한다.
  • Company 관리자는 company_memberships.status = active 목록을 다시 읽고, 유효한 기존 선택 또는 유일한 Company만 적용한다. 복수 Company에서 유효한 선택이 없으면 임의 첫 행을 쓰지 않는다.
  • Company 관리자 목록·단건 재조회는 모두 auth.getUser()로 검증한 user.id를 명시한다. 클라이언트가 전달한 임의 사용자 ID나 이메일을 권한 근거로 사용하지 않는다.
  • 직원은 자기 workforce_identities와 활성 office_memberships, 활성 office_module_entitlements를 다시 읽는다. RLS는 auth.uid()와 사용자 ID를 교차 검증한다.
  • 재수화된 Company·Office·상품 범위가 라우트 게이트의 입력이다. 이후 업무 테이블의 최종 권한은 계속 각 RLS/RPC가 결정한다.
  • 같은 access token 안에서는 검증 결과를 재사용하고, token이 갱신되면 다시 서버 검증한다. 로그아웃은 이 검증 캐시와 로컬 principal을 함께 지운다.
  • 이 슬라이스는 기존 테이블·RLS·GRANT를 소비하며 스키마 변경은 없다.

leysys의 전체 Prototype 메뉴 프로필은 프런트 탐색 권한일 뿐이다. DB role, RLS, Office grant, 저장 API 권한을 확장하지 않는다.

모델

  • companies: 계약·청구 주체, 고유 slug/company_code
  • company_memberships: Auth 사용자와 Company 관리자 관계, (company_id, user_id) unique
  • workforce_identities: Company 안에서 고유한 username, 상태 invited | active | suspended
  • offices: Company 소유 업무·장부 단위. office_number는 Company 안에서 유일한 0 이상의 불변 순번
  • office_grants: (workforce_id, office_id) unique, role과 세부 권한 정책 참조
  • account_audit_events: append-only 감사 이력

Company 코드는 직원 tenant discovery용이며 비밀값이 아니다. 관리자 인증에서는 사용하지 않는다. 패스워드 검증 전 Company 존재 여부나 사용자 존재 여부를 구분해 노출하지 않는다.

Office 0·순번 불변식 (2026-07-16)

  • 모든 Company에는 정확히 하나의 office_number = 0이 있어야 한다. 회원가입 이메일 확인 트리거가 Company·owner 멤버십과 같은 트랜잭션에서 ${회사명} 본점, OFFICE-0000, general 에디션으로 생성하고 owner에게 office_admin을 부여한다.
  • Office 0은 샘플 데이터가 아니라 실제 본점 업무 범위다. 가입 시 상품 entitlement·Company 계약 품목을 만들지 않으며 unit_count = 0이다. 본점 상품은 사용자가 명시적으로 배정한다.
  • 기존 데이터 이관 시 Company의 최초 Office를 0으로, 나머지를 생성 순서대로 1 이상으로 보정한다. Office가 없던 기존 Company에는 Office 0을 생성한다.
  • (company_id, office_number) unique와 office_number >= 0 check를 유지한다. Office 번호·소속 Company·offices.code 등록코드는 생성 후 변경할 수 없고 Office 0 직접 삭제는 primary_office_required로 거부한다. Company 삭제 cascade는 허용한다.
  • 새 Office 생성은 Company별 advisory transaction lock 안에서 max(office_number) + 1을 할당한다. 삭제 번호를 재사용하지 않는다.
  • create_company_office* 신규 RPC는 p_company_id를 필수로 받고 해당 Company의 active 관리자 멤버십을 검증한다. 구버전 무컨텍스트 overload는 active 관리자 Company가 정확히 하나일 때만 위임하며, 복수이면 company_context_required를 반환한다.
  • 순번 정본 migration은 20260716160000_add_company_primary_office_numbering.sql, 등록코드 불변 후속은 20260716231729_lock_office_code.sql이다.

인증·인가 불변식

  1. 로그인 방법과 화면 선택은 권리를 만들지 않는다.
  2. Workspace-only Principal은 Organization Affiliation과 Workspace Membership을 가지되 Organization Membership을 갖지 않아도 된다.
  3. 상태와 유효기간을 판정 시점에 함께 확인한다. terminal Membership은 새 세대 없이 재활성화할 수 없다.
  4. Role Assignment는 Principal이나 scope가 아니라 Membership ID 세대에 바인딩한다.
  5. Code는 공개 식별자이며 비밀이 아니다. Code와 Login ID 존재 여부는 인증 전에 구분해 노출하지 않는다.
  6. Organization/Workspace 선택은 서버가 기존 Membership을 재검증해 session에 기록하며 새로운 권한을 만들지 않는다.
  7. 브라우저 localStorage principal은 표시 캐시다. token 변경 시 canonical session과 Membership을 다시 읽는다.
  8. 레거시 Company/Office scope와 canonical Organization/Workspace UUID는 Prototype에서 동일하지만 Production API는 resource row에서 scope를 도출해야 한다.

감사 이벤트

현재 slice는 로그인 성공을 authorization_audit_events에 append-only로 기록한다. 필수 값은 Principal, Organization/Workspace, session, request, decision reason, 이전 hash와 현재 hash다. 초대·중지·역할 변경·실패 로그인·세션 폐기 이벤트와 외부 tamper-evident 저장소 복제는 후속 단계다.

API 권장

  • POST /iam/login (iam-login)
  • POST /iam/context (iam-context)
  • POST /iam/invitations
  • POST /iam/memberships/{id}/suspend
  • POST /iam/memberships/{id}/resume
  • GET /iam/me/workspaces?q=&cursor=
  • GET /audit-events?actor=&action=&officeId=&cursor=

모든 변경 API는 idempotency key와 optimistic concurrency/version을 지원하고 감사 이벤트와 같은 트랜잭션 경계에서 처리한다.

FE 라우팅 경계 (2026-07-13)

  • 조직관리의 Workspace·권한 화면은 /administration/organization/office/general에서 administration 셸 안에 렌더한다.
  • Company 콘솔의 /headquarters/console/office/general은 본사 범위 화면으로 별도 유지한다.
  • 두 화면이 같은 Office 데이터 컴포넌트를 소비하더라도 인가 기준과 API 계약은 기존 Company·Office scope를 그대로 사용한다. 경로를 권한 근거로 신뢰하지 않는다.

Workspace 전환과 조직관리 분리 (2026-07-26)

  • 상단 전환 UI는 접근 가능한 Workspace Membership만 표시한다. Organization은 Workspace 행이 아니며 company:{id} 선택값을 서버 API 계약으로 추가하지 않는다.
  • 신규 Organization에는 같은 트랜잭션에서 Workspace 0이 생성되므로 최초 업무 범위는 Workspace 0이다.
  • 계약·결제·조직 정보·Workspace 권한은 company-admin/administration/organization/*에서 관리한다. 메뉴 노출과 라우트 경로는 인가 근거가 아니며 각 API는 기존 Organization Membership과 역할을 다시 검증한다.
  • 이 변경은 FE 컨텍스트·라우팅 분리이며 DB schema, IAM Membership, API payload, 감사 이벤트 형식을 변경하지 않는다.