Skip to content

[security][P2] Per-org / per-user IP allow-list (record-level allowed_ip_ranges) — ADR-0069 D5 #2571

Description

@os-zhuang

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_ranges setting (global scope) → AuthManager.isClientIpAllowed → enforced in the auth-route middleware (auth-plugin.ts), rejecting sign-in/session from an outside IP with IP_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; customSession knows 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

Activity

  1. os-zhuang commented on Jul 8, 2026

    @os-zhuang
    ContributorAuthor

    设计意见备案:per-org allowed_ip_ranges 的组合语义 —— 收紧(交集),而非替换

    实现本 issue 时会遇到的核心设计决策:org 级 IP 名单与全局名单如何组合。建议采用"收紧/交集"语义:

    • 请求必须同时通过全局名单(若配置)与 org 名单(若配置)—— org 管理员只能在平台管理员划定的边界内进一步收窄,不能放宽。
    • 理由:与既有反提权原则同构(org admin 不能自授平台能力、RBAC 表对 organization_admin 只读)。"替换"语义会产生反直觉漏洞 —— 给 org 配了名单反而解除了平台级限制,配置动作本身变成了放权。
    • 同样适用于将来的 sys_user.allowed_ip_ranges override:user 级继续与 org/全局取交集。
    • 失败姿态沿用现状:任一名单为空 = 该层不限制(fail-open per layer),名单存在但 IP 无法判定 = 沿用 isClientIpAllowed 现有行为,不因加层而改变。

    实现落点:sys_organization.allowed_ip_ranges 字段(+ 可选 user 级)、auth-plugin.ts IP 门(:844-862)在全局校验后追加 org/user 查表(带缓存,避免热路径查库)。require_ip_match(ADR-0069 D5)仍未建,可与本项同批。

    本项维持 deferred,不阻断上线 —— 此评论仅把设计方向定下来,避免实现时重新争论。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions