diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..feb4ac3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,46 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build: + name: Gradle build (Java 21, Testcontainers) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Java 21 (GraalVM CE) + uses: graalvm/setup-graalvm@v1 + with: + java-version: '21' + distribution: 'graalvm-community' + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Gradle (wrapper) + uses: gradle/actions/setup-gradle@v4 + + - name: Build (compile + test) + run: ./gradlew build --no-daemon --stacktrace + + - name: Upload test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: | + **/build/reports/tests/ + **/build/test-results/ + if-no-files-found: ignore + retention-days: 7 diff --git a/build.gradle.kts b/build.gradle.kts index d502116..efef04b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,6 +35,10 @@ subprojects { "testRuntimeOnly"("org.junit.platform:junit-platform-launcher") } + tasks.withType().configureEach { + options.compilerArgs.add("-parameters") + } + tasks.withType().configureEach { useJUnitPlatform() } diff --git a/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/Group.java b/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/Group.java new file mode 100644 index 0000000..67df203 --- /dev/null +++ b/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/Group.java @@ -0,0 +1,24 @@ +package kr.devslab.kit.access; + +import java.util.Objects; +import java.util.Optional; +import kr.devslab.kit.core.id.GroupId; +import kr.devslab.kit.core.id.TenantId; + +public record Group( + GroupId id, + TenantId tenantId, + String code, + String name, + Optional parentGroupId +) { + + public Group { + Objects.requireNonNull(id, "Group id must not be null"); + Objects.requireNonNull(tenantId, "Group tenantId must not be null"); + if (code == null || code.isBlank()) { + throw new IllegalArgumentException("Group code must not be null or blank"); + } + parentGroupId = parentGroupId == null ? Optional.empty() : parentGroupId; + } +} diff --git a/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/PermissionChecker.java b/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/PermissionChecker.java index e9863f8..98a7b57 100644 --- a/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/PermissionChecker.java +++ b/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/PermissionChecker.java @@ -1,5 +1,7 @@ package kr.devslab.kit.access; +import kr.devslab.kit.access.policy.PolicyContext; + public interface PermissionChecker { boolean hasPermission(Permission permission); @@ -8,9 +10,19 @@ public interface PermissionChecker { boolean hasAllPermissions(Permission... permissions); + default boolean isAllowed(Permission permission, String policyName, PolicyContext context) { + return hasPermission(permission); + } + default void check(Permission permission) { if (!hasPermission(permission)) { throw new PermissionDeniedException(permission); } } + + default void check(Permission permission, String policyName, PolicyContext context) { + if (!isAllowed(permission, policyName, context)) { + throw new PermissionDeniedException(permission); + } + } } diff --git a/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/policy/Policy.java b/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/policy/Policy.java new file mode 100644 index 0000000..0baa090 --- /dev/null +++ b/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/policy/Policy.java @@ -0,0 +1,8 @@ +package kr.devslab.kit.access.policy; + +public interface Policy { + + String name(); + + PolicyDecision evaluate(PolicyContext context); +} diff --git a/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/policy/PolicyContext.java b/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/policy/PolicyContext.java new file mode 100644 index 0000000..5a4a1d7 --- /dev/null +++ b/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/policy/PolicyContext.java @@ -0,0 +1,45 @@ +package kr.devslab.kit.access.policy; + +import java.util.Map; +import java.util.Optional; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.core.id.UserId; + +public record PolicyContext( + Optional userId, + Optional tenantId, + String resourceType, + String resourceId, + Map resourceAttributes, + Map environmentAttributes +) { + + public PolicyContext { + userId = userId == null ? Optional.empty() : userId; + tenantId = tenantId == null ? Optional.empty() : tenantId; + resourceAttributes = resourceAttributes == null ? Map.of() : Map.copyOf(resourceAttributes); + environmentAttributes = environmentAttributes == null ? Map.of() : Map.copyOf(environmentAttributes); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private Optional userId = Optional.empty(); + private Optional tenantId = Optional.empty(); + private String resourceType; + private String resourceId; + private Map resourceAttributes = Map.of(); + private Map environmentAttributes = Map.of(); + + public Builder user(UserId id) { this.userId = Optional.ofNullable(id); return this; } + public Builder tenant(TenantId id) { this.tenantId = Optional.ofNullable(id); return this; } + public Builder resource(String type, String id) { this.resourceType = type; this.resourceId = id; return this; } + public Builder resourceAttributes(Map attrs) { this.resourceAttributes = attrs; return this; } + public Builder environmentAttributes(Map attrs) { this.environmentAttributes = attrs; return this; } + public PolicyContext build() { + return new PolicyContext(userId, tenantId, resourceType, resourceId, resourceAttributes, environmentAttributes); + } + } +} diff --git a/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/policy/PolicyDecision.java b/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/policy/PolicyDecision.java new file mode 100644 index 0000000..44f5858 --- /dev/null +++ b/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/policy/PolicyDecision.java @@ -0,0 +1,7 @@ +package kr.devslab.kit.access.policy; + +public enum PolicyDecision { + ALLOW, + DENY, + NOT_APPLICABLE +} diff --git a/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/policy/PolicyEvaluator.java b/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/policy/PolicyEvaluator.java new file mode 100644 index 0000000..6147057 --- /dev/null +++ b/devslab-kit-access-api/src/main/java/kr/devslab/kit/access/policy/PolicyEvaluator.java @@ -0,0 +1,6 @@ +package kr.devslab.kit.access.policy; + +public interface PolicyEvaluator { + + PolicyDecision evaluate(String policyName, PolicyContext context); +} diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/entity/PlatformGroupEntity.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/entity/PlatformGroupEntity.java new file mode 100644 index 0000000..e11ba42 --- /dev/null +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/entity/PlatformGroupEntity.java @@ -0,0 +1,52 @@ +package kr.devslab.kit.access.core.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table( + name = "platform_group", + uniqueConstraints = @UniqueConstraint(name = "uq_platform_group_tenant_code", columnNames = {"tenant_id", "code"}) +) +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PlatformGroupEntity { + + @Id + @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) + private UUID id; + + @Column(name = "tenant_id", nullable = false, length = 64) + private String tenantId; + + @Column(name = "code", nullable = false, length = 64) + private String code; + + @Column(name = "name", nullable = false, length = 128) + private String name; + + @Column(name = "parent_group_id", columnDefinition = "uuid") + private UUID parentGroupId; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + public PlatformGroupEntity(UUID id, String tenantId, String code, String name, UUID parentGroupId, Instant createdAt) { + this.id = id; + this.tenantId = tenantId; + this.code = code; + this.name = name; + this.parentGroupId = parentGroupId; + this.createdAt = createdAt; + } +} diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/entity/PlatformGroupRoleEntity.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/entity/PlatformGroupRoleEntity.java new file mode 100644 index 0000000..3a469e8 --- /dev/null +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/entity/PlatformGroupRoleEntity.java @@ -0,0 +1,44 @@ +package kr.devslab.kit.access.core.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table( + name = "platform_group_role", + uniqueConstraints = @UniqueConstraint(name = "uq_platform_group_role_group_role", columnNames = {"group_id", "role_id"}) +) +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PlatformGroupRoleEntity { + + @Id + @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) + private UUID id; + + @Column(name = "group_id", columnDefinition = "uuid", nullable = false) + private UUID groupId; + + @Column(name = "role_id", columnDefinition = "uuid", nullable = false) + private UUID roleId; + + @Column(name = "assigned_at", nullable = false) + private Instant assignedAt; + + public PlatformGroupRoleEntity(UUID id, UUID groupId, UUID roleId, Instant assignedAt) { + this.id = id; + this.groupId = groupId; + this.roleId = roleId; + this.assignedAt = assignedAt; + } +} diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/entity/PlatformUserGroupEntity.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/entity/PlatformUserGroupEntity.java new file mode 100644 index 0000000..637e58f --- /dev/null +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/entity/PlatformUserGroupEntity.java @@ -0,0 +1,44 @@ +package kr.devslab.kit.access.core.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table( + name = "platform_user_group", + uniqueConstraints = @UniqueConstraint(name = "uq_platform_user_group_user_group", columnNames = {"user_id", "group_id"}) +) +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PlatformUserGroupEntity { + + @Id + @Column(name = "id", columnDefinition = "uuid", nullable = false, updatable = false) + private UUID id; + + @Column(name = "user_id", columnDefinition = "uuid", nullable = false) + private UUID userId; + + @Column(name = "group_id", columnDefinition = "uuid", nullable = false) + private UUID groupId; + + @Column(name = "joined_at", nullable = false) + private Instant joinedAt; + + public PlatformUserGroupEntity(UUID id, UUID userId, UUID groupId, Instant joinedAt) { + this.id = id; + this.userId = userId; + this.groupId = groupId; + this.joinedAt = joinedAt; + } +} diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformGroupRepository.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformGroupRepository.java new file mode 100644 index 0000000..57ed7a4 --- /dev/null +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformGroupRepository.java @@ -0,0 +1,16 @@ +package kr.devslab.kit.access.core.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import kr.devslab.kit.access.core.entity.PlatformGroupEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface JpaPlatformGroupRepository extends JpaRepository { + + Optional findByTenantIdAndCode(String tenantId, String code); + + List findAllByTenantId(String tenantId); + + List findAllByParentGroupId(UUID parentGroupId); +} diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformGroupRoleRepository.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformGroupRoleRepository.java new file mode 100644 index 0000000..c0f5148 --- /dev/null +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformGroupRoleRepository.java @@ -0,0 +1,16 @@ +package kr.devslab.kit.access.core.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import kr.devslab.kit.access.core.entity.PlatformGroupRoleEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface JpaPlatformGroupRoleRepository extends JpaRepository { + + List findAllByGroupId(UUID groupId); + + Optional findByGroupIdAndRoleId(UUID groupId, UUID roleId); + + void deleteByGroupIdAndRoleId(UUID groupId, UUID roleId); +} diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformPermissionRepository.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformPermissionRepository.java index cb480fa..c3b5b2d 100644 --- a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformPermissionRepository.java +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformPermissionRepository.java @@ -15,8 +15,13 @@ public interface JpaPlatformPermissionRepository extends JpaRepository findCodesForUserId(@Param("userId") UUID userId); } diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformUserGroupRepository.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformUserGroupRepository.java new file mode 100644 index 0000000..63587bf --- /dev/null +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/repository/JpaPlatformUserGroupRepository.java @@ -0,0 +1,18 @@ +package kr.devslab.kit.access.core.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import kr.devslab.kit.access.core.entity.PlatformUserGroupEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface JpaPlatformUserGroupRepository extends JpaRepository { + + List findAllByUserId(UUID userId); + + List findAllByGroupId(UUID groupId); + + Optional findByUserIdAndGroupId(UUID userId, UUID groupId); + + void deleteByUserIdAndGroupId(UUID userId, UUID groupId); +} diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/DefaultPermissionChecker.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/DefaultPermissionChecker.java index da5abbd..d1f989e 100644 --- a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/DefaultPermissionChecker.java +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/DefaultPermissionChecker.java @@ -4,6 +4,9 @@ import kr.devslab.kit.access.Permission; import kr.devslab.kit.access.PermissionChecker; import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository; +import kr.devslab.kit.access.policy.PolicyContext; +import kr.devslab.kit.access.policy.PolicyDecision; +import kr.devslab.kit.access.policy.PolicyEvaluator; import kr.devslab.kit.identity.CurrentUserProvider; import org.springframework.transaction.annotation.Transactional; @@ -11,13 +14,16 @@ public class DefaultPermissionChecker implements PermissionChecker { private final CurrentUserProvider currentUserProvider; private final JpaPlatformPermissionRepository permissionRepository; + private final PolicyEvaluator policyEvaluator; public DefaultPermissionChecker( CurrentUserProvider currentUserProvider, - JpaPlatformPermissionRepository permissionRepository + JpaPlatformPermissionRepository permissionRepository, + PolicyEvaluator policyEvaluator ) { this.currentUserProvider = currentUserProvider; this.permissionRepository = permissionRepository; + this.policyEvaluator = policyEvaluator; } @Override @@ -50,6 +56,19 @@ public boolean hasAllPermissions(Permission... permissions) { return true; } + @Override + @Transactional(readOnly = true) + public boolean isAllowed(Permission permission, String policyName, PolicyContext context) { + if (!hasPermission(permission)) { + return false; + } + if (policyName == null || policyEvaluator == null) { + return true; + } + PolicyDecision decision = policyEvaluator.evaluate(policyName, context); + return decision != PolicyDecision.DENY; + } + private Set currentPermissionCodes() { return currentUserProvider.current() .map(user -> permissionRepository.findCodesForUserId(user.id().value())) diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/DefaultPolicyEvaluator.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/DefaultPolicyEvaluator.java new file mode 100644 index 0000000..bb1b5e5 --- /dev/null +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/DefaultPolicyEvaluator.java @@ -0,0 +1,37 @@ +package kr.devslab.kit.access.core.service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import kr.devslab.kit.access.policy.Policy; +import kr.devslab.kit.access.policy.PolicyContext; +import kr.devslab.kit.access.policy.PolicyDecision; +import kr.devslab.kit.access.policy.PolicyEvaluator; + +public class DefaultPolicyEvaluator implements PolicyEvaluator { + + private final Map policiesByName; + + public DefaultPolicyEvaluator(List policies) { + Map map = new HashMap<>(); + for (Policy policy : policies) { + Policy previous = map.put(policy.name(), policy); + if (previous != null) { + throw new IllegalStateException("Duplicate Policy bean for name: " + policy.name()); + } + } + this.policiesByName = Map.copyOf(map); + } + + @Override + public PolicyDecision evaluate(String policyName, PolicyContext context) { + if (policyName == null) { + return PolicyDecision.NOT_APPLICABLE; + } + Policy policy = policiesByName.get(policyName); + if (policy == null) { + return PolicyDecision.NOT_APPLICABLE; + } + return policy.evaluate(context); + } +} diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/GroupMembershipService.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/GroupMembershipService.java new file mode 100644 index 0000000..c773d68 --- /dev/null +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/GroupMembershipService.java @@ -0,0 +1,54 @@ +package kr.devslab.kit.access.core.service; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import kr.devslab.kit.access.core.entity.PlatformUserGroupEntity; +import kr.devslab.kit.access.core.repository.JpaPlatformUserGroupRepository; +import kr.devslab.kit.core.id.GroupId; +import kr.devslab.kit.core.id.UserId; +import org.springframework.transaction.annotation.Transactional; + +public class GroupMembershipService { + + private final JpaPlatformUserGroupRepository repository; + private final Clock clock; + + public GroupMembershipService(JpaPlatformUserGroupRepository repository, Clock clock) { + this.repository = repository; + this.clock = clock; + } + + @Transactional + public void addMember(UserId userId, GroupId groupId) { + if (repository.findByUserIdAndGroupId(userId.value(), groupId.value()).isPresent()) { + return; + } + repository.save(new PlatformUserGroupEntity( + UUID.randomUUID(), + userId.value(), + groupId.value(), + Instant.now(clock) + )); + } + + @Transactional + public void removeMember(UserId userId, GroupId groupId) { + repository.deleteByUserIdAndGroupId(userId.value(), groupId.value()); + } + + @Transactional(readOnly = true) + public List findGroupsForUser(UserId userId) { + return repository.findAllByUserId(userId.value()).stream() + .map(entity -> GroupId.of(entity.getGroupId())) + .toList(); + } + + @Transactional(readOnly = true) + public List findUsersInGroup(GroupId groupId) { + return repository.findAllByGroupId(groupId.value()).stream() + .map(entity -> UserId.of(entity.getUserId())) + .toList(); + } +} diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/GroupRoleService.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/GroupRoleService.java new file mode 100644 index 0000000..cc8ac19 --- /dev/null +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/GroupRoleService.java @@ -0,0 +1,47 @@ +package kr.devslab.kit.access.core.service; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import kr.devslab.kit.access.core.entity.PlatformGroupRoleEntity; +import kr.devslab.kit.access.core.repository.JpaPlatformGroupRoleRepository; +import kr.devslab.kit.core.id.GroupId; +import kr.devslab.kit.core.id.RoleId; +import org.springframework.transaction.annotation.Transactional; + +public class GroupRoleService { + + private final JpaPlatformGroupRoleRepository repository; + private final Clock clock; + + public GroupRoleService(JpaPlatformGroupRoleRepository repository, Clock clock) { + this.repository = repository; + this.clock = clock; + } + + @Transactional + public void assign(GroupId groupId, RoleId roleId) { + if (repository.findByGroupIdAndRoleId(groupId.value(), roleId.value()).isPresent()) { + return; + } + repository.save(new PlatformGroupRoleEntity( + UUID.randomUUID(), + groupId.value(), + roleId.value(), + Instant.now(clock) + )); + } + + @Transactional + public void revoke(GroupId groupId, RoleId roleId) { + repository.deleteByGroupIdAndRoleId(groupId.value(), roleId.value()); + } + + @Transactional(readOnly = true) + public List findRoleIdsForGroup(GroupId groupId) { + return repository.findAllByGroupId(groupId.value()).stream() + .map(entity -> RoleId.of(entity.getRoleId())) + .toList(); + } +} diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/GroupService.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/GroupService.java new file mode 100644 index 0000000..ada6e05 --- /dev/null +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/GroupService.java @@ -0,0 +1,73 @@ +package kr.devslab.kit.access.core.service; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import kr.devslab.kit.access.Group; +import kr.devslab.kit.access.core.entity.PlatformGroupEntity; +import kr.devslab.kit.access.core.repository.JpaPlatformGroupRepository; +import kr.devslab.kit.core.id.GroupId; +import kr.devslab.kit.core.id.TenantId; +import org.springframework.transaction.annotation.Transactional; + +public class GroupService { + + private final JpaPlatformGroupRepository repository; + private final Clock clock; + + public GroupService(JpaPlatformGroupRepository repository, Clock clock) { + this.repository = repository; + this.clock = clock; + } + + @Transactional + public Group create(TenantId tenantId, String code, String name, GroupId parentGroupId) { + if (repository.findByTenantIdAndCode(tenantId.value(), code).isPresent()) { + throw new IllegalStateException("Group already exists: tenant=" + tenantId + " code=" + code); + } + PlatformGroupEntity entity = new PlatformGroupEntity( + UUID.randomUUID(), + tenantId.value(), + code, + name, + parentGroupId == null ? null : parentGroupId.value(), + Instant.now(clock) + ); + repository.save(entity); + return toModel(entity); + } + + @Transactional + public void rename(GroupId id, String newName) { + PlatformGroupEntity entity = repository.findById(id.value()) + .orElseThrow(() -> new IllegalArgumentException("Group not found: " + id)); + entity.setName(newName); + } + + @Transactional + public void delete(GroupId id) { + repository.deleteById(id.value()); + } + + @Transactional(readOnly = true) + public Optional findByTenantAndCode(TenantId tenantId, String code) { + return repository.findByTenantIdAndCode(tenantId.value(), code).map(this::toModel); + } + + @Transactional(readOnly = true) + public List findAllByTenant(TenantId tenantId) { + return repository.findAllByTenantId(tenantId.value()).stream().map(this::toModel).toList(); + } + + private Group toModel(PlatformGroupEntity entity) { + return new Group( + GroupId.of(entity.getId()), + TenantId.of(entity.getTenantId()), + entity.getCode(), + entity.getName(), + Optional.ofNullable(entity.getParentGroupId()).map(GroupId::of) + ); + } +} diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/PermissionAdminService.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/PermissionAdminService.java new file mode 100644 index 0000000..a1d9e17 --- /dev/null +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/PermissionAdminService.java @@ -0,0 +1,55 @@ +package kr.devslab.kit.access.core.service; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import kr.devslab.kit.access.Permission; +import kr.devslab.kit.access.core.entity.PlatformPermissionEntity; +import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository; +import kr.devslab.kit.core.id.PermissionId; +import org.springframework.transaction.annotation.Transactional; + +public class PermissionAdminService { + + private final JpaPlatformPermissionRepository repository; + private final Clock clock; + + public PermissionAdminService(JpaPlatformPermissionRepository repository, Clock clock) { + this.repository = repository; + this.clock = clock; + } + + @Transactional + public Permission create(String code, String description) { + if (repository.findByCode(code).isPresent()) { + throw new IllegalStateException("Permission already exists: code=" + code); + } + PlatformPermissionEntity entity = new PlatformPermissionEntity(UUID.randomUUID(), code, description, Instant.now(clock)); + repository.save(entity); + return Permission.of(entity.getCode()); + } + + @Transactional + public void updateDescription(PermissionId id, String newDescription) { + PlatformPermissionEntity e = repository.findById(id.value()) + .orElseThrow(() -> new IllegalArgumentException("Permission not found: " + id)); + e.setDescription(newDescription); + } + + @Transactional + public void delete(PermissionId id) { + repository.deleteById(id.value()); + } + + @Transactional(readOnly = true) + public Optional findById(PermissionId id) { + return repository.findById(id.value()); + } + + @Transactional(readOnly = true) + public List listAll() { + return repository.findAll(); + } +} diff --git a/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/RoleAdminService.java b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/RoleAdminService.java new file mode 100644 index 0000000..3e00323 --- /dev/null +++ b/devslab-kit-access-core/src/main/java/kr/devslab/kit/access/core/service/RoleAdminService.java @@ -0,0 +1,60 @@ +package kr.devslab.kit.access.core.service; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import kr.devslab.kit.access.Role; +import kr.devslab.kit.access.core.entity.PlatformRoleEntity; +import kr.devslab.kit.access.core.repository.JpaPlatformRoleRepository; +import kr.devslab.kit.core.id.RoleId; +import kr.devslab.kit.core.id.TenantId; +import org.springframework.transaction.annotation.Transactional; + +public class RoleAdminService { + + private final JpaPlatformRoleRepository repository; + private final Clock clock; + + public RoleAdminService(JpaPlatformRoleRepository repository, Clock clock) { + this.repository = repository; + this.clock = clock; + } + + @Transactional + public Role create(TenantId tenantId, String code, String name) { + if (repository.findByTenantIdAndCode(tenantId.value(), code).isPresent()) { + throw new IllegalStateException("Role already exists: tenant=" + tenantId + " code=" + code); + } + PlatformRoleEntity entity = new PlatformRoleEntity(UUID.randomUUID(), tenantId.value(), code, name, Instant.now(clock)); + repository.save(entity); + return toModel(entity); + } + + @Transactional + public void rename(RoleId id, String newName) { + PlatformRoleEntity e = repository.findById(id.value()) + .orElseThrow(() -> new IllegalArgumentException("Role not found: " + id)); + e.setName(newName); + } + + @Transactional + public void delete(RoleId id) { + repository.deleteById(id.value()); + } + + @Transactional(readOnly = true) + public Optional findById(RoleId id) { + return repository.findById(id.value()).map(this::toModel); + } + + @Transactional(readOnly = true) + public List listByTenant(TenantId tenantId) { + return repository.findAllByTenantId(tenantId.value()).stream().map(this::toModel).toList(); + } + + private Role toModel(PlatformRoleEntity entity) { + return new Role(RoleId.of(entity.getId()), entity.getCode(), entity.getName()); + } +} diff --git a/devslab-kit-access-core/src/main/resources/db/migration/V5__platform_group.sql b/devslab-kit-access-core/src/main/resources/db/migration/V5__platform_group.sql new file mode 100644 index 0000000..ae8b12d --- /dev/null +++ b/devslab-kit-access-core/src/main/resources/db/migration/V5__platform_group.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS platform_group ( + id uuid PRIMARY KEY, + tenant_id varchar(64) NOT NULL, + code varchar(64) NOT NULL, + name varchar(128) NOT NULL, + parent_group_id uuid REFERENCES platform_group(id) ON DELETE SET NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_platform_group_tenant_code UNIQUE (tenant_id, code) +); +CREATE INDEX IF NOT EXISTS idx_platform_group_parent ON platform_group(parent_group_id); +CREATE INDEX IF NOT EXISTS idx_platform_group_tenant ON platform_group(tenant_id); + +CREATE TABLE IF NOT EXISTS platform_user_group ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL, + group_id uuid NOT NULL REFERENCES platform_group(id) ON DELETE CASCADE, + joined_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_platform_user_group_user_group UNIQUE (user_id, group_id) +); +CREATE INDEX IF NOT EXISTS idx_platform_user_group_user ON platform_user_group(user_id); +CREATE INDEX IF NOT EXISTS idx_platform_user_group_group ON platform_user_group(group_id); + +CREATE TABLE IF NOT EXISTS platform_group_role ( + id uuid PRIMARY KEY, + group_id uuid NOT NULL REFERENCES platform_group(id) ON DELETE CASCADE, + role_id uuid NOT NULL, + assigned_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_platform_group_role_group_role UNIQUE (group_id, role_id) +); +CREATE INDEX IF NOT EXISTS idx_platform_group_role_group ON platform_group_role(group_id); +CREATE INDEX IF NOT EXISTS idx_platform_group_role_role ON platform_group_role(role_id); diff --git a/devslab-kit-admin-api/build.gradle.kts b/devslab-kit-admin-api/build.gradle.kts new file mode 100644 index 0000000..9a8363c --- /dev/null +++ b/devslab-kit-admin-api/build.gradle.kts @@ -0,0 +1,19 @@ +description = "devslab-kit :: admin REST API" + +dependencies { + api(project(":devslab-kit-identity-api")) + api(project(":devslab-kit-identity-core")) + api(project(":devslab-kit-access-api")) + api(project(":devslab-kit-access-core")) + api(project(":devslab-kit-menu-api")) + api(project(":devslab-kit-menu-core")) + api(project(":devslab-kit-audit-api")) + api(project(":devslab-kit-audit-core")) + api(project(":devslab-kit-tenant-api")) + + api("org.springframework.boot:spring-boot-starter-webmvc") + implementation("org.springframework.boot:spring-boot-starter-validation") + + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/AdminApiExceptionHandler.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/AdminApiExceptionHandler.java new file mode 100644 index 0000000..34267a1 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/AdminApiExceptionHandler.java @@ -0,0 +1,57 @@ +package kr.devslab.kit.admin; + +import jakarta.validation.ConstraintViolationException; +import java.util.List; +import kr.devslab.kit.access.PermissionDeniedException; +import kr.devslab.kit.identity.AccountLoginException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(basePackages = "kr.devslab.kit.admin") +public class AdminApiExceptionHandler { + + @ExceptionHandler(AccountLoginException.class) + public ResponseEntity handleLogin(AccountLoginException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiError.of(401, "Unauthorized", ex.getMessage())); + } + + @ExceptionHandler(PermissionDeniedException.class) + public ResponseEntity handleDenied(PermissionDeniedException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiError.of(403, "Forbidden", ex.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleBadRequest(IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiError.of(400, "Bad Request", ex.getMessage())); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleConflict(IllegalStateException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiError.of(409, "Conflict", ex.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + List details = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .toList(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiError.of(400, "Bad Request", "Validation failed", details)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException ex) { + List details = ex.getConstraintViolations().stream() + .map(v -> v.getPropertyPath() + ": " + v.getMessage()) + .toList(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiError.of(400, "Bad Request", "Constraint violation", details)); + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/AdminApiPaths.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/AdminApiPaths.java new file mode 100644 index 0000000..04b49fc --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/AdminApiPaths.java @@ -0,0 +1,16 @@ +package kr.devslab.kit.admin; + +public final class AdminApiPaths { + + public static final String BASE = "/admin/api/v1"; + + public static final String USERS = BASE + "/users"; + public static final String ROLES = BASE + "/roles"; + public static final String PERMISSIONS = BASE + "/permissions"; + public static final String GROUPS = BASE + "/groups"; + public static final String MENUS = BASE + "/menus"; + public static final String AUDIT_LOGS = BASE + "/audit-logs"; + + private AdminApiPaths() { + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/ApiError.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/ApiError.java new file mode 100644 index 0000000..cab7d86 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/ApiError.java @@ -0,0 +1,15 @@ +package kr.devslab.kit.admin; + +import java.time.Instant; +import java.util.List; + +public record ApiError(int status, String error, String message, List details, Instant timestamp) { + + public static ApiError of(int status, String error, String message) { + return new ApiError(status, error, message, List.of(), Instant.now()); + } + + public static ApiError of(int status, String error, String message, List details) { + return new ApiError(status, error, message, details, Instant.now()); + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogAdminController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogAdminController.java new file mode 100644 index 0000000..94a45cd --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogAdminController.java @@ -0,0 +1,37 @@ +package kr.devslab.kit.admin.audit; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import kr.devslab.kit.admin.AdminApiPaths; +import kr.devslab.kit.audit.core.service.AuditLogQueryService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(AdminApiPaths.AUDIT_LOGS) +public class AuditLogAdminController { + + private final AuditLogQueryService service; + + public AuditLogAdminController(AuditLogQueryService service) { + this.service = service; + } + + @GetMapping + public List queryByTenant( + @RequestParam String tenantId, + @RequestParam(required = false) Instant since + ) { + Instant cutoff = since == null ? Instant.EPOCH : since; + return service.findByTenantSince(tenantId, cutoff).stream().map(AuditLogResponse::from).toList(); + } + + @GetMapping("/user/{userId}") + public List queryByUser(@PathVariable UUID userId) { + return service.findByUser(userId).stream().map(AuditLogResponse::from).toList(); + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogResponse.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogResponse.java new file mode 100644 index 0000000..5a13814 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/audit/AuditLogResponse.java @@ -0,0 +1,32 @@ +package kr.devslab.kit.admin.audit; + +import java.time.Instant; +import java.util.UUID; +import kr.devslab.kit.audit.core.entity.PlatformAuditLogEntity; + +public record AuditLogResponse( + UUID id, + String actionCode, + UUID actorUserId, + String actorTenantId, + String actorDisplayName, + String targetType, + String targetId, + String metadataJson, + Instant occurredAt +) { + + public static AuditLogResponse from(PlatformAuditLogEntity entity) { + return new AuditLogResponse( + entity.getId(), + entity.getActionCode(), + entity.getActorUserId(), + entity.getActorTenantId(), + entity.getActorDisplayName(), + entity.getTargetType(), + entity.getTargetId(), + entity.getMetadataJson(), + entity.getOccurredAt() + ); + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/group/CreateGroupRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/group/CreateGroupRequest.java new file mode 100644 index 0000000..0d55cae --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/group/CreateGroupRequest.java @@ -0,0 +1,13 @@ +package kr.devslab.kit.admin.group; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.UUID; + +public record CreateGroupRequest( + @NotBlank @Size(max = 64) String tenantId, + @NotBlank @Size(max = 64) String code, + @NotBlank @Size(max = 128) String name, + UUID parentGroupId +) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/group/GroupAdminController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/group/GroupAdminController.java new file mode 100644 index 0000000..2d796c4 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/group/GroupAdminController.java @@ -0,0 +1,105 @@ +package kr.devslab.kit.admin.group; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import kr.devslab.kit.access.Group; +import kr.devslab.kit.access.core.service.GroupMembershipService; +import kr.devslab.kit.access.core.service.GroupRoleService; +import kr.devslab.kit.access.core.service.GroupService; +import kr.devslab.kit.admin.AdminApiPaths; +import kr.devslab.kit.core.id.GroupId; +import kr.devslab.kit.core.id.RoleId; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.core.id.UserId; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(AdminApiPaths.GROUPS) +public class GroupAdminController { + + private final GroupService groupService; + private final GroupMembershipService membershipService; + private final GroupRoleService groupRoleService; + + public GroupAdminController( + GroupService groupService, + GroupMembershipService membershipService, + GroupRoleService groupRoleService + ) { + this.groupService = groupService; + this.membershipService = membershipService; + this.groupRoleService = groupRoleService; + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody CreateGroupRequest req) { + Group group = groupService.create( + TenantId.of(req.tenantId()), + req.code(), + req.name(), + req.parentGroupId() == null ? null : GroupId.of(req.parentGroupId()) + ); + return ResponseEntity.status(201).body(group); + } + + @GetMapping + public List list(@RequestParam String tenantId) { + return groupService.findAllByTenant(TenantId.of(tenantId)); + } + + @PutMapping("/{id}") + public ResponseEntity rename(@PathVariable UUID id, @Valid @RequestBody RenameGroupRequest req) { + groupService.rename(GroupId.of(id), req.name()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable UUID id) { + groupService.delete(GroupId.of(id)); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{id}/members/{userId}") + public ResponseEntity addMember(@PathVariable UUID id, @PathVariable UUID userId) { + membershipService.addMember(UserId.of(userId), GroupId.of(id)); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}/members/{userId}") + public ResponseEntity removeMember(@PathVariable UUID id, @PathVariable UUID userId) { + membershipService.removeMember(UserId.of(userId), GroupId.of(id)); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{id}/members") + public List members(@PathVariable UUID id) { + return membershipService.findUsersInGroup(GroupId.of(id)); + } + + @PostMapping("/{id}/roles/{roleId}") + public ResponseEntity assignRole(@PathVariable UUID id, @PathVariable UUID roleId) { + groupRoleService.assign(GroupId.of(id), RoleId.of(roleId)); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}/roles/{roleId}") + public ResponseEntity revokeRole(@PathVariable UUID id, @PathVariable UUID roleId) { + groupRoleService.revoke(GroupId.of(id), RoleId.of(roleId)); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{id}/roles") + public List roles(@PathVariable UUID id) { + return groupRoleService.findRoleIdsForGroup(GroupId.of(id)); + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/group/RenameGroupRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/group/RenameGroupRequest.java new file mode 100644 index 0000000..0e1a583 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/group/RenameGroupRequest.java @@ -0,0 +1,7 @@ +package kr.devslab.kit.admin.group; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RenameGroupRequest(@NotBlank @Size(max = 128) String name) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/CreateMenuRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/CreateMenuRequest.java new file mode 100644 index 0000000..945131a --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/CreateMenuRequest.java @@ -0,0 +1,16 @@ +package kr.devslab.kit.admin.menu; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.UUID; + +public record CreateMenuRequest( + @NotBlank @Size(max = 64) String tenantId, + @NotBlank @Size(max = 64) String code, + @NotBlank @Size(max = 255) String label, + @Size(max = 255) String path, + UUID parentId, + int sortOrder, + @Size(max = 128) String requiredPermissionCode +) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuAdminController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuAdminController.java new file mode 100644 index 0000000..2a24d4c --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuAdminController.java @@ -0,0 +1,61 @@ +package kr.devslab.kit.admin.menu; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import kr.devslab.kit.admin.AdminApiPaths; +import kr.devslab.kit.core.id.MenuId; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.menu.core.service.MenuAdminService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(AdminApiPaths.MENUS) +public class MenuAdminController { + + private final MenuAdminService service; + + public MenuAdminController(MenuAdminService service) { + this.service = service; + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody CreateMenuRequest req) { + var entity = service.create( + TenantId.of(req.tenantId()), + req.code(), + req.label(), + req.path(), + req.parentId() == null ? null : MenuId.of(req.parentId()), + req.sortOrder(), + req.requiredPermissionCode() + ); + return ResponseEntity.status(201).body(MenuResponse.from(entity)); + } + + @GetMapping + public List list(@RequestParam String tenantId) { + return service.listByTenant(TenantId.of(tenantId)).stream().map(MenuResponse::from).toList(); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable UUID id, @Valid @RequestBody UpdateMenuRequest req) { + service.update(MenuId.of(id), req.label(), req.path(), req.sortOrder(), req.requiredPermissionCode()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable UUID id) { + service.delete(MenuId.of(id)); + return ResponseEntity.noContent().build(); + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuResponse.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuResponse.java new file mode 100644 index 0000000..a61b5b2 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuResponse.java @@ -0,0 +1,29 @@ +package kr.devslab.kit.admin.menu; + +import java.util.UUID; +import kr.devslab.kit.menu.core.entity.PlatformMenuEntity; + +public record MenuResponse( + UUID id, + String tenantId, + String code, + String label, + String path, + UUID parentId, + int sortOrder, + String requiredPermissionCode +) { + + public static MenuResponse from(PlatformMenuEntity entity) { + return new MenuResponse( + entity.getId(), + entity.getTenantId(), + entity.getCode(), + entity.getLabel(), + entity.getPath(), + entity.getParentId(), + entity.getSortOrder(), + entity.getRequiredPermissionCode() + ); + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/UpdateMenuRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/UpdateMenuRequest.java new file mode 100644 index 0000000..e2b3fd3 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/UpdateMenuRequest.java @@ -0,0 +1,11 @@ +package kr.devslab.kit.admin.menu; + +import jakarta.validation.constraints.Size; + +public record UpdateMenuRequest( + @Size(max = 255) String label, + @Size(max = 255) String path, + Integer sortOrder, + @Size(max = 128) String requiredPermissionCode +) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/permission/CreatePermissionRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/permission/CreatePermissionRequest.java new file mode 100644 index 0000000..0d1d9d3 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/permission/CreatePermissionRequest.java @@ -0,0 +1,10 @@ +package kr.devslab.kit.admin.permission; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreatePermissionRequest( + @NotBlank @Size(max = 128) String code, + @Size(max = 512) String description +) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/permission/PermissionAdminController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/permission/PermissionAdminController.java new file mode 100644 index 0000000..1934c34 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/permission/PermissionAdminController.java @@ -0,0 +1,64 @@ +package kr.devslab.kit.admin.permission; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import kr.devslab.kit.access.core.service.PermissionAdminService; +import kr.devslab.kit.admin.AdminApiPaths; +import kr.devslab.kit.core.id.PermissionId; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(AdminApiPaths.PERMISSIONS) +public class PermissionAdminController { + + private final PermissionAdminService service; + + public PermissionAdminController(PermissionAdminService service) { + this.service = service; + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody CreatePermissionRequest req) { + var permission = service.create(req.code(), req.description()); + return service.listAll().stream() + .filter(e -> e.getCode().equals(permission.code())) + .findFirst() + .map(PermissionResponse::from) + .map(r -> ResponseEntity.status(201).body(r)) + .orElseGet(() -> ResponseEntity.status(201).build()); + } + + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable UUID id) { + return service.findById(PermissionId.of(id)) + .map(PermissionResponse::from) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @GetMapping + public List list() { + return service.listAll().stream().map(PermissionResponse::from).toList(); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable UUID id, @Valid @RequestBody UpdatePermissionRequest req) { + service.updateDescription(PermissionId.of(id), req.description()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable UUID id) { + service.delete(PermissionId.of(id)); + return ResponseEntity.noContent().build(); + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/permission/PermissionResponse.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/permission/PermissionResponse.java new file mode 100644 index 0000000..617c455 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/permission/PermissionResponse.java @@ -0,0 +1,12 @@ +package kr.devslab.kit.admin.permission; + +import java.time.Instant; +import java.util.UUID; +import kr.devslab.kit.access.core.entity.PlatformPermissionEntity; + +public record PermissionResponse(UUID id, String code, String description, Instant createdAt) { + + public static PermissionResponse from(PlatformPermissionEntity entity) { + return new PermissionResponse(entity.getId(), entity.getCode(), entity.getDescription(), entity.getCreatedAt()); + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/permission/UpdatePermissionRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/permission/UpdatePermissionRequest.java new file mode 100644 index 0000000..1cd6de4 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/permission/UpdatePermissionRequest.java @@ -0,0 +1,6 @@ +package kr.devslab.kit.admin.permission; + +import jakarta.validation.constraints.Size; + +public record UpdatePermissionRequest(@Size(max = 512) String description) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/role/CreateRoleRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/role/CreateRoleRequest.java new file mode 100644 index 0000000..b119bee --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/role/CreateRoleRequest.java @@ -0,0 +1,11 @@ +package kr.devslab.kit.admin.role; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateRoleRequest( + @NotBlank @Size(max = 64) String tenantId, + @NotBlank @Size(max = 64) String code, + @NotBlank @Size(max = 128) String name +) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/role/RenameRoleRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/role/RenameRoleRequest.java new file mode 100644 index 0000000..63a57eb --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/role/RenameRoleRequest.java @@ -0,0 +1,7 @@ +package kr.devslab.kit.admin.role; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record RenameRoleRequest(@NotBlank @Size(max = 128) String name) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/role/RoleAdminController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/role/RoleAdminController.java new file mode 100644 index 0000000..ee71730 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/role/RoleAdminController.java @@ -0,0 +1,106 @@ +package kr.devslab.kit.admin.role; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import kr.devslab.kit.access.Role; +import kr.devslab.kit.access.core.service.RoleAdminService; +import kr.devslab.kit.access.core.service.RolePermissionService; +import kr.devslab.kit.access.core.service.UserRoleService; +import kr.devslab.kit.admin.AdminApiPaths; +import kr.devslab.kit.core.id.PermissionId; +import kr.devslab.kit.core.id.RoleId; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.core.id.UserId; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(AdminApiPaths.ROLES) +public class RoleAdminController { + + private final RoleAdminService roleAdminService; + private final RolePermissionService rolePermissionService; + private final UserRoleService userRoleService; + + public RoleAdminController( + RoleAdminService roleAdminService, + RolePermissionService rolePermissionService, + UserRoleService userRoleService + ) { + this.roleAdminService = roleAdminService; + this.rolePermissionService = rolePermissionService; + this.userRoleService = userRoleService; + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody CreateRoleRequest req) { + Role role = roleAdminService.create(TenantId.of(req.tenantId()), req.code(), req.name()); + return ResponseEntity.status(201).body(role); + } + + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable UUID id) { + return roleAdminService.findById(RoleId.of(id)) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @GetMapping + public List list(@RequestParam String tenantId) { + return roleAdminService.listByTenant(TenantId.of(tenantId)); + } + + @PutMapping("/{id}") + public ResponseEntity rename(@PathVariable UUID id, @Valid @RequestBody RenameRoleRequest req) { + roleAdminService.rename(RoleId.of(id), req.name()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable UUID id) { + roleAdminService.delete(RoleId.of(id)); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{id}/permissions") + public List listPermissions(@PathVariable UUID id) { + return rolePermissionService.findPermissionIdsForRole(RoleId.of(id)); + } + + @PostMapping("/{id}/permissions/{permissionId}") + public ResponseEntity grantPermission(@PathVariable UUID id, @PathVariable UUID permissionId) { + rolePermissionService.grant(RoleId.of(id), PermissionId.of(permissionId)); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}/permissions/{permissionId}") + public ResponseEntity revokePermission(@PathVariable UUID id, @PathVariable UUID permissionId) { + rolePermissionService.revoke(RoleId.of(id), PermissionId.of(permissionId)); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{id}/users/{userId}") + public ResponseEntity assignToUser( + @PathVariable UUID id, + @PathVariable UUID userId, + @RequestParam String tenantId + ) { + userRoleService.assign(UserId.of(userId), RoleId.of(id), TenantId.of(tenantId)); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}/users/{userId}") + public ResponseEntity revokeFromUser(@PathVariable UUID id, @PathVariable UUID userId) { + userRoleService.revoke(UserId.of(userId), RoleId.of(id)); + return ResponseEntity.noContent().build(); + } +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/user/CreateUserRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/user/CreateUserRequest.java new file mode 100644 index 0000000..5f85c3a --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/user/CreateUserRequest.java @@ -0,0 +1,13 @@ +package kr.devslab.kit.admin.user; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record CreateUserRequest( + @NotBlank @Size(max = 64) String tenantId, + @NotBlank @Size(max = 255) String loginId, + @Size(max = 255) String email, + @NotBlank @Size(min = 8, max = 255) String rawPassword, + @Size(max = 32) String providerType +) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/user/ResetPasswordRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/user/ResetPasswordRequest.java new file mode 100644 index 0000000..329c798 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/user/ResetPasswordRequest.java @@ -0,0 +1,7 @@ +package kr.devslab.kit.admin.user; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 255) String newRawPassword) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/user/UpdateUserStatusRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/user/UpdateUserStatusRequest.java new file mode 100644 index 0000000..516357a --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/user/UpdateUserStatusRequest.java @@ -0,0 +1,7 @@ +package kr.devslab.kit.admin.user; + +import jakarta.validation.constraints.NotNull; +import kr.devslab.kit.identity.UserStatus; + +public record UpdateUserStatusRequest(@NotNull UserStatus status) { +} diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/user/UserAdminController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/user/UserAdminController.java new file mode 100644 index 0000000..9ebc8a1 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/user/UserAdminController.java @@ -0,0 +1,91 @@ +package kr.devslab.kit.admin.user; + +import jakarta.validation.Valid; +import java.util.List; +import java.util.UUID; +import kr.devslab.kit.admin.AdminApiPaths; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.core.id.UserId; +import kr.devslab.kit.identity.UserAccountView; +import kr.devslab.kit.identity.core.service.PlatformUserAccountAdminService; +import kr.devslab.kit.identity.core.service.PlatformUserAccountService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(AdminApiPaths.USERS) +public class UserAdminController { + + private final PlatformUserAccountAdminService adminService; + private final PlatformUserAccountService readService; + + public UserAdminController( + PlatformUserAccountAdminService adminService, + PlatformUserAccountService readService + ) { + this.adminService = adminService; + this.readService = readService; + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody CreateUserRequest req) { + UserAccountView view = adminService.create( + TenantId.of(req.tenantId()), + req.loginId(), + req.email(), + req.rawPassword(), + req.providerType() + ); + return ResponseEntity.status(201).body(view); + } + + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable UUID id) { + return readService.findById(id) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @GetMapping + public List list(@RequestParam String tenantId) { + return adminService.listByTenant(TenantId.of(tenantId)); + } + + @PutMapping("/{id}/lock") + public ResponseEntity lock(@PathVariable UUID id) { + adminService.lock(UserId.of(id)); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/{id}/unlock") + public ResponseEntity unlock(@PathVariable UUID id) { + adminService.unlock(UserId.of(id)); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/{id}/status") + public ResponseEntity updateStatus(@PathVariable UUID id, @Valid @RequestBody UpdateUserStatusRequest req) { + adminService.setStatus(UserId.of(id), req.status()); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/{id}/password") + public ResponseEntity resetPassword(@PathVariable UUID id, @Valid @RequestBody ResetPasswordRequest req) { + adminService.resetPassword(UserId.of(id), req.newRawPassword()); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable UUID id) { + adminService.delete(UserId.of(id)); + return ResponseEntity.noContent().build(); + } +} diff --git a/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/AuditLogQueryService.java b/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/AuditLogQueryService.java new file mode 100644 index 0000000..ae6277c --- /dev/null +++ b/devslab-kit-audit-core/src/main/java/kr/devslab/kit/audit/core/service/AuditLogQueryService.java @@ -0,0 +1,27 @@ +package kr.devslab.kit.audit.core.service; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import kr.devslab.kit.audit.core.entity.PlatformAuditLogEntity; +import kr.devslab.kit.audit.core.repository.JpaPlatformAuditLogRepository; +import org.springframework.transaction.annotation.Transactional; + +public class AuditLogQueryService { + + private final JpaPlatformAuditLogRepository repository; + + public AuditLogQueryService(JpaPlatformAuditLogRepository repository) { + this.repository = repository; + } + + @Transactional(readOnly = true) + public List findByTenantSince(String tenantId, Instant since) { + return repository.findAllByActorTenantIdAndOccurredAtAfter(tenantId, since); + } + + @Transactional(readOnly = true) + public List findByUser(UUID userId) { + return repository.findAllByActorUserId(userId); + } +} diff --git a/devslab-kit-autoconfigure/build.gradle.kts b/devslab-kit-autoconfigure/build.gradle.kts index 15a5c5d..419543e 100644 --- a/devslab-kit-autoconfigure/build.gradle.kts +++ b/devslab-kit-autoconfigure/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { api(project(":devslab-kit-menu-core")) api(project(":devslab-kit-audit-api")) api(project(":devslab-kit-audit-core")) + api(project(":devslab-kit-admin-api")) implementation("org.springframework.boot:spring-boot-autoconfigure") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AccessAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AccessAutoConfiguration.java index e4f81ed..a149474 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AccessAutoConfiguration.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AccessAutoConfiguration.java @@ -2,13 +2,23 @@ import jakarta.persistence.EntityManager; import java.time.Clock; +import java.util.List; import kr.devslab.kit.access.PermissionChecker; +import kr.devslab.kit.access.core.repository.JpaPlatformGroupRepository; +import kr.devslab.kit.access.core.repository.JpaPlatformGroupRoleRepository; import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository; import kr.devslab.kit.access.core.repository.JpaPlatformRolePermissionRepository; +import kr.devslab.kit.access.core.repository.JpaPlatformUserGroupRepository; import kr.devslab.kit.access.core.repository.JpaPlatformUserRoleRepository; import kr.devslab.kit.access.core.service.DefaultPermissionChecker; +import kr.devslab.kit.access.core.service.DefaultPolicyEvaluator; +import kr.devslab.kit.access.core.service.GroupMembershipService; +import kr.devslab.kit.access.core.service.GroupRoleService; +import kr.devslab.kit.access.core.service.GroupService; import kr.devslab.kit.access.core.service.RolePermissionService; import kr.devslab.kit.access.core.service.UserRoleService; +import kr.devslab.kit.access.policy.Policy; +import kr.devslab.kit.access.policy.PolicyEvaluator; import kr.devslab.kit.identity.CurrentUserProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -42,13 +52,38 @@ public RolePermissionService rolePermissionService(JpaPlatformRolePermissionRepo return new RolePermissionService(repository, clock); } + @Bean + @ConditionalOnMissingBean + public GroupService groupService(JpaPlatformGroupRepository repository, Clock clock) { + return new GroupService(repository, clock); + } + + @Bean + @ConditionalOnMissingBean + public GroupMembershipService groupMembershipService(JpaPlatformUserGroupRepository repository, Clock clock) { + return new GroupMembershipService(repository, clock); + } + + @Bean + @ConditionalOnMissingBean + public GroupRoleService groupRoleService(JpaPlatformGroupRoleRepository repository, Clock clock) { + return new GroupRoleService(repository, clock); + } + + @Bean + @ConditionalOnMissingBean + public PolicyEvaluator policyEvaluator(List policies) { + return new DefaultPolicyEvaluator(policies); + } + @Bean @ConditionalOnMissingBean @ConditionalOnBean(CurrentUserProvider.class) public PermissionChecker permissionChecker( CurrentUserProvider currentUserProvider, - JpaPlatformPermissionRepository permissionRepository + JpaPlatformPermissionRepository permissionRepository, + PolicyEvaluator policyEvaluator ) { - return new DefaultPermissionChecker(currentUserProvider, permissionRepository); + return new DefaultPermissionChecker(currentUserProvider, permissionRepository, policyEvaluator); } } diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AdminApiAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AdminApiAutoConfiguration.java new file mode 100644 index 0000000..ab19043 --- /dev/null +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AdminApiAutoConfiguration.java @@ -0,0 +1,66 @@ +package kr.devslab.kit.autoconfigure; + +import java.time.Clock; +import kr.devslab.kit.access.core.repository.JpaPlatformPermissionRepository; +import kr.devslab.kit.access.core.repository.JpaPlatformRoleRepository; +import kr.devslab.kit.access.core.service.PermissionAdminService; +import kr.devslab.kit.access.core.service.RoleAdminService; +import kr.devslab.kit.audit.core.repository.JpaPlatformAuditLogRepository; +import kr.devslab.kit.audit.core.service.AuditLogQueryService; +import kr.devslab.kit.identity.PasswordHasher; +import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository; +import kr.devslab.kit.identity.core.service.PlatformUserAccountAdminService; +import kr.devslab.kit.menu.core.repository.JpaPlatformMenuRepository; +import kr.devslab.kit.menu.core.service.MenuAdminService; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration(after = {IdentityAutoConfiguration.class, AccessAutoConfiguration.class, MenuAutoConfiguration.class, AuditAutoConfiguration.class}) +@ConditionalOnClass(jakarta.persistence.EntityManager.class) +@ConditionalOnProperty( + prefix = "devslab.kit.admin-api", + name = "enabled", + havingValue = "true", + matchIfMissing = true +) +public class AdminApiAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public PlatformUserAccountAdminService platformUserAccountAdminService( + JpaPlatformUserAccountRepository repository, + PasswordHasher passwordHasher, + ApplicationEventPublisher eventPublisher, + Clock clock + ) { + return new PlatformUserAccountAdminService(repository, passwordHasher, eventPublisher, clock); + } + + @Bean + @ConditionalOnMissingBean + public RoleAdminService roleAdminService(JpaPlatformRoleRepository repository, Clock clock) { + return new RoleAdminService(repository, clock); + } + + @Bean + @ConditionalOnMissingBean + public PermissionAdminService permissionAdminService(JpaPlatformPermissionRepository repository, Clock clock) { + return new PermissionAdminService(repository, clock); + } + + @Bean + @ConditionalOnMissingBean + public MenuAdminService menuAdminService(JpaPlatformMenuRepository repository, Clock clock) { + return new MenuAdminService(repository, clock); + } + + @Bean + @ConditionalOnMissingBean + public AuditLogQueryService auditLogQueryService(JpaPlatformAuditLogRepository repository) { + return new AuditLogQueryService(repository); + } +} diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AuditAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AuditAutoConfiguration.java index b2bb712..744260f 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AuditAutoConfiguration.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/AuditAutoConfiguration.java @@ -27,6 +27,12 @@ ) public class AuditAutoConfiguration { + @Bean + @ConditionalOnMissingBean + public ObjectMapper objectMapper() { + return new ObjectMapper(); + } + @Bean @ConditionalOnMissingBean public AuditLogService auditLogService(JpaPlatformAuditLogRepository repository, ObjectMapper objectMapper) { diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java index 2a72936..1582e70 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/DevslabKitProperties.java @@ -36,6 +36,8 @@ public static class Identity { private boolean enabled = true; private Mode mode = Mode.LOCAL; + private int maxFailedAttempts = 5; + private java.time.Duration lockoutDuration = java.time.Duration.ofMinutes(15); public boolean isEnabled() { return enabled; @@ -53,6 +55,22 @@ public void setMode(Mode mode) { this.mode = mode; } + public int getMaxFailedAttempts() { + return maxFailedAttempts; + } + + public void setMaxFailedAttempts(int maxFailedAttempts) { + this.maxFailedAttempts = maxFailedAttempts; + } + + public java.time.Duration getLockoutDuration() { + return lockoutDuration; + } + + public void setLockoutDuration(java.time.Duration lockoutDuration) { + this.lockoutDuration = lockoutDuration; + } + public enum Mode { LOCAL, EXTERNAL_JWT, @@ -80,6 +98,24 @@ public static class Tenant { private TenantMode mode = TenantMode.SINGLE; private String defaultTenantId = "default"; private Resolver resolver = Resolver.FIXED; + private String headerName = "X-Tenant-Id"; + private int subdomainIndex = 0; + + public String getHeaderName() { + return headerName; + } + + public void setHeaderName(String headerName) { + this.headerName = headerName; + } + + public int getSubdomainIndex() { + return subdomainIndex; + } + + public void setSubdomainIndex(int subdomainIndex) { + this.subdomainIndex = subdomainIndex; + } public boolean isEnabled() { return enabled; diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/IdentityAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/IdentityAutoConfiguration.java index fcede5e..594ac6a 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/IdentityAutoConfiguration.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/IdentityAutoConfiguration.java @@ -47,9 +47,18 @@ public LocalLoginService localLoginService( JpaPlatformUserAccountRepository repository, PasswordHasher passwordHasher, ApplicationEventPublisher eventPublisher, - Clock clock + Clock clock, + DevslabKitProperties properties ) { - return new LocalLoginService(repository, passwordHasher, eventPublisher, clock); + DevslabKitProperties.Identity identity = properties.getIdentity(); + return new LocalLoginService( + repository, + passwordHasher, + eventPublisher, + clock, + identity.getMaxFailedAttempts(), + identity.getLockoutDuration() + ); } @Bean diff --git a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/TenantAutoConfiguration.java b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/TenantAutoConfiguration.java index 397c3d6..0514aff 100644 --- a/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/TenantAutoConfiguration.java +++ b/devslab-kit-autoconfigure/src/main/java/kr/devslab/kit/autoconfigure/TenantAutoConfiguration.java @@ -4,6 +4,8 @@ import kr.devslab.kit.tenant.TenantResolver; import kr.devslab.kit.tenant.core.DefaultTenantContextHolder; import kr.devslab.kit.tenant.core.FixedTenantResolver; +import kr.devslab.kit.tenant.core.HeaderTenantResolver; +import kr.devslab.kit.tenant.core.SubdomainTenantResolver; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -29,6 +31,19 @@ public TenantContextHolder tenantContextHolder() { @Bean @ConditionalOnMissingBean public TenantResolver tenantResolver(DevslabKitProperties properties) { - return new FixedTenantResolver(properties.getTenant().getDefaultTenantId()); + DevslabKitProperties.Tenant tenant = properties.getTenant(); + return switch (tenant.getResolver()) { + case FIXED -> new FixedTenantResolver(tenant.getDefaultTenantId()); + case HEADER -> new HeaderTenantResolver(tenant.getHeaderName(), tenant.getDefaultTenantId()); + case SUBDOMAIN -> new SubdomainTenantResolver(tenant.getSubdomainIndex(), tenant.getDefaultTenantId()); + case JWT -> throw new IllegalStateException( + "JWT TenantResolver requires the devslab-kit-oauth2-resource-server-starter (not yet shipped). " + + "Provide a custom TenantResolver bean for now." + ); + case CUSTOM -> throw new IllegalStateException( + "Resolver mode CUSTOM requires the consumer app to define its own TenantResolver bean. " + + "Add one and the @ConditionalOnMissingBean default will step aside." + ); + }; } } diff --git a/devslab-kit-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/devslab-kit-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 2afc4f6..8b53efc 100644 --- a/devslab-kit-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/devslab-kit-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -3,3 +3,4 @@ kr.devslab.kit.autoconfigure.IdentityAutoConfiguration kr.devslab.kit.autoconfigure.AccessAutoConfiguration kr.devslab.kit.autoconfigure.MenuAutoConfiguration kr.devslab.kit.autoconfigure.AuditAutoConfiguration +kr.devslab.kit.autoconfigure.AdminApiAutoConfiguration diff --git a/devslab-kit-core/src/main/java/kr/devslab/kit/core/id/GroupId.java b/devslab-kit-core/src/main/java/kr/devslab/kit/core/id/GroupId.java new file mode 100644 index 0000000..af4d7f8 --- /dev/null +++ b/devslab-kit-core/src/main/java/kr/devslab/kit/core/id/GroupId.java @@ -0,0 +1,24 @@ +package kr.devslab.kit.core.id; + +import java.util.Objects; +import java.util.UUID; + +public record GroupId(UUID value) { + + public GroupId { + Objects.requireNonNull(value, "GroupId value must not be null"); + } + + public static GroupId of(UUID value) { + return new GroupId(value); + } + + public static GroupId of(String value) { + return new GroupId(UUID.fromString(value)); + } + + @Override + public String toString() { + return value.toString(); + } +} diff --git a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/entity/PlatformUserAccountEntity.java b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/entity/PlatformUserAccountEntity.java index ca7babf..38ed2ed 100644 --- a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/entity/PlatformUserAccountEntity.java +++ b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/entity/PlatformUserAccountEntity.java @@ -50,6 +50,12 @@ public class PlatformUserAccountEntity { @Column(name = "provider_type", nullable = false, length = 32) private String providerType; + @Column(name = "failed_login_count", nullable = false) + private int failedLoginCount; + + @Column(name = "locked_until") + private Instant lockedUntil; + @Column(name = "created_at", nullable = false) private Instant createdAt; @@ -78,6 +84,8 @@ public PlatformUserAccountEntity( this.status = status; this.locked = locked; this.providerType = providerType; + this.failedLoginCount = 0; + this.lockedUntil = null; this.createdAt = createdAt; this.updatedAt = updatedAt; } diff --git a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/LocalLoginService.java b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/LocalLoginService.java index 87724de..fe7c05a 100644 --- a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/LocalLoginService.java +++ b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/LocalLoginService.java @@ -1,6 +1,7 @@ package kr.devslab.kit.identity.core.service; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.util.Optional; import java.util.Set; @@ -26,20 +27,26 @@ public class LocalLoginService { private final PasswordHasher passwordHasher; private final ApplicationEventPublisher eventPublisher; private final Clock clock; + private final int maxFailedAttempts; + private final Duration lockoutDuration; public LocalLoginService( JpaPlatformUserAccountRepository repository, PasswordHasher passwordHasher, ApplicationEventPublisher eventPublisher, - Clock clock + Clock clock, + int maxFailedAttempts, + Duration lockoutDuration ) { this.repository = repository; this.passwordHasher = passwordHasher; this.eventPublisher = eventPublisher; this.clock = clock; + this.maxFailedAttempts = Math.max(1, maxFailedAttempts); + this.lockoutDuration = lockoutDuration == null ? Duration.ofMinutes(15) : lockoutDuration; } - @Transactional(readOnly = true) + @Transactional public LoginResult login(LoginCommand command) { Instant now = Instant.now(clock); TenantId tenantId = command.tenantId(); @@ -53,6 +60,14 @@ public LoginResult login(LoginCommand command) { } PlatformUserAccountEntity account = maybeAccount.get(); + // Auto-unlock if lockout window has elapsed + if (account.isLocked() && account.getLockedUntil() != null && !now.isBefore(account.getLockedUntil())) { + account.setLocked(false); + account.setLockedUntil(null); + account.setFailedLoginCount(0); + account.setUpdatedAt(now); + } + if (account.isLocked()) { failAndThrow(tenantId, loginId, LoginFailureReason.ACCOUNT_LOCKED, now); } @@ -65,7 +80,23 @@ public LoginResult login(LoginCommand command) { } if (!passwordHasher.matches(command.rawPassword(), account.getPasswordHash())) { - failAndThrow(tenantId, loginId, LoginFailureReason.BAD_CREDENTIALS, now); + int next = account.getFailedLoginCount() + 1; + account.setFailedLoginCount(next); + account.setUpdatedAt(now); + LoginFailureReason reason = LoginFailureReason.BAD_CREDENTIALS; + if (next >= maxFailedAttempts) { + account.setLocked(true); + account.setLockedUntil(now.plus(lockoutDuration)); + reason = LoginFailureReason.ACCOUNT_LOCKED; + } + failAndThrow(tenantId, loginId, reason, now); + } + + // Success — reset counters + if (account.getFailedLoginCount() != 0 || account.getLockedUntil() != null) { + account.setFailedLoginCount(0); + account.setLockedUntil(null); + account.setUpdatedAt(now); } UserId userId = UserId.of(account.getId()); diff --git a/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/PlatformUserAccountAdminService.java b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/PlatformUserAccountAdminService.java new file mode 100644 index 0000000..4d34b68 --- /dev/null +++ b/devslab-kit-identity-core/src/main/java/kr/devslab/kit/identity/core/service/PlatformUserAccountAdminService.java @@ -0,0 +1,121 @@ +package kr.devslab.kit.identity.core.service; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import kr.devslab.kit.core.id.PublicId; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.core.id.UserId; +import kr.devslab.kit.identity.PasswordHasher; +import kr.devslab.kit.identity.UserAccountView; +import kr.devslab.kit.identity.UserStatus; +import kr.devslab.kit.identity.core.entity.PlatformUserAccountEntity; +import kr.devslab.kit.identity.core.repository.JpaPlatformUserAccountRepository; +import kr.devslab.kit.identity.event.UserAccountCreatedEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.annotation.Transactional; + +public class PlatformUserAccountAdminService { + + private final JpaPlatformUserAccountRepository repository; + private final PasswordHasher passwordHasher; + private final ApplicationEventPublisher eventPublisher; + private final Clock clock; + + public PlatformUserAccountAdminService( + JpaPlatformUserAccountRepository repository, + PasswordHasher passwordHasher, + ApplicationEventPublisher eventPublisher, + Clock clock + ) { + this.repository = repository; + this.passwordHasher = passwordHasher; + this.eventPublisher = eventPublisher; + this.clock = clock; + } + + @Transactional + public UserAccountView create(TenantId tenantId, String loginId, String email, String rawPassword, String providerType) { + if (repository.findByTenantIdAndLoginId(tenantId.value(), loginId).isPresent()) { + throw new IllegalStateException("User already exists: tenant=" + tenantId + " loginId=" + loginId); + } + Instant now = Instant.now(clock); + UUID id = UUID.randomUUID(); + PlatformUserAccountEntity entity = new PlatformUserAccountEntity( + id, + "usr_" + id, + tenantId.value(), + loginId, + email, + rawPassword == null ? null : passwordHasher.hash(rawPassword), + UserStatus.ACTIVE, + false, + providerType == null ? "LOCAL" : providerType, + now, + now + ); + repository.save(entity); + eventPublisher.publishEvent(new UserAccountCreatedEvent(UserId.of(id), tenantId, loginId, now)); + return toView(entity); + } + + @Transactional + public void lock(UserId id) { + PlatformUserAccountEntity e = find(id); + e.setLocked(true); + e.setUpdatedAt(Instant.now(clock)); + } + + @Transactional + public void unlock(UserId id) { + PlatformUserAccountEntity e = find(id); + e.setLocked(false); + e.setUpdatedAt(Instant.now(clock)); + } + + @Transactional + public void setStatus(UserId id, UserStatus status) { + PlatformUserAccountEntity e = find(id); + e.setStatus(status); + e.setUpdatedAt(Instant.now(clock)); + } + + @Transactional + public void resetPassword(UserId id, String newRawPassword) { + PlatformUserAccountEntity e = find(id); + e.setPasswordHash(passwordHasher.hash(newRawPassword)); + e.setUpdatedAt(Instant.now(clock)); + } + + @Transactional + public void delete(UserId id) { + repository.deleteById(id.value()); + } + + @Transactional(readOnly = true) + public List listByTenant(TenantId tenantId) { + return repository.findAll().stream() + .filter(e -> e.getTenantId().equals(tenantId.value())) + .map(this::toView) + .toList(); + } + + private PlatformUserAccountEntity find(UserId id) { + return repository.findById(id.value()) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + id)); + } + + private UserAccountView toView(PlatformUserAccountEntity e) { + return new UserAccountView( + UserId.of(e.getId()), + PublicId.of(e.getPublicId()), + TenantId.of(e.getTenantId()), + e.getLoginId(), + e.getEmail(), + e.getStatus(), + e.isLocked(), + e.getProviderType() + ); + } +} diff --git a/devslab-kit-identity-core/src/main/resources/db/migration/V6__platform_user_account_lockout.sql b/devslab-kit-identity-core/src/main/resources/db/migration/V6__platform_user_account_lockout.sql new file mode 100644 index 0000000..d86f37c --- /dev/null +++ b/devslab-kit-identity-core/src/main/resources/db/migration/V6__platform_user_account_lockout.sql @@ -0,0 +1,3 @@ +ALTER TABLE platform_user_account + ADD COLUMN IF NOT EXISTS failed_login_count integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS locked_until timestamp with time zone; diff --git a/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/service/MenuAdminService.java b/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/service/MenuAdminService.java new file mode 100644 index 0000000..4ee5e62 --- /dev/null +++ b/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/service/MenuAdminService.java @@ -0,0 +1,67 @@ +package kr.devslab.kit.menu.core.service; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import kr.devslab.kit.core.id.MenuId; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.menu.core.entity.PlatformMenuEntity; +import kr.devslab.kit.menu.core.repository.JpaPlatformMenuRepository; +import org.springframework.transaction.annotation.Transactional; + +public class MenuAdminService { + + private final JpaPlatformMenuRepository repository; + private final Clock clock; + + public MenuAdminService(JpaPlatformMenuRepository repository, Clock clock) { + this.repository = repository; + this.clock = clock; + } + + @Transactional + public PlatformMenuEntity create( + TenantId tenantId, + String code, + String label, + String path, + MenuId parentId, + int sortOrder, + String requiredPermissionCode + ) { + PlatformMenuEntity entity = new PlatformMenuEntity( + UUID.randomUUID(), + tenantId.value(), + code, + label, + path, + parentId == null ? null : parentId.value(), + sortOrder, + requiredPermissionCode, + Instant.now(clock) + ); + repository.save(entity); + return entity; + } + + @Transactional + public void update(MenuId id, String label, String path, Integer sortOrder, String requiredPermissionCode) { + PlatformMenuEntity e = repository.findById(id.value()) + .orElseThrow(() -> new IllegalArgumentException("Menu not found: " + id)); + if (label != null) e.setLabel(label); + if (path != null) e.setPath(path); + if (sortOrder != null) e.setSortOrder(sortOrder); + if (requiredPermissionCode != null) e.setRequiredPermissionCode(requiredPermissionCode); + } + + @Transactional + public void delete(MenuId id) { + repository.deleteById(id.value()); + } + + @Transactional(readOnly = true) + public List listByTenant(TenantId tenantId) { + return repository.findAllByTenantIdOrderBySortOrderAsc(tenantId.value()); + } +} diff --git a/devslab-kit-tenant-core/build.gradle.kts b/devslab-kit-tenant-core/build.gradle.kts index 783937d..a7353b3 100644 --- a/devslab-kit-tenant-core/build.gradle.kts +++ b/devslab-kit-tenant-core/build.gradle.kts @@ -2,4 +2,7 @@ description = "devslab-kit :: tenant default implementation" dependencies { api(project(":devslab-kit-tenant-api")) + + compileOnly("org.springframework:spring-web") + compileOnly("jakarta.servlet:jakarta.servlet-api") } diff --git a/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/HeaderTenantResolver.java b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/HeaderTenantResolver.java new file mode 100644 index 0000000..9d982ec --- /dev/null +++ b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/HeaderTenantResolver.java @@ -0,0 +1,46 @@ +package kr.devslab.kit.tenant.core; + +import jakarta.servlet.http.HttpServletRequest; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.tenant.TenantContext; +import kr.devslab.kit.tenant.TenantResolver; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +public class HeaderTenantResolver implements TenantResolver { + + public static final String DEFAULT_HEADER = "X-Tenant-Id"; + + private final String headerName; + private final String defaultTenantId; + + public HeaderTenantResolver(String headerName, String defaultTenantId) { + this.headerName = headerName == null ? DEFAULT_HEADER : headerName; + this.defaultTenantId = defaultTenantId; + } + + @Override + public TenantContext resolve() { + HttpServletRequest request = currentRequest(); + if (request != null) { + String value = request.getHeader(headerName); + if (value != null && !value.isBlank()) { + return new TenantContext(TenantId.of(value.trim())); + } + } + if (defaultTenantId != null && !defaultTenantId.isBlank()) { + return new TenantContext(TenantId.of(defaultTenantId)); + } + throw new IllegalStateException( + "Tenant header '" + headerName + "' missing and no default tenant configured" + ); + } + + private HttpServletRequest currentRequest() { + var attrs = RequestContextHolder.getRequestAttributes(); + if (attrs instanceof ServletRequestAttributes servletAttrs) { + return servletAttrs.getRequest(); + } + return null; + } +} diff --git a/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/SubdomainTenantResolver.java b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/SubdomainTenantResolver.java new file mode 100644 index 0000000..ce277c8 --- /dev/null +++ b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/SubdomainTenantResolver.java @@ -0,0 +1,54 @@ +package kr.devslab.kit.tenant.core; + +import jakarta.servlet.http.HttpServletRequest; +import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.tenant.TenantContext; +import kr.devslab.kit.tenant.TenantResolver; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +public class SubdomainTenantResolver implements TenantResolver { + + private final int subdomainIndex; + private final String defaultTenantId; + + public SubdomainTenantResolver(int subdomainIndex, String defaultTenantId) { + if (subdomainIndex < 0) { + throw new IllegalArgumentException("subdomainIndex must be >= 0"); + } + this.subdomainIndex = subdomainIndex; + this.defaultTenantId = defaultTenantId; + } + + @Override + public TenantContext resolve() { + HttpServletRequest request = currentRequest(); + if (request != null) { + String host = request.getServerName(); + if (host != null) { + String[] parts = host.split("\\."); + if (parts.length > subdomainIndex + 1) { + String segment = parts[subdomainIndex]; + if (!segment.isBlank()) { + return new TenantContext(TenantId.of(segment)); + } + } + } + } + if (defaultTenantId != null && !defaultTenantId.isBlank()) { + return new TenantContext(TenantId.of(defaultTenantId)); + } + throw new IllegalStateException( + "Could not extract tenant from subdomain index " + subdomainIndex + + " and no default tenant configured" + ); + } + + private HttpServletRequest currentRequest() { + var attrs = RequestContextHolder.getRequestAttributes(); + if (attrs instanceof ServletRequestAttributes servletAttrs) { + return servletAttrs.getRequest(); + } + return null; + } +} diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/settings.gradle.kts b/settings.gradle.kts index d30b4eb..71911ef 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,6 +18,7 @@ include( "devslab-kit-menu-core", "devslab-kit-audit-api", "devslab-kit-audit-core", + "devslab-kit-admin-api", "devslab-kit-autoconfigure", "devslab-kit-spring-boot-starter", "devslab-kit-sample-app",