다크모드
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 identifier | Organization Affiliation + Organization Membership |
organization / scoped ID | Organization Code + Login ID + 패스워드 | {login_id}@{org-code} | Organization Affiliation + Organization Membership |
workspace / scoped ID | Workspace Code + Login ID + 패스워드 | {login_id}@{ws-code} | Organization Affiliation + Workspace Membership |
platform | 별도 운영자 IdP | 별도 issuer/client/audience | Platform 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/emailPOST /iam/login/organization/scopedPOST /iam/login/workspace/scopedGET /iam/me/organizationsGET /iam/me/workspaces?organizationId=POST /iam/sessions/contextPOST /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, RLS20260725060811_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_authprovider 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 Authsession_id와login_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-login은 verify_jwt=false인 pre-authentication endpoint다. 공개 함수라는 뜻이지 table access를 공개한다는 뜻은 아니다.
json
{
"loginContext": "workspace",
"identifierType": "WORKSPACE_LOGIN_ID",
"code": "ws-4f8q2r7n5c",
"loginId": "guard01",
"password": "..."
}- 입력 조합과 Code/Login ID 형식을 검증한다.
- 서버가
login_name을 조립하고 identifier의 구조화 namespace FK와 문맥을 함께 검증한다. - identifier 미존재도 dummy Auth 이메일로
signInWithPassword()를 수행해 조회 단계 차이를 줄이고, 실패 응답은 항상 동일 401 문구를 사용한다. - Supabase Auth 성공 후
auth_user_id와 Principal을 다시 일치시킨다. - Organization은 유효 Affiliation + Organization Membership, Workspace는 유효 Affiliation + Workspace Membership을 검사한다. Workspace 경로는 Organization Membership을 조회하지 않는다.
- Role Assignment, 접근 가능한 scope, legacy 업무 표시값과 module activation을 조립한다.
- 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-consoleaudience는 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.sqlsupabase/tests/iam_identity_access_prototype_transaction.sqlsrc/composables/__tests__/iamArchitectureMigration.spec.jssrc/composables/__tests__/iamAuthRepo.spec.jssrc/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는 테이블 권한을 대신하지 않는다.
companiesworkforce_identitiesoffice_membershipsofficesoffice_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_codecompany_memberships: Auth 사용자와 Company 관리자 관계,(company_id, user_id)uniqueworkforce_identities: Company 안에서 고유한username, 상태invited | active | suspendedoffices: 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 >= 0check를 유지한다. 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이다.
인증·인가 불변식
- 로그인 방법과 화면 선택은 권리를 만들지 않는다.
- Workspace-only Principal은 Organization Affiliation과 Workspace Membership을 가지되 Organization Membership을 갖지 않아도 된다.
- 상태와 유효기간을 판정 시점에 함께 확인한다. terminal Membership은 새 세대 없이 재활성화할 수 없다.
- Role Assignment는 Principal이나 scope가 아니라 Membership ID 세대에 바인딩한다.
- Code는 공개 식별자이며 비밀이 아니다. Code와 Login ID 존재 여부는 인증 전에 구분해 노출하지 않는다.
- Organization/Workspace 선택은 서버가 기존 Membership을 재검증해 session에 기록하며 새로운 권한을 만들지 않는다.
- 브라우저 localStorage principal은 표시 캐시다. token 변경 시 canonical session과 Membership을 다시 읽는다.
- 레거시
Company/Officescope와 canonicalOrganization/WorkspaceUUID는 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/invitationsPOST /iam/memberships/{id}/suspendPOST /iam/memberships/{id}/resumeGET /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, 감사 이벤트 형식을 변경하지 않는다.