Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16af9c5f4d | |||
| 844f578601 |
@@ -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
|
||||||
@@ -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
|
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Dockerfile — violin-auth(for drone in kubernetes)
|
||||||
|
# ============================================================
|
||||||
|
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-iam-*.jar /app/app.jar
|
||||||
|
|
||||||
|
USER 1000
|
||||||
|
|
||||||
|
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
|
||||||
@@ -1,20 +1,27 @@
|
|||||||
package cn.violin.iam.controller;
|
package cn.violin.iam.controller;
|
||||||
|
|
||||||
|
import cn.violin.core.entity.UserEntity;
|
||||||
import cn.violin.core.iam.CheckRequest;
|
import cn.violin.core.iam.CheckRequest;
|
||||||
import cn.violin.core.iam.CheckResult;
|
import cn.violin.core.iam.CheckResult;
|
||||||
|
import cn.violin.core.iam.CustomerBindingResponse;
|
||||||
import cn.violin.iam.dto.SubjectAccessReviewRequest;
|
import cn.violin.iam.dto.SubjectAccessReviewRequest;
|
||||||
import cn.violin.iam.dto.SubjectAccessReviewResponse;
|
import cn.violin.iam.dto.SubjectAccessReviewResponse;
|
||||||
|
import cn.violin.iam.mapper.UserMapper;
|
||||||
import cn.violin.iam.security.ServiceAllowlist;
|
import cn.violin.iam.security.ServiceAllowlist;
|
||||||
import cn.violin.iam.security.ServiceAuthValidator;
|
import cn.violin.iam.security.ServiceAuthValidator;
|
||||||
import cn.violin.iam.service.SubjectAccessReviewService;
|
import cn.violin.iam.service.SubjectAccessReviewService;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/internal")
|
@RequestMapping("/api/v1/internal")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -24,6 +31,7 @@ public class InternalController {
|
|||||||
private final SubjectAccessReviewService sarService;
|
private final SubjectAccessReviewService sarService;
|
||||||
private final ServiceAuthValidator serviceAuth;
|
private final ServiceAuthValidator serviceAuth;
|
||||||
private final ServiceAllowlist allowlist;
|
private final ServiceAllowlist allowlist;
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
|
||||||
@PostMapping("/check-permission")
|
@PostMapping("/check-permission")
|
||||||
public CheckResult checkPermission(HttpServletRequest httpReq,
|
public CheckResult checkPermission(HttpServletRequest httpReq,
|
||||||
@@ -49,4 +57,44 @@ public class InternalController {
|
|||||||
.reason(resp.getReason())
|
.reason(resp.getReason())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the authoritative {@code customerId} for a given Authentik {@code sub}.
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*
|
||||||
|
* <p>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".</p>
|
||||||
|
*/
|
||||||
|
@GetMapping("/users/{sub}/customer")
|
||||||
|
public CustomerBindingResponse resolveCustomer(HttpServletRequest httpReq,
|
||||||
|
@PathVariable String sub) {
|
||||||
|
// Reject unauthenticated callers.
|
||||||
|
serviceAuth.verifyAndReturnService(httpReq);
|
||||||
|
|
||||||
|
Optional<UserEntity> 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}).
|
||||||
|
*
|
||||||
|
* <p>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.</p>
|
||||||
|
*/
|
||||||
|
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<HttpServletRequest> captor = ArgumentCaptor.forClass(HttpServletRequest.class);
|
||||||
|
verify(serviceAuth).verifyAndReturnService(captor.capture());
|
||||||
|
assertEquals(req, captor.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user