docs(keycloak-session-store): import the session-storage lab as a new project

The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,56 @@
---
kind: REFERENCE
slug: analysis-finding-a03-f003
title: legacy compatibility surface의 제거 조건을 세 가지로 고정한다
topic: multitenancy-isolation
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:analysis-finding-a03-f003
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# legacy compatibility surface의 제거 조건을 세 가지로 고정한다
## 목적
이름이 deprecated 이거나 legacy 라는 이유로 아직 배선되어 있는 호환 표면을 지워, 마이그레이션이 끝나기 전에 경로가 끊기는 것을 막는다.
## 규칙
1. 이름은 삭제 가능성의 증거가 아니다
deprecated 표시는 방향을 말하지 그 시점을 말하지 않는다.
2. 세 조건이 함께 확인되어야 제거 후보가 된다
외부 프로덕션 참조 0, 대체 경로의 특성화, 설정 경로 제거다.
3. 외부 참조 0 만으로는 부족하다
대체 경로가 같은 동작을 증명하지 못하면 지우는 순간 동작이 바뀐다.
4. 설정 경로가 남아 있으면 운영자가 여전히 그것을 켤 수 있다
코드에서 지워도 설정이 남아 있으면 기동 오류가 된다.
5. 실제 제거는 별도 결정 증거를 요구한다
이 규칙은 후보를 고르는 기준이지 제거 자체를 승인하지 않는다.
## 적용 조건
deprecated 나 legacy 계약이 아직 프로덕션 어댑터나 런타임 배선에 연결된 마이그레이션 구간
## 예외
외부 호환 계약을 의도적으로 유지하는 경우와 대체 경로가 아직 동일 동작을 증명하지 못한 경우에는 제거하지 않는다.
## 예시
deprecated 계약이 프로덕션 배선에 여전히 사용된다. 이름만 보고 죽은 코드로 판단하면 어댑터와 런타임 마이그레이션이 끝나기 전에 경로가 끊긴다.
## 관계
- **notification admin atomic claim contract가 service에서 사용되지 않음**
같은 리프에서 옛 경로가 남아 있는 사례다.
- **같은 개념의 두 어휘가 공존하면 하나를 죽은 것으로 표시한다**
같은 계열의 짝 규칙이다.
- **중복 장치를 찾으면 어느 쪽이 조립됐는지 먼저 확인한다**
제거 전에 확인해야 할 것을 다룬 규칙이다.
@@ -0,0 +1,57 @@
---
kind: REFERENCE
slug: hibernate-filter-is-not-a-security-boundary
title: Hibernate filter는 보안 경계가 아니다
topic: multitenancy-isolation
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:hibernate-filter-is-not-a-security-boundary
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# Hibernate filter는 보안 경계가 아니다
## 목적
ORM 의 필터 기능을 테넌트 격리 경계로 삼아, 그 기능이 적용되지 않는 경로로 다른 테넌트의 행에 도달하는 것을 막는다.
## 규칙
1. 필터는 엔티티 쿼리에만 적용된다
네이티브 SQL 과 벌크 DML 과 참조 획득과 2차 캐시를 통한 도달에는 적용되지 않는다.
2. 기준은 우회 경로의 개수다
이 격리를 우회하는 경로가 몇 개인가를 묻는다. ORM 기능의 답은 항상 0 이 아니다.
3. 실제 경계는 아래층이어야 한다
데이터베이스의 행 수준 보안이나 별도 가드가 경계이고 필터는 편의로만 쓴다.
4. 편의로 쓴다는 것을 적는다
필터가 있으면 그것이 경계라고 읽힌다. 아니라는 것이 코드나 문서에 있어야 한다.
## 적용 조건
테넌트와 소유자와 가시성 격리 전반
ORM 을 통해 데이터에 접근하는 모든 경로
## 예외
읽기 경로만 있고 네이티브 쿼리와 벌크 연산이 구조적으로 금지된 좁은 컨텍스트면 필터로 충분할 수 있다. 다만 그 금지를 아키텍처 테스트 같은 것이 강제해야 하고, 강제가 없으면 전제가 유지되지 않는다.
## 예시
행 수준 보안이 실제 경계로 쓰이고, 그 전제 셋을 시작 검증기가 확인한다.
테넌트 인식 저장소 가드가 별도 경계로 존재한다.
## 관계
- **RLS가 성립하기 위한 세 전제**
실제 경계로 쓰이는 메커니즘이다.
- **RLS가 아무것도 하지 않는 세 가지 방법**
그 경계가 무력화되는 경로다.
- **tenant 컬럼이 있는 테이블의 모든 unique 제약에 그 컬럼이 들어가야 한다**
컬럼 기반 격리를 쓸 때의 짝 규칙이다.
@@ -0,0 +1,62 @@
---
kind: REFERENCE
slug: isolation-settings-must-be-transaction-local
title: 격리 설정은 트랜잭션 로컬이어야 한다
topic: multitenancy-isolation
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:isolation-settings-must-be-transaction-local
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# 격리 설정은 트랜잭션 로컬이어야 한다
## 목적
테넌트 바인딩이나 검색 경로처럼 격리를 결정하는 세션 설정이 풀로 돌아간 커넥션에 남아, 다음 차용자가 그것을 상속하는 것을 막는다.
## 규칙
1. 격리를 결정하는 설정은 트랜잭션과 함께 되돌아간다
세션 스코프면 커넥션이 살아 있는 동안 유지된다.
2. 데이터베이스가 제공하는 트랜잭션 로컬 옵션을 쓴다
PostgreSQL 에서는 설정 함수의 세 번째 인자가 그것을 보장한다.
3. 대응물이 없으면 반환 시 명시적으로 되돌린다
중립 값으로 재설정하는 것이 대안이다.
4. 사용 전 덮어쓰기는 값이 항상 설정될 때만 안전하다
한 경로라도 설정 없이 커넥션을 쓰면 앞 사용자의 값이 적용된다.
5. 커넥션이 테넌트에 고정 할당되면 이 문제가 사라진다
다만 그때는 풀 예산이 새 문제가 된다.
## 적용 조건
행 수준 보안의 테넌트 바인딩
테넌트별 스키마 라우팅
세션 상태로 표현되는 모든 격리
## 예외
테넌트별 데이터베이스처럼 커넥션 자체가 격리 경계인 구성. 그 경우 세션 스코프가 문제가 되지 않는다.
## 예시
검색 경로가 세션 설정이라 풀로 돌아간 커넥션이 마지막 테넌트의 스키마를 들고 있다. 다음 차용자는 어떤 문장도 틀리지 않은 채 거기서 읽고 쓴다.
로컬 타임아웃 설정은 매 트랜잭션 전에 다시 적용하는 방식으로 실무상 가려진다. 그 방식은 값이 항상 설정되는 경우에만 안전하다.
## 관계
- **search_path가 풀로 돌아간 커넥션에 남아 다음 tenant가 상속한다**
이 규칙을 만든 사례다.
- **세션 스코프 설정은 풀로 돌아간 커넥션에 남는다**
같은 성질의 일반형이다.
- **RLS가 성립하기 위한 세 전제**
테넌트 바인딩이 이 규칙을 따라야 하는 이유다.
@@ -0,0 +1,55 @@
---
kind: REFERENCE
slug: tenant-column-belongs-in-every-unique-constraint
title: tenant 컬럼이 있는 테이블의 모든 unique 제약에 그 컬럼이 들어가야 한다
topic: multitenancy-isolation
project: clean-architecture-backend-template
status: 게시 전
sourceRevision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916
rootTreeNode: reference:tenant-column-belongs-in-every-unique-constraint
verifiedOn: # 이 기록은 이번 회차에 실행 확인을 하지 않았다
---
# tenant 컬럼이 있는 테이블의 모든 unique 제약에 그 컬럼이 들어가야 한다
## 목적
테넌트 컬럼이 빠진 유일 제약이 한 테넌트의 삽입을 다른 테넌트의 데이터 때문에 실패시켜, 버그와 정보 유출을 동시에 만드는 것을 막는다.
## 규칙
1. 유일성 요구에 테넌트 컬럼을 포함한다
격리가 컬럼에 의존하면 그 테이블의 모든 유일성 요구가 그 컬럼을 포함해야 한다.
2. 빠뜨리면 두 가지가 동시에 일어난다
정상적인 삽입이 실패하고, 그 실패가 다른 테넌트에 그 값이 존재한다는 사실을 알린다.
3. 부분 유일 인덱스와 배제 제약에도 같은 논리가 적용된다
조건부 유일성도 유일성이다.
4. 전역 유일이 의도라면 그것을 적는다
외부 시스템의 식별자처럼 전역적으로 유일해야 하는 값은 테넌트를 포함하지 않는 것이 맞다. 그때는 그 값이 테넌트 간에 노출되는 것이 의도임을 적어야 한다.
## 적용 조건
판별 컬럼 전략을 쓰는 모든 테이블
테넌트 컬럼이 있는 모든 인덱스와 제약
## 예외
전역 유일이 요구사항인 값. 그 사실과 노출 범위를 함께 적는다.
## 예시
값만으로 걸린 유일 인덱스는 다른 테넌트가 그 값을 이미 썼다는 이유로 한 테넌트의 삽입을 실패시킨다. 존재하지 않아야 할 행의 존재를 알려 주는 것이다.
## 관계
- **RLS가 아무것도 하지 않는 세 가지 방법**
같은 마이그레이션이 함께 다룬 축이다.
- **Hibernate filter는 보안 경계가 아니다**
컬럼 기반 격리의 다른 면이다.
- **위험한 조합은 정책이 아니라 생성자가 거부하게 만든다**
제약으로 강제하는 같은 계열의 원칙이다.