Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/02-domain-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,9 @@

- 状态语义:
- `ACTIVE`:正常使用
- `PENDING`:等待管理员审批(AccessPolicy 返回 PENDING_APPROVAL 时创建)
- `PENDING`:等待管理员审批(AccessPolicy 返回 PENDING_APPROVAL 时创建);批准时必须在同一事务补齐 `@global` membership 后转为 `ACTIVE`
- `DISABLED`:管理员封禁,登录后拒绝所有操作,返回 403
- `MERGED`:已合并到其他账号,保留记录不物理删除,登录时自动跳转到合并目标账号
- `MERGED`:已合并到其他账号,保留记录不物理删除,不允许通过管理员状态接口重新激活
- 授权层在每次请求时检查用户状态,非 `ACTIVE` 用户拒绝所有写操作

### identity_binding
Expand Down
2 changes: 1 addition & 1 deletion docs/03-authentication-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ astron:
### 2.2 准入失败处理

- `DENY`:抛出 `OAuth2AccessDeniedException`,由 `failureHandler` 重定向到 `/access-denied` 页面。不创建用户,不建立 Session。
- `PENDING_APPROVAL`:创建 `user_account`(status=`PENDING`),但不建立业务 Session。抛出 `AccountPendingException`,由 `failureHandler` 重定向到 `/pending-approval` 页面(纯静态提示页,无需登录态)。管理员在后台审批后状态变为 `ACTIVE`,用户下次 OAuth 登录才会正常建立 Session。
- `PENDING_APPROVAL`:首次登录创建 `user_account`(status=`PENDING`),但不建立业务 Session。抛出 `AccountPendingException`,由 `failureHandler` 重定向到 `/pending-approval` 页面(纯静态提示页,无需登录态)。管理员在后台审批时,系统在同一事务内把状态变为 `ACTIVE` 并补齐 `@global` 的 `MEMBER` membership;任一步失败都回滚。后续登录以已绑定账号的持久化状态为准:`ACTIVE` 正常建立 Session,`PENDING` 继续等待,`DISABLED` 拒绝登录;准入策略持续返回 `PENDING_APPROVAL` 不会覆盖已完成的管理员审批

安全边界:PENDING / DISABLED 用户绝不会拥有有效的业务 Session,从根源上杜绝"待审批账号已认证"的风险。

Expand Down
76 changes: 74 additions & 2 deletions scripts/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ BASE_URL="${1:-http://localhost:8080}"
PASS=0
FAIL=0
COOKIE_JAR="$(mktemp)"
REGISTER_RESPONSE_FILE="$(mktemp)"
USERNAME="smoketest_$(date +%s)"
EMAIL="${USERNAME}@example.com"
PASSWORD="Smoke@2026"
NEW_PASSWORD="Smoke@2027"

cleanup() {
rm -f "$COOKIE_JAR"
rm -f "$COOKIE_JAR" "$REGISTER_RESPONSE_FILE"
}

trap cleanup EXIT
Expand Down Expand Up @@ -43,7 +44,7 @@ check "Auth required" "$BASE_URL/api/v1/auth/me" "401"
curl -s -c "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" >/dev/null
CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_JAR" | tail -n 1)"

REGISTER_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
REGISTER_STATUS="$(curl --max-time 10 -s -o "$REGISTER_RESPONSE_FILE" -w "%{http_code}" \
-X POST "$BASE_URL/api/v1/auth/local/register" \
-b "$COOKIE_JAR" \
-c "$COOKIE_JAR" \
Expand All @@ -58,6 +59,18 @@ else
FAIL=$((FAIL + 1))
fi

REGISTERED_USER_ID="$(python3 - "$REGISTER_RESPONSE_FILE" <<'PY'
import json
import sys

try:
with open(sys.argv[1], encoding="utf-8") as response:
print(json.load(response)["data"]["userId"])
except (KeyError, TypeError, json.JSONDecodeError):
pass
PY
)"

AUTH_ME_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" -b "$COOKIE_JAR" "$BASE_URL/api/v1/auth/me" || true)"
if [[ "$AUTH_ME_STATUS" == "200" ]]; then
echo "PASS: Auth me with session (HTTP $AUTH_ME_STATUS)"
Expand Down Expand Up @@ -146,6 +159,65 @@ fi
# Refresh CSRF after login
ADMIN_CSRF="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$ADMIN_COOKIE_JAR" | tail -n 1)"

# Exercise the administrator activation workflow over HTTP. The transactional
# integration test covers the missing-membership precondition; this smoke path
# verifies the deployed controller, security, persistence, and read model.
if [[ -n "$REGISTERED_USER_ID" && "$ADMIN_LOGIN_STATUS" == "200" ]]; then
DISABLE_USER_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
-X POST "$BASE_URL/api/v1/admin/users/$REGISTERED_USER_ID/disable" \
-b "$ADMIN_COOKIE_JAR" \
-H "X-XSRF-TOKEN: $ADMIN_CSRF" || true)"
if [[ "$DISABLE_USER_STATUS" == "200" ]]; then
echo "PASS: Admin disables smoke user (HTTP $DISABLE_USER_STATUS)"
PASS=$((PASS + 1))
else
echo "FAIL: Admin disables smoke user (got $DISABLE_USER_STATUS)"
FAIL=$((FAIL + 1))
fi

APPROVE_USER_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
-X POST "$BASE_URL/api/v1/admin/users/$REGISTERED_USER_ID/approve" \
-b "$ADMIN_COOKIE_JAR" \
-H "X-XSRF-TOKEN: $ADMIN_CSRF" || true)"
if [[ "$APPROVE_USER_STATUS" == "200" ]]; then
echo "PASS: Admin activates smoke user (HTTP $APPROVE_USER_STATUS)"
PASS=$((PASS + 1))
else
echo "FAIL: Admin activates smoke user (got $APPROVE_USER_STATUS)"
FAIL=$((FAIL + 1))
fi

GLOBAL_MEMBERS_RESPONSE="$(curl --max-time 10 -s \
-b "$ADMIN_COOKIE_JAR" \
"$BASE_URL/api/web/namespaces/global/members?size=1000" || true)"
if JSON_INPUT="$GLOBAL_MEMBERS_RESPONSE" python3 - "$REGISTERED_USER_ID" <<'PY'
import json
import os
import sys

user_id = sys.argv[1]
try:
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
except (KeyError, TypeError, json.JSONDecodeError):
raise SystemExit(1)

raise SystemExit(0 if any(
item.get("userId") == user_id and item.get("role") == "MEMBER"
for item in items
) else 1)
PY
then
echo "PASS: Activated user has @global MEMBER membership"
PASS=$((PASS + 1))
else
echo "FAIL: Activated user is missing @global MEMBER membership"
FAIL=$((FAIL + 1))
fi
else
echo "FAIL: Cannot exercise admin activation workflow without registered user and admin session"
FAIL=$((FAIL + 1))
fi

# Create label definition
CREATE_LABEL_STATUS="$(curl --max-time 10 -s -o /dev/null -w "%{http_code}" \
-X POST "$BASE_URL/api/v1/admin/labels" \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.repository.RoleRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
Expand Down Expand Up @@ -44,16 +45,19 @@ public class AdminUserAppService {
private final UserAccountRepository userAccountRepository;
private final UserRoleBindingRepository userRoleBindingRepository;
private final RoleRepository roleRepository;
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;

public AdminUserAppService(
AdminUserSearchRepository adminUserSearchRepository,
UserAccountRepository userAccountRepository,
UserRoleBindingRepository userRoleBindingRepository,
RoleRepository roleRepository) {
RoleRepository roleRepository,
GlobalNamespaceMembershipService globalNamespaceMembershipService) {
this.adminUserSearchRepository = adminUserSearchRepository;
this.userAccountRepository = userAccountRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
this.roleRepository = roleRepository;
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
}

@Transactional(readOnly = true)
Expand Down Expand Up @@ -109,8 +113,14 @@ public AdminUserMutationResponse updateUserStatus(String userId, String status)
UserAccount user = loadUser(userId);
rejectSystemAccountMutation(user);
UserStatus nextStatus = parseManageableStatus(status);
if (nextStatus == UserStatus.ACTIVE && user.getStatus() == UserStatus.MERGED) {
throw new DomainBadRequestException("error.admin.user.status.mergedCannotActivate");
}
user.setStatus(nextStatus);
userAccountRepository.save(user);
if (nextStatus == UserStatus.ACTIVE) {
globalNamespaceMembershipService.ensureMember(user.getId());
}
return new AdminUserMutationResponse(user.getId(), null, nextStatus.name());
}

Expand Down
1 change: 1 addition & 0 deletions server/skillhub-app/src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can mutate SUPER_
error.admin.user.systemAccount.immutable=System accounts cannot be modified from user management
error.admin.user.status.invalid=Invalid user status: {0}
error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here
error.admin.user.status.mergedCannotActivate=Merged accounts cannot be reactivated
error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace
error.skill.publish.nameConflict.private=A private skill with name ''{0}'' has already been published in this namespace
error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{0}'' already exists in this namespace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以修改 SU
error.admin.user.systemAccount.immutable=系统账号不能在用户管理中修改
error.admin.user.status.invalid=无效的用户状态:{0}
error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户
error.admin.user.status.mergedCannotActivate=已合并账号不能重新激活
error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交
error.skill.publish.nameConflict.private=该命名空间下已存在名为"{0}"的已发布私有技能,无法提交
error.skill.approve.nameConflict=无法通过审核:该命名空间下已存在名为"{0}"的已发布技能
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.repository.RoleRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
Expand Down Expand Up @@ -35,11 +36,14 @@ class AdminUserAppServiceTest {
private final UserRoleBindingRepository userRoleBindingRepository = mock(UserRoleBindingRepository.class);
private final RoleRepository roleRepository = mock(RoleRepository.class);
private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class);
private final GlobalNamespaceMembershipService globalNamespaceMembershipService =
mock(GlobalNamespaceMembershipService.class);
private final AdminUserAppService service = new AdminUserAppService(
adminUserSearchRepository,
userAccountRepository,
userRoleBindingRepository,
roleRepository
roleRepository,
globalNamespaceMembershipService
);

@Test
Expand Down Expand Up @@ -159,10 +163,38 @@ void updateUserStatus_updatesPersistedStatus() {
var response = service.updateUserStatus("user-1", "DISABLED");

verify(userAccountRepository).save(user);
verify(globalNamespaceMembershipService, never()).ensureMember(any());
assertThat(user.getStatus()).isEqualTo(UserStatus.DISABLED);
assertThat(response.status()).isEqualTo("DISABLED");
}

@Test
void updateUserStatus_activatingUserEnsuresGlobalMembership() {
UserAccount user = user("user-1", "alice", "alice@example.com", UserStatus.PENDING);
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
when(userAccountRepository.save(user)).thenReturn(user);

var response = service.updateUserStatus("user-1", "ACTIVE");

verify(userAccountRepository).save(user);
verify(globalNamespaceMembershipService).ensureMember("user-1");
assertThat(user.getStatus()).isEqualTo(UserStatus.ACTIVE);
assertThat(response.status()).isEqualTo("ACTIVE");
}

@Test
void updateUserStatus_rejectsReactivatingMergedAccount() {
UserAccount user = user("user-1", "alice", "alice@example.com", UserStatus.MERGED);
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));

assertThrows(DomainBadRequestException.class,
() -> service.updateUserStatus("user-1", "ACTIVE"));

verify(userAccountRepository, never()).save(any(UserAccount.class));
verify(globalNamespaceMembershipService, never()).ensureMember(any());
assertThat(user.getStatus()).isEqualTo(UserStatus.MERGED);
}

@Test
void updateUserStatus_rejectsSystemAccount() {
when(userAccountRepository.findById("builtin-skill-publisher"))
Expand Down
Loading
Loading