feat(self-operated): expose private runtime configuration snapshots

This commit is contained in:
2026-10-13 22:23:48 +08:00
parent 1d0b0918ba
commit 54f79b3902
11 changed files with 356 additions and 11 deletions
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.*;
public class SelfopEgressController {
private final InternalServiceTokenGuard guard;private final SelfopEgressService service;
public SelfopEgressController(InternalServiceTokenGuard guard,SelfopEgressService service){this.guard=guard;this.service=service;}
@GetMapping("/runtime") public ApiResponse<SelfopEgressDtos.RuntimeSnapshot> runtime(@RequestHeader(value="X-Internal-Service-Token",required=false) String token){guard.verify(token);return ApiResponse.success(service.runtime());}
@PostMapping("/authorize") public ApiResponse<?> authorize(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.AuthorizationRequest request){guard.verify(token);try{return ApiResponse.success(service.authorize(request));}catch(EgressRejectedException denied){return ApiResponse.of(ResultCode.BUSINESS_ERROR,"领星请求许可未授予",new SelfopEgressDtos.AuthorizationDenial(denied.code(),denied.sampleDone(),denied.waitUntilUtc()==null?null:denied.waitUntilUtc().toInstant(ZoneOffset.UTC)),null,null);}}
@PostMapping("/complete") public ApiResponse<Boolean> complete(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@RequestBody SelfopEgressDtos.CompletionRequest request){guard.verify(token);service.complete(request);return ApiResponse.success(true);}
}
@@ -2,6 +2,11 @@ package com.baiyue.lastleg.selfoperated.analytics.request;
import java.time.Instant;
public final class SelfopEgressDtos {
private SelfopEgressDtos(){}
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS)
public record RuntimeSnapshot(int schemaVersion,String service,Instant observedAt,String mode,String environment,
boolean continuousEnabled,String sourceAccountFingerprint,boolean grantPresent,String grantFingerprint,
long intervalMs,int permitMaxSeconds,boolean executionEnabled,int pendingMaxSeconds,
boolean exclusiveHandoffVerified,boolean priorEgressClosed,boolean inflightDrained,boolean transferredBudgetVerified){}
public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY}
public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){}
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS)
@@ -9,10 +9,26 @@ import java.time.*;
import java.util.*;
@Service
public class SelfopEgressService {
private static final int PERMIT_MAX_SECONDS=30,PENDING_MAX_SECONDS=120;
private final SelfopEgressMapper mapper; private final SelfopAnalyticsProperties config; private final Clock clock;
@Autowired public SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config){this(mapper,config,Clock.systemUTC());}
SelfopEgressService(SelfopEgressMapper mapper,SelfopAnalyticsProperties config,Clock clock){this.mapper=mapper;this.config=config;this.clock=clock;}
private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC);}
/** Reads the same configuration instance used by authorization; no account, permit or provider access. */
public RuntimeSnapshot runtime(){
return new RuntimeSnapshot(1,"lastleg",clock.instant(),config.getCollectionMode(),config.getEnvironment(),
config.isContinuousBusinessCollectionEnabled(),fingerprint("source-account",config.getSourceAccount()),
config.getExclusiveGrantId()!=null&&!config.getExclusiveGrantId().isBlank(),fingerprint("grant",config.getExclusiveGrantId()),
config.getBusinessMinRequestIntervalMs(),PERMIT_MAX_SECONDS,config.isExecutionEnabled(),pendingMaxSeconds(),
config.isExclusiveHandoffVerified(),config.isPriorEgressClosed(),config.isInflightDrained(),config.isTransferredBudgetVerified());
}
private int pendingMaxSeconds(){return Math.max(1,Math.min(PENDING_MAX_SECONDS,config.getEgressLeaseSeconds()));}
private static String fingerprint(String domain,String value){
if(value==null||value.isBlank())return null;
try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(("selfop-runtime:v1:"+domain+"\n"+value).getBytes(java.nio.charset.StandardCharsets.UTF_8)));}
catch(java.security.NoSuchAlgorithmException impossible){throw new IllegalStateException("SHA-256 unavailable",impossible);}
}
@Transactional(noRollbackFor=EgressRejectedException.class)
public EgressContext reserve(SelfopRequestContext context,Operation operation,int pageSize,String warehouseCode,String detailKey,String requestHash){
if(context==null||context.rootTaskId()==null||context.taskId()==null||context.runId()==null||!Objects.equals(context.sessionId(),"selfop-"+context.rootTaskId()))throw reject("EGRESS_CONTEXT_INVALID");
@@ -41,7 +57,7 @@ public class SelfopEgressService {
if(budget.lastRequestAtUtc!=null&&now.isBefore(budget.lastRequestAtUtc.plusNanos((long)interval*1000000)))throw new EgressRejectedException("ACCOUNT_INTERVAL_WAIT",false,budget.lastRequestAtUtc.plusNanos((long)interval*1000000));
if("VALIDATION".equals(context.executionPurpose())&&budget.reservedHttp>=budget.dailyLimit)throw new EgressRejectedException("ACCOUNT_DAILY_BUDGET",false,now.plusHours(8).toLocalDate().plusDays(1).atStartOfDay().minusHours(8));
var outbound=new EgressContext(context.sourceAccount(),context.executionPurpose(),String.valueOf(context.rootTaskId()),context.sessionId(),UUID.randomUUID().toString(),context.exclusiveGrantId());
var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(Math.max(1,Math.min(120,config.getEgressLeaseSeconds())));
var pending=new SelfopApiBudget.Pending();pending.context=outbound;pending.operation=operation;pending.internalRequestHash=requestHash;pending.taskId=context.taskId();pending.runId=context.runId();pending.expiresAtUtc=now.plusSeconds(pendingMaxSeconds());
if("VALIDATION".equals(context.executionPurpose())&&pending.expiresAtUtc.isAfter(config.getExclusiveGrantExpiresAtUtc()))pending.expiresAtUtc=config.getExclusiveGrantExpiresAtUtc();
if(validation!=null&&pending.expiresAtUtc.isAfter(validation.deadlineAtUtc))pending.expiresAtUtc=validation.deadlineAtUtc;
budget.pending=pending;budget.reservedHttp++;budget.lastRequestAtUtc=now;
@@ -66,7 +82,7 @@ public class SelfopEgressService {
if(account.getApiPauseReason()!=null||account.getApiCooldownUntilUtc()!=null&&now.isBefore(account.getApiCooldownUntilUtc()))throw reject("ACCOUNT_PAUSED");
if("VALIDATION".equals(c.executionPurpose())){var v=validation(root);if(v.stopReason!=null||!now.isBefore(v.deadlineAtUtc)){stop(root,v,v.stopReason==null?"VALIDATION_DEADLINE":v.stopReason,false,now);throw new EgressRejectedException("VALIDATION_STOPPED",true,null);}}
if (!request.requestHash().matches("[a-f0-9]{64}"))throw reject("EGRESS_REQUEST_HASH_INVALID");
pending.requestHash=request.requestHash();pending.consumed=true;LocalDateTime expiry=now.plusSeconds(30);if(expiry.isAfter(pending.expiresAtUtc))expiry=pending.expiresAtUtc;pending.expiresAtUtc=expiry;budget.lastRequestAtUtc=now;save(account,budget);
pending.requestHash=request.requestHash();pending.consumed=true;LocalDateTime expiry=now.plusSeconds(PERMIT_MAX_SECONDS);if(expiry.isAfter(pending.expiresAtUtc))expiry=pending.expiresAtUtc;pending.expiresAtUtc=expiry;budget.lastRequestAtUtc=now;save(account,budget);
return new AttemptPermit(c,request.operation(),request.requestHash(),1,expiry.toInstant(ZoneOffset.UTC),true,true);
}
@Transactional(noRollbackFor=EgressRejectedException.class)
@@ -3,6 +3,30 @@ info:
title: Self-operated Analytics Internal Egress Authority
version: 1.0.0
paths:
/internal/lastleg/self-operated-analytics/egress/runtime:
get:
operationId: getSelfoperatedEgressRuntime
summary: 读取脱敏的实际出口配置快照
description: >-
仅内部服务令牌,不提供公网路由。直接读取 Egress 执行所用的同一配置实例,
不读取数据库、不预留或消费许可、不调用供应商。响应不是部署验证结论,
不包含 verified、镜像版本、原始账号、grant、Token 或 URL。
intervalMs 为当前配置间隔,持久账户间隔可能更严格,须另行只读核验。
pendingMaxSeconds 为实际配置经 1 至 120 秒裁剪后的上限;permitMaxSeconds 为单次许可上限,
VALIDATION 仍可能受验证截止及 grant 截止进一步缩短。
security: [{ internalServiceToken: [] }]
responses:
'200':
description: ApiResponse 成功响应,data 为只读白名单快照
content:
application/json:
schema:
type: object
properties:
code: { type: string }
data: { $ref: '#/components/schemas/RuntimeSnapshot' }
'401': { description: 内部令牌无效 }
'503': { description: 内部令牌未配置 }
/internal/lastleg/self-operated-analytics/egress/authorize:
post:
operationId: authorizeSelfoperatedEgressAttempt
@@ -11,8 +35,10 @@ paths:
仅内部服务令牌。客户端已登记的 context 和 operation 必须完全匹配;
requestHash 是 Integration 对 OMP 路径和有序业务 JSON 的摘要,首次消费绑定,
后续完成必须完全匹配。同 attemptId 再次消费拒绝。allowedAttempts 固定 1,
expiresAt 最多 30 秒且不超过验证根截止或人工独占窗口。OFF 默认拒绝,
BUSINESS 仅接受 production 或 test;TEST 任务入口仅允许受控人工任务及合法派生,不允许自动采集计划。独占交接证明及接续预算由服务端配置核验。
expiresAt 最多 30 秒;VALIDATION 不超过验证根截止及 grant 截止。
BUSINESS 不受人为会话或 grant 截止阻断,但保留短许可过期、身份、显式停止及账户保护。
OFF 默认拒绝;BUSINESS 仅接受 production 或 test,人工根及合法派生保持原身份,
连续计划根另须连续开关与持久化 planner 身份。独占交接证明及接续计数由服务端配置核验。
security: [{ internalServiceToken: [] }]
requestBody:
required: true
@@ -64,6 +90,36 @@ components:
securitySchemes:
internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token }
schemas:
RuntimeSnapshot:
type: object
additionalProperties: false
required: [schemaVersion, service, observedAt, mode, environment, continuousEnabled, sourceAccountFingerprint, grantPresent, grantFingerprint, intervalMs, permitMaxSeconds, executionEnabled, pendingMaxSeconds, exclusiveHandoffVerified, priorEgressClosed, inflightDrained, transferredBudgetVerified]
properties:
schemaVersion: { type: integer, enum: [1] }
service: { type: string, enum: [lastleg] }
observedAt: { type: string, format: date-time, description: UTC 观测时间 }
mode: { type: string, nullable: true, description: 原样返回实际配置,不将未知值转为 BUSINESS }
environment: { type: string, nullable: true }
continuousEnabled: { type: boolean }
sourceAccountFingerprint:
type: string
nullable: true
pattern: '^[a-f0-9]{64}$'
description: 'SHA-256 UTF-8 输入为 selfop-runtime:v1:source-account 加一个 LF 再加原始值;不 trim;null 或 blank 返回显式 null。'
grantPresent: { type: boolean, description: grant 原值非 null 且非 blank }
grantFingerprint:
type: string
nullable: true
pattern: '^[a-f0-9]{64}$'
description: 'SHA-256 UTF-8 输入为 selfop-runtime:v1:grant 加一个 LF 再加原始值;不 trim;null 或 blank 返回显式 null。'
intervalMs: { type: integer, format: int64, description: 实际配置的 BUSINESS 最小请求间隔毫秒数,未合并持久账户间隔 }
permitMaxSeconds: { type: integer, enum: [30] }
executionEnabled: { type: boolean }
pendingMaxSeconds: { type: integer, minimum: 1, maximum: 120 }
exclusiveHandoffVerified: { type: boolean }
priorEgressClosed: { type: boolean }
inflightDrained: { type: boolean }
transferredBudgetVerified: { type: boolean }
EgressContext:
type: object
required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId]
@@ -4,6 +4,63 @@ import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
class SelfopEgressControllerTest {
@Test void runtimeRequiresInternalTokenBeforeReadingConfiguration(){
var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service);
for(String token:new String[]{null,"","wrong"})assertThatThrownBy(()->c.runtime(token))
.isInstanceOf(org.springframework.web.server.ResponseStatusException.class)
.satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401));
verifyNoInteractions(service);
var missing=new SelfopEgressController(new InternalServiceTokenGuard(""),service);
assertThatThrownBy(()->missing.runtime("synthetic")).isInstanceOf(org.springframework.web.server.ResponseStatusException.class)
.satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(503));
verifyNoInteractions(service);c.runtime("synthetic");verify(service).runtime();
}
@Test void runtimeUsesExecutionConfigurationAndSerializesOnlyWhitelistedFields() throws Exception {
var storage=mock(SelfopEgressMapper.class);var config=new com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties();
config.setCollectionMode("BUSINESS");config.setEnvironment("test");config.setContinuousBusinessCollectionEnabled(true);
config.setSourceAccount("synthetic-private-account");config.setExclusiveGrantId("synthetic-private-grant");
config.setInternalServiceToken("synthetic-private-token");config.setIntegrationBaseUrl("http://synthetic-private-host");
config.setBusinessMinRequestIntervalMs(1200);config.setEgressLeaseSeconds(999);
config.setExclusiveHandoffVerified(true);config.setPriorEgressClosed(true);config.setInflightDrained(true);config.setTransferredBudgetVerified(true);
var now=java.time.Instant.parse("2026-10-13T00:00:00Z");
var service=new SelfopEgressService(storage,config,java.time.Clock.fixed(now,java.time.ZoneOffset.UTC));
var controller=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service);
var response=controller.runtime("synthetic");var runtime=response.data();
assertThat(runtime.schemaVersion()).isEqualTo(1);assertThat(runtime.service()).isEqualTo("lastleg");assertThat(runtime.observedAt()).isEqualTo(now);
assertThat(runtime.mode()).isEqualTo("BUSINESS");assertThat(runtime.environment()).isEqualTo("test");assertThat(runtime.continuousEnabled()).isTrue();
assertThat(runtime.intervalMs()).isEqualTo(1200);assertThat(runtime.permitMaxSeconds()).isEqualTo(30);assertThat(runtime.pendingMaxSeconds()).isEqualTo(120);
assertThat(runtime.executionEnabled()).isTrue();assertThat(runtime.grantPresent()).isTrue();
assertThat(runtime.exclusiveHandoffVerified()&&runtime.priorEgressClosed()&&runtime.inflightDrained()&&runtime.transferredBudgetVerified()).isTrue();
var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
String serialized=mapper.writeValueAsString(response);var data=mapper.readTree(serialized).path("data");
var names=new java.util.HashSet<String>();data.fieldNames().forEachRemaining(names::add);
assertThat(names).containsExactlyInAnyOrder("schemaVersion","service","observedAt","mode","environment","continuousEnabled",
"sourceAccountFingerprint","grantPresent","grantFingerprint","intervalMs","permitMaxSeconds","executionEnabled","pendingMaxSeconds",
"exclusiveHandoffVerified","priorEgressClosed","inflightDrained","transferredBudgetVerified");
assertThat(serialized).doesNotContain("synthetic-private-account","synthetic-private-grant","synthetic-private-token","synthetic-private-host");
config.setCollectionMode("VALIDATION");config.setContinuousBusinessCollectionEnabled(false);config.setBusinessMinRequestIntervalMs(5000);config.setEgressLeaseSeconds(0);
var changed=controller.runtime("synthetic").data();assertThat(changed.mode()).isEqualTo("VALIDATION");assertThat(changed.continuousEnabled()).isFalse();
assertThat(changed.intervalMs()).isEqualTo(5000);assertThat(changed.pendingMaxSeconds()).isEqualTo(1);
assertThat(runtime.mode()).isEqualTo("BUSINESS");verifyNoInteractions(storage);
}
@Test void runtimeFingerprintsUseRawUtf8WithSeparateDomainsAndExplicitNulls() throws Exception {
var storage=mock(SelfopEgressMapper.class);var config=new com.baiyue.lastleg.selfoperated.analytics.model.SelfopAnalyticsProperties();
var service=new SelfopEgressService(storage,config);
String value=" fixture-账户 ";config.setSourceAccount(value);config.setExclusiveGrantId(value);
var digest=java.security.MessageDigest.getInstance("SHA-256");var hex=java.util.HexFormat.of();
var runtime=service.runtime();
assertThat(runtime.sourceAccountFingerprint()).isEqualTo(hex.formatHex(digest.digest(("selfop-runtime:v1:source-account\n"+value).getBytes(java.nio.charset.StandardCharsets.UTF_8))));
assertThat(runtime.grantFingerprint()).isEqualTo(hex.formatHex(digest.digest(("selfop-runtime:v1:grant\n"+value).getBytes(java.nio.charset.StandardCharsets.UTF_8))));
assertThat(runtime.sourceAccountFingerprint()).isNotEqualTo(runtime.grantFingerprint());
config.setExclusiveGrantId(value.trim());assertThat(service.runtime().grantFingerprint()).isNotEqualTo(runtime.grantFingerprint());
var mapper=new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
for(String empty:new String[]{null,""," \t"}){
config.setSourceAccount(empty);config.setExclusiveGrantId(empty);var absent=service.runtime();
assertThat(absent.grantPresent()).isFalse();assertThat(absent.grantFingerprint()).isNull();assertThat(absent.sourceAccountFingerprint()).isNull();
var node=mapper.valueToTree(absent);assertThat(node.has("grantFingerprint")).isTrue();assertThat(node.path("grantFingerprint").isNull()).isTrue();
}
verifyNoInteractions(storage);
}
@Test void invalidInternalTokenCannotReachAuthority(){
var service=mock(SelfopEgressService.class);var c=new SelfopEgressController(new InternalServiceTokenGuard("synthetic"),service);
assertThatThrownBy(()->c.authorize("other",null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class).satisfies(e->assertThat(((org.springframework.web.server.ResponseStatusException)e).getStatusCode().value()).isEqualTo(401));