diff --git a/docs/getting-started/tutorial.ko.md b/docs/getting-started/tutorial.ko.md index 1b5e39a..f6c60a5 100644 --- a/docs/getting-started/tutorial.ko.md +++ b/docs/getting-started/tutorial.ko.md @@ -1,4 +1,4 @@ -# 튜토리얼: 0에서 실행까지 +# 튜토리얼: 빈 프로젝트부터 실행까지 **devslab-kit을 한 번도 안 써본 사람**을 위한, 복붙으로 따라 하는 완전 가이드입니다. 끝까지 하면 로그인, 관리자 계정, 역할·권한, 직접 만든 권한 보호 엔드포인트, 테넌트 단위 데이터, ABAC diff --git a/docs/guides/access.ko.md b/docs/guides/access.ko.md index 27166df..6e4cb2e 100644 --- a/docs/guides/access.ko.md +++ b/docs/guides/access.ko.md @@ -36,7 +36,7 @@ class UserAdminService { **그룹**은 여러 사용자를 위해 역할을 묶습니다 — 역할을 일일이 붙이는 대신 사용자를 `eng-team`에 한 번 넣으면 됩니다. 사용자의 유효 권한은 직접 역할과 그룹 역할의 합집합입니다. -## ABAC 정책 +## ABAC 정책 { #abac-policies } RBAC는 "이 사용자가 권한을 가졌는가?"에 답합니다. ABAC는 더 세밀한 "…*이 특정 리소스에 대해, 지금?*"에 답합니다. **`Policy`** 빈을 하나 이상 구현하면, kit의 diff --git a/docs/guides/admin-console.ko.md b/docs/guides/admin-console.ko.md index bbb6133..dae34e7 100644 --- a/docs/guides/admin-console.ko.md +++ b/docs/guides/admin-console.ko.md @@ -49,14 +49,14 @@ ## Platform -### Menus (메뉴) +### Menus (메뉴) { #menus } 제품 UI가 사용자별로 렌더할 수 있는 권한 필터링 네비게이션 트리. - **트리**로 표시됩니다. 루트 항목을 **Create** 하거나, 노드의 **자식 추가** 동작을 사용. - 각 항목은 라벨, 경로, 아이콘, **필요 권한**(기존 권한 코드 드롭다운 — 그 권한을 가진 사용자만 항목을 봄), 표시 순서를 가집니다. 노드별 **수정** / **삭제**. - kit은 로그인 사용자에게 필터된 트리를 제공하고, 그리는 방식은 프런트엔드가 정합니다([Menus 가이드](menus.md)). -### Tenants (테넌트) +### Tenants (테넌트) { #tenants } 격리된 작업공간; 모든 플랫폼 데이터는 테넌트 단위. - 테넌트 **생성**(`code` + 이름). **상태 변경**(`ACTIVE` / `SUSPENDED` / `ARCHIVED`). **삭제**. @@ -64,7 +64,7 @@ ### Policies (ABAC) 역할 위에 얹는 속성 기반 규칙. **정책은 코드**입니다(당신이 `Policy` 빈을 구현 — -[Access 가이드](access.md#abac-정책) 참고). 이 화면은 등록된 정책을 **나열**하고 **테스트**합니다. +[Access 가이드](access.md#abac-policies) 참고). 이 화면은 등록된 정책을 **나열**하고 **테스트**합니다. - 정책을 고르고 **주체**(사용자/테넌트), **자원**(타입·id·속성), **환경** 속성을 채운 뒤 **Test**. - 결과는 **결정**(`PERMIT` / `DENY` / `NOT_APPLICABLE`)에 **이유**와 **매칭된 규칙**까지 — 부작용 없는 dry-run. @@ -105,6 +105,6 @@ kit의 **적용된 설정**(`devslab.kit.*`)을 읽기 전용으로 보는 화 ## 더 보기 -- [튜토리얼: 0에서 실행까지](../getting-started/tutorial.md) — 위 작업 다수를 API + 콘솔로 수행. +- [튜토리얼: 빈 프로젝트부터 실행까지](../getting-started/tutorial.md) — 위 작업 다수를 API + 콘솔로 수행. - [Access (RBAC + ABAC)](access.md) · [Menus](menus.md) · [Config Sync](config-sync.md) - [관리자 REST API](../reference/admin-api.md) — 모든 화면 뒤의 엔드포인트. diff --git a/docs/guides/menus.ko.md b/docs/guides/menus.ko.md index bc8beee..e9965e7 100644 --- a/docs/guides/menus.ko.md +++ b/docs/guides/menus.ko.md @@ -1,26 +1,88 @@ # 동적 메뉴 -kit은 **사용자별 메뉴 트리**를 만듭니다: 전체 메뉴를 한 번 정의하면(항목마다 필요 권한 -지정), 각 사용자는 허용된 항목만 보게 됩니다. +**동적 메뉴**는 사용자마다 자기 권한으로 볼 수 있는 항목만 보이는 내비게이션 트리입니다. 전체 +메뉴를 **한 번만** 정의하고 항목마다 필요 권한을 붙여 두면, kit이 사용자마다 걸러진 사본을 +내려줍니다. 프론트엔드 곳곳에 `v-if="canSeeX"`를 흩뿌릴 필요가 없습니다. -## 동작 방식 +처음이면 [튜토리얼](../getting-started/tutorial.md)부터 — 이 가이드는 권한 몇 개가 정의된 +실행 중인 앱이 있다고 가정합니다. -1. 메뉴 항목은 선택적 `requiredPermission`과 순서와 함께 저장됩니다. -2. `MenuProvider`가 주어진 사용자에 대해 트리를 만들면서, 사용자가 갖지 못한 필요 권한의 - 항목을 **걸러냅니다**(그리고 비게 된 가지를 잘라냅니다). -3. 결과는 불변 `MenuTree(List roots)`입니다. +## 개념 잡기 + +``` +한 번 정의 (테넌트 단위) kit이 요청마다 필터링 화면 렌더링 +───────────────────────── ──────────────────── ────────── +Dashboard (권한 없음) ┐ +Users (user.read 필요) ├──► menusFor(현재 사용자) ──► MenuTree JSON + └ Invite (user.write 필요) │ 못 보는 항목 제거, → 사이드바 +Billing (billing.read 필요)┘ 빈 가지 정리 +``` + +세 부분으로 나뉩니다: + +1. **메뉴 항목**은 kit에 저장됩니다(한 줄에 라벨, 경로, 선택적 `requiredPermission`, 표시 + 순서, 중첩용 선택적 부모). +2. **`MenuProvider`**가 특정 사용자 기준 트리를 만듭니다 — `requiredPermission`이 없는 사용자 + 에게서 항목을 **빼고**, 그 결과 비어 버린 가지를 **정리**합니다. +3. **프론트엔드**는 걸러진 트리를 받아 그대로 그립니다. 가시성을 스스로 판단하지 않습니다. + +## 1단계 — 메뉴 항목 정의 + +항목마다: `code`(고정 id), `label`(사용자가 보는 글자), `path`(링크 위치), 선택적 `icon`, +선택적 `requiredPermission`, `displayOrder`, 선택적 `parentId`(최상위면 생략). + +=== "관리자 콘솔" + + 1. [관리자 콘솔](admin-console.md) → **Menus** 열기. + 2. **New** 클릭 후 label / path / icon 입력. + 3. **Required permission**에서, 이 항목을 보려면 가져야 할 권한 선택(비우면 "모두에게"). + 4. 중첩하려면 새 항목의 **Parent**를 기존 항목으로 설정. + 5. 드래그로 순서 변경 — `displayOrder`로 저장됩니다. + + 전체 화면은 [관리자 콘솔 가이드 → Menus](admin-console.md#menus) 참고. + +=== "REST API" + + ```bash + # user.read 보유자에게만 보이는 최상위 "Users" 항목: + curl -X POST localhost:8080/admin/api/v1/menus \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "tenantId": "default", + "code": "users", + "label": "Users", + "path": "/users", + "icon": "pi pi-users", + "requiredPermission": "user.read", + "displayOrder": 20, + "parentId": null + }' + ``` + + `requiredPermission`과 `parentId`는 선택 — 공개·최상위 항목이면 생략(또는 `null`). + `menus` 리소스 전체(목록, 트리, 수정, 삭제)는 [관리자 REST API](../reference/admin-api.md) 참고. + +## 2단계 — 걸러진 트리를 프론트엔드에 내려주기 + +현재 사용자의 트리를 돌려주는 엔드포인트 하나를 두세요. 필터링은 kit이 하니, `menusFor`가 +주는 걸 그대로 반환하면 됩니다: ```java +// src/main/java/com/example/myapp/NavController.java import kr.devslab.kit.menu.MenuProvider; import kr.devslab.kit.menu.MenuTree; +import kr.devslab.kit.identity.CurrentUserProvider; @RestController class NavController { + private final MenuProvider menus; private final CurrentUserProvider users; NavController(MenuProvider menus, CurrentUserProvider users) { - this.menus = menus; this.users = users; + this.menus = menus; + this.users = users; } @GetMapping("/api/nav") @@ -30,17 +92,60 @@ class NavController { } ``` -## 항목 관리 +응답은 `MenuTree` — 루트 `MenuItem` 목록이고, 각 항목의 허용된 자식이 `children` 아래에 +중첩됩니다: + +```json +{ + "roots": [ + { "code": "dashboard", "label": "Dashboard", "path": "/", "icon": "pi pi-home", + "requiredPermission": null, "children": [] }, + { "code": "users", "label": "Users", "path": "/users", "icon": "pi pi-users", + "requiredPermission": "user.read", "children": [ + { "code": "users.invite", "label": "Invite", "path": "/users/invite", + "icon": "pi pi-user-plus", "requiredPermission": "user.write", "children": [] } + ] } + ] +} +``` + +`user.read`가 **없는** 사용자에게는 `users` 노드 자체가 안 보이고 — 유일한 자식도 같이 +사라지므로 — 덩그러니 남는 게 없습니다. -메뉴 항목의 생성·수정·순서변경·삭제는 관리자 API(`menus`)로 합니다 — -[관리자 REST API](../reference/admin-api.md) 참고. -[관리자 콘솔](https://github.com/devslab-kr/devslab-kit-admin-ui)이 트리 편집기를 제공합니다. +## 3단계 — 화면에 그리기 + +프론트엔드는 트리를 그대로 렌더링합니다. 최소 예: + +```vue + + + +``` ## 캐싱 -사용자별 트리는 공유 [캐시](cache.md)에 (사용자 id를 키로) 캐시되어, 반복되는 내비게이션 -요청이 매번 재계산하지 않습니다. 사용자의 노출 메뉴를 수정하면 해당 항목이 evict됩니다. +사용자별 트리는 공유 [캐시](cache.md)에 (user id 키로) 캐시되어, 반복되는 내비게이션 요청 +마다 다시 계산하지 않습니다. 사용자가 볼 수 있는 메뉴를 수정하면 해당 항목이 자동으로 +무효화됩니다 — 직접 관리할 일이 없습니다. !!! note "의존 방향" - 메뉴는 권한을 참조할 수 있지만, 권한은 메뉴를 전혀 모릅니다 — 그 반대 방향 의존은 - 없습니다(핵심 [설계 원칙](../index.md#why-a-starter)). + 메뉴는 권한을 참조하지만, 권한은 메뉴를 전혀 모릅니다 — 의존이 거꾸로 뒤집히지 않습니다 + (핵심 [설계 원칙](../index.md#why-a-starter)). + +## 더 보기 + +- [Access (RBAC + ABAC)](access.md) — 항목에 붙일 권한을 정의. +- [관리자 콘솔 → Menus](admin-console.md#menus) — 트리 편집기. +- [관리자 REST API](../reference/admin-api.md) — `menus` 리소스. diff --git a/docs/guides/menus.md b/docs/guides/menus.md index 083f29e..eba41fd 100644 --- a/docs/guides/menus.md +++ b/docs/guides/menus.md @@ -1,26 +1,93 @@ # Dynamic Menus -The kit builds a **per-user menu tree**: you define the full menu once (with a -required permission per item), and each user sees only the items they're allowed. +A **dynamic menu** is a navigation tree where each user sees only the items their +permissions allow. You define the full menu **once**, tag each item with a required +permission, and the kit hands every user their own filtered copy. No `v-if="canSeeX"` +scattered across your frontend. -## How it works +New here? Do the [Tutorial](../getting-started/tutorial.md) first, then come back — +this guide assumes you have a running app with a few permissions defined. -1. Menu items are stored with an optional `requiredPermission` and an order. -2. `MenuProvider` builds the tree for a given user, **filtering out** items whose - required permission the user lacks (and pruning now-empty branches). -3. The result is an immutable `MenuTree(List roots)`. +## The mental model + +``` +You define (once, per tenant) Kit filters (per request) You render +───────────────────────────── ───────────────────────── ────────── +Dashboard (no permission) ┐ +Users (needs user.read) ├──► menusFor(currentUser) ──► MenuTree JSON + └ Invite (needs user.write) │ drops items the user → your sidebar +Billing (needs billing.read)┘ can't see, prunes empties +``` + +Three moving parts: + +1. **Menu items** live in the kit (one row each: a label, a path, an optional + `requiredPermission`, a display order, an optional parent for nesting). +2. **`MenuProvider`** builds the tree for a given user — **dropping** items whose + `requiredPermission` the user lacks and **pruning** branches that end up empty. +3. **Your frontend** fetches the filtered tree and renders it. It never decides + visibility itself. + +## Step 1 — Define your menu items + +Each item has: a `code` (stable id), a `label` (what users see), a `path` (where it +links), an optional `icon`, an optional `requiredPermission`, a `displayOrder`, and an +optional `parentId` (omit for a top-level item). + +=== "Admin console" + + 1. Open the [admin console](admin-console.md) → **Menus**. + 2. Click **New** and fill in label / path / icon. + 3. In **Required permission**, pick the permission a user must hold to see this + item (leave blank for "everyone"). + 4. To nest, set the new item's **Parent** to an existing item. + 5. Drag to reorder — the order is saved as `displayOrder`. + + See the [Admin Console guide → Menus](admin-console.md#menus) for the full screen. + +=== "REST API" + + ```bash + # Top-level "Users" item, visible only to holders of user.read: + curl -X POST localhost:8080/admin/api/v1/menus \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{ + "tenantId": "default", + "code": "users", + "label": "Users", + "path": "/users", + "icon": "pi pi-users", + "requiredPermission": "user.read", + "displayOrder": 20, + "parentId": null + }' + ``` + + `requiredPermission` and `parentId` are optional — omit (or `null`) for a + public, top-level item. See the [Admin REST API](../reference/admin-api.md) for + the full `menus` resource (list, tree, update, delete). + +## Step 2 — Serve the filtered tree to your frontend + +Expose one endpoint that returns the current user's tree. The kit does the filtering; +you just return what `menusFor` gives you: ```java +// src/main/java/com/example/myapp/NavController.java import kr.devslab.kit.menu.MenuProvider; import kr.devslab.kit.menu.MenuTree; +import kr.devslab.kit.identity.CurrentUserProvider; @RestController class NavController { + private final MenuProvider menus; private final CurrentUserProvider users; NavController(MenuProvider menus, CurrentUserProvider users) { - this.menus = menus; this.users = users; + this.menus = menus; + this.users = users; } @GetMapping("/api/nav") @@ -30,19 +97,60 @@ class NavController { } ``` -## Managing items +The response is a `MenuTree` — a list of root `MenuItem`s, each with its allowed +children nested under `children`: + +```json +{ + "roots": [ + { "code": "dashboard", "label": "Dashboard", "path": "/", "icon": "pi pi-home", + "requiredPermission": null, "children": [] }, + { "code": "users", "label": "Users", "path": "/users", "icon": "pi pi-users", + "requiredPermission": "user.read", "children": [ + { "code": "users.invite", "label": "Invite", "path": "/users/invite", + "icon": "pi pi-user-plus", "requiredPermission": "user.write", "children": [] } + ] } + ] +} +``` + +A user **without** `user.read` simply won't see the `users` node at all — and because +its only child is then gone too, nothing dangling is left behind. -Create, edit, reorder and delete menu items through the admin API (`menus`) — see -[Admin REST API](../reference/admin-api.md). The -[admin console](https://github.com/devslab-kr/devslab-kit-admin-ui) provides a tree -editor for them. +## Step 3 — Render it + +Your frontend renders the tree verbatim. A minimal example: + +```vue + + + +``` ## Caching The per-user tree is cached on the shared [cache](cache.md) (keyed by user id), so repeated navigation requests don't recompute it. Editing a user's visible menus -evicts their entry. +evicts their entry automatically — you don't manage this. !!! note "Direction of dependency" Menus may reference permissions, but permissions know nothing about menus — the dependency never reverses (a core [design principle](../index.md#why-a-starter)). + +## See also + +- [Access (RBAC + ABAC)](access.md) — define the permissions you tag items with. +- [Admin Console → Menus](admin-console.md#menus) — the tree editor. +- [Admin REST API](../reference/admin-api.md) — the `menus` resource. diff --git a/docs/guides/tenancy.ko.md b/docs/guides/tenancy.ko.md index 670eb54..55f9207 100644 --- a/docs/guides/tenancy.ko.md +++ b/docs/guides/tenancy.ko.md @@ -1,26 +1,43 @@ # 멀티테넌시 -`devslab-kit`은 항상 **테넌트 컨텍스트** 안에서 동작합니다 — 싱글 테넌트 배포라도 -추상화를 건너뛰지 않고 default 테넌트를 resolve하므로, 코드에서 "테넌트 없음"을 특수 -처리할 일이 없습니다. +**테넌트**는 격리된 작업공간 — 한 고객/조직과 그 모든 데이터입니다. `devslab-kit`에는 **항상 +테넌트가 컨텍스트에 존재**합니다. 싱글 테넌트 앱이라도 추상화를 건너뛰지 않고 `default` 테넌트를 +resolve하므로, **코드는 고객이 한 곳이든 수천이든 동일**합니다. "테넌트 없음" 특수 처리를 할 일이 +없습니다. -## 모드 +사전 지식 없다고 가정합니다. 처음이면 [튜토리얼](../getting-started/tutorial.md)부터 — 8단계가 +실행 중인 앱에서 테넌시를 보여줍니다. -| `tenant.mode` | 동작 | -| --- | --- | -| `single` | 테넌트 하나(`tenant.default-tenant-id`). 리졸버가 항상 그것을 반환. | -| `multi` | 선택한 리졸버가 요청마다 테넌트를 resolve. | +## 모드 선택 -## 리졸버 +```yaml +# src/main/resources/application.yml +devslab: + kit: + tenant: + mode: single # single | multi + resolver: fixed # fixed | header | jwt | subdomain + default-tenant-id: default +``` -`tenant.resolver`로 하나 선택: +| `mode` | 언제 | 동작 | +| --- | --- | --- | +| `single` | 고객 한 곳 / 내부 도구 | 모든 요청이 `default-tenant-id` 로 resolve | +| `multi` | 다수 고객 SaaS | **resolver**가 요청마다 테넌트 결정 | -| 리졸버 | 테넌트 결정 기준 | -| --- | --- | -| `fixed` | 항상 `tenant.default-tenant-id` (싱글 테넌트 기본값). | -| `header` | 요청 헤더(예: `X-Tenant-Id`). | -| `jwt` | 인증된 JWT의 클레임. | -| `subdomain` | 요청 호스트의 서브도메인(`acme.app.com` → `acme`). | +`single` + `fixed` 로 시작하세요. 두 번째 테넌트를 실제로 온보딩할 때 `multi` 로 전환 — 코드 변경 +없이 설정만 바꾸면 됩니다. + +## 리졸버 (멀티 테넌트) + +`multi` 모드에서 **리졸버**가 이 요청이 누구 것인지 결정합니다: + +| `resolver` | 테넌트를 무엇에서 | 예 | +| --- | --- | --- | +| `fixed` | 항상 `default-tenant-id` | (싱글 테넌트 기본) | +| `header` | 요청 헤더(기본 `X-Tenant-Id`) | `X-Tenant-Id: acme` | +| `jwt` | 인증된 JWT의 클레임 | 로그인 사용자의 테넌트 | +| `subdomain` | 요청 호스트의 서브도메인 | `acme.app.com` → `acme` | ```yaml devslab: @@ -28,39 +45,113 @@ devslab: tenant: mode: multi resolver: header + header: X-Tenant-Id # header 리졸버만 사용 +``` + +```bash +# header 리졸버면 모든 요청이 테넌트를 실어 보냄: +curl localhost:8080/api/invoices -H 'X-Tenant-Id: acme' ``` -## 사용 +## 코드에서 사용 -활성 테넌트를 resolve하려면 `TenantResolver`를(현재 요청에 바인딩된 것을 읽으려면 -`TenantContextHolder`를) 주입하세요: +### 현재 테넌트 읽기 + +`TenantContextHolder`는 현재 요청에 바인딩된 테넌트를 담습니다(당신 코드 실행 전에 kit이 설정): ```java -import kr.devslab.kit.tenant.TenantResolver; +// src/main/java/com/example/myapp/InvoiceService.java +import kr.devslab.kit.tenant.TenantContextHolder; @Service -class ReportService { - private final TenantResolver tenants; +class InvoiceService { + + private final TenantContextHolder tenantContext; + private final InvoiceRepository invoices; + + InvoiceService(TenantContextHolder tenantContext, InvoiceRepository invoices) { + this.tenantContext = tenantContext; + this.invoices = invoices; + } - ReportService(TenantResolver tenants) { this.tenants = tenants; } + private String currentTenant() { + return tenantContext.current() + .orElseThrow(() -> new IllegalStateException("no tenant in context")) + .tenantId().value(); + } + + List list() { + return invoices.findByTenantId(currentTenant()); // 테넌트 간 누수 금지 + } - void run() { - String tenantId = tenants.resolve().tenantId().value(); - // … tenantId로 쿼리 범위 지정 … + Invoice create(String amount) { + return invoices.save(new Invoice(UUID.randomUUID(), currentTenant(), amount)); } } ``` -## Override +(웹 요청 *밖*에서 테넌트를 resolve해야 하면 — 예: 스케줄 잡 — `TenantResolver`를 주입: +`tenantResolver.resolve().tenantId().value()`.) -커스텀 결정 전략(DB 조회, 헤더+경로 조합 등)이 필요하면 직접 `TenantResolver` 빈을 -선언하세요. kit의 기본 구현이 물러납니다: +### 데이터를 테넌트 단위로 격리 + +규칙은 단순합니다: **테넌트 소유 엔터티마다 `tenant_id`를 두고 모든 쿼리를 그걸로 필터링.** ```java -@Bean -TenantResolver tenantResolver() { - return () -> /* 당신의 TenantContext */; +// src/main/java/com/example/myapp/Invoice.java +@Entity +class Invoice { + @Id private UUID id; + @Column(name = "tenant_id", nullable = false) private String tenantId; + private String amount; + // 생성자 + getter … } ``` -모든 키는 [설정 레퍼런스](../reference/configuration.md#tenant)를 참고하세요. +```java +// src/main/java/com/example/myapp/InvoiceRepository.java +interface InvoiceRepository extends JpaRepository { + List findByTenantId(String tenantId); + Optional findByIdAndTenantId(UUID id, String tenantId); // 단건 조회도 +} +``` + +이게 패턴의 전부 — `single`/`multi` 동일합니다. + +## 커스텀 리졸버 + +내장으로 안 되는 전략(DB 조회, header-or-path, API 키 → 테넌트 매핑)이 필요하면, 자신의 +`TenantResolver` 빈을 선언하면 kit 기본이 물러납니다(모든 kit 빈이 `@ConditionalOnMissingBean`): + +```java +// src/main/java/com/example/myapp/ApiKeyTenantResolver.java +import kr.devslab.kit.tenant.TenantResolver; +import kr.devslab.kit.tenant.TenantContext; +import kr.devslab.kit.core.id.TenantId; + +@Component +class ApiKeyTenantResolver implements TenantResolver { + + private final HttpServletRequest request; // request-scoped + private final TenantDirectory directory; // 당신의 조회기 + + ApiKeyTenantResolver(HttpServletRequest request, TenantDirectory directory) { + this.request = request; + this.directory = directory; + } + + @Override + public TenantContext resolve() { + String apiKey = request.getHeader("X-Api-Key"); + String tenantId = directory.tenantForApiKey(apiKey); // 예: DB 조회 + return TenantContext.of(TenantId.of(tenantId)); + } +} +``` + +## 테넌트 관리 + +테넌트 생성 / 정지 / 보관은 admin 콘솔 **Tenants** 화면(또는 `tenants` REST 엔드포인트)에서 — +[Admin 콘솔 가이드](admin-console.md#tenants) 참고. + +모든 키는 [설정 레퍼런스](../reference/configuration.md#tenant) 참고. diff --git a/docs/guides/tenancy.md b/docs/guides/tenancy.md index 500135e..20a5674 100644 --- a/docs/guides/tenancy.md +++ b/docs/guides/tenancy.md @@ -1,26 +1,44 @@ # Multi-tenancy -`devslab-kit` always runs inside a **tenant context** — even single-tenant -deployments resolve a default tenant rather than skipping the abstraction, so your -code never special-cases "no tenant". +A **tenant** is an isolated workspace — one customer/org and all of its data. In +`devslab-kit` there is **always a tenant in context**, even in a single-tenant app: a +single-tenant deployment resolves a `default` tenant instead of skipping the abstraction, +so **your code is identical** whether you ship to one customer or thousands. You never +write a "no tenant" special case. -## Modes +This guide assumes no prior knowledge. New here? Do the +[Tutorial](../getting-started/tutorial.md) first — Step 8 shows tenancy in a running app. -| `tenant.mode` | Behaviour | -| --- | --- | -| `single` | One tenant (`tenant.default-tenant-id`). The resolver always returns it. | -| `multi` | The tenant is resolved per request by the chosen resolver. | +## Pick a mode -## Resolvers +```yaml +# src/main/resources/application.yml +devslab: + kit: + tenant: + mode: single # single | multi + resolver: fixed # fixed | header | jwt | subdomain + default-tenant-id: default +``` -Pick one with `tenant.resolver`: +| `mode` | When to use | Behaviour | +| --- | --- | --- | +| `single` | One customer / an internal tool | Every request resolves `default-tenant-id`. | +| `multi` | A SaaS serving many customers | The **resolver** picks the tenant per request. | -| Resolver | Resolves the tenant from | -| --- | --- | -| `fixed` | Always `tenant.default-tenant-id` (the single-tenant default). | -| `header` | A request header (e.g. `X-Tenant-Id`). | -| `jwt` | A claim on the authenticated JWT. | -| `subdomain` | The request host's subdomain (`acme.app.com` → `acme`). | +Start with `single` + `fixed`. Switch to `multi` when you actually onboard a second +tenant — no code changes, only config. + +## Resolvers (multi-tenant) + +In `multi` mode the **resolver** decides whose request this is: + +| `resolver` | Resolves the tenant from | Example | +| --- | --- | --- | +| `fixed` | always `default-tenant-id` | (the single-tenant default) | +| `header` | a request header (default `X-Tenant-Id`) | `X-Tenant-Id: acme` | +| `jwt` | a claim on the authenticated JWT | the signed-in user's tenant | +| `subdomain` | the request host's subdomain | `acme.app.com` → `acme` | ```yaml devslab: @@ -28,39 +46,117 @@ devslab: tenant: mode: multi resolver: header + header: X-Tenant-Id # only used by the header resolver +``` + +```bash +# with the header resolver, every request carries the tenant: +curl localhost:8080/api/invoices -H 'X-Tenant-Id: acme' ``` -## Using it +## Use it in your code -Inject `TenantResolver` to resolve the active tenant (or `TenantContextHolder` to -read the one bound to the current request): +### Read the current tenant + +`TenantContextHolder` holds the tenant bound to the current request (set by the kit +before your code runs): ```java -import kr.devslab.kit.tenant.TenantResolver; +// src/main/java/com/example/myapp/InvoiceService.java +import kr.devslab.kit.tenant.TenantContextHolder; @Service -class ReportService { - private final TenantResolver tenants; +class InvoiceService { + + private final TenantContextHolder tenantContext; + private final InvoiceRepository invoices; + + InvoiceService(TenantContextHolder tenantContext, InvoiceRepository invoices) { + this.tenantContext = tenantContext; + this.invoices = invoices; + } - ReportService(TenantResolver tenants) { this.tenants = tenants; } + private String currentTenant() { + return tenantContext.current() + .orElseThrow(() -> new IllegalStateException("no tenant in context")) + .tenantId().value(); + } + + List list() { + return invoices.findByTenantId(currentTenant()); // never leak across tenants + } - void run() { - String tenantId = tenants.resolve().tenantId().value(); - // … scope your query to tenantId … + Invoice create(String amount) { + return invoices.save(new Invoice(UUID.randomUUID(), currentTenant(), amount)); } } ``` -## Override +(There's also `TenantResolver` — inject it to resolve the tenant *outside* a web request, +e.g. in a scheduled job: `tenantResolver.resolve().tenantId().value()`.) -Need a custom resolution strategy (a database lookup, a composite of header + -path, …)? Declare your own `TenantResolver` bean and the kit's default backs off: +### Scope your data by tenant + +The rule is simple: **put `tenant_id` on every tenant-owned entity and filter every query +by it.** ```java -@Bean -TenantResolver tenantResolver() { - return () -> /* your TenantContext */; +// src/main/java/com/example/myapp/Invoice.java +@Entity +class Invoice { + @Id private UUID id; + @Column(name = "tenant_id", nullable = false) private String tenantId; + private String amount; + // constructor + getters … } ``` -See the [Configuration reference](../reference/configuration.md#tenant) for all keys. +```java +// src/main/java/com/example/myapp/InvoiceRepository.java +interface InvoiceRepository extends JpaRepository { + List findByTenantId(String tenantId); + Optional findByIdAndTenantId(UUID id, String tenantId); // look-ups too +} +``` + +That's the whole pattern — identical in `single` and `multi` mode. + +## Custom resolver + +Need a strategy the built-ins don't cover (a DB lookup, header-or-path, an API key → +tenant map)? Declare your own `TenantResolver` bean and the kit's default backs off +(every kit bean is `@ConditionalOnMissingBean`): + +```java +// src/main/java/com/example/myapp/ApiKeyTenantResolver.java +import kr.devslab.kit.tenant.TenantResolver; +import kr.devslab.kit.tenant.TenantContext; +import kr.devslab.kit.core.id.TenantId; + +@Component +class ApiKeyTenantResolver implements TenantResolver { + + private final HttpServletRequest request; // request-scoped + private final TenantDirectory directory; // your own lookup + + ApiKeyTenantResolver(HttpServletRequest request, TenantDirectory directory) { + this.request = request; + this.directory = directory; + } + + @Override + public TenantContext resolve() { + String apiKey = request.getHeader("X-Api-Key"); + String tenantId = directory.tenantForApiKey(apiKey); // e.g. a DB lookup + return TenantContext.of(TenantId.of(tenantId)); + } +} +``` + +## Manage tenants + +Create / suspend / archive tenants from the admin console's **Tenants** screen +(or the `tenants` REST endpoint) — see the +[Admin Console guide](admin-console.md#tenants). + +See the [Configuration reference](../reference/configuration.md#tenant) for every key. diff --git a/mkdocs.yml b/mkdocs.yml index 9ea6474..356bd42 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,7 +70,7 @@ plugins: Getting Started: 시작하기 Installation: 설치 Quick Start: 빠른 시작 - Tutorial (zero to running): 튜토리얼 (0에서 실행까지) + Tutorial (zero to running): 튜토리얼 (처음부터 끝까지) Guides: 가이드 Multi-tenancy: 멀티테넌시 Access (RBAC + ABAC): 접근 제어 (RBAC + ABAC)