From 16af9c5f4da44bf5bcf28e6d8c9df101da432e96 Mon Sep 17 00:00:00 2001 From: simple321vip Date: Fri, 10 Jul 2026 10:59:01 +0800 Subject: [PATCH] feat: add Dockerfile2 --- .gitea/workflows/iam-ci.yml | 54 ++++++++ .github/workflows/iam-ci.yml | 59 -------- Dockerfile | 4 +- .../iam/controller/InternalController.java | 48 +++++++ .../InternalControllerBindingTest.java | 131 ++++++++++++++++++ 5 files changed, 235 insertions(+), 61 deletions(-) create mode 100644 .gitea/workflows/iam-ci.yml delete mode 100644 .github/workflows/iam-ci.yml create mode 100644 src/test/java/cn/violin/iam/controller/InternalControllerBindingTest.java diff --git a/.gitea/workflows/iam-ci.yml b/.gitea/workflows/iam-ci.yml new file mode 100644 index 0000000..8ac5996 --- /dev/null +++ b/.gitea/workflows/iam-ci.yml @@ -0,0 +1,54 @@ +name: iam-ci + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + env: + VIOLIN_IAM_SERVICE_TOKEN_SECRET: "0011223344556677889900aabbccddeeff0011223344556677889900aabbccddeeff" + VIOLIN_JWT_PRIVATE_KEY_PATH: "" + SPRING_PROFILES_ACTIVE: dev + + steps: + # Step 1: compile + run tests. Replaces Drone's `compile` step but does + # NOT skip tests — this is a CI gate on every PR. + - name: Compile + test violin-iam + image: gitea.violin-work.online/registry/maven:3.9-eclipse-temurin-22 + env: + MAVEN_OPTS: "-Dmaven.repo.local=/root/.m2/repository" + commands: + - mkdir -p /root/.m2/repository + - for m in violin-parent violin-common violin-core; do + echo "--- installing $m ---"; + mvn -B -f "$m/pom.xml" clean install -DskipTests; + done + - mvn -B -f violin-iam/pom.xml verify + + # Step 2: build image via kaniko and push to the violin registry. + # Mirrors the `drone-kaniko` plugin used by .drone.yml. Only runs on + # pushes to main (PR branches just want the test gate). + - name: Build and push image + if: gitea.ref == 'refs/heads/main' + image: gitea.violin-work.online/registry/drone-kaniko:latest + settings: + repo: gitea.violin-work.online/violin/violin-iam + tags: + - latest + - ${GITEA_SHA:0:8} + dockerfile: Dockerfile + context: . + registry: gitea.violin-work.online + username: simple321vip + password: + from_secret: docker_password + + # Step 3: roll the deployment. Only after main-branch image push. + - name: Deploy to violin namespace + if: gitea.ref == 'refs/heads/main' + image: gitea.violin-work.online/registry/kubectl:latest + commands: + - kubectl set image deployment/violin-iam violin-iam=gitea.violin-work.online/violin/violin-iam:${GITEA_SHA:0:8} -n violin diff --git a/.github/workflows/iam-ci.yml b/.github/workflows/iam-ci.yml deleted file mode 100644 index 4860130..0000000 --- a/.github/workflows/iam-ci.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: iam-ci - -on: - push: - branches: [main] - pull_request: - paths: ['**'] - -jobs: - build: - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: violin - POSTGRES_PASSWORD: violin - POSTGRES_DB: violin - ports: - - 5432:5432 - options: >- - --health-cmd="pg_isready -U violin" - --health-interval=5s - --health-timeout=3s - --health-retries=5 - - env: - VIOLIN_IAM_SERVICE_TOKEN_SECRET: "0011223344556677889900aabbccddeeff" - VIOLIN_JWT_PRIVATE_KEY_PATH: "" - SPRING_PROFILES_ACTIVE: dev - SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/violin - SPRING_DATASOURCE_USERNAME: violin - SPRING_DATASOURCE_PASSWORD: violin - VIOLIN_JWT_JWKS_URL: http://localhost:8080/violin-iam/.well-known/jwks.json - - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: corretto - java-version: '17' - - - name: Cache Maven - uses: actions/cache@v4 - with: - path: ~/.m2/repository - key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} - - - name: Install violin-parent + violin-common + violin-core - run: | - for m in violin-parent violin-common violin-core; do - echo "--- installing $m ---" - mvn -B -f "$m/pom.xml" -o clean install -DskipTests || mvn -B -f "$m/pom.xml" clean install -DskipTests - done - - - name: Build + test violin-iam - run: mvn -B -f violin-iam/pom.xml verify diff --git a/Dockerfile b/Dockerfile index 4cdf2dd..2ce88b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,13 @@ # ============================================================ # Dockerfile — violin-auth(for drone in kubernetes) # ============================================================ -FROM ccr.ccs.tencentyun.com/kona_jdk/kona-jdk:17 +FROM gitea.violin-work.online/registry/eclipse-temurin:22-jre # set timezone ENV TZ=Asia/Shanghai LANG=C.UTF-8 WORKDIR /app -COPY target/violin-auth-*.jar /app/app.jar +COPY target/violin-iam-*.jar /app/app.jar USER 1000 diff --git a/src/main/java/cn/violin/iam/controller/InternalController.java b/src/main/java/cn/violin/iam/controller/InternalController.java index 34f4de8..48854dd 100644 --- a/src/main/java/cn/violin/iam/controller/InternalController.java +++ b/src/main/java/cn/violin/iam/controller/InternalController.java @@ -1,20 +1,27 @@ package cn.violin.iam.controller; +import cn.violin.core.entity.UserEntity; import cn.violin.core.iam.CheckRequest; import cn.violin.core.iam.CheckResult; +import cn.violin.core.iam.CustomerBindingResponse; import cn.violin.iam.dto.SubjectAccessReviewRequest; import cn.violin.iam.dto.SubjectAccessReviewResponse; +import cn.violin.iam.mapper.UserMapper; import cn.violin.iam.security.ServiceAllowlist; import cn.violin.iam.security.ServiceAuthValidator; import cn.violin.iam.service.SubjectAccessReviewService; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +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; +import java.util.Optional; + @RestController @RequestMapping("/api/v1/internal") @RequiredArgsConstructor @@ -24,6 +31,7 @@ public class InternalController { private final SubjectAccessReviewService sarService; private final ServiceAuthValidator serviceAuth; private final ServiceAllowlist allowlist; + private final UserMapper userMapper; @PostMapping("/check-permission") public CheckResult checkPermission(HttpServletRequest httpReq, @@ -49,4 +57,44 @@ public class InternalController { .reason(resp.getReason()) .build(); } + + /** + * Resolve the authoritative {@code customerId} for a given Authentik {@code sub}. + * + *

Service-to-service channel only: caller must present a valid + * {@code X-Service-*} HMAC; {@link ServiceAuthValidator#verifyAndReturnService} + * ensures non-empty service id and {@link ServiceAllowlist} is consulted + * downstream services can decide whether {@code sub} belongs to a customer + * they're allowed to read.

+ * + *

The returned binding carries {@code active=true} when we observed a + * live {@code t_user} row with {@code is_deleted=false} AND a non-null + * {@code customer_id}; otherwise {@code active=false} so callers can + * distinguish "no binding" from "binding to unknown tenant".

+ */ + @GetMapping("/users/{sub}/customer") + public CustomerBindingResponse resolveCustomer(HttpServletRequest httpReq, + @PathVariable String sub) { + // Reject unauthenticated callers. + serviceAuth.verifyAndReturnService(httpReq); + + Optional opt = userMapper.selectByUserId(sub); + if (opt.isEmpty()) { + return CustomerBindingResponse.builder() + .sub(sub) + .customerId(null) + .active(false) + .build(); + } + UserEntity user = opt.get(); + String customerId = user.getCustomerId(); + boolean active = customerId != null + && !customerId.isBlank() + && Boolean.FALSE.equals(user.getIsDeleted()); + return CustomerBindingResponse.builder() + .sub(sub) + .customerId(active ? customerId : null) + .active(active) + .build(); + } } diff --git a/src/test/java/cn/violin/iam/controller/InternalControllerBindingTest.java b/src/test/java/cn/violin/iam/controller/InternalControllerBindingTest.java new file mode 100644 index 0000000..4a981e9 --- /dev/null +++ b/src/test/java/cn/violin/iam/controller/InternalControllerBindingTest.java @@ -0,0 +1,131 @@ +package cn.violin.iam.controller; + +import cn.violin.core.entity.UserEntity; +import cn.violin.core.iam.CustomerBindingResponse; +import cn.violin.iam.dto.SubjectAccessReviewResponse; +import cn.violin.iam.mapper.UserMapper; +import cn.violin.iam.security.ServiceAllowlist; +import cn.violin.iam.security.ServiceAuthValidator; +import cn.violin.iam.service.SubjectAccessReviewService; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Pure-unit tests for {@link InternalController}'s binding resolution endpoint + * ({@code GET /api/v1/internal/users/{sub}/customer}). + * + *

We bypass Spring MVC and the HMAC validator by mocking the request-time + * dependencies ({@link ServiceAuthValidator} and {@link ServiceAllowlist}). + * The {@link UserMapper} is also a mock, so the test class never touches a database.

+ */ +class InternalControllerBindingTest { + + private UserMapper userMapper; + private SubjectAccessReviewService sarService; + private ServiceAuthValidator serviceAuth; + private ServiceAllowlist allowlist; + private InternalController controller; + + @BeforeEach + void setUp() { + userMapper = mock(UserMapper.class); + sarService = mock(SubjectAccessReviewService.class); + serviceAuth = mock(ServiceAuthValidator.class); + allowlist = mock(ServiceAllowlist.class); + controller = new InternalController(sarService, serviceAuth, allowlist, userMapper); + + when(sarService.check(any())).thenReturn( + SubjectAccessReviewResponse.builder().allowed(true).reason("ok").build()); + + when(serviceAuth.verifyAndReturnService(any(HttpServletRequest.class))) + .thenReturn("violin-wiki"); + when(allowlist.resolveTenant(eq("violin-wiki"), any())).thenAnswer(inv -> inv.getArgument(1)); + } + + @Test + void activeBindingReturnsCustomerAndFlag() { + UserEntity user = new UserEntity(); + user.setUserId("alice"); + user.setCustomerId("tenant-A"); + user.setIsDeleted(false); + when(userMapper.selectByUserId("alice")).thenReturn(Optional.of(user)); + + HttpServletRequest req = mock(HttpServletRequest.class); + CustomerBindingResponse resp = controller.resolveCustomer(req, "alice"); + + assertTrue(resp.isActive()); + assertEquals("alice", resp.getSub()); + assertEquals("tenant-A", resp.getCustomerId()); + verify(serviceAuth).verifyAndReturnService(req); + } + + @Test + void inactiveWhenCustomerIdNull() { + UserEntity user = new UserEntity(); + user.setUserId("bob"); + user.setCustomerId(null); + user.setIsDeleted(false); + when(userMapper.selectByUserId("bob")).thenReturn(Optional.of(user)); + + CustomerBindingResponse resp = controller.resolveCustomer(mock(HttpServletRequest.class), "bob"); + + assertFalse(resp.isActive()); + assertEquals("bob", resp.getSub()); + assertNull(resp.getCustomerId()); + } + + @Test + void inactiveWhenUserSoftDeleted() { + UserEntity user = new UserEntity(); + user.setUserId("carol"); + user.setCustomerId("tenant-X"); + user.setIsDeleted(true); + when(userMapper.selectByUserId("carol")).thenReturn(Optional.of(user)); + + CustomerBindingResponse resp = controller.resolveCustomer(mock(HttpServletRequest.class), "carol"); + + assertFalse(resp.isActive()); + assertNotNull(resp); + assertNull(resp.getCustomerId()); + } + + @Test + void inactiveWhenUserNotFound() { + when(userMapper.selectByUserId("ghost")).thenReturn(Optional.empty()); + + CustomerBindingResponse resp = controller.resolveCustomer(mock(HttpServletRequest.class), "ghost"); + + assertFalse(resp.isActive()); + assertEquals("ghost", resp.getSub()); + assertNull(resp.getCustomerId()); + } + + @Test + void verifyAndReturnServiceCalledEvenForUnknownSub() { + // The HMAC gate runs *before* the lookup; cache poisoning risk is mitigated + // by ensuring every request still pays the validator cost. + when(userMapper.selectByUserId("anybody")).thenReturn(Optional.empty()); + + HttpServletRequest req = mock(HttpServletRequest.class); + controller.resolveCustomer(req, "anybody"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpServletRequest.class); + verify(serviceAuth).verifyAndReturnService(captor.capture()); + assertEquals(req, captor.getValue()); + } +}