diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be6c3c40..3128032d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,12 @@ jobs: chmod +x gradlew ./gradlew test --no-daemon --build-cache --parallel + # The released CLI is an ordinary Gradle distribution, so it can break without any + # test failing. Building it on every change keeps the release path from rotting + # between the tags that actually publish it. + - name: Build the distributable CLI archive + run: ./gradlew :apps:idea2strategy-cli:distZip --no-daemon --build-cache + container-contracts: runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 00000000..4f4e3f7e --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,70 @@ +name: CLI release + +# Publishes the Idea2Strategy CLI as an installable archive. +# +# The archive is the one the Gradle application plugin already produces, so nothing +# about the build differs from a local `installDist`. Uploading uses the runner's own +# `gh` rather than a release action: one fewer third-party action to pin and to trust +# with a write-scoped token. + +on: + push: + tags: ['cli-v*'] + workflow_dispatch: + inputs: + tag: + description: Existing tag to publish (cli-vX.Y.Z) + required: true + +permissions: + contents: write + +concurrency: + group: cli-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + - name: Verify immutable GitHub Actions references + run: | + chmod +x scripts/test-github-actions-pins.sh + ./scripts/test-github-actions-pins.sh + - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: 21 + - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + with: + cache-read-only: true + + # The tool contract is what an external AI reads before it does anything. A release + # whose contract does not parse is worse than no release, so it is checked here and + # not only in the unit tests that build it. + - name: Build and verify the distribution + run: | + chmod +x gradlew + ./gradlew :apps:idea2strategy-cli:test :apps:idea2strategy-cli:distZip \ + --no-daemon --build-cache + jq -e '.contractCommand and .workflow and .exitCodes' \ + apps/idea2strategy-cli/src/main/resources/idea2strategy-ai-tool-contract.json > /dev/null + + - name: Publish the archive and its checksum + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.inputs.tag || github.ref_name }} + run: | + set -euo pipefail + archive="$(ls apps/idea2strategy-cli/build/distributions/*.zip)" + sha256sum "$archive" > "${archive}.sha256" + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1 \ + || gh release create "$TAG" --repo "$GITHUB_REPOSITORY" \ + --title "Idea2Strategy CLI $TAG" \ + --notes "Requires Java 21. Verify with the published .sha256, unzip, and run \`bin/idea2strategy tool-contract\`." + gh release upload "$TAG" "$archive" "${archive}.sha256" \ + --repo "$GITHUB_REPOSITORY" --clobber diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/delegation/DelegationConfiguration.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/delegation/DelegationConfiguration.java new file mode 100644 index 00000000..d0aff2e2 --- /dev/null +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/delegation/DelegationConfiguration.java @@ -0,0 +1,38 @@ +package com.idea2strategy.backend.api.delegation; + +import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationService; +import com.idea2strategy.backend.persistence.delegation.DelegatedAuthorizationJooqAdapter; +import java.time.Clock; +import java.util.Base64; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty(name = {"spring.datasource.url", "identity.crypto.customer-jwt-signing-key"}) +@Import(DelegatedAuthorizationJooqAdapter.class) +public class DelegationConfiguration { + @Bean + DelegatedAuthorizationService delegatedAuthorizationService( + DelegatedAuthorizationJooqAdapter adapter, + HmacDelegatedCredentials credentials, + Clock identityClock) { + return new DelegatedAuthorizationService(adapter, credentials, identityClock, UUID::randomUUID); + } + + /** + * Falls back to the refresh-token key so a deployment that has not provisioned a dedicated + * delegation key still stores digests rather than raw credentials. Both are 256-bit identity + * secrets from the same store; a separate key is preferable and is what the property is for. + */ + @Bean + HmacDelegatedCredentials hmacDelegatedCredentials( + @Value("${identity.crypto.delegated-credential-hmac-key:${identity.crypto.refresh-token-hmac-key}}") + String key, + @Value("${identity.crypto.delegated-credential-key-version:1}") short keyVersion) { + return new HmacDelegatedCredentials(Base64.getDecoder().decode(key), keyVersion); + } +} diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/delegation/DelegationController.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/delegation/DelegationController.java new file mode 100644 index 00000000..d1ec3702 --- /dev/null +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/delegation/DelegationController.java @@ -0,0 +1,207 @@ +package com.idea2strategy.backend.api.delegation; + +import com.idea2strategy.backend.application.common.CurrentPrincipal; +import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationCommand; +import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationCommandType; +import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationResult; +import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationScope; +import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationService; +import com.idea2strategy.backend.application.delegation.DelegationGrantContextPort; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.stream.Collectors; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Grants and revokes the delegation an external tool edits under. + * + *
The raw credential is returned once, here, and never again: only its digest is stored. A
+ * caller that loses it revokes and grants a new one.
+ */
+@RestController
+@RequestMapping("/api/v1/delegations")
+@ConditionalOnProperty(name = {"spring.datasource.url", "identity.crypto.customer-jwt-signing-key"})
+public class DelegationController {
+ static final String DISCLOSURE_POLICY_CODE = "delegation.strategy-edit.disclosure";
+
+ private final DelegatedAuthorizationService service;
+ private final DelegationGrantContextPort grantContext;
+ private final CurrentPrincipal principal;
+ private final Clock clock;
+ private final Duration defaultLifetime;
+
+ public DelegationController(
+ DelegatedAuthorizationService service,
+ DelegationGrantContextPort grantContext,
+ CurrentPrincipal principal,
+ Clock clock,
+ @Value("${delegation.default-lifetime:PT24H}") Duration defaultLifetime) {
+ this.service = service;
+ this.grantContext = grantContext;
+ this.principal = principal;
+ this.clock = clock;
+ this.defaultLifetime = defaultLifetime;
+ }
+
+ @PostMapping
+ public ResponseEntity Only the digest is stored, so a database reader cannot replay a delegation, and the raw value
+ * is returned exactly once at grant. This mirrors how customer refresh tokens are handled; the key
+ * version travels with the digest so a future key rotation can tell old rows from new ones instead
+ * of invalidating every delegation at once.
+ */
+public final class HmacDelegatedCredentials implements DelegatedCredentialMaterialPort {
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private final byte[] key;
+ private final short keyVersion;
+
+ public HmacDelegatedCredentials(byte[] key, short keyVersion) {
+ Objects.requireNonNull(key, "key");
+ if (key.length < 32) {
+ throw new IllegalArgumentException("Delegated credential HMAC key must contain at least 256 bits");
+ }
+ if (keyVersion < 1) {
+ throw new IllegalArgumentException("Delegated credential key version must be positive");
+ }
+ this.key = key.clone();
+ this.keyVersion = keyVersion;
+ }
+
+ @Override
+ public DelegatedCredentialMaterial issue() {
+ byte[] value = new byte[32];
+ RANDOM.nextBytes(value);
+ String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(value);
+ return new DelegatedCredentialMaterial(raw, digest(raw), keyVersion);
+ }
+
+ private String digest(String raw) {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(key, "HmacSHA256"));
+ return Base64.getUrlEncoder()
+ .withoutPadding()
+ .encodeToString(mac.doFinal(raw.getBytes(StandardCharsets.UTF_8)));
+ } catch (GeneralSecurityException exception) {
+ throw new IllegalStateException("Delegated credential digest failed", exception);
+ }
+ }
+}
diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/strategy/DelegatedBasicEditController.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/strategy/DelegatedBasicEditController.java
new file mode 100644
index 00000000..d5d58c80
--- /dev/null
+++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/strategy/DelegatedBasicEditController.java
@@ -0,0 +1,168 @@
+package com.idea2strategy.backend.api.strategy;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.idea2strategy.backend.application.common.CurrentPrincipal;
+import com.idea2strategy.backend.application.strategy.BasicStrategyCatalogQueryService;
+import com.idea2strategy.backend.application.strategy.DelegatedBasicEditOperation;
+import com.idea2strategy.backend.application.strategy.DelegatedBasicEditPreview;
+import com.idea2strategy.backend.application.strategy.DelegatedBasicStrategyEditService;
+import com.idea2strategy.backend.application.strategy.DelegatedStrategyEditor;
+import com.idea2strategy.backend.application.strategy.StrategyDocumentQueryService;
+import com.idea2strategy.backend.domain.strategy.StrategyDocument;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Operation-level Basic editing for delegated external tools.
+ *
+ * The owner-facing editor writes whole documents through {@link StrategyDocumentController}. A
+ * delegated tool may only submit the four official operations, and only through a reviewed preview:
+ * {@code apply} recomputes the preview and refuses anything whose hash differs from the one the
+ * caller reviewed. The edit sequence travels the same round trip so a delegated apply cannot
+ * silently overwrite a concurrent owner edit.
+ */
+@RestController
+@RequestMapping("/api/v1/strategies/{strategyId}/basic-edits")
+@ConditionalOnProperty(name = {"spring.datasource.url", "identity.crypto.customer-jwt-signing-key"})
+public class DelegatedBasicEditController {
+ private final DelegatedBasicStrategyEditService editService;
+ private final BasicStrategyCatalogQueryService catalogService;
+ private final StrategyDocumentQueryService documentQueryService;
+ private final CurrentPrincipal principal;
+ private final ObjectMapper objectMapper = new ObjectMapper();
+
+ public DelegatedBasicEditController(
+ DelegatedBasicStrategyEditService editService,
+ BasicStrategyCatalogQueryService catalogService,
+ StrategyDocumentQueryService documentQueryService,
+ CurrentPrincipal principal) {
+ this.editService = editService;
+ this.catalogService = catalogService;
+ this.documentQueryService = documentQueryService;
+ this.principal = principal;
+ }
+
+ @PostMapping("/preview")
+ public PreviewResponse preview(
+ @PathVariable UUID strategyId,
+ @RequestBody DelegatedBasicEditRequest request) {
+ long expectedEditSequence = resolveExpectedEditSequence(strategyId, request);
+ DelegatedBasicEditPreview preview = editService.preview(
+ editor(request),
+ strategyId,
+ expectedEditSequence,
+ catalogService.getLatestPublished(),
+ operations(request));
+ return new PreviewResponse(
+ preview.beforeHash(),
+ preview.previewHash(),
+ preview.changes(),
+ readJson(preview.proposedSemanticDocument()),
+ preview.valid(),
+ expectedEditSequence);
+ }
+
+ @PostMapping("/apply")
+ public AppliedResponse apply(
+ @PathVariable UUID strategyId,
+ @RequestBody DelegatedBasicEditRequest request) {
+ if (request.previewHash() == null || request.previewHash().isBlank()) {
+ throw new IllegalArgumentException("A reviewed preview hash is required to apply an edit");
+ }
+ StrategyDocument applied = editService.apply(
+ editor(request),
+ strategyId,
+ resolveExpectedEditSequence(strategyId, request),
+ catalogService.getLatestPublished(),
+ operations(request),
+ request.previewHash());
+ return new AppliedResponse(
+ applied.strategyId(),
+ applied.semanticHash(),
+ applied.editSequence(),
+ applied.updatedAt());
+ }
+
+ /**
+ * The owner-facing client always knows the sequence it read. The CLI learns it from the preview
+ * response and returns it on apply, so an omitted value is only ever the first preview of a
+ * round trip; reading it here would defeat the optimistic lock on apply.
+ */
+ private long resolveExpectedEditSequence(UUID strategyId, DelegatedBasicEditRequest request) {
+ if (request.expectedEditSequence() != null) {
+ return request.expectedEditSequence();
+ }
+ if (request.previewHash() != null && !request.previewHash().isBlank()) {
+ throw new IllegalArgumentException(
+ "An applied edit must carry the edit sequence returned by its preview");
+ }
+ return documentQueryService.getOwned(strategyId).editSequence();
+ }
+
+ private DelegatedStrategyEditor editor(DelegatedBasicEditRequest request) {
+ if (request.authorizationId() == null || request.credentialId() == null) {
+ throw new IllegalArgumentException("Delegated authorization and credential are required");
+ }
+ return new DelegatedStrategyEditor(
+ principal.accountId(), request.authorizationId(), request.credentialId());
+ }
+
+ private List Kept separate from {@link StrategyAuthoringExceptionHandler} because these responses carry a
+ * machine-readable {@code code}: the external tool contract promises stable reasons, not only
+ * stable HTTP statuses, and an AI tool decides whether to stop, re-preview, or ask the user from
+ * that field. Owner-facing authoring endpoints make no such promise, and widening the existing
+ * advice would extend one to them by accident.
+ */
+@RestControllerAdvice(assignableTypes = DelegatedBasicEditController.class)
+public class DelegatedBasicEditExceptionHandler {
+ @ExceptionHandler(DelegatedStrategyScopeDeniedException.class)
+ ProblemDetail scopeDenied(DelegatedStrategyScopeDeniedException exception) {
+ return problem(HttpStatus.FORBIDDEN, "SCOPE_DENIED", "Delegated scope denied", exception);
+ }
+
+ @ExceptionHandler(DelegatedBasicEditPreviewMismatchException.class)
+ ProblemDetail previewMismatch(DelegatedBasicEditPreviewMismatchException exception) {
+ return problem(HttpStatus.CONFLICT, "PREVIEW_MISMATCH", "Reviewed preview mismatch", exception);
+ }
+
+ @ExceptionHandler(DelegatedBasicEditRejectedException.class)
+ ProblemDetail editRejected(DelegatedBasicEditRejectedException exception) {
+ return problem(
+ HttpStatus.UNPROCESSABLE_ENTITY, "EDIT_REJECTED", "Delegated edit rejected", exception);
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ ProblemDetail invalidRequest(IllegalArgumentException exception) {
+ return problem(HttpStatus.BAD_REQUEST, "INVALID_REQUEST", "Invalid delegated edit", exception);
+ }
+
+ @ExceptionHandler(NoSuchElementException.class)
+ ProblemDetail notFound(NoSuchElementException exception) {
+ return problem(HttpStatus.NOT_FOUND, "STRATEGY_NOT_FOUND", "Strategy not found", exception);
+ }
+
+ private static ProblemDetail problem(
+ HttpStatus status, String code, String title, RuntimeException exception) {
+ ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, exception.getMessage());
+ problem.setTitle(title);
+ problem.setProperty("code", code);
+ problem.setProperty("message", exception.getMessage());
+ return problem;
+ }
+}
diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/strategy/StrategyDraftConfiguration.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/strategy/StrategyDraftConfiguration.java
index 23e99956..b7128438 100644
--- a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/strategy/StrategyDraftConfiguration.java
+++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/strategy/StrategyDraftConfiguration.java
@@ -4,6 +4,7 @@
import com.idea2strategy.backend.application.strategy.BasicStrategyDraftCommandService;
import com.idea2strategy.backend.application.strategy.BasicStructureCatalogQueryService;
import com.idea2strategy.backend.application.strategy.BasicStrategyValidationCommandService;
+import com.idea2strategy.backend.application.strategy.DelegatedBasicStrategyEditService;
import com.idea2strategy.backend.application.strategy.SecureStrategyEditLeaseTokenGenerator;
import com.idea2strategy.backend.application.strategy.StrategyCopyCommandService;
import com.idea2strategy.backend.application.strategy.StrategyDocumentQueryService;
@@ -12,6 +13,7 @@
import com.idea2strategy.backend.application.strategy.StrategyReleaseInputCatalogQueryService;
import com.idea2strategy.backend.persistence.strategy.BasicStrategyDraftJpaCommandAdapter;
import com.idea2strategy.backend.persistence.strategy.BasicStructureCatalogJooqQueryAdapter;
+import com.idea2strategy.backend.persistence.strategy.DelegatedBasicStrategyEditJooqAdapter;
import com.idea2strategy.backend.persistence.strategy.StrategyDocumentJpaEntity;
import com.idea2strategy.backend.persistence.strategy.StrategyDocumentJooqQueryAdapter;
import com.idea2strategy.backend.persistence.strategy.StrategyDocumentSpringDataRepository;
@@ -57,7 +59,8 @@
StrategyEditLeaseJpaCommandAdapter.class,
StrategyValidationRunJpaCommandAdapter.class,
StrategyValidationRunJooqQueryAdapter.class,
- StrategyReleaseInputCatalogJooqQueryAdapter.class
+ StrategyReleaseInputCatalogJooqQueryAdapter.class,
+ DelegatedBasicStrategyEditJooqAdapter.class
})
public class StrategyDraftConfiguration {
@Bean
@@ -147,6 +150,19 @@ StrategyValidationQueryService strategyValidationQueryService(
return new StrategyValidationQueryService(validationQueryAdapter, documentQueryAdapter, principal);
}
+ @Bean
+ DelegatedBasicStrategyEditService delegatedBasicStrategyEditService(
+ StrategyJooqQueryAdapter strategyQueryAdapter,
+ StrategyDocumentJooqQueryAdapter documentQueryAdapter,
+ DelegatedBasicStrategyEditJooqAdapter delegatedEditAdapter) {
+ return new DelegatedBasicStrategyEditService(
+ strategyQueryAdapter,
+ documentQueryAdapter,
+ delegatedEditAdapter,
+ delegatedEditAdapter,
+ Clock.systemUTC());
+ }
+
@Bean
StrategyReleaseInputCatalogQueryService strategyReleaseInputCatalogQueryService(
StrategyReleaseInputCatalogJooqQueryAdapter queryAdapter) {
diff --git a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java
new file mode 100644
index 00000000..95573db3
--- /dev/null
+++ b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java
@@ -0,0 +1,160 @@
+package com.idea2strategy.backend.api.journey;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.idea2strategy.backend.api.identity.AccountVerificationEmailRequested;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.event.ApplicationEvents;
+import org.springframework.test.context.event.RecordApplicationEvents;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.web.context.WebApplicationContext;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+/**
+ * The journey an external AI tool actually performs, over HTTP.
+ *
+ * Every step here was already covered somewhere — the CLI against a stub, the edit service
+ * against fakes, the grant against Postgres — and the product still could not do it, because the
+ * routes the CLI posts to did not exist. Covering the pieces is what let that happen, so this test
+ * runs the line end to end over HTTP: sign up, log in, create a strategy, delegate editing of it,
+ * reach the edit service under that delegation, and lose access the moment it is revoked.
+ *
+ * It stops short of applying blocks. A new strategy has no groups and the delegated operations
+ * cannot create one, so a real apply needs a valid Basic skeleton with a catalog and instruments;
+ * that belongs in a strategy-authoring fixture rather than here. What this test does establish is
+ * the part that was actually broken — that the routes exist and that a granted delegation carries
+ * a request through authorization, which no stub could show.
+ */
+@Testcontainers(disabledWithoutDocker = true)
+@SpringBootTest
+@RecordApplicationEvents
+class ExternalToolDelegatedEditJourneyIntegrationTest {
+ private static final String EMAIL = "delegated-edit@example.com";
+ private static final String PASSWORD = "CorrectHorse!2026";
+
+ @Container
+ static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16-alpine");
+
+ @DynamicPropertySource
+ static void properties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ registry.add("spring.jpa.hibernate.ddl-auto", () -> "none");
+ registry.add("spring.flyway.enabled", () -> "true");
+ String key = Base64.getEncoder().encodeToString(
+ "01234567890123456789012345678901".getBytes(StandardCharsets.UTF_8));
+ String jwtKey = Base64.getEncoder().encodeToString(
+ "abcdefabcdefabcdefabcdefabcdefab".getBytes(StandardCharsets.UTF_8));
+ registry.add("identity.crypto.email-encryption-key", () -> key);
+ registry.add("identity.crypto.lookup-hmac-key", () -> key);
+ registry.add("identity.crypto.verification-hmac-key", () -> key);
+ registry.add("identity.crypto.refresh-token-hmac-key", () -> key);
+ registry.add("identity.crypto.customer-jwt-signing-key", () -> jwtKey);
+ }
+
+ @Autowired WebApplicationContext context;
+ @Autowired ObjectMapper json;
+ @Autowired ApplicationEvents events;
+
+ @Test
+ void anExternalToolDelegatesThenPreviewsAndAppliesABasicEdit() throws Exception {
+ MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build();
+
+ mvc.perform(post("/api/v1/auth/signup")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"email":"%s","password":"%s","nickname":"delegator"}
+ """.formatted(EMAIL, PASSWORD)))
+ .andExpect(status().isAccepted());
+ String verificationToken = events.stream(AccountVerificationEmailRequested.class)
+ .findFirst()
+ .map(AccountVerificationEmailRequested::verificationToken)
+ .orElseThrow();
+ mvc.perform(post("/api/v1/auth/verify-email")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"verificationToken":"%s"}
+ """.formatted(verificationToken)))
+ .andExpect(status().isNoContent());
+
+ String accessToken = json.readTree(mvc.perform(post("/api/v1/auth/login")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"email":"%s","password":"%s"}
+ """.formatted(EMAIL, PASSWORD)))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString())
+ .path("accessToken").asText();
+
+ String strategyId = json.readTree(mvc.perform(post("/api/v1/strategies")
+ .header("Authorization", "Bearer " + accessToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"Delegated draft\",\"mode\":\"BASIC\"}"))
+ .andExpect(status().isCreated())
+ .andReturn().getResponse().getContentAsString())
+ .path("id").asText();
+
+ JsonNode grant = json.readTree(mvc.perform(post("/api/v1/delegations")
+ .header("Authorization", "Bearer " + accessToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"name":"assistant","scopes":["STRATEGY_EDIT"],"strategyIds":["%s"]}
+ """.formatted(strategyId)))
+ .andExpect(status().isCreated())
+ .andReturn().getResponse().getContentAsString());
+ // Returned exactly once. Nothing later in the journey can recover it.
+ assertThat(grant.path("credential").asText()).isNotBlank();
+
+ String editBody = """
+ {"authorizationId":"%s","credentialId":"%s","operations":[
+ {"action":"ADD_BLOCK","arguments":{"groupId":"buy","blockId":"b1",
+ "elementCode":"PRICE_CHANGE_PERCENT"}}]}
+ """.formatted(
+ grant.path("authorizationId").asText(), grant.path("credentialId").asText());
+
+ // A freshly created strategy is {"groups":[],"mode":"BASIC"} and the four delegated
+ // operations cannot create a group, so this edit is refused on its merits — which is the
+ // assertion that matters here. EDIT_REJECTED means the delegation was accepted and the
+ // request reached the edit service; a delegation that did not authorize would answer 403
+ // SCOPE_DENIED, and a missing route would answer 404, which is what it did before this
+ // change. Applying real blocks needs a valid Basic skeleton and is covered separately.
+ JsonNode refusal = json.readTree(mvc.perform(
+ post("/api/v1/strategies/" + strategyId + "/basic-edits/preview")
+ .header("Authorization", "Bearer " + accessToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(editBody))
+ .andExpect(status().isUnprocessableEntity())
+ .andReturn().getResponse().getContentAsString());
+ assertThat(refusal.path("code").asText()).isEqualTo("EDIT_REJECTED");
+
+ // Revoking takes effect at once: the same call now fails authorization instead of merits.
+ mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+ .delete("/api/v1/delegations/" + grant.path("authorizationId").asText())
+ .header("Authorization", "Bearer " + accessToken))
+ .andExpect(status().isNoContent());
+
+ JsonNode denied = json.readTree(mvc.perform(
+ post("/api/v1/strategies/" + strategyId + "/basic-edits/preview")
+ .header("Authorization", "Bearer " + accessToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(editBody))
+ .andExpect(status().isForbidden())
+ .andReturn().getResponse().getContentAsString());
+ assertThat(denied.path("code").asText()).isEqualTo("SCOPE_DENIED");
+ }
+}
diff --git a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/strategy/DelegatedBasicEditRouteRegistrationTest.java b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/strategy/DelegatedBasicEditRouteRegistrationTest.java
new file mode 100644
index 00000000..deaa16bc
--- /dev/null
+++ b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/strategy/DelegatedBasicEditRouteRegistrationTest.java
@@ -0,0 +1,115 @@
+package com.idea2strategy.backend.api.strategy;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+import com.idea2strategy.backend.application.common.CurrentPrincipal;
+import com.idea2strategy.backend.application.strategy.BasicStrategyCatalogQueryService;
+import com.idea2strategy.backend.application.strategy.DelegatedBasicStrategyEditService;
+import com.idea2strategy.backend.application.strategy.StrategyDocumentQueryService;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+/**
+ * Pins the routes the external tool CLI calls.
+ *
+ * This test exists because these endpoints were absent from the API for the whole life of the
+ * CLI and nobody noticed: the CLI's own tests drive a stub HTTP server, so they proved the client
+ * sent a correct request to a path that did not exist. A stub can only agree with itself. The path
+ * strings below are duplicated from {@code Idea2StrategyCli} on purpose — a rename on either side
+ * must break a test rather than a released tool.
+ */
+class DelegatedBasicEditRouteRegistrationTest {
+ private static final String BASE = "/api/v1/strategies/{strategyId}/basic-edits";
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withUserConfiguration(DelegatedEditDependencies.class, DelegatedBasicEditController.class)
+ .withPropertyValues(
+ "spring.datasource.url=jdbc:postgresql://unused/test",
+ "identity.crypto.customer-jwt-signing-key=test-customer-jwt-signing-key");
+
+ @Test
+ void registersTheDelegatedEditControllerWhenTheStrategyDraftModuleIsEnabled() {
+ contextRunner.run(context -> {
+ assertThat(context).hasNotFailed();
+ assertThat(context).hasSingleBean(DelegatedBasicEditController.class);
+ });
+ }
+
+ @Test
+ void keepsTheControllerDisabledWhenTheStrategyDraftModuleIsDisabled() {
+ new ApplicationContextRunner()
+ .withUserConfiguration(DelegatedEditDependencies.class, DelegatedBasicEditController.class)
+ .run(context -> {
+ assertThat(context).hasNotFailed();
+ assertThat(context).doesNotHaveBean(DelegatedBasicEditController.class);
+ });
+ }
+
+ /**
+ * The tool contract names {@code data.diff} as the field an external AI must inspect before
+ * applying. If that field ever becomes the resulting document instead of the change list, the
+ * review gate still passes mechanically while nothing reviewable was reviewed.
+ */
+ @Test
+ void previewReportsTheChangeListAsTheReviewableDiff() throws Exception {
+ var diff = DelegatedBasicEditController.PreviewResponse.class.getRecordComponents()[2];
+
+ assertThat(diff.getName()).isEqualTo("diff");
+ assertThat(diff.getType()).isEqualTo(List.class);
+ }
+
+ @Test
+ void refusalAdviceStaysScopedToTheDelegatedRoute() {
+ var advice = DelegatedBasicEditExceptionHandler.class
+ .getAnnotation(org.springframework.web.bind.annotation.RestControllerAdvice.class);
+
+ assertThat(advice.assignableTypes()).containsExactly(DelegatedBasicEditController.class);
+ }
+
+ @Test
+ void exposesExactlyThePathsTheExternalToolCliCalls() {
+ RequestMapping base = DelegatedBasicEditController.class.getAnnotation(RequestMapping.class);
+ assertThat(base).isNotNull();
+ assertThat(base.value()).containsExactly(BASE);
+
+ assertThat(postMappings()).containsExactlyInAnyOrder("/preview", "/apply");
+ }
+
+ private static List Both are read rather than accepted from the request on purpose. The auth epoch is what makes a
+ * delegation die when the account re-authenticates, so a client that could name it could outlive a
+ * password change. The disclosure document is the text the customer was actually shown; letting a
+ * request choose it would let a delegation claim consent to something else.
+ */
+public interface DelegationGrantContextPort {
+ long currentAuthEpoch(UUID accountId);
+
+ UUID currentDisclosurePolicyDocumentId(String policyCode);
+}
diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicEditPreviewMismatchException.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicEditPreviewMismatchException.java
new file mode 100644
index 00000000..875f8cb1
--- /dev/null
+++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicEditPreviewMismatchException.java
@@ -0,0 +1,8 @@
+package com.idea2strategy.backend.application.strategy;
+
+/** The reviewed preview hash does not describe the edit being applied. */
+public final class DelegatedBasicEditPreviewMismatchException extends DelegatedBasicEditRejectedException {
+ public DelegatedBasicEditPreviewMismatchException(String message) {
+ super(message);
+ }
+}
diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicEditRejectedException.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicEditRejectedException.java
index 694b466c..171bd191 100644
--- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicEditRejectedException.java
+++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicEditRejectedException.java
@@ -1,6 +1,14 @@
package com.idea2strategy.backend.application.strategy;
-public final class DelegatedBasicEditRejectedException extends RuntimeException {
+/**
+ * A delegated Basic edit was refused.
+ *
+ * Two refusals mean something different to the caller and carry their own subtype, because the
+ * external tool contract maps them to distinct stable exit codes: scope denial says the delegation
+ * never permitted this, while a preview mismatch says the reviewed diff is not the one being
+ * applied. Every other refusal is an ordinary invalid operation and uses this type directly.
+ */
+public class DelegatedBasicEditRejectedException extends RuntimeException {
public DelegatedBasicEditRejectedException(String message) {
super(message);
}
diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditService.java
index 0f2607f3..e1c684d8 100644
--- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditService.java
+++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditService.java
@@ -78,7 +78,7 @@ public StrategyDocument apply(
editor, strategyId, expectedEditSequence, catalog, operations,
DelegatedStrategyScope.STRATEGY_EDIT);
if (!preview.previewHash().equals(reviewedPreviewHash)) {
- throw new DelegatedBasicEditRejectedException(
+ throw new DelegatedBasicEditPreviewMismatchException(
"Reviewed preview does not match the requested edit");
}
if (!preview.valid()) {
@@ -97,7 +97,7 @@ public StrategyDocument apply(
return switch (commandPort.replace(replacement, expectedEditSequence, editor, clock.instant())) {
case UPDATED -> replacement;
case STALE_EDIT_SEQUENCE -> throw new StrategyDraftConflictException();
- case UNAUTHORIZED -> throw new DelegatedBasicEditRejectedException(
+ case UNAUTHORIZED -> throw new DelegatedStrategyScopeDeniedException(
"Delegated authorization is not active for strategy editing");
};
}
diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedStrategyScopeDeniedException.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedStrategyScopeDeniedException.java
new file mode 100644
index 00000000..dc11f8ef
--- /dev/null
+++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedStrategyScopeDeniedException.java
@@ -0,0 +1,8 @@
+package com.idea2strategy.backend.application.strategy;
+
+/** The delegation does not carry an active scope for the requested edit. */
+public final class DelegatedStrategyScopeDeniedException extends DelegatedBasicEditRejectedException {
+ public DelegatedStrategyScopeDeniedException(String message) {
+ super(message);
+ }
+}
diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/delegation/DelegatedAuthorizationJooqAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/delegation/DelegatedAuthorizationJooqAdapter.java
new file mode 100644
index 00000000..ef6d7549
--- /dev/null
+++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/delegation/DelegatedAuthorizationJooqAdapter.java
@@ -0,0 +1,273 @@
+package com.idea2strategy.backend.persistence.delegation;
+
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationCommand;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationCommandPort;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationCommandType;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationDecision;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationExecution;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationMutation;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationResult;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationScope;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationSnapshot;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationStatus;
+import com.idea2strategy.backend.application.delegation.DelegationGrantContextPort;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.TreeSet;
+import java.util.UUID;
+import org.jooq.DSLContext;
+import org.jooq.Record;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Grants, replaces, and revokes delegated authorizations in one transaction.
+ *
+ * The event row is the idempotency receipt: {@code (authorization_id, idempotency_key)} is
+ * unique, so a replayed request finds its own event and returns the state it already produced
+ * without issuing a second credential. That ordering matters — deciding first and detecting the
+ * replay afterwards would mint a credential the caller never receives.
+ */
+@Component
+public class DelegatedAuthorizationJooqAdapter
+ implements DelegatedAuthorizationCommandPort, DelegationGrantContextPort {
+ private final DSLContext dsl;
+
+ public DelegatedAuthorizationJooqAdapter(DSLContext dsl) {
+ this.dsl = Objects.requireNonNull(dsl, "dsl");
+ }
+
+ @Override
+ public long currentAuthEpoch(UUID accountId) {
+ Record epochRow = dsl.fetchOne(
+ "select auth_epoch from identity.account_security_states where account_id = ?",
+ accountId);
+ Long epoch = epochRow == null ? null : epochRow.get("auth_epoch", Long.class);
+ if (epoch == null) {
+ throw new java.util.NoSuchElementException("Account has no security state");
+ }
+ return epoch;
+ }
+
+ @Override
+ public UUID currentDisclosurePolicyDocumentId(String policyCode) {
+ Record documentRow = dsl.fetchOne(
+ "select id from identity.policy_documents "
+ + "where policy_code = ? and retired_at is null and published_at <= now() "
+ + "order by published_at desc, version desc, id desc limit 1",
+ policyCode);
+ UUID id = documentRow == null ? null : documentRow.get("id", UUID.class);
+ if (id == null) {
+ throw new java.util.NoSuchElementException(
+ "No published disclosure document for " + policyCode);
+ }
+ return id;
+ }
+
+ @Override
+ @Transactional
+ public DelegatedAuthorizationExecution executeAtomically(
+ DelegatedAuthorizationCommand command, Instant at, DelegatedAuthorizationDecision decision) {
+ UUID subjectId = command.commandType() == DelegatedAuthorizationCommandType.REPLACE
+ ? command.replacesAuthorizationId()
+ : command.authorizationId();
+
+ Optional The target insert selects from the strategy row itself, so this is raised when the strategy is
+ * not the granting account's own live Basic strategy. Failing here rather than skipping the row
+ * matters: a delegation whose targets silently vanished would be granted, returned to the caller,
+ * and then authorize nothing.
+ */
+public class DelegatedStrategyTargetRejectedException extends RuntimeException {
+ public DelegatedStrategyTargetRejectedException(String message) {
+ super(message);
+ }
+}
diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/strategy/DelegatedBasicStrategyEditJooqAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/strategy/DelegatedBasicStrategyEditJooqAdapter.java
index 0a9f7803..b5f07c8d 100644
--- a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/strategy/DelegatedBasicStrategyEditJooqAdapter.java
+++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/strategy/DelegatedBasicStrategyEditJooqAdapter.java
@@ -1,11 +1,11 @@
package com.idea2strategy.backend.persistence.strategy;
import com.idea2strategy.backend.application.strategy.DelegatedBasicEditCommandPort;
-import com.idea2strategy.backend.application.strategy.DelegatedBasicEditRejectedException;
import com.idea2strategy.backend.application.strategy.DelegatedBasicEditReplaceResult;
import com.idea2strategy.backend.application.strategy.DelegatedStrategyAuthorizationPort;
import com.idea2strategy.backend.application.strategy.DelegatedStrategyEditor;
import com.idea2strategy.backend.application.strategy.DelegatedStrategyScope;
+import com.idea2strategy.backend.application.strategy.DelegatedStrategyScopeDeniedException;
import com.idea2strategy.backend.application.strategy.StrategyDocumentJson;
import com.idea2strategy.backend.domain.strategy.StrategyDocument;
import java.nio.charset.StandardCharsets;
@@ -32,7 +32,7 @@ public void requireAuthorized(
DelegatedStrategyScope scope,
Instant at) {
if (!isAuthorized(editor, strategyId, scope, at)) {
- throw new DelegatedBasicEditRejectedException(
+ throw new DelegatedStrategyScopeDeniedException(
"Delegated authorization is not active for " + scope.name());
}
}
diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/delegation/DelegatedAuthorizationGrantPersistenceIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/delegation/DelegatedAuthorizationGrantPersistenceIntegrationTest.java
new file mode 100644
index 00000000..0e41a854
--- /dev/null
+++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/delegation/DelegatedAuthorizationGrantPersistenceIntegrationTest.java
@@ -0,0 +1,239 @@
+package com.idea2strategy.backend.persistence.delegation;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationCommand;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationCommandType;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationScope;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationService;
+import com.idea2strategy.backend.application.delegation.DelegatedAuthorizationStatus;
+import com.idea2strategy.backend.application.delegation.DelegatedCredentialMaterial;
+import com.idea2strategy.backend.application.strategy.DelegatedStrategyEditor;
+import com.idea2strategy.backend.application.strategy.DelegatedStrategyScope;
+import com.idea2strategy.backend.application.strategy.DelegatedStrategyScopeDeniedException;
+import com.idea2strategy.backend.persistence.strategy.DelegatedBasicStrategyEditJooqAdapter;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Set;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.testcontainers.containers.PostgreSQLContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+/**
+ * Grants a delegation with the production adapter, then edits under it with the production
+ * authorization check.
+ *
+ * This is the join the project had no coverage for. The edit check reads ten columns across five
+ * tables — status, expiry mode and instant, auth epoch, credential type and expiry, pinned owner and
+ * access epoch — and any one of them written differently by the grant makes every edit deny with no
+ * indication of which column was wrong. Asserting the grant's rows in isolation would not catch
+ * that; only running the real check against the real grant does.
+ */
+@Testcontainers(disabledWithoutDocker = true)
+@SpringBootTest(classes = DelegatedAuthorizationGrantPersistenceIntegrationTest.TestApplication.class)
+class DelegatedAuthorizationGrantPersistenceIntegrationTest {
+ private static final UUID ACCOUNT_ID = UUID.fromString("10000000-0000-4000-8000-000000000092");
+ private static final UUID STRATEGY_ID = UUID.fromString("50000000-0000-4000-8000-000000000092");
+ private static final UUID OTHER_STRATEGY_ID = UUID.fromString("50000000-0000-4000-8000-000000000093");
+ private static final Instant NOW = Instant.parse("2026-08-01T12:00:00Z");
+ private static final String HASH_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ private static final String HASH_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+
+ @Container
+ static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16-alpine");
+
+ @DynamicPropertySource
+ static void databaseProperties(DynamicPropertyRegistry registry) {
+ registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
+ registry.add("spring.datasource.username", POSTGRES::getUsername);
+ registry.add("spring.datasource.password", POSTGRES::getPassword);
+ registry.add("spring.jpa.hibernate.ddl-auto", () -> "none");
+ registry.add("spring.flyway.enabled", () -> "true");
+ }
+
+ @Autowired
+ private DelegatedAuthorizationJooqAdapter grantAdapter;
+
+ @Autowired
+ private DelegatedBasicStrategyEditJooqAdapter editAdapter;
+
+ @Autowired
+ private JdbcTemplate jdbc;
+
+ private DelegatedAuthorizationService service;
+
+ /**
+ * Resolved from the database rather than inserted here: the migration already publishes this
+ * document, and a fixture that inserted its own would silently stop covering the row a real
+ * grant actually points at.
+ */
+ private UUID policyId;
+
+ @BeforeEach
+ void prepareAccountAndStrategies() {
+ var at = NOW.atOffset(ZoneOffset.UTC);
+ jdbc.update("delete from identity.delegated_authorization_events");
+ jdbc.update(
+ "insert into identity.accounts (id, lifecycle_status, status_changed_at) values (?, 'ACTIVE', ?) "
+ + "on conflict (id) do nothing",
+ ACCOUNT_ID, at);
+ jdbc.update(
+ "insert into identity.account_security_states (account_id, auth_epoch, updated_at) "
+ + "values (?, 4, ?) on conflict (account_id) do nothing",
+ ACCOUNT_ID, at);
+ policyId = grantAdapter.currentDisclosurePolicyDocumentId("delegation.strategy-edit.disclosure");
+ insertStrategy(STRATEGY_ID, "Delegated draft");
+ insertStrategy(OTHER_STRATEGY_ID, "Untargeted draft");
+
+ service = new DelegatedAuthorizationService(
+ grantAdapter,
+ // token_digest is UNIQUE, as it must be: two delegations sharing a digest would
+ // authorize each other. The real HMAC of a random 256-bit value satisfies that, so
+ // the fake has to vary too or it tests a constraint violation instead of a grant.
+ () -> new DelegatedCredentialMaterial(
+ "raw-" + UUID.randomUUID(),
+ UUID.randomUUID().toString().replace("-", "").repeat(2),
+ (short) 1),
+ Clock.fixed(NOW, ZoneOffset.UTC),
+ UUID::randomUUID);
+ }
+
+ @Test
+ void grantsADelegationTheEditCheckAccepts() {
+ UUID authorizationId = UUID.randomUUID();
+
+ var result = service.execute(createCommand(authorizationId, "grant-1", Set.of(STRATEGY_ID)));
+
+ assertThat(result.status()).isEqualTo(DelegatedAuthorizationStatus.ACTIVE);
+ assertThat(result.rawCredential()).isPresent();
+ editAdapter.requireAuthorized(
+ new DelegatedStrategyEditor(ACCOUNT_ID, authorizationId, result.credentialId()),
+ STRATEGY_ID,
+ DelegatedStrategyScope.STRATEGY_EDIT,
+ NOW.plusSeconds(60));
+ }
+
+ @Test
+ void deniesAStrategyTheGrantNeverTargeted() {
+ UUID authorizationId = UUID.randomUUID();
+ var result = service.execute(createCommand(authorizationId, "grant-2", Set.of(STRATEGY_ID)));
+
+ assertThatThrownBy(() -> editAdapter.requireAuthorized(
+ new DelegatedStrategyEditor(ACCOUNT_ID, authorizationId, result.credentialId()),
+ OTHER_STRATEGY_ID,
+ DelegatedStrategyScope.STRATEGY_EDIT,
+ NOW.plusSeconds(60)))
+ .isInstanceOf(DelegatedStrategyScopeDeniedException.class);
+ }
+
+ /** The disclosure promises an expiry, so a grant that carries one must actually stop working. */
+ @Test
+ void deniesTheDelegationOnceItsExpiryHasPassed() {
+ UUID authorizationId = UUID.randomUUID();
+ var result = service.execute(createCommand(authorizationId, "grant-3", Set.of(STRATEGY_ID)));
+
+ assertThatThrownBy(() -> editAdapter.requireAuthorized(
+ new DelegatedStrategyEditor(ACCOUNT_ID, authorizationId, result.credentialId()),
+ STRATEGY_ID,
+ DelegatedStrategyScope.STRATEGY_EDIT,
+ NOW.plusSeconds(86_400 + 60)))
+ .isInstanceOf(DelegatedStrategyScopeDeniedException.class);
+ }
+
+ @Test
+ void replayingTheSameCommandReturnsTheFirstGrantWithoutIssuingASecondCredential() {
+ UUID authorizationId = UUID.randomUUID();
+ var command = createCommand(authorizationId, "grant-4", Set.of(STRATEGY_ID));
+
+ var first = service.execute(command);
+ var replay = service.execute(command);
+
+ assertThat(replay.authorizationId()).isEqualTo(first.authorizationId());
+ assertThat(replay.credentialId()).isEqualTo(first.credentialId());
+ assertThat(replay.rawCredential()).isEmpty();
+ assertThat(jdbc.queryForObject(
+ "select count(*) from identity.delegated_credentials where authorization_id = ?",
+ Integer.class,
+ authorizationId))
+ .isEqualTo(1);
+ }
+
+ @Test
+ void revokedDelegationsStopAuthorizingImmediately() {
+ UUID authorizationId = UUID.randomUUID();
+ var result = service.execute(createCommand(authorizationId, "grant-5", Set.of(STRATEGY_ID)));
+
+ service.execute(new DelegatedAuthorizationCommand(
+ DelegatedAuthorizationCommandType.REVOKE, ACCOUNT_ID, authorizationId, null, 1L, 4L,
+ "revoked", policyId, Set.of(), Set.of(), null, "USER_REQUESTED", "revoke-5",
+ "revoke-hash-5", UUID.randomUUID()));
+
+ assertThatThrownBy(() -> editAdapter.requireAuthorized(
+ new DelegatedStrategyEditor(ACCOUNT_ID, authorizationId, result.credentialId()),
+ STRATEGY_ID,
+ DelegatedStrategyScope.STRATEGY_EDIT,
+ NOW.plusSeconds(60)))
+ .isInstanceOf(DelegatedStrategyScopeDeniedException.class);
+ }
+
+ @Test
+ void refusesToTargetAStrategyTheAccountDoesNotOwn() {
+ UUID foreignStrategy = UUID.randomUUID();
+
+ assertThatThrownBy(() -> service.execute(
+ createCommand(UUID.randomUUID(), "grant-6", Set.of(foreignStrategy))))
+ .isInstanceOf(DelegatedStrategyTargetRejectedException.class);
+ }
+
+ private DelegatedAuthorizationCommand createCommand(
+ UUID authorizationId, String idempotencyKey, Set