콘텐츠로 이동

Bouncer

웹 조인 진입을 평가하고 허용 또는 차단하는 Supporting 도메인. VPN/Proxy/Tor 네트워크 감지와 국가 블랙리스트를 per-guild 정책으로 통합하며, 판정 결과를 감사 가능한 형태로 기록한다. 클럽 문지기(bouncer) 처럼 길드 입구에서 방문자의 자격을 확인한다.

Bounded context

  • Type — Supporting
  • Sibling contexts — Member (웹 조인 플로우의 호출자), Guild (길드 존재 확인), Licensing (권한 게이트), Audit (판정 이벤트 구독), Notification (선택적 관리자 알림)
  • Location in codebaseengine/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_id PK). 존재하지 않으면 "정책 없음 = 전체 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}$ (애플리케이션 UpsertPolicy validation 으로 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

  • Persistenceengine/bouncer/adapter/persistence/ — sqlc 기반 Policy/Decision Repository
  • IP intelligenceengine/bouncer/adapter/ipintel/ — MaxMind GeoLite2 (mmdb) + ipquery.io + Tor list 조합 (ADR-0034)
  • License gateengine/bouncer/adapter/license/ — Licensing 도메인 Can(feature="bouncer.evaluate") 래퍼
  • Hasherengine/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.decisions per-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 플랜 제한