《互联网产品的服务端架构·交易、增长与平台化》10.1 应用、权限与配额

一个第三方应用为了省事申请了全部 scope,被拖库后影响所有用户;文章从应用身份与用户身份分离讲起,说明最小权限、按应用与接口两个维度配额的取舍,并给出 App、Authorize、CheckQuota 的 Go 实现、边界测试与真实运行输出。

一个“多给点权限省得再申请”的应用

某开放平台接入第三方 CRM,申请单上列着订单、用户、发票、营销、物流五组 scope。开发者说“先都勾上,省得以后反复申请”,审批同学看到是合作方,直接通过。上线后这个应用实际只调用了订单读取和用户基础资料两个接口,其余 scope 一次都没用过。

三个月后,应用把测试密钥写进了前端仓库,攻击者拿到密钥后批量导出订单和用户手机号。受影响的不只是一个商家,而是所有落在这些 scope 覆盖范围内的数据。复盘时最刺眼的一句话是:这个应用从来没有调用过发票、营销、物流接口,但这些权限一直开着。

开放平台的权限问题通常不是“校验写错了”,而是“根本没想清楚谁在调用、以谁的名义调用、调用到什么程度”。这三件事对应应用身份、用户身份和 scope,混在一起就会放大一次泄露的影响面。

应用身份与用户身份是两条独立的校验

应用身份回答“哪个第三方在调用平台”,凭证是应用 ID 和 secret;用户身份回答“这次调用以哪个用户的名义”,凭证是用户授权后拿到的 access token。两者缺一不可:只有应用身份,平台不知道数据属于谁;只有用户身份,平台不知道是哪个应用在代表用户操作。

凭证回答的问题泄露后的影响建议生命周期
应用 ID + secret谁在调用平台攻击者可冒充应用,消耗配额并读取已授权数据与开发者主体绑定,支持轮换
用户 access_token以谁的名义调用攻击者可读取该用户的数据随用户授权,可单独撤销
scope应用被允许做什么越权读写,影响面从一个用户扩大到全租户审核后授予,变更走审批
配额一天能调用多少次恶意刷取拖垮平台、推高账单按应用与接口分别配置

一个常见错误是把应用 secret 当成用户令牌用,或者在网关只校验了 secret 就放行所有接口。secret 泄露的影响是应用级的,用户令牌泄露的影响是用户级的;把两者合并成一把钥匙,等于让一次泄露同时具备两种影响。

最小权限:scope 要有语义、粒度和默认拒绝

scope 的命名建议固定为 resource:action,例如 order:read、order:write、user:profile:read。名字要能让审批人一眼判断影响面,不要用 all、full、v2 这种无法审计的词。匹配必须精确且大小写敏感,不做前缀匹配,也不做通配符匹配,否则 order:read 会意外匹配到 order:read:all。

scope 列表在加载时要去掉首尾空白、丢弃空值、去重并排序。去重不是为了好看,而是为了让“配置里写了两遍”和“配置里写了一遍”产生完全相同的授权结果,避免重复项在审计报表里制造噪声。默认行为必须是拒绝:没有显式授予的 scope 一律报错,而不是“没配置就放行”。

失败场景:全量 scope 加一把共享密钥

回到开头。这个应用同时具备三个条件:scope 覆盖全部业务、secret 放在客户端、配额没有单独配置。攻击者拿到 secret 后不需要理解业务,只要按接口文档逐个调用,就能在几个小时内导出全量订单。平台侧看到的是“一个正常应用在正常调用”,因为签名、scope、IP 白名单全部通过。

更麻烦的是账单。假设这个应用每天有 20 万次正常调用,攻击者追加了 300 万次导出请求,按每次 0.002 元计算,一天多出 6000 元成本;如果接口背后还有短信、地图、物流查询等按次计费的下游,损失会继续放大。配额缺失让“能调用”直接等于“可以无限调用”。

第三个变种是用户令牌被应用长期保存。应用为了“提升体验”,把用户的 refresh token 存进自己的数据库,用户撤销授权后仍然能用旧令牌调用。平台必须支持按用户撤销,并且撤销要立刻生效,不能等令牌自然过期。

配额要按应用与接口两个维度

只按应用配一个总配额不够。一个应用可能正常调用 10 万次订单查询,却在某个营销接口上被刷 50 万次;总配额看不出异常,单接口配额才能挡住。反过来,只按接口配额也不够,因为攻击者可以分散到多个接口,绕过单接口上限。

实践里至少有两层:应用级日配额负责账单和总量,接口级速率限制负责瞬时压力和热点保护。应用级配额为 0 表示不限量,这个值只应该给内部应用或经过单独审批的合作方;外部应用必须填写正数。已用次数为负数说明计数器或统计链路有 bug,应该直接报错,而不是当成 0 继续放行。

配额检查也有顺序:先校验应用身份,再校验 scope,最后看配额。身份不合法时不需要查配额,scope 不足时也不应该消耗配额。这个顺序能减少无效查询,也能让错误码更准确地告诉调用方该修什么。

用 App、Authorize 与 CheckQuota 把住入口

下面的实现把三件事拆开:Normalize 负责规整应用定义,Authorize 只做 scope 精确匹配,CheckQuota 只看用量和上限。三个函数都不依赖存储,调用方可以从配置中心、数据库或缓存里取到 App 后再调用。

// file: internal/openplatform/app.go
// tier: L1
// verify: go test ./internal/openplatform/

package openplatform

import (
	"errors"
	"fmt"
	"sort"
	"strings"
)

var (
	// ErrEmptyAppID 表示请求没有携带应用身份。
	ErrEmptyAppID = errors.New("openplatform: empty app id")
	// ErrEmptyScope 表示请求没有指明需要的权限。
	ErrEmptyScope = errors.New("openplatform: empty scope")
	// ErrScopeDenied 表示应用没有获得该 scope。
	ErrScopeDenied = errors.New("openplatform: scope not granted")
	// ErrNegativeQuota 表示每日配额被配置成负数。
	ErrNegativeQuota = errors.New("openplatform: negative daily quota")
	// ErrQuotaExceeded 表示应用当天的调用额度已经用尽。
	ErrQuotaExceeded = errors.New("openplatform: daily quota exceeded")
	// ErrInvalidUsage 表示已用次数为负数,调用方统计有误。
	ErrInvalidUsage = errors.New("openplatform: invalid usage")
)

// App 是第三方应用在开放平台上的身份。App.ID 回答“谁在调用”,
// 用户令牌回答“以谁的名义调用”,两者必须分开校验,不能互相替代。
type App struct {
	ID         string
	Scopes     []string
	DailyQuota int
}

// Normalize 返回应用定义的一份规整副本:scope 去掉首尾空白、丢掉空值、
// 去重并排序。DailyQuota 为 0 表示不限量,负数视为配置错误。
func Normalize(a App) (App, error) {
	if strings.TrimSpace(a.ID) == "" {
		return App{}, ErrEmptyAppID
	}
	if a.DailyQuota < 0 {
		return App{}, fmt.Errorf("%w: %d", ErrNegativeQuota, a.DailyQuota)
	}
	seen := make(map[string]struct{}, len(a.Scopes))
	scopes := make([]string, 0, len(a.Scopes))
	for _, s := range a.Scopes {
		s = strings.TrimSpace(s)
		if s == "" {
			continue
		}
		if _, ok := seen[s]; ok {
			continue
		}
		seen[s] = struct{}{}
		scopes = append(scopes, s)
	}
	sort.Strings(scopes)
	a.Scopes = scopes
	return a, nil
}

// Authorize 检查应用是否被授予了某个 scope。匹配是精确且大小写敏感的,
// 不做前缀、通配或包含匹配;空 scope 直接拒绝。Scopes 会在校验前去重,
// 因此重复配置不会放大权限,也不会改变匹配结果。
func Authorize(a App, scope string) error {
	if strings.TrimSpace(scope) == "" {
		return ErrEmptyScope
	}
	normalized, err := Normalize(a)
	if err != nil {
		return err
	}
	for _, granted := range normalized.Scopes {
		if granted == scope {
			return nil
		}
	}
	return fmt.Errorf("%w: app=%s scope=%s", ErrScopeDenied, a.ID, scope)
}

// CheckQuota 判断应用在已经调用 used 次之后还能不能继续调用。
// DailyQuota 是当天允许的调用上限,0 表示不限量;used 为已发生的调用数。
// used 达到上限时拒绝,因为这一次调用会让总数超过配额。
func CheckQuota(a App, used int) error {
	normalized, err := Normalize(a)
	if err != nil {
		return err
	}
	if used < 0 {
		return fmt.Errorf("%w: %d", ErrInvalidUsage, used)
	}
	if normalized.DailyQuota == 0 {
		return nil
	}
	if used >= normalized.DailyQuota {
		return fmt.Errorf("%w: app=%s used=%d quota=%d", ErrQuotaExceeded, a.ID, used, normalized.DailyQuota)
	}
	return nil
}

实现里有几个关键点:

  • Normalize 返回副本,不修改调用方传入的 App。配置加载和请求校验可以共享同一份数据,不会因为一次校验把原始配置改写掉。
  • 应用 ID 用 strings.TrimSpace 判空,只有空格组成的 ID 会被拒绝。ID 本身不做裁剪,保留原值便于审计时和开发者后台对齐。
  • scope 去空、去重、排序。重复配置不会放大权限,排序让审计报表和测试断言稳定。
  • Authorize 先拒绝空 scope,再校验应用定义,最后逐个精确比较。Order:Read、 order:read、order 都不会匹配 order:read。
  • CheckQuota 在 used < 0 时直接报错,说明统计链路有问题;DailyQuota == 0 表示不限量,只对内部应用开放。
  • 已用次数达到上限就拒绝,因为这一次调用会让总数超过配额。边界值 used == quota 返回 ErrQuotaExceeded,测试会固定这个语义。

这套代码不负责存储、轮换和审计,那些属于开放平台的控制面。服务端只保证“拿到一个应用定义后,授权和配额判断是确定的、可测试的”。

测试:去重、精确匹配与配额边界

测试重点不是覆盖率,而是把几个容易退化的语义钉死:输入不能被修改、匹配必须精确、配额边界不能漂移。

// file: internal/openplatform/app_test.go
// tier: L1
// verify: go test ./internal/openplatform/

package openplatform

import (
	"errors"
	"reflect"
	"testing"
)

func TestNormalizeDedupesAndSorts(t *testing.T) {
	got, err := Normalize(App{
		ID:         "app-1",
		Scopes:     []string{"order:read", " order:write ", "order:read", "", "  "},
		DailyQuota: 100,
	})
	if err != nil {
		t.Fatalf("Normalize = %v, want nil", err)
	}
	if got.ID != "app-1" {
		t.Fatalf("ID = %q, want app-1", got.ID)
	}
	want := []string{"order:read", "order:write"}
	if !reflect.DeepEqual(got.Scopes, want) {
		t.Fatalf("Scopes = %v, want %v", got.Scopes, want)
	}
	if got.DailyQuota != 100 {
		t.Fatalf("DailyQuota = %d, want 100", got.DailyQuota)
	}
}

func TestNormalizeDoesNotMutateInput(t *testing.T) {
	scopes := []string{"b", "a", "b"}
	_, err := Normalize(App{ID: "app-1", Scopes: scopes})
	if err != nil {
		t.Fatalf("Normalize = %v, want nil", err)
	}
	if want := []string{"b", "a", "b"}; !reflect.DeepEqual(scopes, want) {
		t.Fatalf("input scopes mutated to %v, want %v", scopes, want)
	}
}

func TestNormalizeRejectsInvalidApp(t *testing.T) {
	if _, err := Normalize(App{ID: "   "}); !errors.Is(err, ErrEmptyAppID) {
		t.Fatalf("blank id = %v, want ErrEmptyAppID", err)
	}
	if _, err := Normalize(App{ID: "app-1", DailyQuota: -1}); !errors.Is(err, ErrNegativeQuota) {
		t.Fatalf("negative quota = %v, want ErrNegativeQuota", err)
	}
}

func TestAuthorizeExactMatch(t *testing.T) {
	app := App{ID: "app-1", Scopes: []string{"order:read", "order:write", "order:read"}}
	for _, scope := range []string{"order:read", "order:write"} {
		if err := Authorize(app, scope); err != nil {
			t.Fatalf("Authorize(%q) = %v, want nil", scope, err)
		}
	}
	for _, scope := range []string{"order:delete", "Order:Read", "order", " order:read"} {
		if err := Authorize(app, scope); !errors.Is(err, ErrScopeDenied) {
			t.Fatalf("Authorize(%q) = %v, want ErrScopeDenied", scope, err)
		}
	}
	for _, scope := range []string{"", "   "} {
		if err := Authorize(app, scope); !errors.Is(err, ErrEmptyScope) {
			t.Fatalf("Authorize(%q) = %v, want ErrEmptyScope", scope, err)
		}
	}
}

func TestAuthorizeRejectsInvalidApp(t *testing.T) {
	if err := Authorize(App{}, "order:read"); !errors.Is(err, ErrEmptyAppID) {
		t.Fatalf("empty app = %v, want ErrEmptyAppID", err)
	}
	bad := App{ID: "app-1", DailyQuota: -5}
	if err := Authorize(bad, "order:read"); !errors.Is(err, ErrNegativeQuota) {
		t.Fatalf("negative quota = %v, want ErrNegativeQuota", err)
	}
}

func TestCheckQuota(t *testing.T) {
	app := App{ID: "app-1", DailyQuota: 3}
	for _, used := range []int{0, 1, 2} {
		if err := CheckQuota(app, used); err != nil {
			t.Fatalf("used=%d = %v, want nil", used, err)
		}
	}
	for _, used := range []int{3, 4, 1000} {
		if err := CheckQuota(app, used); !errors.Is(err, ErrQuotaExceeded) {
			t.Fatalf("used=%d = %v, want ErrQuotaExceeded", used, err)
		}
	}
	if err := CheckQuota(App{ID: "app-2", DailyQuota: 0}, 1000000); err != nil {
		t.Fatalf("unlimited quota = %v, want nil", err)
	}
	if err := CheckQuota(app, -1); !errors.Is(err, ErrInvalidUsage) {
		t.Fatalf("used=-1 = %v, want ErrInvalidUsage", err)
	}
	if err := CheckQuota(App{}, 0); !errors.Is(err, ErrEmptyAppID) {
		t.Fatalf("empty app = %v, want ErrEmptyAppID", err)
	}
}

TestNormalizeDedupesAndSorts 验证空白 scope 被丢弃、重复项只保留一次、结果有序,同时确认 ID 和配额原样保留。TestNormalizeDoesNotMutateInput 在调用前后比较原切片,防止以后有人为了省一次分配直接原地排序。

TestAuthorizeExactMatch 覆盖大小写、前缀、带空格三种“看起来像但不是”的输入,它们都必须返回 ErrScopeDenied;空 scope 则返回 ErrEmptyScope。TestAuthorizeRejectsInvalidApp 确认空 ID 和负配额在校验 scope 之前就被挡住。

TestCheckQuota 对配额 3 逐一检查 0、1、2 放行,3、4、1000 拒绝;配额 0 时一百万次调用仍然放行;used=-1 返回 ErrInvalidUsage。这些用例把“达到上限即拒绝”和“0 表示不限量”两个约定固定下来。

运行命令与真实输出

在 lab 目录执行:

cd .local/checks/webapp-lab && go test ./internal/openplatform/ -run 'TestNormalize|TestAuthorize|TestCheckQuota' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestNormalizeDedupesAndSorts
--- PASS: TestNormalizeDedupesAndSorts (0.00s)
=== RUN   TestNormalizeDoesNotMutateInput
--- PASS: TestNormalizeDoesNotMutateInput (0.00s)
=== RUN   TestNormalizeRejectsInvalidApp
--- PASS: TestNormalizeRejectsInvalidApp (0.00s)
=== RUN   TestAuthorizeExactMatch
--- PASS: TestAuthorizeExactMatch (0.00s)
=== RUN   TestAuthorizeRejectsInvalidApp
--- PASS: TestAuthorizeRejectsInvalidApp (0.00s)
=== RUN   TestCheckQuota
--- PASS: TestCheckQuota (0.00s)
PASS
ok  	webapp/internal/openplatform	0.239s

小结

应用身份和用户身份必须分开校验,scope 要最小化、精确匹配、默认拒绝,配额要同时按应用和接口两个维度配置。Normalize、Authorize、CheckQuota 把规整、授权和配额判断拆成三个纯函数,边界由测试固定。下一节转向回调:即使调用方身份正确,一条被重放的请求仍然可能造成重复发货。

阅读导航:上一节:目录与学习路径 · 下一节:10.2 回调、签名与重放 。

常见错误与边界

  • 把应用 secret 当成用户令牌。secret 泄露是应用级影响,用户令牌泄露是用户级影响,两者不能合并成一把钥匙。
  • scope 用通配符或前缀匹配。order:read 意外匹配 order:read:all,权限边界会比设计时更宽。
  • 审批时按“合作方”放行全量 scope。合作方也会被拖库,最小权限要按实际调用记录收敛。
  • 默认放行未配置的 scope。没有显式授予就应该拒绝,不能把“没写”解释成“都可以”。
  • 只配应用级总配额。攻击者集中刷一个接口时,总配额看不出异常,必须再加接口级限流。
  • 配额为 0 直接理解成“不能用”。本实现约定 0 表示不限量,内部应用和外部应用要用不同默认值。
  • 已用次数为负数时当成 0。统计链路已经出错,继续放行会让配额彻底失效。
  • 授权、配额、审计混在一个函数里。纯函数拆开后才能单测边界,也才能在网关和业务层复用。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查