はじめに
レッスン 11 では基本認証について説明しました @Authenticated そして @RolesAllowed。この記事では、きめ細かい RBAC (リソースレベルの権限) について詳しく説明し、レルムロールとクライアントロールと複合ロール、グループベースのアクセス制御、ユーザー/ロールをプログラムで管理するためのKeycloak管理クライアント、マルチテナンシーパターン、および実稼働用の監査ログを区別します。
分散システムの概要
┌─────────────────────────────────────────────────────────┐
│ Keycloak Realm │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Realm Roles │ │ Client Roles │ │ Groups │ │
│ │ │ │ │ │ │ │
│ │ • admin │ │ product-svc: │ │ /sellers │ │
│ │ • customer │ │ • manage │ │ /premium │ │
│ │ • seller │ │ • view │ │ /customers │ │
│ │ • moderator │ │ │ │ /vip │ │
│ │ │ │ order-svc: │ │ /staff │ │
│ │ │ │ • process │ │ /warehouse │ │
│ │ │ │ • refund │ │ /support │ │
│ └──────┬──────┘ └──────┬───────┘ └───────┬────────┘ │
│ │ │ │ │
│ └────────────────┼──────────────────┘ │
│ ▼ │
│ ┌─────────────┐ │
│ │ User │ │
│ │ JWT Token │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────────┘
レルム ロール vs クライアント ロール vs 複合ロール
レルムの役割
レルム全体のスコープ — クロスサービス 権限に使用されます。
Realm Roles:
├── admin → Full access mọi service
├── customer → Mua hàng, xem đơn
├── seller → Quản lý sản phẩm
└── moderator → Review, moderate content
クライアントの役割
各クライアント (サービス) のスコープ — サービス固有の権限に使用されます。
Client: product-service
├── product:create → Tạo sản phẩm
├── product:update → Sửa sản phẩm
├── product:delete → Xoá sản phẩm
├── product:view → Xem sản phẩm
└── product:manage → Full CRUD
Client: order-service
├── order:create → Tạo đơn hàng
├── order:view → Xem đơn hàng
├── order:process → Xử lý đơn (confirm, ship)
├── order:cancel → Huỷ đơn
└── order:refund → Hoàn tiền
複合ロール
複数の役割を 1 つの親役割にグループ化します。
Composite Role: "seller"
├── product:create
├── product:update
├── product:view
├── order:view
└── order:process
Composite Role: "admin"
├── seller (inherits all seller roles)
├── product:delete
├── order:cancel
├── order:refund
└── user:manage
レルム JSON での構成
{
"realm": "ecommerce",
"roles": {
"realm": [
{
"name": "admin",
"composite": true,
"composites": {
"realm": ["seller", "moderator"],
"client": {
"product-service": ["product:manage", "product:delete"],
"order-service": ["order:process", "order:refund", "order:cancel"]
}
}
},
{
"name": "seller",
"composite": true,
"composites": {
"client": {
"product-service": ["product:create", "product:update", "product:view"],
"order-service": ["order:view", "order:process"]
}
}
},
{ "name": "customer" },
{ "name": "moderator" }
],
"client": {
"product-service": [
{ "name": "product:create" },
{ "name": "product:update" },
{ "name": "product:delete" },
{ "name": "product:view" },
{ "name": "product:manage" }
],
"order-service": [
{ "name": "order:create" },
{ "name": "order:view" },
{ "name": "order:process" },
{ "name": "order:cancel" },
{ "name": "order:refund" }
]
}
}
}
Quarkus でのクライアント ロールの使用
へ @RolesAllowed クライアント ロールを受け取るには、トークン内のクレームをマップする必要があります。
# Map client roles vào SecurityIdentity
quarkus.oidc.roles.role-claim-path=\
resource_access/product-service/roles
# Hoặc dùng realm_access cho realm roles (default)
# quarkus.oidc.roles.role-claim-path=realm_access/roles
JWT トークンの構造:
{
"sub": "user-123",
"realm_access": {
"roles": ["seller", "customer"]
},
"resource_access": {
"product-service": {
"roles": ["product:create", "product:update", "product:view"]
},
"order-service": {
"roles": ["order:view", "order:process"]
}
}
}
@Path("/api/v1/products")
public class ProductResource {
// Realm role
@POST
@RolesAllowed("seller")
public Response createProduct(CreateProductRequest req) {
// ...
}
// Client role (fine-grained)
@DELETE @Path("/{id}")
@RolesAllowed("product:delete")
public Response deleteProduct(@PathParam("id") Long id) {
// Chỉ admin có product:delete (qua composite role)
}
// Nhiều roles (OR logic — bất kỳ role nào match)
@GET @Path("/{id}")
@RolesAllowed({"product:view", "customer", "seller"})
public ProductDTO getProduct(@PathParam("id") Long id) {
// ...
}
}
グループベースのアクセス制御
Keycloakでグループを構成する
{
"groups": [
{
"name": "sellers",
"subGroups": [
{
"name": "premium-sellers",
"realmRoles": ["seller"],
"clientRoles": {
"product-service": ["product:manage"]
},
"attributes": {
"max_products": ["1000"],
"commission_rate": ["0.05"]
}
},
{
"name": "basic-sellers",
"realmRoles": ["seller"],
"clientRoles": {
"product-service": ["product:create", "product:view"]
},
"attributes": {
"max_products": ["50"],
"commission_rate": ["0.10"]
}
}
]
},
{
"name": "staff",
"subGroups": [
{
"name": "warehouse",
"realmRoles": [],
"clientRoles": {
"order-service": ["order:process"]
}
},
{
"name": "support",
"realmRoles": ["moderator"],
"clientRoles": {
"order-service": ["order:view", "order:cancel", "order:refund"]
}
}
]
}
]
}
グループをトークンにマップする
Keycloak管理コンソール→クライアントスコープ→マッパーの作成で:
| 設定 | 値 |
|---|---|
| マッパータイプ | グループメンバーシップ |
| トークン要求名 | グループ |
| 完全なグループ パス | オン |
| IDトークンに追加 | オン |
| アクセストークンに追加 | オン |
JWT トークンには次の内容が含まれます:
{
"groups": ["/sellers/premium-sellers"],
"realm_access": { "roles": ["seller"] },
"resource_access": {
"product-service": { "roles": ["product:manage"] }
}
}
Quarkus でのグループの使用
@ApplicationScoped
public class GroupAuthorizationService {
@Inject
JsonWebToken jwt;
/**
* Check user thuộc group cụ thể
*/
public boolean belongsToGroup(String groupPath) {
Set<String> groups = jwt.getClaim("groups");
if (groups == null) return false;
return groups.stream()
.anyMatch(g -> g.equals(groupPath)
|| g.startsWith(groupPath + "/"));
}
/**
* Lấy group attribute (VD: max_products, commission_rate)
*/
public Optional<String> getGroupAttribute(String attrName) {
// Group attributes thường map qua custom claim
Object value = jwt.getClaim(attrName);
return Optional.ofNullable(value)
.map(Object::toString);
}
/**
* Kiểm tra seller tier
*/
public SellerTier getSellerTier() {
Set<String> groups = jwt.getClaim("groups");
if (groups == null) return SellerTier.NONE;
if (groups.contains("/sellers/premium-sellers")) {
return SellerTier.PREMIUM;
} else if (groups.contains("/sellers/basic-sellers")) {
return SellerTier.BASIC;
}
return SellerTier.NONE;
}
public enum SellerTier { NONE, BASIC, PREMIUM }
}
@Path("/api/v1/products")
@Authenticated
public class ProductResource {
@Inject
GroupAuthorizationService groupAuth;
@Inject
JsonWebToken jwt;
@POST
@RolesAllowed("seller")
public Response createProduct(CreateProductRequest req) {
// Kiểm tra seller quota
GroupAuthorizationService.SellerTier tier =
groupAuth.getSellerTier();
int maxProducts = switch (tier) {
case PREMIUM -> 1000;
case BASIC -> 50;
default -> 0;
};
long currentCount = Product.count(
"sellerId", jwt.getSubject());
if (currentCount >= maxProducts) {
throw new BusinessException(403,
"Bạn đã đạt giới hạn " + maxProducts
+ " sản phẩm. Nâng cấp lên Premium"
+ " để đăng thêm.");
}
// Proceed with creation...
return Response.status(201).build();
}
}
詳細な認可 - リソース所有者のチェック
方法 1: インライン チェック (単純)
@Path("/api/v1/orders")
@Authenticated
public class OrderResource {
@Inject
JsonWebToken jwt;
@Inject
SecurityIdentity identity;
@Inject
OrderRepository orderRepo;
@Inject
SecurityAuditService auditService;
@GET @Path("/{id}")
public OrderDTO getOrder(@PathParam("id") Long id) {
Order order = orderRepo.findByIdOptional(id)
.orElseThrow(() ->
new ResourceNotFoundException("Order", id));
// Resource owner check
String currentUserId = jwt.getSubject();
boolean isOwner =
order.customerId.equals(currentUserId);
boolean isAdmin = identity.hasRole("admin");
boolean isSupport = identity.hasRole("moderator");
if (!isOwner && !isAdmin && !isSupport) {
auditService.logAccess("DENIED",
"Order", id.toString());
throw new ForbiddenException(
"Bạn không có quyền xem đơn hàng này");
}
auditService.logAccess("VIEW", "Order", id.toString());
// Support chỉ thấy thông tin giới hạn
if (isSupport && !isOwner) {
return OrderDTO.redacted(order);
}
return OrderDTO.from(order);
}
@PUT @Path("/{id}/cancel")
public OrderDTO cancelOrder(@PathParam("id") Long id) {
Order order = orderRepo.findByIdOptional(id)
.orElseThrow(() ->
new ResourceNotFoundException("Order", id));
String currentUserId = jwt.getSubject();
boolean isOwner =
order.customerId.equals(currentUserId);
boolean hasRefundRole =
identity.hasRole("order:cancel");
// Customer chỉ cancel đơn CREATED/CONFIRMED
if (isOwner) {
if (!Set.of("CREATED", "CONFIRMED")
.contains(order.status)) {
throw new BusinessException(400,
"Chỉ huỷ được đơn ở trạng thái"
+ " CREATED hoặc CONFIRMED");
}
} else if (!hasRefundRole) {
throw new ForbiddenException(
"Bạn không có quyền huỷ đơn hàng này");
}
order.cancel(currentUserId);
auditService.logAccess("CANCEL",
"Order", id.toString());
return OrderDTO.from(order);
}
}
方法 2: カスタム @ResourceOwner 注釈(再利用)
// === Annotation ===
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@InterceptorBinding
public @interface RequiresOwnership {
// Sử dụng @Nonbinding cho các attribute
// để CDI không dùng chúng làm qualifier
@Nonbinding
String paramName() default "id";
@Nonbinding
String resourceType() default "";
@Nonbinding
String[] bypassRoles() default {"admin"};
}
// === Ownership Resolver Interface ===
public interface OwnershipResolver<ID> {
/**
* Trả về userId sở hữu resource.
* Null nếu resource không tồn tại.
*/
String resolveOwner(String resourceType, ID resourceId);
}
// === Concrete Resolver ===
@ApplicationScoped
public class OrderOwnershipResolver
implements OwnershipResolver<Long> {
@Inject
OrderRepository orderRepo;
@Override
public String resolveOwner(String resourceType,
Long resourceId) {
return orderRepo.findByIdOptional(resourceId)
.map(o -> o.customerId)
.orElse(null);
}
}
@ApplicationScoped
public class ProductOwnershipResolver
implements OwnershipResolver<Long> {
@Inject
ProductRepository productRepo;
@Override
public String resolveOwner(String resourceType,
Long resourceId) {
return productRepo.findByIdOptional(resourceId)
.map(p -> p.sellerId)
.orElse(null);
}
}
// === Interceptor ===
@RequiresOwnership
@Interceptor
@Priority(Interceptor.Priority.APPLICATION + 10)
public class OwnershipInterceptor {
private static final Logger LOG =
Logger.getLogger(OwnershipInterceptor.class);
@Inject
JsonWebToken jwt;
@Inject
SecurityIdentity identity;
@Inject
SecurityAuditService auditService;
@Inject
Instance<OwnershipResolver<?>> resolvers;
@AroundInvoke
public Object checkOwnership(InvocationContext ctx)
throws Exception {
RequiresOwnership annotation = ctx.getMethod()
.getAnnotation(RequiresOwnership.class);
// 1. Check bypass roles (admin, etc.)
for (String role : annotation.bypassRoles()) {
if (identity.hasRole(role)) {
LOG.debugf("Bypass ownership check:"
+ " user=%s has role=%s",
jwt.getSubject(), role);
return ctx.proceed();
}
}
// 2. Extract resource ID từ method params
Object resourceId =
extractParam(ctx, annotation.paramName());
if (resourceId == null) {
throw new IllegalStateException(
"Cannot find @PathParam(\""
+ annotation.paramName()
+ "\") in method " + ctx.getMethod());
}
// 3. Resolve owner
String resourceType = annotation.resourceType();
String ownerId = resolveOwner(resourceType,
resourceId);
if (ownerId == null) {
throw new ResourceNotFoundException(
resourceType, resourceId);
}
// 4. Compare
String currentUserId = jwt.getSubject();
if (!ownerId.equals(currentUserId)) {
auditService.logAccess("OWNERSHIP_DENIED",
resourceType, resourceId.toString());
throw new ForbiddenException(
"Bạn không có quyền truy cập "
+ resourceType + " #" + resourceId);
}
return ctx.proceed();
}
@SuppressWarnings("unchecked")
private String resolveOwner(String resourceType,
Object resourceId) {
for (OwnershipResolver resolver : resolvers) {
try {
return resolver.resolveOwner(
resourceType, resourceId);
} catch (ClassCastException e) {
// Wrong resolver type, try next
}
}
throw new IllegalStateException(
"No OwnershipResolver found for: "
+ resourceType);
}
private Object extractParam(InvocationContext ctx,
String paramName) {
Parameter[] params =
ctx.getMethod().getParameters();
for (int i = 0; i < params.length; i++) {
PathParam pp = params[i]
.getAnnotation(PathParam.class);
if (pp != null
&& pp.value().equals(paramName)) {
return ctx.getParameters()[i];
}
}
return null;
}
}
@RequiresOwnership を使用する
@Path("/api/v1/orders")
@Authenticated
public class OrderResource {
@GET @Path("/{id}")
@RequiresOwnership(
paramName = "id",
resourceType = "Order",
bypassRoles = {"admin", "moderator"})
public OrderDTO getOrder(@PathParam("id") Long id) {
// Nếu đến đây = đã pass ownership check
return OrderDTO.from(orderRepo.findById(id));
}
@PUT @Path("/{id}")
@RequiresOwnership(
paramName = "id",
resourceType = "Order") // chỉ admin bypass
public OrderDTO updateOrder(
@PathParam("id") Long id,
UpdateOrderRequest request) {
return orderService.update(id, request);
}
}
@Path("/api/v1/products")
@Authenticated
public class ProductResource {
@PUT @Path("/{id}")
@RolesAllowed("seller")
@RequiresOwnership(
paramName = "id",
resourceType = "Product")
public ProductDTO updateProduct(
@PathParam("id") Long id,
UpdateProductRequest request) {
// Seller chỉ sửa được sản phẩm của mình
return productService.update(id, request);
}
}
カスタム クレーム — トークンの強化
Keycloakプロトコルマッパー
Keycloak管理→クライアントスコープ→マッパーを介してカスタム属性をトークンに追加します。
| 設定 | 値 |
|---|---|
| マッパータイプ | ユーザー属性 |
| ユーザー属性 | 販売者層 |
| トークン要求名 | 販売者層 |
| JSON タイプを要求する | 文字列 |
| アクセストークンに追加 | オン |
または、複雑なロジックには スクリプト マッパー を使用します。
// Keycloak Script Mapper
// Tính seller_level dựa trên số sản phẩm đã bán
var level = 'basic';
var salesCount = user.getAttribute('total_sales');
if (salesCount && parseInt(salesCount[0]) > 1000) {
level = 'gold';
} else if (salesCount && parseInt(salesCount[0]) > 100) {
level = 'silver';
}
token.setOtherClaims('seller_level', level);
Quarkus でカスタム クレームを読む
@ApplicationScoped
public class TokenClaimService {
@Inject
JsonWebToken jwt;
public String getSellerTier() {
return jwt.getClaim("seller_tier");
}
public String getSellerLevel() {
return jwt.getClaim("seller_level");
}
/**
* Lấy tất cả custom claims dạng Map
*/
public Map<String, Object> getCustomClaims() {
return Map.of(
"seller_tier",
Optional.ofNullable(
jwt.getClaim("seller_tier"))
.orElse("none"),
"seller_level",
Optional.ofNullable(
jwt.getClaim("seller_level"))
.orElse("basic"),
"groups",
Optional.ofNullable(
jwt.<Set<String>>getClaim("groups"))
.orElse(Set.of())
);
}
}
Keycloak管理クライアント
依存関係
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-keycloak-admin-client</artifactId>
</dependency>
構成
# === Development: Password Grant ===
quarkus.keycloak.admin-client.server-url=http://localhost:8180
quarkus.keycloak.admin-client.realm=master
quarkus.keycloak.admin-client.client-id=admin-cli
quarkus.keycloak.admin-client.grant-type=PASSWORD
quarkus.keycloak.admin-client.username=admin
quarkus.keycloak.admin-client.password=admin
# === Production: Service Account (Client Credentials) ===
%prod.quarkus.keycloak.admin-client.server-url=\
${KC_SERVER_URL}
%prod.quarkus.keycloak.admin-client.realm=master
%prod.quarkus.keycloak.admin-client.client-id=ecommerce-admin
%prod.quarkus.keycloak.admin-client.client-secret=\
${KC_ADMIN_SECRET}
%prod.quarkus.keycloak.admin-client.grant-type=\
CLIENT_CREDENTIALS
Keycloak でのサービス アカウントのセットアップ: クライアント
ecommerce-admin→ 設定 → サービスアカウントロール → 割り当てrealm-admin役割。役割。
DTO
public record CreateUserRequest(
@NotBlank String username,
@Email @NotBlank String email,
@Size(min = 8) String password,
@NotBlank String firstName,
@NotBlank String lastName
) {}
public record UpdateUserRequest(
String firstName,
String lastName,
String email,
Map<String, List<String>> attributes
) {}
public record UserDTO(
String id,
String username,
String email,
String firstName,
String lastName,
boolean enabled,
boolean emailVerified,
List<String> realmRoles,
Map<String, List<String>> clientRoles,
List<String> groups,
Map<String, List<String>> attributes,
Long createdTimestamp
) {
public static UserDTO from(UserRepresentation user,
List<String> roles,
Map<String, List<String>> cRoles,
List<String> groups) {
return new UserDTO(
user.getId(), user.getUsername(),
user.getEmail(), user.getFirstName(),
user.getLastName(), user.isEnabled(),
user.isEmailVerified(),
roles, cRoles, groups,
user.getAttributes(),
user.getCreatedTimestamp());
}
}
public record UserPageDTO(
List<UserDTO> users,
int total,
int page,
int size
) {}
ユーザー管理サービス (フル)
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.*;
import org.keycloak.representations.idm.*;
import jakarta.ws.rs.core.Response;
@ApplicationScoped
public class KeycloakUserService {
private static final Logger LOG =
Logger.getLogger(KeycloakUserService.class);
@Inject
Keycloak keycloak;
@Inject
SecurityAuditService auditService;
@ConfigProperty(name = "app.keycloak.realm",
defaultValue = "ecommerce")
String realmName;
// === Helper Methods ===
private RealmResource realm() {
return keycloak.realm(realmName);
}
private UsersResource users() {
return realm().users();
}
private UserResource user(String userId) {
return users().get(userId);
}
// =============================================
// CREATE USER
// =============================================
public String createUser(CreateUserRequest request) {
// 1. Check duplicate username/email
List<UserRepresentation> existing =
users().searchByEmail(request.email(), true);
if (!existing.isEmpty()) {
throw new BusinessException(409,
"Email đã được sử dụng: "
+ request.email());
}
existing = users().searchByUsername(
request.username(), true);
if (!existing.isEmpty()) {
throw new BusinessException(409,
"Username đã tồn tại: "
+ request.username());
}
// 2. Build UserRepresentation
UserRepresentation user = new UserRepresentation();
user.setUsername(request.username());
user.setEmail(request.email());
user.setFirstName(request.firstName());
user.setLastName(request.lastName());
user.setEnabled(true);
user.setEmailVerified(false);
// Set initial attributes
user.setAttributes(Map.of(
"registered_from", List.of("ecommerce-api"),
"registration_date",
List.of(LocalDate.now().toString())
));
// 3. Create user in Keycloak
try (Response response = users().create(user)) {
if (response.getStatus() != 201) {
String error = response.readEntity(
String.class);
LOG.errorf("Failed to create user: %s",
error);
throw new BusinessException(400,
"Không thể tạo user: " + error);
}
// 4. Extract user ID
String userId = extractUserId(response);
// 5. Set password
setPassword(userId, request.password(), false);
// 6. Assign default role
assignRealmRole(userId, "customer");
// 7. Gửi email verify (optional)
sendVerificationEmail(userId);
LOG.infof("Created user: %s (%s)",
request.username(), userId);
auditService.logAdminAction(
"CREATE_USER", userId);
return userId;
}
}
private String extractUserId(Response response) {
String locationHeader =
response.getHeaderString("Location");
if (locationHeader == null) {
throw new BusinessException(500,
"Missing Location header in response");
}
return locationHeader
.replaceAll(".*/([^/]+)$", "$1");
}
// =============================================
// UPDATE USER
// =============================================
public UserDTO updateUser(String userId,
UpdateUserRequest request) {
UserRepresentation user =
user(userId).toRepresentation();
if (request.firstName() != null) {
user.setFirstName(request.firstName());
}
if (request.lastName() != null) {
user.setLastName(request.lastName());
}
if (request.email() != null) {
// Check email uniqueness
List<UserRepresentation> existing =
users().searchByEmail(
request.email(), true);
if (existing.stream()
.anyMatch(u -> !u.getId()
.equals(userId))) {
throw new BusinessException(409,
"Email đã được sử dụng");
}
user.setEmail(request.email());
user.setEmailVerified(false);
}
if (request.attributes() != null) {
Map<String, List<String>> attrs =
new HashMap<>(user.getAttributes() != null
? user.getAttributes()
: Map.of());
attrs.putAll(request.attributes());
user.setAttributes(attrs);
}
user(userId).update(user);
auditService.logAdminAction("UPDATE_USER", userId);
return getUser(userId);
}
// =============================================
// PASSWORD MANAGEMENT
// =============================================
public void setPassword(String userId, String password,
boolean temporary) {
CredentialRepresentation cred =
new CredentialRepresentation();
cred.setType(CredentialRepresentation.PASSWORD);
cred.setValue(password);
cred.setTemporary(temporary);
user(userId).resetPassword(cred);
}
public void sendVerificationEmail(String userId) {
try {
user(userId).sendVerifyEmail();
} catch (Exception e) {
LOG.warnf("Could not send verification"
+ " email to user %s: %s",
userId, e.getMessage());
}
}
public void sendPasswordResetEmail(String userId) {
user(userId).executeActionsEmail(
List.of("UPDATE_PASSWORD"));
}
// =============================================
// ROLE MANAGEMENT
// =============================================
public void assignRealmRole(String userId,
String roleName) {
RoleRepresentation role = realm().roles()
.get(roleName).toRepresentation();
user(userId).roles().realmLevel()
.add(List.of(role));
auditService.logAdminAction(
"ASSIGN_ROLE:" + roleName, userId);
}
public void removeRealmRole(String userId,
String roleName) {
RoleRepresentation role = realm().roles()
.get(roleName).toRepresentation();
user(userId).roles().realmLevel()
.remove(List.of(role));
auditService.logAdminAction(
"REMOVE_ROLE:" + roleName, userId);
}
public void assignClientRole(String userId,
String clientId,
String roleName) {
// Find client by clientId
ClientRepresentation client = realm().clients()
.findByClientId(clientId).stream()
.findFirst()
.orElseThrow(() -> new BusinessException(404,
"Client not found: " + clientId));
RoleRepresentation role = realm().clients()
.get(client.getId()).roles()
.get(roleName).toRepresentation();
user(userId).roles()
.clientLevel(client.getId())
.add(List.of(role));
auditService.logAdminAction(
"ASSIGN_CLIENT_ROLE:"
+ clientId + "/" + roleName, userId);
}
public void removeClientRole(String userId,
String clientId,
String roleName) {
ClientRepresentation client = realm().clients()
.findByClientId(clientId).stream()
.findFirst()
.orElseThrow(() -> new BusinessException(404,
"Client not found: " + clientId));
RoleRepresentation role = realm().clients()
.get(client.getId()).roles()
.get(roleName).toRepresentation();
user(userId).roles()
.clientLevel(client.getId())
.remove(List.of(role));
}
// =============================================
// GROUP MANAGEMENT
// =============================================
public void joinGroup(String userId, String groupId) {
user(userId).joinGroup(groupId);
auditService.logAdminAction(
"JOIN_GROUP:" + groupId, userId);
}
public void leaveGroup(String userId, String groupId) {
user(userId).leaveGroup(groupId);
auditService.logAdminAction(
"LEAVE_GROUP:" + groupId, userId);
}
public List<GroupRepresentation> getUserGroups(
String userId) {
return user(userId).groups();
}
public List<GroupRepresentation> listAllGroups() {
return realm().groups().groups();
}
// =============================================
// SEARCH & GET
// =============================================
public UserDTO getUser(String userId) {
UserRepresentation user =
user(userId).toRepresentation();
// Realm roles
List<String> realmRoles = user(userId).roles()
.realmLevel().listEffective().stream()
.map(RoleRepresentation::getName)
.filter(r -> !r.startsWith("default-roles-"))
.toList();
// Client roles
Map<String, List<String>> clientRoles =
new HashMap<>();
for (ClientRepresentation client :
realm().clients().findAll()) {
List<RoleRepresentation> roles =
user(userId).roles()
.clientLevel(client.getId())
.listEffective();
if (!roles.isEmpty()) {
clientRoles.put(client.getClientId(),
roles.stream()
.map(RoleRepresentation::getName)
.toList());
}
}
// Groups
List<String> groups = user(userId).groups().stream()
.map(GroupRepresentation::getPath)
.toList();
return UserDTO.from(user, realmRoles,
clientRoles, groups);
}
public UserPageDTO searchUsers(String keyword,
int page, int size) {
int first = page * size;
List<UserDTO> users =
users().search(keyword, first, size).stream()
.map(u -> {
List<String> roles = user(u.getId())
.roles().realmLevel()
.listEffective().stream()
.map(RoleRepresentation::getName)
.filter(r ->
!r.startsWith("default-roles-"))
.toList();
return UserDTO.from(u, roles,
Map.of(), List.of());
})
.toList();
int total = users().count(keyword);
return new UserPageDTO(users, total, page, size);
}
// =============================================
// ENABLE / DISABLE / DELETE
// =============================================
public void setUserEnabled(String userId, boolean en) {
UserRepresentation user =
user(userId).toRepresentation();
user.setEnabled(en);
user(userId).update(user);
auditService.logAdminAction(
en ? "ENABLE_USER" : "DISABLE_USER", userId);
}
public void deleteUser(String userId) {
user(userId).remove();
auditService.logAdminAction("DELETE_USER", userId);
}
// =============================================
// SESSION MANAGEMENT
// =============================================
public List<UserSessionRepresentation> getUserSessions(
String userId) {
return user(userId).getUserSessions();
}
public void logoutUser(String userId) {
user(userId).logout();
auditService.logAdminAction("LOGOUT_USER", userId);
}
public void logoutAllSessions(String userId) {
user(userId).getUserSessions()
.forEach(session ->
realm().deleteSession(session.getId()));
auditService.logAdminAction(
"LOGOUT_ALL_SESSIONS", userId);
}
}
管理者 REST API (フル)
@Path("/api/v1/admin/users")
@RolesAllowed("admin")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Admin - User Management")
public class AdminUserResource {
@Inject
KeycloakUserService userService;
// === CRUD ===
@POST
@Operation(summary = "Tạo user mới")
public Response createUser(
@Valid CreateUserRequest request) {
String userId = userService.createUser(request);
return Response.created(
URI.create("/api/v1/admin/users/" + userId))
.entity(Map.of("userId", userId))
.build();
}
@GET
@Operation(summary = "Tìm kiếm users")
public UserPageDTO searchUsers(
@QueryParam("q") String keyword,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
return userService.searchUsers(keyword, page, size);
}
@GET @Path("/{userId}")
@Operation(summary = "Lấy thông tin user chi tiết")
public UserDTO getUser(
@PathParam("userId") String userId) {
return userService.getUser(userId);
}
@PUT @Path("/{userId}")
@Operation(summary = "Cập nhật thông tin user")
public UserDTO updateUser(
@PathParam("userId") String userId,
UpdateUserRequest request) {
return userService.updateUser(userId, request);
}
@DELETE @Path("/{userId}")
@Operation(summary = "Xoá user")
public Response deleteUser(
@PathParam("userId") String userId) {
userService.deleteUser(userId);
return Response.noContent().build();
}
// === Role Management ===
@POST @Path("/{userId}/roles/{roleName}")
@Operation(summary = "Gán realm role cho user")
public Response assignRealmRole(
@PathParam("userId") String userId,
@PathParam("roleName") String roleName) {
userService.assignRealmRole(userId, roleName);
return Response.ok(Map.of(
"message", "Đã gán role: " + roleName))
.build();
}
@DELETE @Path("/{userId}/roles/{roleName}")
@Operation(summary = "Gỡ realm role khỏi user")
public Response removeRealmRole(
@PathParam("userId") String userId,
@PathParam("roleName") String roleName) {
userService.removeRealmRole(userId, roleName);
return Response.ok().build();
}
@POST
@Path("/{userId}/clients/{clientId}/roles/{roleName}")
@Operation(summary = "Gán client role cho user")
public Response assignClientRole(
@PathParam("userId") String userId,
@PathParam("clientId") String clientId,
@PathParam("roleName") String roleName) {
userService.assignClientRole(
userId, clientId, roleName);
return Response.ok().build();
}
@DELETE
@Path("/{userId}/clients/{clientId}/roles/{roleName}")
@Operation(summary = "Gỡ client role khỏi user")
public Response removeClientRole(
@PathParam("userId") String userId,
@PathParam("clientId") String clientId,
@PathParam("roleName") String roleName) {
userService.removeClientRole(
userId, clientId, roleName);
return Response.ok().build();
}
// === Group Management ===
@GET @Path("/{userId}/groups")
@Operation(summary = "Lấy danh sách groups của user")
public List<GroupRepresentation> getUserGroups(
@PathParam("userId") String userId) {
return userService.getUserGroups(userId);
}
@POST @Path("/{userId}/groups/{groupId}")
@Operation(summary = "Thêm user vào group")
public Response joinGroup(
@PathParam("userId") String userId,
@PathParam("groupId") String groupId) {
userService.joinGroup(userId, groupId);
return Response.ok().build();
}
@DELETE @Path("/{userId}/groups/{groupId}")
@Operation(summary = "Xoá user khỏi group")
public Response leaveGroup(
@PathParam("userId") String userId,
@PathParam("groupId") String groupId) {
userService.leaveGroup(userId, groupId);
return Response.ok().build();
}
// === Account Actions ===
@PUT @Path("/{userId}/enable")
@Operation(summary = "Kích hoạt user")
public Response enableUser(
@PathParam("userId") String userId) {
userService.setUserEnabled(userId, true);
return Response.ok().build();
}
@PUT @Path("/{userId}/disable")
@Operation(summary = "Vô hiệu hoá user")
public Response disableUser(
@PathParam("userId") String userId) {
userService.setUserEnabled(userId, false);
return Response.ok().build();
}
@POST @Path("/{userId}/reset-password")
@Operation(summary = "Gửi email reset password")
public Response resetPassword(
@PathParam("userId") String userId) {
userService.sendPasswordResetEmail(userId);
return Response.ok(Map.of(
"message", "Đã gửi email reset password"))
.build();
}
@POST @Path("/{userId}/verify-email")
@Operation(summary = "Gửi email xác thực")
public Response verifyEmail(
@PathParam("userId") String userId) {
userService.sendVerificationEmail(userId);
return Response.ok().build();
}
// === Session Management ===
@GET @Path("/{userId}/sessions")
@Operation(summary = "Lấy active sessions của user")
public List<UserSessionRepresentation> getSessions(
@PathParam("userId") String userId) {
return userService.getUserSessions(userId);
}
@DELETE @Path("/{userId}/sessions")
@Operation(summary = "Logout tất cả sessions")
public Response logoutAll(
@PathParam("userId") String userId) {
userService.logoutAllSessions(userId);
return Response.ok(Map.of(
"message", "Đã logout tất cả sessions"))
.build();
}
// === Groups List ===
@GET @Path("/groups")
@Operation(summary = "Liệt kê tất cả groups")
public List<GroupRepresentation> listGroups() {
return userService.listAllGroups();
}
}
自己登録エンドポイント
@Path("/api/v1/auth")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Authentication")
public class AuthResource {
@Inject
KeycloakUserService userService;
@Inject
SecurityAuditService auditService;
@POST @Path("/register")
@PermitAll
@Operation(summary = "Đăng ký tài khoản mới")
public Response register(
@Valid RegisterRequest request) {
String userId = userService.createUser(
new CreateUserRequest(
request.username(), request.email(),
request.password(), request.firstName(),
request.lastName()));
auditService.logAccess("REGISTER",
"User", userId);
return Response.status(201)
.entity(Map.of(
"message", "Đăng ký thành công."
+ " Kiểm tra email để xác thực.",
"userId", userId))
.build();
}
@GET @Path("/me")
@Authenticated
@Operation(summary = "Lấy thông tin user hiện tại")
public UserProfileDTO getProfile(
@Context SecurityContext ctx) {
JsonWebToken jwt = (JsonWebToken) ctx
.getUserPrincipal();
return new UserProfileDTO(
jwt.getSubject(),
jwt.getName(),
jwt.getClaim("email"),
jwt.getClaim("preferred_username"),
jwt.getGroups(),
jwt.getClaim("seller_tier"),
jwt.getClaim("realm_access"));
}
}
public record RegisterRequest(
@NotBlank @Size(min = 3, max = 50) String username,
@Email @NotBlank String email,
@NotBlank @Size(min = 8, message =
"Mật khẩu tối thiểu 8 ký tự") String password,
@NotBlank String firstName,
@NotBlank String lastName
) {}
public record UserProfileDTO(
String id,
String name,
String email,
String username,
Set<String> roles,
String sellerTier,
Object realmAccess
) {}
マルチテナント パターン
SaaS の場合 — 各テナントは 1 レルムまたは 1 グループ + テナント属性です。
アプローチ 1: グループ + 属性によるテナント
@ApplicationScoped
public class TenantResolver {
@Inject
JsonWebToken jwt;
/**
* Resolve tenant từ JWT token.
* Token chứa custom claim "tenant_id"
* (mapped từ user attribute trong Keycloak)
*/
public String getCurrentTenantId() {
String tenantId = jwt.getClaim("tenant_id");
if (tenantId == null || tenantId.isBlank()) {
throw new ForbiddenException(
"User không thuộc tenant nào");
}
return tenantId;
}
}
// Hibernate Filter cho multi-tenancy
@FilterDef(
name = "tenantFilter",
parameters = @ParamDef(
name = "tenantId", type = String.class))
@Filter(
name = "tenantFilter",
condition = "tenant_id = :tenantId")
@Entity
@Table(name = "products")
public class Product extends PanacheEntity {
@Column(name = "tenant_id", nullable = false)
public String tenantId;
public String name;
public BigDecimal price;
// ...
}
// Auto-apply tenant filter
@ApplicationScoped
public class TenantFilterService {
@Inject
EntityManager em;
@Inject
TenantResolver tenantResolver;
/**
* Enable tenant filter cho mọi query
* trong request scope
*/
public void enableTenantFilter() {
Session session = em.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId",
tenantResolver.getCurrentTenantId());
}
}
// Jakarta REST Filter — tự động enable
@Provider
@Authenticated
public class TenantRequestFilter
implements ContainerRequestFilter {
@Inject
TenantFilterService tenantFilter;
@Override
public void filter(
ContainerRequestContext ctx) {
tenantFilter.enableTenantFilter();
}
}
アプローチ 2: レルムごとのテナント
# Mỗi tenant có realm riêng
# Resolve realm từ subdomain hoặc header
quarkus.oidc.auth-server-url=\
http://keycloak:8080/realms/${tenant.realm}
# Dùng TenantConfigResolver
quarkus.oidc.tenant-enabled=true
@ApplicationScoped
public class CustomTenantConfigResolver
implements TenantConfigResolver {
@Override
public Uni<OidcTenantConfig> resolve(
RoutingContext context,
OidcRequestContext<OidcTenantConfig> reqCtx) {
// Resolve tenant từ subdomain
String host = context.request().host();
String tenant = host.split("\\.")[0];
// acme.ecommerce.xdev.asia → tenant = "acme"
OidcTenantConfig config = new OidcTenantConfig();
config.setTenantId(tenant);
config.setAuthServerUrl(
"http://keycloak:8080/realms/" + tenant);
config.setClientId("ecommerce-api");
config.setApplicationType(
OidcTenantConfig.ApplicationType.SERVICE);
return Uni.createFrom().item(config);
}
}
監査ログ
構造化監査サービス
@ApplicationScoped
public class SecurityAuditService {
private static final Logger AUDIT =
Logger.getLogger("SECURITY_AUDIT");
@Inject
JsonWebToken jwt;
@Inject
@ConfigProperty(name = "quarkus.application.name")
String serviceName;
/**
* Log resource access (view, update, delete)
*/
public void logAccess(String action, String resource,
String resourceId) {
AuditEntry entry = new AuditEntry(
UUID.randomUUID().toString(),
Instant.now(),
serviceName,
resolveUserId(),
resolveUsername(),
action,
resource,
resourceId,
"SUCCESS",
null);
AUDIT.infof("%s", entry.toJson());
}
/**
* Log admin actions (create/delete user, assign role)
*/
public void logAdminAction(String action,
String targetUserId) {
AuditEntry entry = new AuditEntry(
UUID.randomUUID().toString(),
Instant.now(),
serviceName,
resolveUserId(),
resolveUsername(),
action,
"User",
targetUserId,
"SUCCESS",
null);
// Admin actions → WARN level cho visibility
AUDIT.warnf("ADMIN_ACTION | %s", entry.toJson());
}
/**
* Log security violations (unauthorized access,
* ownership check failures)
*/
public void logViolation(String action, String resource,
String resourceId,
String reason) {
AuditEntry entry = new AuditEntry(
UUID.randomUUID().toString(),
Instant.now(),
serviceName,
resolveUserId(),
resolveUsername(),
action,
resource,
resourceId,
"DENIED",
reason);
// Security violations → ERROR level
AUDIT.errorf("SECURITY_VIOLATION | %s",
entry.toJson());
}
private String resolveUserId() {
return jwt != null ? jwt.getSubject() : "anonymous";
}
private String resolveUsername() {
return jwt != null
? jwt.getClaim("preferred_username")
: "anonymous";
}
}
public record AuditEntry(
String id,
Instant timestamp,
String service,
String userId,
String username,
String action,
String resourceType,
String resourceId,
String result,
String reason
) {
public String toJson() {
return String.format(
"{\"id\":\"%s\",\"timestamp\":\"%s\","
+ "\"service\":\"%s\",\"userId\":\"%s\","
+ "\"username\":\"%s\",\"action\":\"%s\","
+ "\"resourceType\":\"%s\","
+ "\"resourceId\":\"%s\","
+ "\"result\":\"%s\",\"reason\":\"%s\"}",
id, timestamp, service, userId, username,
action, resourceType, resourceId,
result, reason != null ? reason : "");
}
}
application.properties での監査ログイン
# Separate audit log file
quarkus.log.category."SECURITY_AUDIT".level=INFO
quarkus.log.handler.file."audit".enable=true
quarkus.log.handler.file."audit".path=logs/audit.log
quarkus.log.handler.file."audit".rotation.max-file-size=50M
quarkus.log.handler.file."audit".rotation.max-backup-index=30
quarkus.log.handler.file."audit".format=%d{yyyy-MM-dd HH:mm:ss} %m%n
quarkus.log.category."SECURITY_AUDIT".handlers=audit
quarkus.log.category."SECURITY_AUDIT".use-parent-handlers=false
セキュリティのテスト
@QuarkusTest
class AdminUserResourceTest {
// === Test admin access ===
@Test
@TestSecurity(user = "admin1",
roles = {"admin"})
void testListUsersAsAdmin() {
given()
.queryParam("q", "test")
.when().get("/api/v1/admin/users")
.then()
.statusCode(200)
.body("users", notNullValue())
.body("total", greaterThanOrEqualTo(0));
}
// === Test forbidden for non-admin ===
@Test
@TestSecurity(user = "customer1",
roles = {"customer"})
void testListUsersAsCustomerForbidden() {
given()
.when().get("/api/v1/admin/users")
.then()
.statusCode(403);
}
// === Test resource owner ===
@Test
@TestSecurity(user = "owner-123",
roles = {"customer"})
void testGetOwnOrder() {
// Assume order #1 belongs to owner-123
given()
.when().get("/api/v1/orders/1")
.then()
.statusCode(200);
}
@Test
@TestSecurity(user = "other-user",
roles = {"customer"})
void testGetOtherUserOrderForbidden() {
// order #1 belongs to owner-123, not other-user
given()
.when().get("/api/v1/orders/1")
.then()
.statusCode(403);
}
// === Test admin bypass ownership ===
@Test
@TestSecurity(user = "admin1",
roles = {"admin"})
void testAdminCanViewAnyOrder() {
given()
.when().get("/api/v1/orders/1")
.then()
.statusCode(200);
}
// === Test self-registration ===
@Test
void testRegister() {
given()
.contentType("application/json")
.body("""
{
"username": "newuser",
"email": "[email protected]",
"password": "Password123!",
"firstName": "New",
"lastName": "User"
}
""")
.when().post("/api/v1/auth/register")
.then()
.statusCode(201)
.body("userId", notNullValue());
}
// === Test client roles ===
@Test
@TestSecurity(user = "seller1",
roles = {"seller", "product:create",
"product:update", "product:view"})
void testSellerCanCreateProduct() {
given()
.contentType("application/json")
.body("""
{"name": "Test Product",
"price": 100000,
"stockQuantity": 10,
"categoryId": 1}
""")
.when().post("/api/v1/products")
.then()
.statusCode(201);
}
@Test
@TestSecurity(user = "seller1",
roles = {"seller", "product:create"})
void testSellerCannotDeleteProduct() {
// seller không có product:delete
given()
.when().delete("/api/v1/products/1")
.then()
.statusCode(403);
}
}
演習
- KeycloakレルムJSONでレルムロール、クライアントロール、複合ロールを設定する
2.実装する
@RequiresOwnershipOrder および Product リソースのアノテーション + インターセプター - Keycloak管理クライアントの統合: CRUDユーザー、ロールの割り当て/削除、グループの管理
- Swagger ドキュメントを使用して Admin API エンドポイントを作成する
- 電子メール検証を備えた自己登録エンドポイントを実装する
- グループベースのアクセスを構成します: 製品割り当てのある販売者 (プレミアム/ベーシック)
- 別個のログ ファイルで構造化された監査ログを追加します。
- テストを作成します。
@TestSecurity管理者、顧客、販売者、リソース所有者向け - (上級) Hibernate フィルターまたはレルムごとのアプローチを使用したマルチテナントの実装
概要
| 特長 | 説明 |
|---|---|
| レルムの役割 | クロスサービス権限: 管理者、顧客、販売者 |
| クライアントの役割 | サービス固有: 製品:作成、注文:返金 |
| 複合ロール | 役割の収集: 販売者 = 製品:作成 + 製品:表示 + ... |
| グループ | ユーザーを整理します: /sellers/premium、/staff/support |
| @RequiresOwnership | リソースレベルの認可、管理者バイパス |
| カスタムクレーム | Keycloakプロトコルマッパーによるトークンエンリッチメント |
| 管理クライアント | プログラムによるユーザー、ロール、グループ、セッションの CRUD |
| マルチテナント | グループごとまたはレルムごとのテナント分離 |
| 監査ログ | セキュリティ イベントの構造化ログ、別ファイル |
| @TestSecurity | テストでの模擬認証/認可 |
次の記事: REST クライアント — マイクロサービス間の同期通信。