멀티테넌트 org 온보딩
사람이 AI슈트에 들어오는 방식
사람이 AI슈트에 들어오는 전 경로가 구현·라이브 배포됐습니다 — 관리자 초대 온보딩(1회용 초대 링크로 직원 활성화), 공개 셀프서비스 org 생성(누구나 org를 만들고 첫 owner가 됨), 그리고 소유자 합의하 조직 연합(org 간 동의 기반 링크)까지 모두 실제로 동작합니다. org는 안으로(중첩 팀), 밖으로(연합 링크) 확장됩니다. 남은 건 한 신원이 여러 org를 오가는 계정⟂멤버십 하나뿐입니다. (신뢰 코어와 콘솔은 형제 페이지 /spec·/console.)
사람이 AI슈트에 들어오는 모델 — 이제 현실
온보딩의 핵심은 네 가지로 요약됩니다. 이 전체 모델은 합의된 방향이었고, 이제 구현·라이브입니다 — 초대 온보딩·셀프서비스 org 생성·조직 연합이 모두 실제로 동작합니다.
누구나 org를 만들 수 있다
로그인한 사람은 누구나 org를 만들고 그 org의 owner가 됩니다. org의 "규모"(팀·파트·본부·기업)는 강제된 위계가 아니라 라벨일 뿐입니다. (이 셀프서비스 생성은 이제 공개 /signup으로 라이브)
두 가지 진입 역할
org 생성자/owner vs 초대된 직원. 모르는 계정이 그냥 로그인하면 "초대 필요"로 거부되고 org 생성으로 안내되며, 기존 org에 자동으로 합류하지 않습니다.
초대된 직원은 절대 자가 가입하지 않는다
admin이 구성원(이메일+팀+역할)을 추가하면 시스템이 1회용 first-auth 링크를 만들고, admin이 안전한 채널로 공유하면, 직원이 인증(Google 또는 비밀번호)으로 교환해야 비로소 활성화됩니다.
org는 두 방향으로 확장된다
한 org 안에서는 중첩 팀으로, 독립적으로 소유된 org들 사이에서는 동의 기반 연합 링크로 — 각 org를 소유한 계정의 승인으로만 연결됩니다. (조직 연합도 이제 라이브)
구현된 설계 결정
아래는 합의되어 잠긴 설계 결정들이며, 그대로 유지되어 이제 모두 구현·라이브입니다.
한 계정 = 한 org (불변식8 유지)
현재 사용자는 한 org에 묶여 있고(불변식8 그대로 유지), 시스템은 그 단일 org 모델로 라이브입니다. 계정⟂멤버십 분리와 org 스위처는 유일한 이후 목표입니다.
org "규모" = 라벨
org 규모는 강제 위계가 아니라 라벨입니다. org 내부 위계는 중첩 팀으로, org 간 연결은 연합 링크로 표현합니다.
수락은 소셜 + 비밀번호 둘 다
직원 수락은 Google과 비밀번호를 모두 지원하되, 비밀번호 경로는 강화하고 초대된 org가 require_sso면 비활성(Google 전용)입니다.
SSO 도메인 자동 프로비저닝 제거
도메인으로 자동 합류시키던 기존 동작을 제거했습니다. 직원은 언제나 초대로만 들어오며, 도메인 allowlist는 hd 힌트로 강등됐습니다.
오픈 org 생성 — 이제 라이브
가입을 마친 누구나 org를 만들고 첫 owner가 되는 셀프서비스 생성이 공개 /signup으로 라이브입니다. 모르는 로그인은 "초대 필요"로 거부됩니다.
연합은 동의로만
한 org가 다른 org를 연결하려면 그 org를 소유한 계정의 승인이 반드시 필요합니다. 조직 연합은 이제 라이브입니다.
초대 링크 = 1회 표시 bearer
초대 링크는 admin에게 1회만 표시되어 직접 공유합니다(자동 이메일 발송 없음). 따라서 링크 자체가 bearer 자격증명이라, 토큰을 그 현실에 맞게 설계했습니다(05 참조).
초대 온보딩 슬라이스 — 구현·라이브
합의된 흐름을 실제로 만든 슬라이스이며, 이제 라이브입니다. 원래 강조점은 대부분이 신규였다는 것 — 이전 코드엔 구성원을 만드는 기능 자체가 없었습니다(기존 멤버 서비스는 읽기/수정만, User 행을 쓰는 곳은 시드와 제거 대상 SSO 자동 프로비저닝뿐이었습니다). 그래서 단순 "연결"이 아니라 새로 도입된 쓰기 표면이며, 이제 그것이 라이브로 동작합니다.
- "구성원 추가" admin 서비스 — 이메일+팀+역할로 pending User 행 + 초대를 삽입하고, 이메일 유일성 규칙을 처리합니다.
- admin API + "구성원 추가" 프론트 폼 — 구성원 화면에서 동작합니다.
- 1회용 초대 토큰 + 1회 표시 first-auth 링크 — admin이 복사해 공유하고, pending 구성원은 "초대됨" 배지 + 재발급/취소를 노출합니다.
- 공개
/invite/<token>수락 화면 + 토큰-게이트 set-password 엔드포인트 + Google 수락 바인딩. - 전체 수명주기 audit — 초대 생성·취소·재발급·수락·거부를 모두 기록합니다.
- 제거됨: SSO 도메인 자동 프로비저닝 — 자동 합류로 회귀 금지.
초대(admin)와 수락(직원), 두 흐름
온보딩은 두 단계로 나뉩니다 — admin이 사람을 초대하고, 직원이 링크를 수락해 활성화됩니다. 아래는 구현된 흐름입니다(라이브).
초대admin이 구성원을 추가
https://app.aisuit.dev/invite/<token>를 admin에게 1회만 노출invite.created 기록수락직원이 링크를 교환 — 두 분기
공개 /invite/<token> 화면에서 토큰을 검증(존재·pending·미만료, 아니면 균일 not-found)한 뒤, 둘 중 하나로 수락합니다.
소셜 신원 바인딩
/invite에서 단명 httponly 초대 쿠키를 설정하고(+서명된 OAuth state에 초대 참조를 실어), SSO로 redirect합니다. callback은 그 특정 초대를 로드해 pending+미만료를 확인하고, 이메일 일치 AND org 일치를 확인한 뒤 원자적으로 소비하고, 초대된 pending 사용자에 소셜 신원을 바인딩합니다.
토큰-게이트 set-password
토큰-게이트 set-password 엔드포인트로 POST합니다. 링크 자체가 bearer이므로 강화합니다 — 수락자가 초대 이메일을 재확인하고, 토큰은 1회 + 짧은 만료 + 높은 엔트로피, 그리고 초대된 org가 require_sso면 이 분기 자체를 제공하지 않습니다(Google 전용).
status='active', 토큰 소비, 세션 시작 → 직원 워크스페이스로 진입. audit: invite.redeemed(method) 또는 invite.redeem_denied(reason).무엇을 어떻게 담는가 (구현됨)
대부분은 기존 구조 위에 얹혔습니다. users.status는 이미 존재해 마이그레이션 없이 그대로 쓰고, 초대를 담는 테이블이 새로 추가됐습니다 — 아래는 모두 실제 라이브 DB에 존재하며 additive로 마이그레이션된 상태입니다.
| 대상 | 설계 | 비고 |
|---|---|---|
users.status | invited | active | disabled 사용 | 이미 존재 — 마이그레이션 없음 |
member_invitations | 조직 · pending 사용자 · 이메일 · 역할 · 팀(nullable) · 초대자 · token_hash(sha256 1회·≥32바이트 엔트로피) · 만료 · 수락시각 · 상태 · 생성시각 | 실재 · raw 토큰은 링크에만, 저장은 해시 |
이메일 유일성
같은 org+이메일로 초대하면 먼저 조회합니다 — 활성이면 "이미 구성원입니다"로 거부(깔끔한 메시지), pending이면 그 행을 재사용하고 새 토큰만 발급(중복 INSERT 금지), disabled면 재활성 경로로 처리합니다.
영속 사용자↔팀 연결
팀은 사람의 속성이므로 users.team_id를 추가해 확정했습니다(이전엔 팀 연결이 agent 배정에만 있어 pending 사용자를 담을 수 없었습니다). 이제 라이브 DB에 존재합니다.
organizations.owner_user_id·scale), 연합 링크(org_links·org_link_grants) 테이블도 이제 실재합니다 — 셀프서비스 org 생성과 조직 연합이 라이브이기 때문입니다. 아직 남은 것은 계정⟂멤버십 분리뿐입니다.초대 온보딩의 보안 약속
초대 링크가 곧 자격증명이라는 사실이 보안 설계 전체를 끌고 갑니다. 아래는 적용된 방어선입니다(라이브에서 모두 강제됩니다).
- 초대 토큰 = bearer 자격증명 — 1회 · ≥32바이트 엔트로피 · 짧은 만료 · 저장은 sha256 해시 · raw는 링크에만 · 미로깅 · HTTPS만 · 취소/재발급 지원.
- 소셜 바인딩 OAuth 왕복 — 초대를 redirect 전반에 스레딩합니다(서명 state + 단명 쿠키). callback이 그 특정 초대에 바인딩해 이메일/org 일치를 확인하고 원자적으로 소비하며, 기존 계정 fallthrough를 금지합니다 — 이것이 핵심 수정입니다.
- 비밀번호 경로가 약한 고리 — 링크가 인증 요소이자 계정 주장이므로, 이메일 재확인 + 1회/짧은 만료/높은 엔트로피 +
require_sso면 Google 전용 강제로 보강합니다. - audit 필수 —
invite.created / revoked / reissued / redeemed / redeem_denied를 모두 기록하되, raw 토큰은 절대 미기록. - 수락 전 접근 0 — pending 사용자는 세션·권한이 없습니다. 역할/팀은 초대 때 admin이 지정하며 도메인 추론 금지.
- 테넌트 격리(불변식8) — 초대/토큰/pending은 org-스코프이고, cross-org/무효 토큰은 균일 not-found로 응답합니다.
require_sso org에서는 비밀번호 분기를 아예 제공하지 않습니다.hd 힌트로 강등됐고, 둘 다 정리되어 자동 합류로의 회귀가 사라졌습니다.온보딩만 하고 끝내지 않는다
온보딩만 만들고 offboarding을 못 하면 구멍이 됩니다. 그래서 offboarding 수명주기까지 함께 구현·라이브입니다 — kill-switch·재활성화·in-flight 잔여 정리·org 소유권 이전이 모두 동작합니다.
비활성화 kill-switch + 재활성화
admin offboard 액션이 사용자를 disabled로 전이하고, 그 사람의 사람 세션 + 활성 MCP 토큰을 폐기합니다(in-flight 잔여 정리). disabled 사용자는 재활성화로 되돌릴 수 있습니다.
org 소유권 이전
owner를 offboard하면 org가 owner 없이 남아 교착될 수 있습니다. 그래서 org 소유권 이전을 구현해 offboarding 교착에서 벗어날 수 있게 했습니다. 미수락(만료) 초대는 status='invited'로 걸러 활성 목록에서 제외합니다.
조직 연합은 라이브, 남은 목표는 하나
한때 "이후 목표"였던 셋 중 셀프서비스 org 생성과 조직 연합은 이제 라이브입니다. 유일하게 남은 이후 목표는 계정⟂멤버십이며, 라이브 시스템은 그 전까지 한 계정 = 한 org를 유지합니다.
셀프서비스 org 생성
공개 /signup → 새 org + 첫 owner(생성자=owner). 로그인 화면에 진입 링크가 있고, IP throttle로 자동 org 남발을 차단합니다. 계정⟂멤버십 전까지 한 계정 = 한 org.
동의 기반 org 연합
org owner가 다른 org를 각 owner의 승인으로 연결합니다(org_links + consent FSM). "링크가 무엇을 grant하나"는 읽기 전용 거버넌스 롤업 먼저로 확정됐고, 이것이 유일하게 승인된 스코프·감사되는 cross-org 다리입니다 — 테넌트 벽(불변식8)은 그대로. owner 전용 "조직 연결" 콘솔 화면도 라이브입니다.
계정⟂멤버십 신원
한 신원 → 여러 멤버십 + org 스위처. 진짜 비용은 컬럼이 아니라, SSO 진입점을 "provider가 org를 고른다"에서 "검증된 신원 → 멤버십 선택"으로 re-key하는 것입니다. 그래서 수락 시 issuer+sub를 저장해 나중에 re-key할 수 있게 해뒀습니다. signup은 그 전까지 계정당 두 번째 org 생성을 거부합니다.
federation-design-v0.1.md에서 다룹니다. 남은 계정⟂멤버십만 아직 목표이며, 라이브 설계는 그 길을 막지 않도록 수락 시 issuer+sub를 이미 저장해 둡니다.결정된 질문과 아직 열린 질문
아래 질문들 대부분은 이미 결정·구현됐습니다(녹색 확정). 나머지는 계정⟂멤버십과 함께 아직 열려 있는 항목입니다.
require_sso org에서 수락은 Google 전용(비밀번호 분기 없음).확정users.team_id.확정