Repository navigation
Description
Remaining ADR-0069 D5 gap (P2, defense-in-depth — not a launch blocker). Surfaced by the 2026-07-04 enterprise-auth evaluation.
Current state (already landed)
- Global IP allow-list:
auth.allowed_ip_rangessetting (global scope) →AuthManager.isClientIpAllowed→ enforced in the auth-route middleware (auth-plugin.ts), rejecting sign-in/session from an outside IP withIP_NOT_ALLOWED. Fails open when the client IP can't be determined (missing proxy header ≠ lockout). - IPv4 CIDR + exact IPv4/IPv6 matching via
ipMatchesRange.
Gap
ADR-0069 D5/D7 also specify record-level ranges so different tenants/users can have their own IP fence:
sys_organization.allowed_ip_ranges(CIDR[]) — per-org.sys_user.allowed_ip_ranges(CIDR[], optional override) — per-user.
Neither field exists today; only the one global setting.
⚠️ Design decision to lock before implementing: tighten-only, not replace
ADR-0069 D5 phrases the per-user range as "evaluated before org" — which reads like replacement (most-specific wins). That is a privilege-escalation surface: an org admin (who can edit sys_organization) or a user could set a record-level range that admits an IP the global floor forbids, bypassing the platform's network fence.
Recommendation (aligns with ADR-0007's "an org can only tighten, never loosen, the global floor"): the effective allow decision is the intersection (AND) down the chain, not replace:
allowed(ip) = matchesGlobal(ip) // platform floor (if set)
AND (orgRanges ? matchesOrg(ip) : true) // org may only narrow
AND (userRanges ? matchesUser(ip) : true) // user may only narrow
i.e. a record-level range can only remove IPs, never add one the level above rejected. Keep the existing fail-open when the IP is undetermined. This must be decided (and stated in the ADR) before writing code — it's a correctness/security call, not a detail.
Scope
- Add
sys_organization.allowed_ip_ranges+sys_user.allowed_ip_ranges(CIDR[] text) fields. - Extend the IP gate: resolve the caller's org (active org / membership) + user, evaluate the intersection chain above. The org/user resolution seam already exists (the middleware runs before better-auth;
customSessionknows the active org). - Tests: global-only (unchanged), org narrows, user narrows, a record-level range that tries to widen beyond the floor is not honored, undetermined-IP fail-open.
- Update ADR-0069 D5 with the chosen semantics.
Relation
- ADR-0069 D5/D7 · [umbrella][security] 授权全生命周期缺口地图 — 包开发 → 生产上线(P0–P3) #2561 P2 (企业认证 P2) · sibling shared-rate-limit-store work (ADR-0069 D2).
- Deferred here because the 2026-07-04 launch is multi-node but not per-tenant-IP-scoped; global allow-list + shared rate-limit store cover that shape. Do this when a multi-tenant B2B customer needs per-company IP fencing.
Activity
docs(adr-0069): mark shared multi-node rate-limit store as landed (#2572
feat(auth): shared cross-node rate-limit + session store via cache se…
docs(authz): two-doors permission separation is landed, not a roadmap…
os-zhuang commentedon Jul 8, 2026
设计意见备案:per-org
allowed_ip_ranges的组合语义 —— 收紧(交集),而非替换实现本 issue 时会遇到的核心设计决策:org 级 IP 名单与全局名单如何组合。建议采用"收紧/交集"语义:
organization_admin只读)。"替换"语义会产生反直觉漏洞 —— 给 org 配了名单反而解除了平台级限制,配置动作本身变成了放权。sys_user.allowed_ip_rangesoverride:user 级继续与 org/全局取交集。isClientIpAllowed现有行为,不因加层而改变。实现落点:
sys_organization.allowed_ip_ranges字段(+ 可选 user 级)、auth-plugin.tsIP 门(:844-862)在全局校验后追加 org/user 查表(带缓存,避免热路径查库)。require_ip_match(ADR-0069 D5)仍未建,可与本项同批。本项维持 deferred,不阻断上线 —— 此评论仅把设计方向定下来,避免实现时重新争论。
Generated by Claude Code
docs(audits): recheck the parsed-but-unenforced security props cluster (