Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions .github/workflows/cli-release.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<GrantResponse> create(@RequestBody CreateDelegationRequest request) {
UUID accountId = principal.accountId();
Set<DelegatedAuthorizationScope> scopes = scopes(request.scopes());
Set<UUID> targets = targets(request.strategyIds());
// A delegation with no expiry never stops working. The customer-visible disclosure
// promises one, so an omitted value becomes the configured default rather than nothing.
Instant expiresAt = request.expiresAt() != null
? request.expiresAt()
: clock.instant().plus(defaultLifetime);
if (!expiresAt.isAfter(clock.instant())) {
throw new IllegalArgumentException("A delegation must expire in the future");
}

UUID authorizationId = UUID.randomUUID();
UUID correlationId = UUID.randomUUID();
String requestHash = hash(accountId, scopes, targets, expiresAt, request.name());
DelegatedAuthorizationResult result = service.execute(new DelegatedAuthorizationCommand(
DelegatedAuthorizationCommandType.CREATE,
accountId,
authorizationId,
null,
0L,
grantContext.currentAuthEpoch(accountId),
requireName(request.name()),
grantContext.currentDisclosurePolicyDocumentId(DISCLOSURE_POLICY_CODE),
scopes,
targets,
expiresAt,
"USER_REQUESTED",
"delegation-create:" + requestHash,
requestHash,
correlationId));

return ResponseEntity.status(HttpStatus.CREATED).body(new GrantResponse(
result.authorizationId(),
result.credentialId(),
result.rawCredential().orElse(null),
result.expiresAt(),
scopes.stream().map(Enum::name).sorted().toList(),
targets.stream().map(UUID::toString).sorted().toList()));
}

@DeleteMapping("/{authorizationId}")
public ResponseEntity<Void> revoke(@PathVariable UUID authorizationId) {
UUID accountId = principal.accountId();
String requestHash = hash(accountId, Set.of(), Set.of(authorizationId), null, "revoke");
service.execute(new DelegatedAuthorizationCommand(
DelegatedAuthorizationCommandType.REVOKE,
accountId,
authorizationId,
null,
1L,
grantContext.currentAuthEpoch(accountId),
"revoked",
grantContext.currentDisclosurePolicyDocumentId(DISCLOSURE_POLICY_CODE),
Set.of(),
Set.of(),
null,
"USER_REQUESTED",
"delegation-revoke:" + requestHash,
requestHash,
UUID.randomUUID()));
return ResponseEntity.noContent().build();
}

/**
* Only the two Basic editing scopes are reachable here. The enum carries wider ones for other
* flows, and an external tool must not be able to name them by spelling them in a request.
*/
private static Set<DelegatedAuthorizationScope> scopes(List<String> requested) {
if (requested == null || requested.isEmpty()) {
throw new IllegalArgumentException("At least one delegation scope is required");
}
Set<DelegatedAuthorizationScope> allowed = Set.of(
DelegatedAuthorizationScope.STRATEGY_EDIT, DelegatedAuthorizationScope.STRATEGY_VALIDATE);
Set<DelegatedAuthorizationScope> scopes = requested.stream()
.map(String::trim)
.map(value -> {
try {
return DelegatedAuthorizationScope.valueOf(value);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("Unknown delegation scope: " + value);
}
})
.collect(Collectors.toUnmodifiableSet());
if (!allowed.containsAll(scopes)) {
throw new IllegalArgumentException("Only Basic edit and validation scopes may be delegated");
}
return scopes;
}

/**
* The authorization check requires a pinned target, so a delegation without one is granted,
* returned, and then authorizes nothing. Refusing it here keeps that from looking like success.
*/
private static Set<UUID> targets(List<UUID> strategyIds) {
if (strategyIds == null || strategyIds.isEmpty()) {
throw new IllegalArgumentException("A delegation must name at least one target strategy");
}
return Set.copyOf(strategyIds);
}

private static String requireName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("A delegation name is required");
}
return name.trim();
}

private static String hash(
UUID accountId,
Set<DelegatedAuthorizationScope> scopes,
Set<UUID> targets,
Instant expiresAt,
String name) {
TreeSet<String> parts = new TreeSet<>();
scopes.forEach(scope -> parts.add("scope:" + scope.name()));
targets.forEach(target -> parts.add("target:" + target));
String canonical = accountId + "|" + name + "|" + expiresAt + "|" + String.join(",", parts);
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is required", exception);
}
}

public record CreateDelegationRequest(
String name, List<String> scopes, List<UUID> strategyIds, Instant expiresAt) {}

public record GrantResponse(
UUID authorizationId,
UUID credentialId,
String credential,
Instant expiresAt,
List<String> scopes,
List<String> strategyIds) {
@Override
public String toString() {
return "GrantResponse[credential=REDACTED]";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.idea2strategy.backend.api.delegation;

import com.idea2strategy.backend.application.delegation.DelegatedCredentialMaterial;
import com.idea2strategy.backend.application.delegation.DelegatedCredentialMaterialPort;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Objects;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

/**
* Issues the secret a delegated tool holds.
*
* <p>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);
}
}
}
Loading