Merge branch 'main' into feature/issue-155-password-reset-otp

This commit is contained in:
dongmucat 2026-04-10 13:48:31 +08:00
commit bdfca49e35
19 changed files with 1130 additions and 45 deletions

View file

@ -0,0 +1,161 @@
name: PR Batch Test Deploy
on:
workflow_dispatch:
inputs:
pr_numbers:
description: "Comma/newline separated PR numbers to merge onto the base branch"
required: true
type: string
base_ref:
description: "Base branch to build from"
required: false
default: main
type: string
deploy_channel:
description: "Floating image tag used by the shared HK test machine"
required: false
default: manual-test-hk
type: string
concurrency:
group: pr-batch-test-runtime
cancel-in-progress: false
permissions:
contents: read
packages: write
pull-requests: read
env:
DOCKER_PLATFORM: linux/amd64
jobs:
build-and-deploy:
name: Build And Deploy Manual Test Batch
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure helper scripts are executable
run: chmod +x scripts/prepare-pr-batch.sh scripts/deploy-test-runtime.sh
- name: Validate deploy secrets
env:
TEST_RUNTIME_SSH_HOST: ${{ secrets.TEST_RUNTIME_SSH_HOST }}
TEST_RUNTIME_SSH_KEY: ${{ secrets.TEST_RUNTIME_SSH_KEY }}
run: |
[[ -n "${TEST_RUNTIME_SSH_HOST}" ]] || { echo "::error::Missing secret TEST_RUNTIME_SSH_HOST"; exit 1; }
[[ -n "${TEST_RUNTIME_SSH_KEY}" ]] || { echo "::error::Missing secret TEST_RUNTIME_SSH_KEY"; exit 1; }
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Merge selected PRs onto base ref
id: batch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
scripts/prepare-pr-batch.sh \
--pr-list "${{ inputs.pr_numbers }}" \
--base-ref "${{ inputs.base_ref }}" \
--deploy-channel "${{ inputs.deploy_channel }}"
- name: Build and push backend image
uses: docker/build-push-action@v6
with:
context: ./server
file: ./server/Dockerfile
platforms: ${{ env.DOCKER_PLATFORM }}
push: true
provenance: false
sbom: false
tags: |
ghcr.io/${{ github.repository_owner }}/skillhub-server:${{ steps.batch.outputs.deploy_tag }}
ghcr.io/${{ github.repository_owner }}/skillhub-server:${{ steps.batch.outputs.immutable_tag }}
cache-from: type=gha,scope=manual-test-server
cache-to: type=gha,mode=max,scope=manual-test-server
- name: Build and push frontend image
uses: docker/build-push-action@v6
with:
context: ./web
file: ./web/Dockerfile
platforms: ${{ env.DOCKER_PLATFORM }}
push: true
provenance: false
sbom: false
tags: |
ghcr.io/${{ github.repository_owner }}/skillhub-web:${{ steps.batch.outputs.deploy_tag }}
ghcr.io/${{ github.repository_owner }}/skillhub-web:${{ steps.batch.outputs.immutable_tag }}
cache-from: type=gha,scope=manual-test-web
cache-to: type=gha,mode=max,scope=manual-test-web
- name: Build and push scanner image
uses: docker/build-push-action@v6
with:
context: ./scanner
file: ./scanner/Dockerfile
platforms: ${{ env.DOCKER_PLATFORM }}
push: true
provenance: false
sbom: false
tags: |
ghcr.io/${{ github.repository_owner }}/skillhub-scanner:${{ steps.batch.outputs.deploy_tag }}
ghcr.io/${{ github.repository_owner }}/skillhub-scanner:${{ steps.batch.outputs.immutable_tag }}
cache-from: type=gha,scope=manual-test-scanner
cache-to: type=gha,mode=max,scope=manual-test-scanner
- name: Prepare deploy key
id: ssh
env:
TEST_RUNTIME_SSH_KEY: ${{ secrets.TEST_RUNTIME_SSH_KEY }}
run: |
key_file="${RUNNER_TEMP}/test-runtime.key"
printf '%s\n' "${TEST_RUNTIME_SSH_KEY}" > "${key_file}"
chmod 600 "${key_file}"
echo "key_file=${key_file}" >> "${GITHUB_OUTPUT}"
- name: Deploy batch images to HK test runtime
env:
TEST_RUNTIME_SSH_HOST: ${{ secrets.TEST_RUNTIME_SSH_HOST }}
TEST_RUNTIME_SSH_USER: ${{ secrets.TEST_RUNTIME_SSH_USER }}
TEST_RUNTIME_SSH_PORT: ${{ secrets.TEST_RUNTIME_SSH_PORT }}
run: |
ssh_port="${TEST_RUNTIME_SSH_PORT:-22}"
ssh_user="${TEST_RUNTIME_SSH_USER:-skillhub-deploy}"
scripts/deploy-test-runtime.sh \
--host "${TEST_RUNTIME_SSH_HOST}" \
--user "${ssh_user}" \
--port "${ssh_port}" \
--key-file "${{ steps.ssh.outputs.key_file }}" \
--deploy-tag "${{ steps.batch.outputs.deploy_tag }}" \
--immutable-tag "${{ steps.batch.outputs.immutable_tag }}" \
--merged-sha "${{ steps.batch.outputs.merged_sha }}" \
--pr-csv "${{ steps.batch.outputs.pr_csv }}" \
--run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
- name: Publish final summary
run: |
{
echo "### HK manual test runtime updated"
echo
echo "- URL: \`https://skill.xf-yun.com.cn\`"
echo "- Base ref: \`${{ steps.batch.outputs.base_ref }}\`"
echo "- Floating tag: \`${{ steps.batch.outputs.deploy_tag }}\`"
echo "- Immutable tag: \`${{ steps.batch.outputs.immutable_tag }}\`"
echo "- Merged SHA: \`${{ steps.batch.outputs.merged_sha }}\`"
echo "- PR list: \`${{ steps.batch.outputs.pr_csv }}\`"
} >> "${GITHUB_STEP_SUMMARY}"

View file

@ -0,0 +1,81 @@
# PR Batch Test Runtime
This repository includes a manual GitHub Actions workflow that builds a
synthetic test image set from multiple PRs and deploys it to the shared
Hong Kong manual-test machine.
Workflow file:
- `.github/workflows/pr-batch-test-deploy.yml`
## What the workflow does
When you trigger the workflow manually, it:
1. checks out the repository and fetches the selected base branch
2. parses the PR list you provide and deduplicates it while preserving order
3. verifies that every PR is still open and targets the chosen base branch
4. merges the selected PR heads onto the base branch in the exact order you supplied
5. fails fast if any PR conflicts with the base branch or with an earlier PR in the batch
6. builds `server`, `web`, and `scanner` images for `linux/amd64`
7. pushes both a floating tag and an immutable tag to GHCR
8. SSHes into the HK test machine as a dedicated deploy user
9. calls a root-owned deployment wrapper through `sudo`
10. updates `/opt/skillhub-runtime/.env.release` and runs `docker compose pull && docker compose up -d`
The floating tag is the shared environment channel. By default it is
`manual-test-hk`. Each run also pushes an immutable tag for traceability:
- floating tag example: `manual-test-hk`
- immutable tag example: `manual-test-hk-128-3d4a8e7f9a1b`
The runtime always deploys the floating tag, so the same test URL keeps
working while still letting maintainers look up the exact image version
used by a given run.
## Required GitHub secrets
Add these repository or environment secrets before using the workflow:
- `TEST_RUNTIME_SSH_HOST`: test machine hostname or IP
- `TEST_RUNTIME_SSH_KEY`: private key content used by GitHub Actions
Optional secrets:
- `TEST_RUNTIME_SSH_USER`: defaults to `skillhub-deploy`
- `TEST_RUNTIME_SSH_PORT`: defaults to `22`
The remote machine should expose a root-owned deployment command at:
- `/usr/local/bin/skillhub-test-deploy`
The dedicated deploy user is expected to have passwordless sudo access to
that command only.
## Recommended usage
Open the workflow in GitHub Actions and fill in:
- `pr_numbers`: a comma-separated or newline-separated list such as `123, 124, 130`
- `base_ref`: usually `main`
- `deploy_channel`: keep the default `manual-test-hk` for the shared test machine
The merge order matters. If PR `124` depends on `123`, list `123` first.
## Runtime metadata on the server
After deployment, the workflow writes a small metadata file here:
- `/opt/skillhub-runtime/manual-test-deployment.txt`
It records:
- deploy time
- floating tag
- immutable tag
- merged synthetic SHA
- PR list
- GitHub Actions run URL
This makes it easy for testers and maintainers to confirm which batch is
currently deployed.

128
scripts/deploy-test-runtime.sh Executable file
View file

@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: scripts/deploy-test-runtime.sh [options]
Options:
--host <host> Remote SSH host
--user <user> Remote SSH user. Default: skillhub-deploy
--port <port> Remote SSH port. Default: 22
--key-file <path> SSH private key for deployment
--deploy-tag <tag> Floating image tag to deploy
--immutable-tag <tag> Immutable image tag for traceability
--merged-sha <sha> Synthetic merge commit SHA
--pr-csv <list> Comma-separated PR numbers
--run-url <url> GitHub Actions run URL
EOF
}
ssh_host=""
ssh_user="skillhub-deploy"
ssh_port="22"
ssh_key_file=""
deploy_tag=""
immutable_tag=""
merged_sha=""
pr_csv=""
run_url=""
while [[ $# -gt 0 ]]; do
case "$1" in
--host)
[[ $# -ge 2 ]] || { echo "Missing value for --host" >&2; exit 1; }
ssh_host="$2"
shift 2
;;
--user)
[[ $# -ge 2 ]] || { echo "Missing value for --user" >&2; exit 1; }
ssh_user="$2"
shift 2
;;
--port)
[[ $# -ge 2 ]] || { echo "Missing value for --port" >&2; exit 1; }
ssh_port="$2"
shift 2
;;
--key-file)
[[ $# -ge 2 ]] || { echo "Missing value for --key-file" >&2; exit 1; }
ssh_key_file="$2"
shift 2
;;
--deploy-tag)
[[ $# -ge 2 ]] || { echo "Missing value for --deploy-tag" >&2; exit 1; }
deploy_tag="$2"
shift 2
;;
--immutable-tag)
[[ $# -ge 2 ]] || { echo "Missing value for --immutable-tag" >&2; exit 1; }
immutable_tag="$2"
shift 2
;;
--merged-sha)
[[ $# -ge 2 ]] || { echo "Missing value for --merged-sha" >&2; exit 1; }
merged_sha="$2"
shift 2
;;
--pr-csv)
[[ $# -ge 2 ]] || { echo "Missing value for --pr-csv" >&2; exit 1; }
pr_csv="$2"
shift 2
;;
--run-url)
[[ $# -ge 2 ]] || { echo "Missing value for --run-url" >&2; exit 1; }
run_url="$2"
shift 2
;;
--help|-h)
usage
exit 0
;;
*)
echo "Unsupported argument: $1" >&2
usage >&2
exit 1
;;
esac
done
[[ -n "${ssh_host}" ]] || { echo "--host is required" >&2; exit 1; }
[[ -n "${ssh_key_file}" ]] || { echo "--key-file is required" >&2; exit 1; }
[[ -n "${deploy_tag}" ]] || { echo "--deploy-tag is required" >&2; exit 1; }
[[ -n "${immutable_tag}" ]] || { echo "--immutable-tag is required" >&2; exit 1; }
ssh_opts=(
-i "${ssh_key_file}"
-o BatchMode=yes
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=accept-new
-o ServerAliveInterval=15
-o ServerAliveCountMax=3
-o TCPKeepAlive=yes
-o ConnectTimeout=10
-p "${ssh_port}"
)
ssh "${ssh_opts[@]}" "${ssh_user}@${ssh_host}" bash -s -- \
"${deploy_tag}" \
"${immutable_tag}" \
"${merged_sha}" \
"${pr_csv}" \
"${run_url}" <<'EOF'
set -euo pipefail
deploy_tag="$1"
immutable_tag="$2"
merged_sha="$3"
pr_csv="$4"
run_url="${5:-}"
sudo /usr/local/bin/skillhub-test-deploy \
--deploy-tag "${deploy_tag}" \
--immutable-tag "${immutable_tag}" \
--merged-sha "${merged_sha}" \
--pr-csv "${pr_csv}" \
--run-url "${run_url}"
EOF

174
scripts/prepare-pr-batch.sh Executable file
View file

@ -0,0 +1,174 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: scripts/prepare-pr-batch.sh --pr-list "123,456" [options]
Options:
--base-ref <ref> Base branch to merge onto. Default: main
--deploy-channel <tag> Floating image tag for the shared test runtime.
Default: manual-test-hk
EOF
}
base_ref="main"
deploy_channel="manual-test-hk"
pr_input=""
while [[ $# -gt 0 ]]; do
case "$1" in
--base-ref)
[[ $# -ge 2 ]] || { echo "Missing value for --base-ref" >&2; exit 1; }
base_ref="$2"
shift 2
;;
--deploy-channel)
[[ $# -ge 2 ]] || { echo "Missing value for --deploy-channel" >&2; exit 1; }
deploy_channel="$2"
shift 2
;;
--pr-list)
[[ $# -ge 2 ]] || { echo "Missing value for --pr-list" >&2; exit 1; }
pr_input="$2"
shift 2
;;
--help|-h)
usage
exit 0
;;
*)
echo "Unsupported argument: $1" >&2
usage >&2
exit 1
;;
esac
done
: "${GH_TOKEN:?GH_TOKEN is required}"
if [[ -z "${pr_input}" ]]; then
echo "--pr-list is required" >&2
exit 1
fi
normalized_input="$(printf '%s' "${pr_input}" | tr ',;\r\n\t' ' ')"
declare -a pr_numbers=()
for token in ${normalized_input}; do
if [[ ! "${token}" =~ ^[0-9]+$ ]]; then
echo "Invalid PR number: ${token}" >&2
exit 1
fi
already_seen=false
if [[ "${#pr_numbers[@]}" -gt 0 ]]; then
for existing in "${pr_numbers[@]}"; do
if [[ "${existing}" == "${token}" ]]; then
already_seen=true
break
fi
done
fi
if [[ "${already_seen}" == "true" ]]; then
continue
fi
pr_numbers+=("${token}")
done
if [[ "${#pr_numbers[@]}" -eq 0 ]]; then
echo "No PR numbers were parsed from --pr-list" >&2
exit 1
fi
sanitized_channel="$(
printf '%s' "${deploy_channel}" |
tr '[:upper:]' '[:lower:]' |
sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g'
)"
if [[ -z "${sanitized_channel}" ]]; then
echo "Deploy channel resolved to an empty tag" >&2
exit 1
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch --no-tags origin "${base_ref}"
git checkout -B manual-test-batch "origin/${base_ref}"
summary_file="${RUNNER_TEMP:-/tmp}/manual-test-batch-summary.md"
current_pr=""
trap 'status=$?; if [[ $status -ne 0 && -n "${current_pr}" ]]; then echo "Failed while merging PR #${current_pr}" >&2; fi' EXIT
{
echo "### Manual Test Batch"
echo
echo "- Base ref: \`${base_ref}\`"
echo "- Deploy channel: \`${sanitized_channel}\`"
echo "- Selected PRs:"
} > "${summary_file}"
for pr in "${pr_numbers[@]}"; do
current_pr="${pr}"
IFS=$'\t' read -r state pr_base is_draft title url <<EOF
$(gh pr view "${pr}" \
--json state,baseRefName,isDraft,title,url \
--jq '[.state, .baseRefName, (.isDraft|tostring), .title, .url] | @tsv')
EOF
if [[ "${state}" != "OPEN" ]]; then
echo "PR #${pr} is not open (state=${state})" >&2
exit 1
fi
if [[ "${pr_base}" != "${base_ref}" ]]; then
echo "PR #${pr} targets ${pr_base}, expected ${base_ref}" >&2
exit 1
fi
git fetch --no-tags origin "pull/${pr}/head:refs/remotes/origin/manual-test-pr-${pr}"
git merge --no-ff --no-edit \
-m "Merge PR #${pr} for manual test batch" \
"refs/remotes/origin/manual-test-pr-${pr}"
if [[ "${is_draft}" == "true" ]]; then
title="${title} [draft]"
fi
echo " - #${pr} ${title} (${url})" >> "${summary_file}"
done
merged_sha="$(git rev-parse HEAD)"
short_sha="$(git rev-parse --short=12 HEAD)"
run_token="${GITHUB_RUN_NUMBER:-manual}"
immutable_tag="${sanitized_channel}-${run_token}-${short_sha}"
pr_csv="$(IFS=,; echo "${pr_numbers[*]}")"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
{
echo "base_ref=${base_ref}"
echo "deploy_tag=${sanitized_channel}"
echo "immutable_tag=${immutable_tag}"
echo "merged_sha=${merged_sha}"
echo "short_sha=${short_sha}"
echo "pr_csv=${pr_csv}"
echo "summary_file=${summary_file}"
} >> "${GITHUB_OUTPUT}"
fi
{
echo "- Merged SHA: \`${merged_sha}\`"
echo "- Floating tag: \`${sanitized_channel}\`"
echo "- Immutable tag: \`${immutable_tag}\`"
} >> "${summary_file}"
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
cat "${summary_file}" >> "${GITHUB_STEP_SUMMARY}"
fi

View file

@ -0,0 +1,135 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: /usr/local/bin/skillhub-test-deploy [options]
Options:
--deploy-tag <tag> Floating image tag to deploy
--immutable-tag <tag> Immutable image tag for traceability
--merged-sha <sha> Synthetic merge commit SHA
--pr-csv <list> Comma-separated PR numbers
--run-url <url> GitHub Actions run URL
EOF
}
runtime_dir="/opt/skillhub-runtime"
deploy_tag=""
immutable_tag=""
merged_sha=""
pr_csv=""
run_url=""
while [[ $# -gt 0 ]]; do
case "$1" in
--deploy-tag)
[[ $# -ge 2 ]] || { echo "Missing value for --deploy-tag" >&2; exit 1; }
deploy_tag="$2"
shift 2
;;
--immutable-tag)
[[ $# -ge 2 ]] || { echo "Missing value for --immutable-tag" >&2; exit 1; }
immutable_tag="$2"
shift 2
;;
--merged-sha)
[[ $# -ge 2 ]] || { echo "Missing value for --merged-sha" >&2; exit 1; }
merged_sha="$2"
shift 2
;;
--pr-csv)
[[ $# -ge 2 ]] || { echo "Missing value for --pr-csv" >&2; exit 1; }
pr_csv="$2"
shift 2
;;
--run-url)
[[ $# -ge 2 ]] || { echo "Missing value for --run-url" >&2; exit 1; }
run_url="$2"
shift 2
;;
--help|-h)
usage
exit 0
;;
*)
echo "Unsupported argument: $1" >&2
usage >&2
exit 1
;;
esac
done
[[ -n "${deploy_tag}" ]] || { echo "--deploy-tag is required" >&2; exit 1; }
[[ -n "${immutable_tag}" ]] || { echo "--immutable-tag is required" >&2; exit 1; }
if [[ ! "${deploy_tag}" =~ ^[a-z0-9._-]+$ ]]; then
echo "Invalid deploy tag: ${deploy_tag}" >&2
exit 1
fi
if [[ ! "${immutable_tag}" =~ ^[a-z0-9._-]+$ ]]; then
echo "Invalid immutable tag: ${immutable_tag}" >&2
exit 1
fi
if [[ -n "${merged_sha}" && ! "${merged_sha}" =~ ^[0-9a-f]{7,64}$ ]]; then
echo "Invalid merged SHA: ${merged_sha}" >&2
exit 1
fi
if [[ -n "${pr_csv}" && ! "${pr_csv}" =~ ^[0-9]+(,[0-9]+)*$ ]]; then
echo "Invalid PR list: ${pr_csv}" >&2
exit 1
fi
if [[ -n "${run_url}" && ! "${run_url}" =~ ^https://github\.com/.+/actions/runs/[0-9]+$ ]]; then
echo "Invalid run URL: ${run_url}" >&2
exit 1
fi
set_env_value() {
key="$1"
value="$2"
tmp=".env.release.tmp"
if grep -q "^${key}=" .env.release; then
sed "s|^${key}=.*|${key}=${value}|" .env.release > "${tmp}"
else
cp .env.release "${tmp}"
printf '%s=%s\n' "${key}" "${value}" >> "${tmp}"
fi
mv "${tmp}" .env.release
}
cd "${runtime_dir}"
test -f .env.release
test -f compose.release.yml
cp .env.release ".env.release.bak.$(date +%Y%m%d%H%M%S)"
set_env_value "SKILLHUB_VERSION" "${deploy_tag}"
cat > manual-test-deployment.txt <<METADATA
deployed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
deploy_tag=${deploy_tag}
immutable_tag=${immutable_tag}
merged_sha=${merged_sha}
pr_numbers=${pr_csv}
run_url=${run_url}
METADATA
docker compose --env-file .env.release -f compose.release.yml pull
docker compose --env-file .env.release -f compose.release.yml up -d
docker compose --env-file .env.release -f compose.release.yml ps
web_port="$(awk -F= '/^WEB_PORT=/{print $2}' .env.release | tail -n 1)"
if [[ -z "${web_port}" ]]; then
web_port="80"
fi
curl -fsS http://127.0.0.1:8080/actuator/health >/dev/null
curl -fsS "http://127.0.0.1:${web_port}/nginx-health" >/dev/null

View file

@ -119,6 +119,7 @@ public class ReviewPortalAppService {
Map<Long, NamespaceRole> userNsRoles) {
ReviewTaskStatus reviewStatus = ReviewTaskStatus.valueOf(status.toUpperCase());
Map<Long, NamespaceRole> namespaceRoles = normalizeRoles(userNsRoles);
Set<String> platformRoles = platformRoles(userId);
Pageable pageable = buildReviewPageable(reviewStatus, page, size, sortDirection);
Page<ReviewTask> tasks;
@ -131,11 +132,14 @@ public class ReviewPortalAppService {
userId,
namespace.getType(),
namespaceRoles,
platformRoles(userId))) {
platformRoles)) {
throw new DomainForbiddenException("review.no_permission");
}
tasks = reviewTaskRepository.findByNamespaceIdAndStatus(namespaceId, reviewStatus, pageable);
} else {
if (!hasPlatformReviewRole(platformRoles)) {
throw new DomainForbiddenException("review.no_permission");
}
tasks = reviewTaskRepository.findByStatus(reviewStatus, pageable);
}
@ -146,7 +150,7 @@ public class ReviewPortalAppService {
return PageResponse.from(new PageImpl<>(
governanceQueryRepository.getReviewTaskResponses(visibleItems),
tasks.getPageable(),
visibleItems.size()
tasks.getTotalElements()
));
}
@ -233,6 +237,11 @@ public class ReviewPortalAppService {
return rbacService.getUserRoleCodes(userId);
}
private boolean hasPlatformReviewRole(Set<String> platformRoles) {
return platformRoles.contains("SKILL_ADMIN")
|| platformRoles.contains("SUPER_ADMIN");
}
private Map<Long, NamespaceRole> normalizeRoles(Map<Long, NamespaceRole> userNsRoles) {
return userNsRoles != null ? userNsRoles : Map.of();
}

View file

@ -1,8 +1,9 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
@ -12,13 +13,12 @@ import com.iflytek.skillhub.search.SearchQuery;
import com.iflytek.skillhub.search.SearchQueryService;
import com.iflytek.skillhub.search.SearchResult;
import com.iflytek.skillhub.search.SearchVisibilityScope;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
/**
* Application service that assembles discovery responses from search matches.
@ -36,18 +36,21 @@ public class SkillSearchAppService {
private final NamespaceRepository namespaceRepository;
private final NamespaceService namespaceService;
private final SkillLifecycleProjectionService skillLifecycleProjectionService;
private final RbacService rbacService;
public SkillSearchAppService(
SearchQueryService searchQueryService,
SkillRepository skillRepository,
NamespaceRepository namespaceRepository,
NamespaceService namespaceService,
SkillLifecycleProjectionService skillLifecycleProjectionService) {
SkillLifecycleProjectionService skillLifecycleProjectionService,
RbacService rbacService) {
this.searchQueryService = searchQueryService;
this.skillRepository = skillRepository;
this.namespaceRepository = namespaceRepository;
this.namespaceService = namespaceService;
this.skillLifecycleProjectionService = skillLifecycleProjectionService;
this.rbacService = rbacService;
}
public record SearchResponse(
@ -93,21 +96,36 @@ public class SkillSearchAppService {
}
private SearchVisibilityScope buildVisibilityScope(String userId, Map<Long, NamespaceRole> userNsRoles) {
if (userId == null || userNsRoles == null) {
if (userId == null) {
return SearchVisibilityScope.anonymous();
}
Set<Long> memberNamespaceIds = userNsRoles.keySet();
Set<Long> adminNamespaceIds = userNsRoles.entrySet().stream()
Map<Long, NamespaceRole> normalizedRoles = userNsRoles != null ? userNsRoles : Map.of();
Set<Long> memberNamespaceIds = normalizedRoles.keySet();
Set<Long> adminNamespaceIds = normalizedRoles.entrySet().stream()
.filter(e -> e.getValue() == NamespaceRole.ADMIN)
.map(Map.Entry::getKey)
.collect(java.util.stream.Collectors.toSet());
adminNamespaceIds.addAll(userNsRoles.entrySet().stream()
adminNamespaceIds.addAll(normalizedRoles.entrySet().stream()
.filter(e -> e.getValue() == NamespaceRole.OWNER)
.map(Map.Entry::getKey)
.toList());
return new SearchVisibilityScope(userId, memberNamespaceIds, adminNamespaceIds);
Set<String> platformRoles = rbacService.getUserRoleCodes(userId);
return new SearchVisibilityScope(
userId,
memberNamespaceIds,
adminNamespaceIds,
hasPlatformWideReadAccess(platformRoles)
);
}
private boolean hasPlatformWideReadAccess(Set<String> platformRoles) {
if (platformRoles == null || platformRoles.isEmpty()) {
return false;
}
return platformRoles.contains("SUPER_ADMIN");
}
private SearchResponse searchVisibleSkills(

View file

@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.dto.MemberResponse;
import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse;
import com.iflytek.skillhub.dto.NamespaceResponse;
@ -78,7 +79,10 @@ class NamespaceWorkflowContractTest {
NamespaceResponse namespaceResponse = NamespaceResponse.from(namespace);
NamespaceResponse frozenResponse = NamespaceResponse.from(frozen);
NamespaceResponse archivedResponse = NamespaceResponse.from(archived);
MemberResponse adminMemberResponse = MemberResponse.from(adminMember);
MemberResponse adminMemberResponse = MemberResponse.from(
adminMember,
new UserAccount("user-admin", "Admin", "admin@example.com", null)
);
given(namespacePortalCommandAppService.createNamespace(any(), any()))
.willReturn(namespaceResponse);
@ -123,14 +127,18 @@ class NamespaceWorkflowContractTest {
.content("{\"userId\":\"user-admin\",\"role\":\"ADMIN\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("user-admin"));
.andExpect(jsonPath("$.data.userId").value("user-admin"))
.andExpect(jsonPath("$.data.displayName").value("Admin"))
.andExpect(jsonPath("$.data.email").value("admin@example.com"));
mockMvc.perform(get("/api/web/namespaces/team-flow/members")
.with(auth("owner-1"))
.requestAttr("userId", "owner-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items[0].userId").value("user-admin"));
.andExpect(jsonPath("$.data.items[0].userId").value("user-admin"))
.andExpect(jsonPath("$.data.items[0].displayName").value("Admin"))
.andExpect(jsonPath("$.data.items[0].email").value("admin@example.com"));
mockMvc.perform(put("/api/web/namespaces/team-flow/members/user-admin/role")
.with(csrf())

View file

@ -41,6 +41,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.IntStream;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.never;
@ -221,6 +222,7 @@ class ReviewPortalControllerTest {
@Test
void listReviews_appliesRequestedTimeSortDirection() throws Exception {
stubNamespaceRoles("admin", List.of());
given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN"));
PageRequest pageable = PageRequest.of(
1,
5,
@ -244,6 +246,35 @@ class ReviewPortalControllerTest {
verify(reviewTaskRepository).findByStatus(ReviewTaskStatus.APPROVED, pageable);
}
@Test
void listReviews_preservesRepositoryTotalForDefaultPageSize() throws Exception {
assertReviewTotalPreservedForDefaultPageSize(ReviewTaskStatus.PENDING);
}
@Test
void listApprovedReviews_preservesRepositoryTotalForDefaultPageSize() throws Exception {
assertReviewTotalPreservedForDefaultPageSize(ReviewTaskStatus.APPROVED);
}
@Test
void listRejectedReviews_preservesRepositoryTotalForDefaultPageSize() throws Exception {
assertReviewTotalPreservedForDefaultPageSize(ReviewTaskStatus.REJECTED);
}
@Test
void listReviews_forbidsGlobalQueueForNonPlatformReviewer() throws Exception {
stubNamespaceRoles("namespace-admin", List.of());
given(rbacService.getUserRoleCodes("namespace-admin")).willReturn(Set.of("NAMESPACE_ADMIN"));
mockMvc.perform(get("/api/v1/reviews")
.param("status", "PENDING")
.with(auth("namespace-admin")))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(403));
verify(reviewTaskRepository, never()).findByStatus(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
}
@Test
void downloadReviewVersion_streamsZipForAuthorizedReviewer() throws Exception {
stubNamespaceRoles("admin", List.of());
@ -264,21 +295,7 @@ class ReviewPortalControllerTest {
}
private void stubReviewResponse(ReviewTask task) {
given(governanceQueryRepository.getReviewTaskResponse(task)).willReturn(new ReviewTaskResponse(
task.getId(),
task.getSkillVersionId(),
"team-a",
"skill-a",
"1.0.0",
task.getStatus().name(),
task.getSubmittedBy(),
"Submitter",
task.getReviewedBy(),
null,
task.getReviewComment(),
task.getSubmittedAt(),
task.getReviewedAt()
));
given(governanceQueryRepository.getReviewTaskResponse(task)).willReturn(toReviewResponse(task));
}
private void stubNamespaceRoles(String userId, List<NamespaceMember> members) {
@ -309,12 +326,76 @@ class ReviewPortalControllerTest {
return task;
}
private ReviewTask createReviewTask(Long id, Long namespaceId, String submittedBy, ReviewTaskStatus status) {
ReviewTask task = createReviewTask(id, namespaceId, submittedBy);
setField(task, "status", status);
return task;
}
private Namespace createNamespace(Long id, String slug) {
Namespace namespace = new Namespace(slug, "Team", "owner-1");
setField(namespace, "id", id);
return namespace;
}
private ReviewTaskResponse toReviewResponse(ReviewTask task) {
return new ReviewTaskResponse(
task.getId(),
task.getSkillVersionId(),
"team-a",
"skill-a",
"1.0.0",
task.getStatus().name(),
task.getSubmittedBy(),
"Submitter",
task.getReviewedBy(),
null,
task.getReviewComment(),
task.getSubmittedAt(),
task.getReviewedAt()
);
}
private void assertReviewTotalPreservedForDefaultPageSize(ReviewTaskStatus status) throws Exception {
stubNamespaceRoles("admin", List.of());
Namespace namespace = createNamespace(20L, "team-a");
List<ReviewTask> tasks = IntStream.rangeClosed(1, 20)
.mapToObj(index -> createReviewTask((long) index, 20L, "submitter-" + index, status))
.toList();
List<ReviewTaskResponse> responses = tasks.stream()
.map(this::toReviewResponse)
.toList();
PageRequest pageable = PageRequest.of(
0,
20,
Sort.by(
new Sort.Order(Sort.Direction.DESC, status == ReviewTaskStatus.PENDING ? "submittedAt" : "reviewedAt"),
new Sort.Order(Sort.Direction.DESC, "id")
)
);
given(reviewTaskRepository.findByStatus(status, pageable))
.willReturn(new PageImpl<>(tasks, pageable, 42));
given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace));
given(rbacService.getUserRoleCodes("admin")).willReturn(Set.of("SKILL_ADMIN"));
tasks.forEach(task -> given(reviewService.canViewReview(
task,
"admin",
namespace.getType(),
Map.of(),
Set.of("SKILL_ADMIN"))).willReturn(true));
given(governanceQueryRepository.getReviewTaskResponses(tasks)).willReturn(responses);
mockMvc.perform(get("/api/v1/reviews")
.param("status", status.name())
.with(auth("admin")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(42))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.items.length()").value(20));
}
private void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
@ -7,21 +8,23 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillLifecycleProjectionService;
import com.iflytek.skillhub.search.SearchQuery;
import com.iflytek.skillhub.search.SearchQueryService;
import com.iflytek.skillhub.search.SearchResult;
import org.mockito.ArgumentCaptor;
import com.iflytek.skillhub.search.SearchVisibilityScope;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@ -49,6 +52,9 @@ class SkillSearchAppServiceTest {
@Mock
private NamespaceService namespaceService;
@Mock
private RbacService rbacService;
private SkillSearchAppService service;
@BeforeEach
@ -58,7 +64,8 @@ class SkillSearchAppServiceTest {
skillRepository,
namespaceRepository,
namespaceService,
new SkillLifecycleProjectionService(skillVersionRepository)
new SkillLifecycleProjectionService(skillVersionRepository),
rbacService
);
}
@ -190,6 +197,40 @@ class SkillSearchAppServiceTest {
assertEquals(List.of("code-generation", "official"), captor.getValue().labelSlugs());
}
@Test
void search_shouldIncludeMemberNamespacesInVisibilityScope() {
when(searchQueryService.search(any()))
.thenReturn(new SearchResult(List.of(), 0, 0, 20));
when(rbacService.getUserRoleCodes("user-9")).thenReturn(Set.of("USER"));
service.search("skill", null, "newest", 0, 20, "user-9", Map.of(7L, NamespaceRole.MEMBER));
ArgumentCaptor<SearchQuery> captor = ArgumentCaptor.forClass(SearchQuery.class);
verify(searchQueryService).search(captor.capture());
SearchVisibilityScope scope = captor.getValue().visibilityScope();
assertEquals("user-9", scope.userId());
assertEquals(Set.of(7L), scope.memberNamespaceIds());
assertEquals(Set.of(), scope.adminNamespaceIds());
assertEquals(false, scope.platformWideAccess());
}
@Test
void search_shouldGrantPlatformWideAccessToSuperAdmin() {
when(searchQueryService.search(any()))
.thenReturn(new SearchResult(List.of(), 0, 0, 20));
when(rbacService.getUserRoleCodes("admin-1")).thenReturn(Set.of("SUPER_ADMIN", "USER"));
service.search("skill", null, "newest", 0, 20, "admin-1", Map.of());
ArgumentCaptor<SearchQuery> captor = ArgumentCaptor.forClass(SearchQuery.class);
verify(searchQueryService).search(captor.capture());
SearchVisibilityScope scope = captor.getValue().visibilityScope();
assertEquals("admin-1", scope.userId());
assertEquals(true, scope.platformWideAccess());
}
private void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);

View file

@ -35,7 +35,7 @@ public class OAuth2LoginSuccessHandler extends SavedRequestAwareAuthenticationSu
if (authentication.getPrincipal() instanceof OAuth2User oAuth2User) {
PlatformPrincipal principal = (PlatformPrincipal) oAuth2User.getAttributes().get("platformPrincipal");
if (principal != null) {
platformSessionService.attachToAuthenticatedSession(principal, authentication, request, true);
platformSessionService.attachToAuthenticatedSession(principal, authentication, request);
}
}
String returnTo = oauthLoginFlowService.consumeReturnTo(request.getSession(false));

View file

@ -6,6 +6,7 @@ import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
@ -52,11 +53,15 @@ class OAuth2LoginHandlersTest {
handler.onAuthenticationSuccess(request, response, authentication);
SecurityContext securityContext = (SecurityContext) session.getAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY
);
assertThat(response.getRedirectedUrl()).isEqualTo("/dashboard/publish");
assertThat(request.getSession(false).getId()).isNotEqualTo(originalSessionId);
assertThat(request.getSession(false).getId()).isEqualTo(originalSessionId);
assertThat(session.getAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE)).isNull();
assertThat(session.getAttribute("platformPrincipal")).isEqualTo(principal);
assertThat(session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).isNotNull();
assertThat(securityContext).isNotNull();
assertThat(securityContext.getAuthentication()).isSameAs(authentication);
}
@Test

View file

@ -16,6 +16,12 @@ import java.util.regex.Pattern;
@Component
public class BasicPrePublishValidator implements PrePublishValidator {
private static final Pattern ASSIGNMENT_WITH_SENSITIVE_KEY = Pattern.compile(
"(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*(.+)$"
);
private static final Pattern QUOTED_LITERAL = Pattern.compile("^(['\"])(.*)\\1$");
private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
private static final Pattern BARE_LITERAL = Pattern.compile("[A-Za-z0-9_\\-]{12,}");
private static final Pattern PLACEHOLDER_VALUE = Pattern.compile(
"(?i).*(your|example|sample|placeholder|changeme|replace|dummy|mock|test|fake|todo|xxx|redacted).*"
);
@ -23,10 +29,7 @@ public class BasicPrePublishValidator implements PrePublishValidator {
new SecretRule(Pattern.compile("(AKIA[0-9A-Z]{16})"), 1, "cloud access key"),
new SecretRule(Pattern.compile("(ghp_[A-Za-z0-9]{20,})"), 1, "GitHub token"),
new SecretRule(Pattern.compile("(sk-[A-Za-z0-9]{20,})"), 1, "API key"),
new SecretRule(
Pattern.compile("(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*['\\\"]?([A-Za-z0-9_\\-]{12,})"),
2,
"secret or token")
new SecretRule(ASSIGNMENT_WITH_SENSITIVE_KEY, 0, "secret or token")
);
@Override
@ -46,7 +49,10 @@ public class BasicPrePublishValidator implements PrePublishValidator {
if (!matcher.find()) {
continue;
}
String matchedValue = matcher.group(rule.valueGroup());
String matchedValue = extractMatchedValue(line, matcher, rule);
if (matchedValue == null) {
continue;
}
if (isPlaceholderValue(matchedValue)) {
continue;
}
@ -87,5 +93,64 @@ public class BasicPrePublishValidator implements PrePublishValidator {
|| value.chars().allMatch(ch -> ch == 'x' || ch == 'X' || ch == '*' || ch == '-');
}
private String extractMatchedValue(String line, Matcher matcher, SecretRule rule) {
if (rule.valueGroup() > 0) {
return matcher.group(rule.valueGroup());
}
Matcher assignmentMatcher = ASSIGNMENT_WITH_SENSITIVE_KEY.matcher(line);
if (!assignmentMatcher.find()) {
return null;
}
String rawValue = assignmentMatcher.group(2).trim();
if (rawValue.isBlank()) {
return null;
}
Matcher quotedLiteralMatcher = QUOTED_LITERAL.matcher(rawValue);
if (quotedLiteralMatcher.matches()) {
return quotedLiteralMatcher.group(2);
}
rawValue = stripInlineComment(rawValue);
if (rawValue.isBlank()) {
return null;
}
quotedLiteralMatcher = QUOTED_LITERAL.matcher(rawValue);
if (quotedLiteralMatcher.matches()) {
return quotedLiteralMatcher.group(2);
}
if (looksLikeExpression(rawValue) || IDENTIFIER.matcher(rawValue).matches()) {
return null;
}
return BARE_LITERAL.matcher(rawValue).matches() ? rawValue : null;
}
private String stripInlineComment(String rawValue) {
int hashIndex = rawValue.indexOf('#');
if (hashIndex >= 0) {
return rawValue.substring(0, hashIndex).trim();
}
return rawValue;
}
private boolean looksLikeExpression(String rawValue) {
return rawValue.contains("(")
|| rawValue.contains(")")
|| rawValue.contains(".")
|| rawValue.contains("[")
|| rawValue.contains("]")
|| rawValue.contains("{")
|| rawValue.contains("}")
|| rawValue.contains(",")
|| rawValue.contains(" ")
|| rawValue.contains("+")
|| rawValue.contains("/");
}
private record SecretRule(Pattern pattern, int valueGroup, String label) {}
}

View file

@ -98,4 +98,71 @@ class BasicPrePublishValidatorTest {
assertTrue(result.passed());
}
@Test
void shouldAllowFunctionCallAssignedToTokenVariable() {
PackageEntry script = new PackageEntry(
"scripts/f2e_mock.py",
"""
token = extract_group_token_value(response, group_choice.group_id)
if token:
return token
""".getBytes(StandardCharsets.UTF_8),
97,
"text/x-python"
);
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
List.of(script),
new SkillMetadata("Safe Skill", "desc", "1.0.0", "body", Map.of()),
"user-1",
1L
));
assertTrue(result.passed());
}
@Test
void shouldAllowIdentifierAssignedToSecretNamedVariable() {
PackageEntry envTemplate = new PackageEntry(
"config.env",
"""
token=generated_token_value
api_key=current_api_key
""".getBytes(StandardCharsets.UTF_8),
46,
"text/plain"
);
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
List.of(envTemplate),
new SkillMetadata("Safe Skill", "desc", "1.0.0", "body", Map.of()),
"user-1",
1L
));
assertTrue(result.passed());
}
@Test
void shouldRejectQuotedSecretWithTrailingComment() {
PackageEntry script = new PackageEntry(
"scripts/publish.py",
"""
token = "ghp_abcdefghijklmnopqrstuvwxyz1234" # do not commit real token
""".getBytes(StandardCharsets.UTF_8),
76,
"text/x-python"
);
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
List.of(script),
new SkillMetadata("Secret Skill", "desc", "1.0.0", "body", Map.of()),
"user-1",
1L
));
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(error -> error.contains("scripts/publish.py")));
}
}

View file

@ -8,9 +8,17 @@ import java.util.Set;
public record SearchVisibilityScope(
String userId,
Set<Long> memberNamespaceIds,
Set<Long> adminNamespaceIds
Set<Long> adminNamespaceIds,
boolean platformWideAccess
) {
public SearchVisibilityScope(
String userId,
Set<Long> memberNamespaceIds,
Set<Long> adminNamespaceIds) {
this(userId, memberNamespaceIds, adminNamespaceIds, false);
}
public static SearchVisibilityScope anonymous() {
return new SearchVisibilityScope(null, Set.of(), Set.of());
return new SearchVisibilityScope(null, Set.of(), Set.of(), false);
}
}

View file

@ -105,6 +105,7 @@ public class PostgresFullTextQueryService implements SearchQueryService {
Set<Long> adminNamespaceIds = query.visibilityScope().adminNamespaceIds().isEmpty()
? Set.of(-1L)
: query.visibilityScope().adminNamespaceIds();
boolean platformWideAccess = query.visibilityScope().platformWideAccess();
StringBuilder sql = new StringBuilder();
sql.append("SELECT d.skill_id ");
@ -117,7 +118,9 @@ public class PostgresFullTextQueryService implements SearchQueryService {
sql.append("AND (d.visibility = 'PUBLIC' ");
if (query.visibilityScope().userId() != null) {
sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND d.namespace_id IN :memberNamespaceIds) ");
sql.append("OR (d.visibility = 'NAMESPACE_ONLY' AND :platformWideAccess = TRUE) ");
sql.append("OR (d.visibility = 'PRIVATE' AND (d.namespace_id IN :adminNamespaceIds OR d.owner_id = :userId)) ");
sql.append("OR (d.visibility = 'PRIVATE' AND :platformWideAccess = TRUE) ");
}
sql.append(") ");
@ -128,6 +131,7 @@ public class PostgresFullTextQueryService implements SearchQueryService {
sql.append("AND (n.status <> 'ARCHIVED' ");
if (query.visibilityScope().userId() != null) {
sql.append("OR d.namespace_id IN :memberNamespaceIds ");
sql.append("OR :platformWideAccess = TRUE ");
}
sql.append(") ");
@ -192,6 +196,7 @@ public class PostgresFullTextQueryService implements SearchQueryService {
if (query.visibilityScope().userId() != null) {
nativeQuery.setParameter("memberNamespaceIds", memberNamespaceIds);
nativeQuery.setParameter("adminNamespaceIds", adminNamespaceIds);
nativeQuery.setParameter("platformWideAccess", platformWideAccess);
nativeQuery.setParameter("userId", query.visibilityScope().userId());
}
@ -238,6 +243,7 @@ public class PostgresFullTextQueryService implements SearchQueryService {
if (query.visibilityScope().userId() != null) {
countQuery.setParameter("memberNamespaceIds", memberNamespaceIds);
countQuery.setParameter("adminNamespaceIds", adminNamespaceIds);
countQuery.setParameter("platformWideAccess", platformWideAccess);
countQuery.setParameter("userId", query.visibilityScope().userId());
}

View file

@ -392,6 +392,40 @@ class PostgresFullTextQueryServiceTest {
assertThat(sqlCaptor.getAllValues().getFirst()).contains("OR d.namespace_id IN :memberNamespaceIds");
}
@Test
void platformWideAccessShouldBypassNamespaceVisibilityRestrictions() {
EntityManager entityManager = mock(EntityManager.class);
Query nativeQuery = mock(Query.class);
Query countQuery = mock(Query.class);
when(entityManager.createNativeQuery(anyString()))
.thenReturn(nativeQuery)
.thenReturn(countQuery);
when(nativeQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(nativeQuery);
when(countQuery.setParameter(anyString(), org.mockito.ArgumentMatchers.any())).thenReturn(countQuery);
when(nativeQuery.getResultList()).thenReturn(List.of());
when(countQuery.getSingleResult()).thenReturn(0L);
PostgresFullTextQueryService service = new PostgresFullTextQueryService(entityManager);
service.search(new SearchQuery(
null,
null,
new SearchVisibilityScope("admin-1", Set.of(), Set.of(), true),
"newest",
0,
12
));
ArgumentCaptor<String> sqlCaptor = ArgumentCaptor.forClass(String.class);
verify(entityManager, org.mockito.Mockito.times(2)).createNativeQuery(sqlCaptor.capture());
assertThat(sqlCaptor.getAllValues().getFirst())
.contains("OR (d.visibility = 'NAMESPACE_ONLY' AND :platformWideAccess = TRUE)")
.contains("OR (d.visibility = 'PRIVATE' AND :platformWideAccess = TRUE)")
.contains("OR :platformWideAccess = TRUE");
verify(nativeQuery).setParameter("platformWideAccess", true);
verify(countQuery).setParameter("platformWideAccess", true);
}
@Test
void maliciousKeywordShouldBeBoundAsParameterInsteadOfInlinedIntoSql() {
EntityManager entityManager = mock(EntityManager.class);

View file

@ -11,6 +11,7 @@ import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.http.apache.ApacheHttpClient;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.*;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
@ -54,17 +55,24 @@ public class S3StorageService implements ObjectStorageService {
builder.endpointOverride(URI.create(properties.getEndpoint()));
}
this.s3Client = builder.build();
this.s3Presigner = buildPresigner();
ensureBucketExists();
}
S3Presigner buildPresigner() {
var presignerBuilder = S3Presigner.builder()
.region(Region.of(properties.getRegion()))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())));
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())))
.serviceConfiguration(S3Configuration.builder()
.pathStyleAccessEnabled(properties.isForcePathStyle())
.build());
if (properties.getPublicEndpoint() != null && !properties.getPublicEndpoint().isBlank()) {
presignerBuilder.endpointOverride(URI.create(properties.getPublicEndpoint()));
} else if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) {
presignerBuilder.endpointOverride(URI.create(properties.getEndpoint()));
}
this.s3Presigner = presignerBuilder.build();
ensureBucketExists();
return presignerBuilder.build();
}
private void ensureBucketExists() {

View file

@ -0,0 +1,56 @@
package com.iflytek.skillhub.storage;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import java.net.URI;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
class S3StorageServiceTest {
@Test
void shouldUsePathStylePresignedUrlWhenForcePathStyleEnabled() {
URI presignedUrl = presignGetObjectUrl(true);
assertThat(presignedUrl.getHost()).isEqualTo("s3.us-east-1.amazonaws.com");
assertThat(presignedUrl.getPath()).isEqualTo("/test-bucket/artifacts/package.tgz");
}
@Test
void shouldUseHostStylePresignedUrlWhenForcePathStyleDisabled() {
URI presignedUrl = presignGetObjectUrl(false);
assertThat(presignedUrl.getHost()).isEqualTo("test-bucket.s3.us-east-1.amazonaws.com");
assertThat(presignedUrl.getPath()).isEqualTo("/artifacts/package.tgz");
}
private URI presignGetObjectUrl(boolean forcePathStyle) {
S3StorageService storageService = new S3StorageService(createProperties(forcePathStyle));
try (var presigner = storageService.buildPresigner()) {
var request = presigner.presignGetObject(
GetObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(10))
.getObjectRequest(GetObjectRequest.builder()
.bucket("test-bucket")
.key("artifacts/package.tgz")
.build())
.build()
);
return URI.create(request.url().toString());
}
}
private S3StorageProperties createProperties(boolean forcePathStyle) {
S3StorageProperties properties = new S3StorageProperties();
properties.setRegion("us-east-1");
properties.setBucket("test-bucket");
properties.setAccessKey("test-access-key");
properties.setSecretKey("test-secret-key");
properties.setEndpoint("https://s3.us-east-1.amazonaws.com");
properties.setForcePathStyle(forcePathStyle);
return properties;
}
}