feat: add Dockerfile2
This commit is contained in:
@@ -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}.
|
||||
*
|
||||
* <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