Compare commits
24 Commits
93c5dff50b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cd96ade89 | |||
| cb852a2bbf | |||
| 40f057030c | |||
| 35f572cb4a | |||
| a4a1f8b577 | |||
| 94f86d308d | |||
| 3cffd4569a | |||
| d237887d83 | |||
| 7f5d0cd511 | |||
| 1b1af41047 | |||
| 2a9670b666 | |||
| 995f0e960e | |||
| 962a469c52 | |||
| 9a8d39bc23 | |||
| ab4c0d8f33 | |||
| 805762c110 | |||
| e83d1d4fff | |||
| e6474e1648 | |||
| 35802353b7 | |||
| 0b09d8ff0a | |||
| 1deae876c3 | |||
| 762fefacac | |||
| 16af9c5f4d | |||
| 844f578601 |
@@ -6,6 +6,11 @@ name: build-and-deploy-dev
|
||||
# clone:
|
||||
# disable: true
|
||||
|
||||
host_aliases:
|
||||
- ip: "192.168.3.49" # ← 替换为 gitea.violin-work.online 的实际 IP
|
||||
hostnames:
|
||||
- gitea.violin-work.online
|
||||
|
||||
volumes:
|
||||
- name: maven-cache
|
||||
claim:
|
||||
|
||||
@@ -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
|
||||
@@ -67,8 +67,6 @@ context. Concrete gotchas to fix:
|
||||
|
||||
## Design notes (do not skip)
|
||||
|
||||
## Design notes (do not skip)
|
||||
|
||||
### JWT revocation propagation — what we tried, what we picked, and why
|
||||
|
||||
The naive question "should we drop `customerId` from the JWT claim so
|
||||
@@ -76,6 +74,7 @@ downstream must call IAM every request?" was the wrong frame. Below is
|
||||
the actual decision tree we walked.
|
||||
|
||||
#### Problem
|
||||
|
||||
A user's `t_user.customer_id` is migrated from tenant A to tenant B. The
|
||||
JWT issued at login still carries `customerId=A`. Without propagation
|
||||
the user keeps operating under A's RBAC scope (and SAR cache) for the
|
||||
@@ -146,4 +145,3 @@ push `(sub, oldCustomerId)` into it. Have `PermissionAspect` consult the cache
|
||||
as a secondary check (after verified gate). Reduce default `expirationMs`
|
||||
in `ViolinJwtProperties` from `86_400_000` (24 h) to `3_600_000` (1 h).
|
||||
These three together close the revocation window to the bound of TTL.
|
||||
|
||||
+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"]
|
||||
@@ -0,0 +1,28 @@
|
||||
# ============================================================
|
||||
# ConfigMap 模板 — violin-auth dev 环境(K8s 测试)
|
||||
# ============================================================
|
||||
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: violin-auth-config
|
||||
namespace: violin-dev
|
||||
labels:
|
||||
app.kubernetes.io/name: violin-auth
|
||||
app.kubernetes.io/component: config
|
||||
environment: dev
|
||||
data:
|
||||
SPRING_PROFILES_ACTIVE: "dev"
|
||||
|
||||
DB_HOST: "violin-postgres-dev"
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: "violin_dev"
|
||||
DB_USER: "postgres"
|
||||
|
||||
AUTHENTIK_ISSUER: "https://auth.violin-work.online"
|
||||
AUTHENTIK_REDIRECT_URI: "https://dev.violin-home.cn/auth/callback"
|
||||
AUTHENTIK_SCOPE: "openid profile email"
|
||||
|
||||
VIOLIN_CORS_ALLOWED_ORIGINS: "https://dev.violin-home.cn,https://*.violin-work.online,http://localhost:5173"
|
||||
|
||||
VIOLIN_SNOWFLAKE_WORKER_ID: "1"
|
||||
@@ -0,0 +1,36 @@
|
||||
# ============================================================
|
||||
# ConfigMap 模板 — violin-auth 生产环境(非敏感配置)
|
||||
# ============================================================
|
||||
# 使用方式:
|
||||
# kubectl apply -f example/configmap-template.yaml
|
||||
#
|
||||
# 说明:
|
||||
# - 非敏感参数放这里,可 commit 进 Git
|
||||
# - 敏感凭据(密码 / secret)走 secret-template.yaml,绝不 commit 真实值
|
||||
# - KubeSphere UI 可直接导入此文件
|
||||
# ============================================================
|
||||
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: violin-auth-config
|
||||
namespace: violin-prod
|
||||
labels:
|
||||
app.kubernetes.io/name: violin-auth
|
||||
app.kubernetes.io/component: config
|
||||
environment: production
|
||||
data:
|
||||
SPRING_PROFILES_ACTIVE: "prod"
|
||||
|
||||
DB_HOST: "violin-postgres"
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: "violin"
|
||||
DB_USER: "postgres"
|
||||
|
||||
AUTHENTIK_ISSUER: "https://auth.violin-work.online"
|
||||
AUTHENTIK_REDIRECT_URI: "https://www.violin-home.cn/auth/callback"
|
||||
AUTHENTIK_SCOPE: "openid profile email"
|
||||
|
||||
VIOLIN_CORS_ALLOWED_ORIGINS: "https://www.violin-home.cn,https://*.violin-work.online"
|
||||
|
||||
VIOLIN_SNOWFLAKE_WORKER_ID: "1"
|
||||
@@ -0,0 +1,27 @@
|
||||
# ============================================================
|
||||
# Secret 模板 — violin-auth dev 环境(K8s 测试)
|
||||
# ============================================================
|
||||
# ⚠️ 真实凭据通过 KubeSphere UI 或 CI/CD 注入,不要 commit 真实值
|
||||
# dev 环境凭据可以和 prod 区分开(推荐),也可以共用(不推荐)
|
||||
# ============================================================
|
||||
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: violin-auth-secret
|
||||
namespace: violin-dev
|
||||
labels:
|
||||
app.kubernetes.io/name: violin-auth
|
||||
app.kubernetes.io/component: secret
|
||||
environment: dev
|
||||
type: Opaque
|
||||
stringData:
|
||||
# ---- 数据库 ----
|
||||
DB_PASSWORD: "CHANGE_ME_DEV_POSTGRES_PASSWORD"
|
||||
|
||||
# ---- JWT ----
|
||||
VIOLIN_JWT_SECRET: "CHANGE_ME_DEV_JWT_SECRET_MIN_32_BYTES"
|
||||
|
||||
# ---- Authentik OIDC(dev application)----
|
||||
AUTHENTIK_CLIENT_ID: "CHANGE_ME_DEV_AUTHENTIK_CLIENT_ID"
|
||||
AUTHENTIK_CLIENT_SECRET: "CHANGE_ME_DEV_AUTHENTIK_CLIENT_SECRET"
|
||||
@@ -0,0 +1,30 @@
|
||||
# ============================================================
|
||||
# Secret 模板 — violin-auth 生产环境(敏感凭据)
|
||||
# ============================================================
|
||||
# ⚠️ 重要:
|
||||
# - 本文件是 TEMPLATE,真实凭据值必须通过以下方式注入:
|
||||
# 1. KubeSphere 控制台 → 配置存储 → Secret → 创建(界面填写真实值)
|
||||
# 2. 或 CI/CD 流水线中通过 Drone Secret / sealed-secrets 动态注入
|
||||
# - 绝不要把 stringData 里的占位符替换成真实值后 commit
|
||||
# ============================================================
|
||||
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: violin-auth-secret
|
||||
namespace: violin-prod
|
||||
labels:
|
||||
app.kubernetes.io/name: violin-auth
|
||||
app.kubernetes.io/component: secret
|
||||
environment: production
|
||||
type: Opaque
|
||||
stringData:
|
||||
# ---- 数据库 ----
|
||||
DB_PASSWORD: "CHANGE_ME_POSTGRES_PASSWORD"
|
||||
|
||||
# ---- JWT ----
|
||||
VIOLIN_JWT_SECRET: "CHANGE_ME_JWT_SECRET_MIN_32_BYTES"
|
||||
|
||||
# ---- Authentik OIDC ----
|
||||
AUTHENTIK_CLIENT_ID: "CHANGE_ME_AUTHENTIK_CLIENT_ID"
|
||||
AUTHENTIK_CLIENT_SECRET: "CHANGE_ME_AUTHENTIK_CLIENT_SECRET"
|
||||
@@ -7,11 +7,11 @@
|
||||
<parent>
|
||||
<groupId>cn.violin</groupId>
|
||||
<artifactId>violin-parent</artifactId>
|
||||
<version>2.1</version>
|
||||
<version>2.2</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>violin-iam</artifactId>
|
||||
<version>2.1</version>
|
||||
<version>2.2</version>
|
||||
<name>violin-iam</name>
|
||||
<description>violin IAM service</description>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<dependency>
|
||||
<groupId>cn.violin</groupId>
|
||||
<artifactId>violin-core</artifactId>
|
||||
<version>2.1</version>
|
||||
<version>2.2.1-SNAPSHOT</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ violin:
|
||||
auth:
|
||||
exclude-paths: /error
|
||||
anonymous-message-code: AUTHORIZATION_MISSING
|
||||
iam:
|
||||
iam:
|
||||
service-id: ${VIOLIN_IAM_SERVICE_ID:violin-caller}
|
||||
url: ${VIOLIN_IAM_URL:http://localhost:8080}
|
||||
context-path: ${VIOLIN_IAM_CONTEXT_PATH:/violin-iam}
|
||||
|
||||
@@ -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