feat(self-operated): expose private runtime configuration snapshots
This commit is contained in:
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.integration.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.integration.config.LingxingWmsProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot;
|
||||
import com.baiyue.integration.provider.lingxing.LingxingEgressGate;
|
||||
import com.baiyue.integration.security.InternalServiceTokenGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Internal token is checked before the active gate is inspected. Never calls the provider. */
|
||||
@RestController
|
||||
public class LingxingEgressRuntimeController {
|
||||
private final InternalServiceTokenGuard guard;
|
||||
private final LingxingEgressGate gate;
|
||||
private final LingxingWmsProperties provider;
|
||||
|
||||
public LingxingEgressRuntimeController(InternalServiceTokenGuard guard, LingxingEgressGate gate,
|
||||
LingxingWmsProperties provider) {
|
||||
this.guard = guard; this.gate = gate; this.provider = provider;
|
||||
}
|
||||
|
||||
@Operation(summary = "读取实际领星出口绑定配置的脱敏快照,无供应商或许可副作用")
|
||||
@GetMapping("/internal/integrations/lingxing/wms/egress/runtime")
|
||||
public ApiResponse<LingxingEgressRuntimeSnapshot> runtime(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token) {
|
||||
guard.verify(token);
|
||||
return ApiResponse.success(gate.runtimeSnapshot(provider.enabled()));
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.integration.dto.lingxing;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Bound runtime configuration only. No credentials, addresses or execution attestation. */
|
||||
public record LingxingEgressRuntimeSnapshot(int schemaVersion, String service, Instant observedAt,
|
||||
String mode, String environment, boolean continuousEnabled, String sourceAccountFingerprint,
|
||||
boolean grantPresent, String grantFingerprint, long intervalMs, int permitMaxSeconds,
|
||||
boolean providerEnabled, String budgetAuthorityFingerprint) { }
|
||||
+24
-1
@@ -8,12 +8,18 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Local serialization supplements the exclusive grant and durable account protection. */
|
||||
@Component
|
||||
public final class LingxingEgressGate {
|
||||
private static final int PERMIT_MAX_SECONDS = 30;
|
||||
private static final Logger log = LoggerFactory.getLogger(LingxingEgressGate.class);
|
||||
private final LingxingEgressProperties properties;
|
||||
private final LingxingBudgetAuthority authority;
|
||||
@@ -30,6 +36,23 @@ public final class LingxingEgressGate {
|
||||
LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) {
|
||||
this.properties = properties; this.authority = authority; this.clock = clock; this.waiter = waiter;
|
||||
}
|
||||
/** The very properties and clock used by execute; no semaphore, authority, budget or HTTP work. */
|
||||
public LingxingEgressRuntimeSnapshot runtimeSnapshot(boolean providerEnabled) {
|
||||
String grant = properties.externalExclusiveGrantId();
|
||||
return new LingxingEgressRuntimeSnapshot(1, "integration", clock.instant(),
|
||||
properties.collectionMode().name(), properties.environmentLabel(),
|
||||
properties.continuousBusinessCollectionEnabled(), fingerprint("source-account", properties.sourceAccount()),
|
||||
grant != null && !grant.isBlank(), fingerprint("grant", grant), properties.minStartInterval().toMillis(),
|
||||
PERMIT_MAX_SECONDS, providerEnabled, fingerprint("budget-authority", properties.budgetAuthorityBaseUri() == null ? null
|
||||
: properties.budgetAuthorityBaseUri().toString()));
|
||||
}
|
||||
private static String fingerprint(String domain, String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(("selfop-runtime:v1:" + domain + "\n" + value).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA256_UNAVAILABLE", impossible); }
|
||||
}
|
||||
public <T> T execute(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
|
||||
try { return guarded(context, operation, hash, call); }
|
||||
catch (LingxingWmsException error) {
|
||||
@@ -61,7 +84,7 @@ public final class LingxingEgressGate {
|
||||
if (permit == null || !context.equals(permit.context()) || operation != permit.operation()
|
||||
|| !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed()
|
||||
|| !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt())
|
||||
|| permit.expiresAt().isAfter(clock.instant().plusSeconds(30))
|
||||
|| permit.expiresAt().isAfter(clock.instant().plusSeconds(PERMIT_MAX_SECONDS))
|
||||
|| (!business() && permit.expiresAt().isAfter(properties.externalExclusiveUntil()))) {
|
||||
recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID");
|
||||
}
|
||||
|
||||
@@ -6,16 +6,17 @@ info:
|
||||
Single-page or single-order adapter. No scheduling, business persistence or public Gateway route.
|
||||
Source times remain uninterpreted strings; warehouse timeZone does not certify source timezone.
|
||||
Server-side collection mode defaults to OFF. VALIDATION requires an externally confirmed exclusive
|
||||
account window; BUSINESS requires environmentLabel exactly production or test. TEST business task creation is restricted by Lastleg to controlled manual commands and their legitimate derived work; the adapter does not schedule work. All five endpoints apply the same gate.
|
||||
account window; BUSINESS requires environmentLabel exactly production or test. TEST business task creation is restricted by Lastleg to controlled commands and their legitimate derived work; the adapter does not schedule work. Provider-query endpoints apply the same gate.
|
||||
Missing exclusive proof, persistent account budget authorization or control context fails closed before egress.
|
||||
At most one account request is in flight and HTTP request starts are separated by at least 5 seconds.
|
||||
At most one account request is in flight; request starts obey the bound minStartInterval (VALIDATION at least 5 seconds).
|
||||
A JVM lock is only local protection and is not proof of cross-environment account exclusivity.
|
||||
Each invocation performs at most one supplier HTTP attempt. No mode uses implicit HTTP retries;
|
||||
429 is never retried, immediately pauses the account and returns persistent cooldown metadata.
|
||||
VALIDATION page size must not exceed 10. Lastleg owns root/session budgets and cooldown persistence.
|
||||
External server configuration supplies externalExclusiveGrantId and externalExclusiveUntil;
|
||||
the grant must match context.exclusiveGrantId and remain valid. A BUSINESS rate-limit pause is
|
||||
recoverable only within the original valid exclusive window, after both the persistently returned
|
||||
External server configuration supplies externalExclusiveGrantId and a VALIDATION externalExclusiveUntil;
|
||||
the nonempty grant must match context.exclusiveGrantId. BUSINESS has no grant, session or root time cutoff;
|
||||
explicit stops, persistent account protections and each short single-attempt permit remain enforced.
|
||||
A BUSINESS rate-limit pause is recoverable after both the persistently returned
|
||||
cooldown and the local minimum fallback cooldown have elapsed, and after the authority issues a new
|
||||
strictly valid single-attempt permit confirming account budget, cooldown, stop limits and root state.
|
||||
Time alone never permits egress. VALIDATION 429 does not automatically recover. Unavailable, lost
|
||||
@@ -32,7 +33,7 @@ x-lastleg-egress-coordination:
|
||||
requestHash and atomically consumes persistent account/root/session budget. Duplicate authorization
|
||||
is refused; arbitrary client-created attempts are refused. complete verifies the same bindings.
|
||||
A permit is bound to its full context, operation and requestHash, requires exclusiveConfirmed=true
|
||||
and budgetConsumed=true, expires after now but no later than now plus 30 seconds or externalExclusiveUntil,
|
||||
and budgetConsumed=true, expires after now but no later than now plus 30 seconds (also within externalExclusiveUntil for VALIDATION),
|
||||
and cannot be reused. Explicit denial uses HTTP 200 with ApiResponse code string B0001 and
|
||||
AuthorizationDenial data. A valid denial is not an uncertain authorization and does not impose
|
||||
a permanent local pause. Unavailable, lost or malformed authorization or an unconfirmed callback blocks further egress;
|
||||
@@ -50,6 +51,26 @@ x-lastleg-egress-coordination:
|
||||
security:
|
||||
- InternalServiceToken: []
|
||||
paths:
|
||||
/internal/integrations/lingxing/wms/egress/runtime:
|
||||
get:
|
||||
operationId: lingxingEgressRuntime
|
||||
summary: Read sanitized configuration from the active egress gate
|
||||
description: >-
|
||||
Internal token authentication only. No provider, database, budget-authority, permit or persistence side effect.
|
||||
Returns actual bound configuration, not a verified claim or a commit identity. Fingerprints use lowercase
|
||||
SHA-256 of UTF-8 "selfop-runtime:v1:<domain>\n" plus the exact original nonblank value, without trimming.
|
||||
Domains are source-account, grant and budget-authority. Missing/blank values yield null, never an empty-value hash.
|
||||
security: [{InternalServiceToken: []}]
|
||||
responses:
|
||||
'200':
|
||||
description: ApiResponse code 200 with runtime snapshot, or A0401 for missing/invalid internal token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
data: {$ref: '#/components/schemas/EgressRuntimeSnapshot'}
|
||||
/internal/integrations/lingxing/wms/warehouses/query:
|
||||
post:
|
||||
summary: Query one page of normal warehouses
|
||||
@@ -154,6 +175,26 @@ components:
|
||||
in: header
|
||||
name: X-Internal-Service-Token
|
||||
schemas:
|
||||
EgressRuntimeSnapshot:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [schemaVersion, service, observedAt, mode, environment, continuousEnabled, grantPresent, intervalMs, permitMaxSeconds, providerEnabled]
|
||||
properties:
|
||||
schemaVersion: {type: integer, enum: [1]}
|
||||
service: {type: string, enum: [integration]}
|
||||
observedAt: {type: string, format: date-time}
|
||||
mode: {type: string, enum: [OFF, VALIDATION, BUSINESS]}
|
||||
environment: {type: string}
|
||||
continuousEnabled: {type: boolean}
|
||||
sourceAccountFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'}
|
||||
grantPresent: {type: boolean}
|
||||
grantFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'}
|
||||
intervalMs:
|
||||
oneOf: [{type: integer, format: int64}, {type: string, pattern: '^[0-9]+$'}]
|
||||
description: Actual bound interval; shared long-value JSON serialization may encode it as a decimal string.
|
||||
permitMaxSeconds: {type: integer, enum: [30]}
|
||||
providerEnabled: {type: boolean}
|
||||
budgetAuthorityFingerprint: {type: string, nullable: true, pattern: '^[a-f0-9]{64}$'}
|
||||
EgressContext:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.integration.controller;
|
||||
|
||||
import com.baiyue.common.web.GlobalExceptionHandler;
|
||||
import com.baiyue.integration.config.LingxingWmsProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressRuntimeSnapshot;
|
||||
import com.baiyue.integration.provider.lingxing.LingxingEgressGate;
|
||||
import com.baiyue.integration.security.InternalServiceTokenGuard;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
class LingxingEgressRuntimeControllerTest {
|
||||
@Test void internalTokenIsRequiredBeforeGateInspectionAndPublicPathDoesNotExist() throws Exception {
|
||||
String token = UUID.randomUUID().toString();var guard = new InternalServiceTokenGuard();
|
||||
ReflectionTestUtils.setField(guard, "token", token);ReflectionTestUtils.invokeMethod(guard, "initializeToken");
|
||||
var gate = mock(LingxingEgressGate.class);var provider = new LingxingWmsProperties(false,null,null,null,null,null);
|
||||
var converter = new MappingJackson2HttpMessageConverter(Jackson2ObjectMapperBuilder.json()
|
||||
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).build());
|
||||
var mvc = MockMvcBuilders.standaloneSetup(new LingxingEgressRuntimeController(guard,gate,provider))
|
||||
.setMessageConverters(converter).setControllerAdvice(new GlobalExceptionHandler()).build();
|
||||
String path = "/internal/integrations/lingxing/wms/egress/runtime";
|
||||
for (String bad : new String[] {"", "wrong", "Bearer user-jwt"}) {
|
||||
mvc.perform(get(path).header("X-Internal-Service-Token",bad)).andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
|
||||
}
|
||||
verifyNoInteractions(gate);
|
||||
mvc.perform(get("/api/integrations/lingxing/wms/egress/runtime")).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(org.hamcrest.Matchers.not("200")))
|
||||
.andExpect(jsonPath("$.data.service").doesNotExist());
|
||||
verifyNoInteractions(gate);
|
||||
var snapshot = new LingxingEgressRuntimeSnapshot(1,"integration",Instant.parse("2026-10-13T14:00:00Z"),
|
||||
"OFF","test",false,null,false,null,5000,30,false,null);
|
||||
when(gate.runtimeSnapshot(false)).thenReturn(snapshot);
|
||||
mvc.perform(get(path).header("X-Internal-Service-Token",token)).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value("200")).andExpect(jsonPath("$.data.service").value("integration"))
|
||||
.andExpect(jsonPath("$.data.observedAt").value("2026-10-13T14:00:00Z"))
|
||||
.andExpect(jsonPath("$.data.mode").value("OFF")).andExpect(jsonPath("$.data.providerEnabled").value(false))
|
||||
.andExpect(jsonPath("$.data.verified").doesNotExist()).andExpect(jsonPath("$.data.grant").doesNotExist());
|
||||
verify(gate).runtimeSnapshot(false);verifyNoMoreInteractions(gate);
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.config.LingxingEgressProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.*;
|
||||
import java.util.HexFormat;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class LingxingEgressRuntimeSnapshotTest {
|
||||
private static final Instant NOW = Instant.parse("2026-10-13T14:00:00Z");
|
||||
private final LingxingBudgetAuthority authority = mock(LingxingBudgetAuthority.class);
|
||||
private LingxingEgressGate gate(CollectionMode mode, String account, String grant, URI uri) {
|
||||
return new LingxingEgressGate(new LingxingEgressProperties(mode, "test", account, grant, null, uri,
|
||||
mode == CollectionMode.VALIDATION ? Duration.ofSeconds(5) : Duration.ofMillis(1200), null, true),
|
||||
authority, Clock.fixed(NOW, ZoneOffset.UTC), millis -> { throw new AssertionError("DIAGNOSTIC_MUST_NOT_WAIT"); });
|
||||
}
|
||||
private static String hash(String domain, String value) throws Exception {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(("selfop-runtime:v1:" + domain + "\n" + value).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
@Test void sameGateReturnsActualBindingsWithDomainSeparatedExactRawFingerprints() throws Exception {
|
||||
var snapshot = gate(CollectionMode.BUSINESS, "same-raw-value", "same-raw-value", URI.create("http://authority:9303")).runtimeSnapshot(true);
|
||||
assertThat(snapshot.schemaVersion()).isEqualTo(1);assertThat(snapshot.service()).isEqualTo("integration");
|
||||
assertThat(snapshot.observedAt()).isEqualTo(NOW);assertThat(snapshot.mode()).isEqualTo("BUSINESS");
|
||||
assertThat(snapshot.environment()).isEqualTo("test");assertThat(snapshot.continuousEnabled()).isTrue();
|
||||
assertThat(snapshot.providerEnabled()).isTrue();assertThat(snapshot.grantPresent()).isTrue();
|
||||
assertThat(snapshot.intervalMs()).isEqualTo(1200);assertThat(snapshot.permitMaxSeconds()).isEqualTo(30);
|
||||
assertThat(snapshot.sourceAccountFingerprint()).isEqualTo(hash("source-account", "same-raw-value"));
|
||||
assertThat(snapshot.grantFingerprint()).isEqualTo(hash("grant", "same-raw-value")).isNotEqualTo(snapshot.sourceAccountFingerprint());
|
||||
assertThat(snapshot.budgetAuthorityFingerprint()).isEqualTo(hash("budget-authority", "http://authority:9303"));
|
||||
assertThat(gate(CollectionMode.BUSINESS, " raw ", " raw ", null).runtimeSnapshot(false).grantFingerprint())
|
||||
.isEqualTo(hash("grant", " raw ")).isNotEqualTo(hash("grant", "raw"));
|
||||
verifyNoInteractions(authority);
|
||||
}
|
||||
@Test void offAndValidationAreObservableAndAbsentOrBlankValuesAreNotHashed() {
|
||||
for (String value : new String[] {null, "", " "}) {
|
||||
var s = gate(CollectionMode.OFF, value, value, null).runtimeSnapshot(false);
|
||||
assertThat(s.mode()).isEqualTo("OFF");assertThat(s.sourceAccountFingerprint()).isNull();
|
||||
assertThat(s.grantPresent()).isFalse();assertThat(s.grantFingerprint()).isNull();
|
||||
assertThat(s.budgetAuthorityFingerprint()).isNull();assertThat(s.providerEnabled()).isFalse();
|
||||
}
|
||||
assertThat(gate(CollectionMode.VALIDATION, null, null, null).runtimeSnapshot(false).intervalMs()).isEqualTo(5000);
|
||||
verifyNoInteractions(authority);
|
||||
}
|
||||
@Test void serializedSnapshotContainsNoRawIdentityAddressCredentialOrVerifiedClaim() throws Exception {
|
||||
var raw = new ObjectMapper().findAndRegisterModules().writeValueAsString(
|
||||
gate(CollectionMode.BUSINESS, "private-account", "private-grant", URI.create("http://private-host:9303")).runtimeSnapshot(true));
|
||||
assertThat(raw).doesNotContain("private-account", "private-grant", "private-host", "http://", "verified", "appSecret", "internalServiceToken");
|
||||
verifyNoInteractions(authority);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user