Bouncer¶
웹 조인 진입을 평가하고 허용 또는 차단하는 Supporting 도메인. VPN/Proxy/Tor 네트워크 감지와 국가 블랙리스트를 per-guild 정책으로 통합하며, 판정 결과를 감사 가능한 형태로 기록한다. 클럽 문지기(bouncer) 처럼 길드 입구에서 방문자의 자격을 확인한다.
Bounded context¶
- Type — Supporting
- Sibling contexts — Member (웹 조인 플로우의 호출자), Guild (길드 존재 확인), Licensing (권한 게이트), Audit (판정 이벤트 구독), Notification (선택적 관리자 알림)
- Location in codebase —
engine/bouncer/
Why this domain exists¶
웹 조인(join.umbra.ink/{slug}) 은 Umbra 의 유일한 공개 진입점이며 동시에 가장 공격받기 쉬운 표면이다. Discord 자체의 verification level 외에 Umbra 가 다음 세 가지 요구에 응답해야 한다.
첫째, Ban evasion 차단 — 기존에 밴 당한 사용자가 VPN 으로 IP 를 바꿔 재가입하는 패턴을 막는다.
둘째, Raid 완화 — Tor / 공용 VPN 뒤에서 조직화된 대량 가입을 선제 차단한다.
셋째, 지역 컴플라이언스 — 특정 국가(운영 미지원, 법적 제재 대상) 의 접근을 길드 관리자가 차단할 수 있게 한다.
이 책임을 Member 나 Guild 에 섞으면 도메인 응집도가 저하되고, 향후 확장(계정 연령, 디바이스 지문, 리스크 점수)이 여러 도메인에 흩어진다. 별도 Bounded Context 로 분리하면 "누가 들어올 수 있는가" 라는 단일 질문에 답하는 책임점이 명확해진다. (ADR-0033)
Domain model¶
Policy¶
per-guild 접근 통제 정책.
Policy
├─ guild_id UUID PK, → guild.guilds.id
├─ block_vpn BOOLEAN default false
├─ block_proxy BOOLEAN default false
├─ block_tor BOOLEAN default false
├─ country_blacklist TEXT[] ISO 3166-1 alpha-2, default '{}'
├─ active BOOLEAN default true (다운그레이드 시 false 로 보존)
├─ notify_on_deny BOOLEAN default false (관리자에 차단 알림)
├─ created_at TIMESTAMPTZ
└─ updated_at TIMESTAMPTZ
- 한 길드당 1 row (
guild_idPK). 존재하지 않으면 "정책 없음 = 전체 allow". country_blacklist는 대문자 2자리 ISO 코드 배열. CHECK 제약으로 포맷 강제.active=false는 Plan 다운그레이드 시 정책 보존용 마킹 (ADR-0035).
Decision¶
판정 결과 기록.
Decision
├─ id UUID v7 PK
├─ guild_id UUID → guild.guilds.id
├─ request_id UUID → member.web_join_requests.id (nullable)
├─ ip_hash BYTEA IP 의 HMAC-SHA256 (원본 IP 저장 금지)
├─ country_code TEXT 2자리 ISO 코드 or NULL
├─ is_vpn BOOLEAN
├─ is_proxy BOOLEAN
├─ is_tor BOOLEAN
├─ outcome TEXT 'allow' | 'deny' (MVP; 'skipped' Phase 2 고려)
├─ reason TEXT 'policy_match_country' | 'policy_match_vpn' | ...
├─ source TEXT 'geolite2+ipquery+tor-list'
├─ provider_status TEXT 'ok' | 'degraded' | 'cached'
└─ decided_at TIMESTAMPTZ
- IP 는 원본 저장 금지 — 개인정보 최소화 원칙. HMAC 키는
BOUNCER_IP_HASH_KEY(env) 로 회전 가능. outcome='skipped'는 Free 길드 bypass 기록 — MVP 에선 저장하지 않음 (비용 절감). Pro 이상만 기록.request_id는 웹 조인 요청과 연결 (사후 분석 용). Bouncer 독립 호출 시 NULL.
IPProfile (value object)¶
외부 공급자 조회 결과를 통합한 값 객체. 도메인 코어는 이 형태로만 IP 정보를 받는다.
IPProfile
├─ CountryCode string ISO 3166-1 alpha-2
├─ IsVPN bool
├─ IsProxy bool
├─ IsTor bool
├─ Source string 조합된 소스 설명
└─ LookupAt time.Time
(ADR-0034 참조)
Aggregates¶
- Policy aggregate — Policy 단독 (1:1 per guild)
- Decision 은 aggregate 가 아닌 이벤트성 기록(append-only)
Invariants¶
- Policy 유일성 —
UNIQUE(guild_id)(한 길드당 정책 하나) - 국가 코드 포맷 —
country_blacklist원소는^[A-Z]{2}$(애플리케이션UpsertPolicyvalidation 으로 enforce — PostgreSQL CHECK 는 배열 원소 서브쿼리 금지로 불가) - IP 원본 비저장 —
decisions.ip_hash는 HMAC 결과만 기록 - Free 길드 skip — Licensing
Can(guild_id, "bouncer.evaluate")가 false 면 즉시 allow 반환, 판정 생략 - Decision append-only — decisions 는 UPDATE/DELETE 금지 (감사 무결성)
State machine¶
Policy 는 단순 CRUD 로 상태 머신이 없음. Decision 도 단일 상태(기록되면 끝).
다만 판정 흐름 은 다음과 같다.
stateDiagram-v2
[*] --> CheckLicense : Evaluate(ip, guild)
CheckLicense --> Allow : Free plan (skip)
CheckLicense --> LoadPolicy : Paid plan
LoadPolicy --> Allow : No policy
LoadPolicy --> Lookup : Policy exists
Lookup --> Evaluate : IPProfile obtained
Lookup --> FailOpen : Provider error
Evaluate --> Allow : No match
Evaluate --> Deny : Policy match
FailOpen --> Allow : Record provider_status='degraded'
Allow --> [*] : Record Decision
Deny --> [*] : Record Decision + optional notify
Domain events¶
Published¶
| Event | Trigger | Payload | Subscribers |
|---|---|---|---|
BouncerDenied | 판정 결과 deny | guild_id, request_id, country_code, reason, is_vpn, is_proxy, is_tor | Audit, (옵션) Notification |
BouncerPolicyChanged | 대시보드에서 정책 업데이트 | guild_id, before, after, actor_user_id | Audit |
BouncerAllowed 는 발행하지 않는다 — 정상 경로는 이벤트 양이 과다하고 감사 가치가 낮다. decisions 테이블만 기록.
Consumed¶
MVP 에서는 없음.
(Phase 2 고려: LicensePlanChanged 구독으로 다운그레이드 시 policies.active=false 자동 설정)
Ports¶
Supporting 도메인이지만 외부 공급자 의존이 있어 outbound port 를 명시적으로 분리한다.
Inbound¶
type Service interface {
// 판정 — 웹 조인 플로우에서 호출
Evaluate(ctx context.Context, guildID uuid.UUID, requestID *uuid.UUID, ip netip.Addr) (*Decision, error)
// 정책 관리 — 대시보드 API 에서 호출
GetPolicy(ctx context.Context, guildID uuid.UUID) (*Policy, error)
UpsertPolicy(ctx context.Context, guildID uuid.UUID, input PolicyInput) (*Policy, error)
// 감사·분석
ListDecisions(ctx context.Context, guildID uuid.UUID, filter DecisionFilter) ([]*Decision, error)
}
Outbound¶
type IPIntelligence interface {
Lookup(ctx context.Context, ip netip.Addr) (*IPProfile, error)
}
type PolicyRepository interface {
FindByGuild(ctx context.Context, guildID uuid.UUID) (*Policy, error)
Upsert(ctx context.Context, p *Policy) error
}
type DecisionRepository interface {
Insert(ctx context.Context, d *Decision) error
List(ctx context.Context, guildID uuid.UUID, filter DecisionFilter) ([]*Decision, error)
}
type LicenseGate interface {
CanEvaluate(ctx context.Context, guildID uuid.UUID) (bool, error)
}
type Hasher interface {
HashIP(ip netip.Addr) []byte // HMAC-SHA256
}
Adapters¶
- Persistence —
engine/bouncer/adapter/persistence/— sqlc 기반 Policy/Decision Repository - IP intelligence —
engine/bouncer/adapter/ipintel/— MaxMind GeoLite2 (mmdb) + ipquery.io + Tor list 조합 (ADR-0034) - License gate —
engine/bouncer/adapter/license/— Licensing 도메인Can(feature="bouncer.evaluate")래퍼 - Hasher —
engine/bouncer/adapter/hasher/—platform/crypto의 HMAC-SHA256 래퍼
Permission model¶
- Bouncer 판정 호출 — 모든 웹 조인 플로우에서 시스템적으로 호출. 최종 활성 여부는 Licensing 이 결정 (Pro+)
- 정책 조회/수정 — 길드 owner 또는
MANAGE_GUILD권한자, 대시보드 경유 - 판정 로그 조회 — 길드 관리자 (대시보드). 외부 노출 없음
Caching strategy¶
| Key | TTL | Purpose |
|---|---|---|
bouncer:ip:{ip} | 24h | IPProfile 캐시 — ipquery.io 호출 억제 |
bouncer:policy:{guild_id} | 5m | Policy 캐시 — 판정 경로의 DB 히트 감소 |
bouncer:tor:set | 1h refresh | Tor exit-node Redis Set — 시간별 cron 갱신 |
정책 업데이트 시 bouncer:policy:{guild_id} 즉시 invalidate.
Failure modes¶
- IP 인텔리전스 공급자 장애 — MVP 는 fail-open (allow +
provider_status='degraded'기록).strict_mode토글은 Phase 2 고려 - MaxMind mmdb 갱신 실패 — cron 재시도 + 관리자 알림. 구버전 DB 로 fallback (국가 DB 는 정확도 감소 허용 범위)
- Tor list fetch 실패 — Redis 에 남은 최신 snapshot 유지. 12h 이상 미갱신 시 경보
- 정책 없음 (Pro 길드) — 전체 allow (기본값). "차단 활성화" 는 명시적 opt-in
- Free 길드 우회 시도 (직접 API 호출) — Licensing gate 가 차단. Bouncer 로직 이전에 Deny 403
Observability¶
bouncer.decisionsper-outcome 카운터 (allow / deny / degraded)- provider latency 히스토그램 (MaxMind lookup, ipquery API, 총 Evaluate)
- cache hit ratio (
bouncer:ip:*) - 정책 변경 이벤트 (
BouncerPolicyChanged) 로 대시보드 활동 가시성
Out of scope (Phase 2+)¶
- Review queue — deny 대신 관리자 승인 대기 상태. 새로운 state 와 알림 플로우 필요
- Country whitelist — 허용 국가만 지정 모드. 운영 실수 시 전면 차단 리스크로 MVP 제외
- 디바이스 지문 — 브라우저/클라이언트 fingerprint
- 계정 연령 규칙 — Discord 계정 생성일 기반
- 리스크 점수 모델 — 여러 신호를 조합한 점수화
- 유료 MaxMind GeoIP2 Anonymous IP — 오탐률 실운영 관찰 후 전환 검토
- Bot 설치 시 기본 정책 적용 — 즉시 차단을 의도치 않게 활성화할 리스크로 opt-in 유지
See also¶
data/bouncer-schema.md— DB 스키마 상세flows/web-join.md— 웹 조인 플로우 (Bouncer gate 포함)domain/member.md— 웹 조인 플로우의 호출자domain/licensing.md— Plan 기반 게이팅adr/0033-bouncer-domain.md— 도메인 신설 결정adr/0034-bouncer-ip-intelligence.md— IP 인텔리전스 공급자adr/0035-bouncer-paid-tier-gating.md— Paid-only 플랜 제한