JWT 能证明请求由谁发出,SCOPE_monitor 能决定这个身份是否可以进入监控 API,但都不能单独证明它可以修改哪一个监控分组。甲组成员即使猜到了乙组服务的 ID,也不应该读到或修改乙组的数据。
本章沿用 Java 21、Spring Boot 4.1.1 与 PostgreSQL,为已有 monitoring_group、monitored_service 增加成员关系。使用已经引入的 JdbcClient 和事务完成授权,不再增加权限框架。这里保护的是监控配置资源;按服务名称保存的旧心跳结果仍属于运维全局数据,不能直接当成已经隔离的用户历史。
1. 定义权限与响应
权限分成两层:安全过滤链检查 Token 和 Scope,应用服务检查数据库中的分组成员关系。
| 身份与关系 | 读取 | 修改 enabled |
|---|---|---|
| 未登录 | 401 | 401 |
| 已登录但没有 monitor Scope | 403 | 403 |
| 有 Scope,但不是目标分组成员 | 404 | 404 |
| 分组 VIEWER | 200 | 403 |
| 分组 EDITOR | 200 | 200 |
不存在的服务也返回 404,与「服务存在但不属于当前用户」保持一致;只读成员已经知道资源存在,所以修改时可以明确返回 403。列表直接过滤不可见行,不先返回全部数据再让前端隐藏。
用户标识采用 JWT 的 (iss, sub)。两个签发方可能拥有相同 sub,因此不能只保存 subject,也不能用昵称或邮箱代替稳定标识。应用只从经过认证的 Jwt 主体取得它们,忽略客户端自行提交的用户 ID。
2. 建立成员关系
前面的审计迁移使用了 V5,本章新增 V6。角色只允许 VIEWER、EDITOR,两者都可以读取,只有 EDITOR 可以修改监控配置。
01create table monitoring_membership (02group_id bigint not null references monitoring_group(id) on delete cascade,03issuer varchar(255) not null,04subject varchar(255) not null,05role varchar(16) not null check (role in ('VIEWER', 'EDITOR')),06primary key (group_id, issuer, subject)07);0809create index idx_monitoring_membership_actor10on monitoring_membership (issuer, subject, group_id);
保留 ddl-auto: validate,继续让 Flyway 管结构。成员的添加、降级和删除暂时由管理员执行受控 SQL;不开放让普通用户提交自己的角色或任意 group ID 的授权接口。下面的语句用于本地教学数据库,真实 issuer 和 subject 应来自身份平台的管理记录:
01insert into monitoring_group (name) values ('backend')02on conflict (name) do nothing;0304insert into monitored_service (name, group_id)05select 'inventory', id from monitoring_group where name = 'backend'06on conflict (name) do nothing;0708insert into monitoring_membership (group_id, issuer, subject, role)09select id, 'https://id.example.test/realms/demo', 'alice', 'EDITOR'10from monitoring_group where name = 'backend'11on conflict (group_id, issuer, subject) do update set role = excluded.role;
URL 是示例身份平台标识,不是可用的公开登录服务。不要修改已执行的 V3、V5,也不要把真实用户授权写成所有环境都会执行的 Flyway 初始化数据。
3. 收紧 HTTP 入口
在已有 SecurityConfiguration.apiSecurity() 中,用下面的授权部分替换原来的 .authorizeHttpRequests(...)。JWT 解码、无状态会话、CORS 和 Bearer Token 的 CSRF 策略继续使用前文配置。
1.authorizeHttpRequests(authorize -> authorize2.requestMatchers(HttpMethod.GET, "/api/v1/health").permitAll()3.requestMatchers("/actuator/health/**").permitAll()4.requestMatchers("/api/v1/monitoring/**").hasAuthority("SCOPE_monitor")5.requestMatchers("/api/v1/health/**", "/actuator/**")6.hasAuthority("SCOPE_monitor.admin")7.anyRequest().denyAll()8)
新入口使用 monitor Scope;旧全局历史和运维端点改为独立的 monitor.admin Scope,避免通过旧接口绕过分组隔离。认证服务器需要为运维客户端显式签发这个 Scope,原有测试也应按实际职责调整身份。公开 GET /api/v1/health 只返回本应用的非敏感存活信息。
同步修改已经写过的成功用例:JWT 篇访问旧 health 管理接口时,将 new SimpleGrantedAuthority("SCOPE_monitor") 改成 new SimpleGrantedAuthority("SCOPE_monitor.admin");端到端测试中的对应 Token 替身,将 .claim("scope", "monitor") 改成 .claim("scope", "monitor.admin")。原来检查权限不足的用例继续保留。下面的新 /api/v1/monitoring/** 测试仍使用 monitor,不能统一替换成管理员身份掩盖越权问题。
原有配置必须继续验证 issuer、audience、签名和有效期:
01spring:02security:03oauth2:04resourceserver:05jwt:06issuer-uri: ${AUTH_ISSUER_URI}07audiences: ${AUTH_AUDIENCE:https://springboot-api.usehook.cn}08mvc:09problemdetails:10enabled: true
Spring Security 将验证后的 JWT 暴露为认证主体;读取声明不等于重新实现 Token 验证。具体规则参见 JWT Resource Server 官方文档。
01package cn.usehook.spring.monitoring;0203import org.springframework.http.HttpStatus;04import org.springframework.security.oauth2.jwt.Jwt;05import org.springframework.web.server.ResponseStatusException;0607public record MonitoringActor(String issuer, String subject) {08public static MonitoringActor from(Jwt jwt) {09if (jwt == null || jwt.getIssuer() == null10|| jwt.getSubject() == null || jwt.getSubject().isBlank()) {11throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);12}13return new MonitoringActor(jwt.getIssuer().toString(), jwt.getSubject());14}15}
4. 把授权放进数据访问路径
下面是完整应用服务。列表最多返回 100 条可见配置,单条查询附带同样的成员条件。这里的列表是有上限的教学接口;需要翻页时继续携带相同授权条件,不能仅在第一页过滤。
01package cn.usehook.spring.monitoring;0203import java.util.List;04import org.springframework.http.HttpStatus;05import org.springframework.jdbc.core.simple.JdbcClient;06import org.springframework.stereotype.Service;07import org.springframework.transaction.annotation.Transactional;08import org.springframework.web.server.ResponseStatusException;0910@Service11public class AuthorizedMonitoringService {12private final JdbcClient jdbc;1314public AuthorizedMonitoringService(JdbcClient jdbc) {15this.jdbc = jdbc;16}1718public record ServiceView(long id, String name, long groupId,19boolean enabled, long version) {}2021private static final String VISIBLE = """22select s.id, s.name, s.group_id, s.enabled, s.version23from monitored_service s24join monitoring_membership m on m.group_id = s.group_id25where m.issuer = :issuer and m.subject = :subject26""";2728public List<ServiceView> list(MonitoringActor actor) {29return visible(VISIBLE + " order by s.id limit 100", actor)30.query(ServiceView.class).list();31}3233public ServiceView get(long id, MonitoringActor actor) {34return visible(VISIBLE + " and s.id = :id", actor)35.param("id", id).query(ServiceView.class).optional()36.orElseThrow(AuthorizedMonitoringService::hidden);37}3839@Transactional(timeout = 5)40public ServiceView changeEnabled(long id, boolean enabled,41MonitoringActor actor) {42String role = visible("""43select m.role44from monitored_service s45join monitoring_membership m on m.group_id = s.group_id46where m.issuer = :issuer and m.subject = :subject and s.id = :id47for update of s, m48""", actor).param("id", id).query(String.class).optional()49.orElseThrow(AuthorizedMonitoringService::hidden);5051if (!"EDITOR".equals(role)) {52throw new ResponseStatusException(HttpStatus.FORBIDDEN,53"The resource is read-only for this member");54}5556jdbc.sql("""57update monitored_service58set enabled = :enabled, version = version + 1,59updated_at = clock_timestamp()60where id = :id61""").param("enabled", enabled).param("id", id).update();62return get(id, actor);63}6465private JdbcClient.StatementSpec visible(String sql, MonitoringActor actor) {66return jdbc.sql(sql).param("issuer", actor.issuer())67.param("subject", actor.subject());68}6970private static ResponseStatusException hidden() {71return new ResponseStatusException(HttpStatus.NOT_FOUND,72"Monitoring resource not found");73}74}
FOR UPDATE OF s, m 同时锁住目标服务和当前成员行,锁一直持有到修改提交。管理员撤销成员或把 EDITOR 降为 VIEWER 时,其 DELETE/UPDATE 必须等待这次修改结束;如果撤销先提交,本次查询便没有可写成员。移动服务所属分组同样需要更新被锁住的服务行。这为权限变化和业务修改建立了明确先后顺序,而非承诺能撤销已经完成的操作。
这里不在持锁期间访问远程系统。所有这些 SQL 使用同一个 DataSource 和 Spring 管理的事务;继续使用项目已有的事务管理器,不创建第二套连接。直接 JDBC 更新不会触发 JPA 审计监听器,所以代码显式修改 updated_at 并递增 version;同一事务内不要混用已经加载的 JPA 实体来覆盖这行数据。
所有新的 HTTP 修改都必须进入这个服务,不能另留一个直接调用 JPA Repository 的公开入口。后续若增加成员管理、服务转组或批量更新,继续维护相同锁顺序并处理事务冲突。PostgreSQL 的锁行为见 显式锁官方文档。
5. Controller 只接收业务输入
请求体只包含 enabled,没有 issuer、subject 或 role;资源 ID 用来定位对象,不能用来决定当前用户身份。Boolean 配合 @NotNull 可区分没有传字段和明确传 false。
01package cn.usehook.spring.controller;0203import cn.usehook.spring.monitoring.AuthorizedMonitoringService;04import cn.usehook.spring.monitoring.AuthorizedMonitoringService.ServiceView;05import cn.usehook.spring.monitoring.MonitoringActor;06import jakarta.validation.Valid;07import jakarta.validation.constraints.NotNull;08import java.util.List;09import org.springframework.security.core.annotation.AuthenticationPrincipal;10import org.springframework.security.oauth2.jwt.Jwt;11import org.springframework.web.bind.annotation.*;1213@RestController14@RequestMapping("/api/v1/monitoring/services")15public class MonitoringController {16private final AuthorizedMonitoringService service;1718public MonitoringController(AuthorizedMonitoringService service) {19this.service = service;20}2122public record ChangeEnabledRequest(@NotNull Boolean enabled) {}2324@GetMapping25public List<ServiceView> list(@AuthenticationPrincipal Jwt jwt) {26return service.list(MonitoringActor.from(jwt));27}2829@GetMapping("/{id}")30public ServiceView get(@PathVariable long id, @AuthenticationPrincipal Jwt jwt) {31return service.get(id, MonitoringActor.from(jwt));32}3334@PatchMapping("/{id}/enabled")35public ServiceView change(@PathVariable long id,36@Valid @RequestBody ChangeEnabledRequest request,37@AuthenticationPrincipal Jwt jwt) {38return service.changeEnabled(id, request.enabled(), MonitoringActor.from(jwt));39}40}
ResponseStatusException 实现 Spring 的 ErrorResponse。前文的异常兜底处理器已经对 ErrorResponse 重新抛出,让问题详情处理器保留 403/404;不要改成捕获所有异常后统一返回 500。这里新端点直接返回 ServiceView,不改变旧心跳接口的响应包装。
6. 用越权测试证明边界
复用前文 TestcontainersConfiguration、Security 测试支持和 Web MVC 测试 Starter。以下测试保留真实授权服务、SQL、事务与数据库,只用 jwt() 建立测试身份。
01package cn.usehook.spring.monitoring;0203import cn.usehook.spring.TestcontainersConfiguration;04import org.junit.jupiter.api.BeforeEach;05import org.junit.jupiter.api.Test;06import org.springframework.beans.factory.annotation.Autowired;07import org.springframework.boot.test.context.SpringBootTest;08import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;09import org.springframework.context.annotation.Import;10import org.springframework.jdbc.core.simple.JdbcClient;11import org.springframework.security.core.authority.SimpleGrantedAuthority;12import org.springframework.security.oauth2.jwt.JwtDecoder;13import org.springframework.test.context.bean.override.mockito.MockitoBean;14import org.springframework.test.web.servlet.MockMvc;15import org.springframework.test.web.servlet.request.RequestPostProcessor;16import org.springframework.transaction.annotation.Transactional;17import static org.assertj.core.api.Assertions.assertThat;18import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;19import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;20import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;2122@SpringBootTest(properties = {23"app.monitoring.initial-delay=1h",24"app.monitoring.jobs.enabled=false",25"app.alerts.enabled=false",26"app.history.retention.enabled=false"27})28@AutoConfigureMockMvc29@Import(TestcontainersConfiguration.class)30@Transactional31class MonitoringAuthorizationTests {32@Autowired MockMvc mvc;33@Autowired JdbcClient jdbc;34@MockitoBean JwtDecoder decoder;35long serviceId;3637@BeforeEach38void seed() {39long groupId = jdbc.sql("""40insert into monitoring_group(name) values ('authorization-test')41returning id42""").query(Long.class).single();43serviceId = jdbc.sql("""44insert into monitored_service(name, group_id)45values ('authorization-test', :group) returning id46""").param("group", groupId).query(Long.class).single();47jdbc.sql("""48insert into monitoring_membership(group_id, issuer, subject, role)49values (:group, 'https://id.example.test', 'alice', 'EDITOR'),50(:group, 'https://id.example.test', 'reader', 'VIEWER')51""").param("group", groupId).update();52}5354private RequestPostProcessor as(String subject) {55return jwt().jwt(token -> token.issuer("https://id.example.test")56.subject(subject)).authorities(new SimpleGrantedAuthority("SCOPE_monitor"));57}5859@Test60void outsiderCannotListReadOrModify() throws Exception {61mvc.perform(get("/api/v1/monitoring/services").with(as("bob")))62.andExpect(status().isOk()).andExpect(content().json("[]"));63mvc.perform(get("/api/v1/monitoring/services/{id}", serviceId).with(as("bob")))64.andExpect(status().isNotFound());65mvc.perform(patch("/api/v1/monitoring/services/{id}/enabled", serviceId)66.with(as("bob")).header("X-User-Id", "alice")67.contentType("application/json").content("{\"enabled\":false}"))68.andExpect(status().isNotFound());69assertThat(jdbc.sql("select enabled from monitored_service where id=:id")70.param("id", serviceId).query(Boolean.class).single()).isTrue();71}7273@Test74void readerCannotWriteButEditorCan() throws Exception {75mvc.perform(get("/api/v1/monitoring/services/{id}", serviceId).with(as("reader")))76.andExpect(status().isOk());77mvc.perform(patch("/api/v1/monitoring/services/{id}/enabled", serviceId)78.with(as("reader")).contentType("application/json")79.content("{\"enabled\":false}")).andExpect(status().isForbidden());80mvc.perform(patch("/api/v1/monitoring/services/{id}/enabled", serviceId)81.with(as("alice")).contentType("application/json")82.content("{\"enabled\":false}"))83.andExpect(status().isOk()).andExpect(jsonPath("$.enabled").value(false));84}8586@Test87void identicalSubjectFromAnotherIssuerHasNoMembership() throws Exception {88mvc.perform(get("/api/v1/monitoring/services/{id}", serviceId)89.with(jwt().jwt(token -> token.issuer("https://other.example.test")90.subject("alice"))91.authorities(new SimpleGrantedAuthority("SCOPE_monitor"))))92.andExpect(status().isNotFound());93}94}
这里 MockMvc 同步调用处于测试线程,@Transactional 可回滚该用例创建的数据;不要把这个结论套到前文 RANDOM_PORT 测试。jwt() 不验证签名,最后一项测试只证明数据库不会串用两个 issuer 的成员;当前单 issuer 生产配置会更早拒绝另一个签发方,返回 401。签名与 issuer 校验仍由 Resource Server 专门的测试和部署验证负责。
执行 ./mvnw -Dtest=MonitoringAuthorizationTests test,并继续增加:缺少 Token 返回 401、缺少 Scope 返回 403,以及不存在 ID 与不可见 ID 都返回相同 404。
7. 验证并发撤权
单线程越权测试无法证明锁行为。可在独立教学数据库打开两个 psql 会话,使用上面的 Alice 成员和 inventory 服务进行验证。
1begin;2select s.id, m.role3from monitored_service s4join monitoring_membership m on m.group_id = s.group_id5where s.name = 'inventory'6and m.issuer = 'https://id.example.test/realms/demo'7and m.subject = 'alice'8for update of s, m;9-- 保持事务打开,切换到会话 B;验证结束后执行 rollback。
1begin;2set local lock_timeout = '1s';3delete from monitoring_membership4where issuer = 'https://id.example.test/realms/demo' and subject = 'alice'5and group_id = (select id from monitoring_group where name = 'backend');6-- 预期锁超时,说明撤权不能插入“检查完成、写入尚未提交”的间隙。7rollback;
让会话 A rollback 后再次执行 B 的删除,便应成功;此时再用 Alice 的 Token 请求该服务应得到 404。测试结束按种子脚本恢复教学成员。撤权前已经完成的读取不会被追回,长时间导出和后台任务还需要明确各自的授权检查时点。
本章使用数据库过滤和短事务锁把权限落实到具体数据。相关 API 可查阅 JdbcClient 官方文档;如果以后引入缓存,缓存键和命中后的授权同样必须保留 (issuer, subject) 或分组权限边界,不能因为命中缓存就跳过检查。