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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.fowoco.server.approval.application.port.ExternalSubmissionRepository;
import com.fowoco.server.approval.domain.ApprovalRequest;
import com.fowoco.server.approval.domain.Evidence;
import com.fowoco.server.approval.domain.EvidenceType;
import com.fowoco.server.approval.domain.ExternalSubmission;
import com.fowoco.server.audit.application.port.AuditEventRepository;
import com.fowoco.server.audit.domain.ActorType;
Expand All @@ -16,9 +17,12 @@
import com.fowoco.server.auth.application.ActorContext;
import com.fowoco.server.auth.domain.UserRole;
import com.fowoco.server.common.error.ApiException;
import com.fowoco.server.common.error.ErrorCode;
import com.fowoco.server.common.id.UuidGenerator;
import com.fowoco.server.common.security.TenantDatabaseContext;
import com.fowoco.server.common.web.RequestMetadata;
import com.fowoco.server.settings.application.port.CompanySettingsRepository;
import com.fowoco.server.settings.domain.ApprovalPolicy;
import com.fowoco.server.task.application.error.TaskErrorCode;
import com.fowoco.server.task.application.TaskReadinessChecker;
import com.fowoco.server.task.application.port.TaskRepository;
Expand All @@ -31,6 +35,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -46,6 +51,7 @@ public class ApprovalService implements ApprovalControlPort {
private final TaskTransitionRecorder transitionRecorder;
private final TaskReadinessChecker taskReadinessChecker;
private final ApprovalRequestRepository approvalRepository;
private final CompanySettingsRepository companySettingsRepository;
private final ExternalSubmissionRepository externalSubmissionRepository;
private final EvidenceRepository evidenceRepository;
private final AuditEventRepository auditRepository;
Expand All @@ -60,6 +66,7 @@ public ApprovalService(
TaskTransitionRecorder transitionRecorder,
TaskReadinessChecker taskReadinessChecker,
ApprovalRequestRepository approvalRepository,
CompanySettingsRepository companySettingsRepository,
ExternalSubmissionRepository externalSubmissionRepository,
EvidenceRepository evidenceRepository,
AuditEventRepository auditRepository,
Expand All @@ -73,6 +80,7 @@ public ApprovalService(
this.transitionRecorder = transitionRecorder;
this.taskReadinessChecker = taskReadinessChecker;
this.approvalRepository = approvalRepository;
this.companySettingsRepository = companySettingsRepository;
this.externalSubmissionRepository = externalSubmissionRepository;
this.evidenceRepository = evidenceRepository;
this.auditRepository = auditRepository;
Expand Down Expand Up @@ -147,7 +155,7 @@ public ApprovalResult approve(
RequestMetadata metadata
) {
bindTenant(actor);
actorAuthorizer.requireHrWrite(actor);
requireApprovalDecisionPermission(actor);
Task task = requireTask(taskId, actor.companyId());
requireTaskVersion(task, command.expectedVersion());
ApprovalRequest approval = requirePendingApproval(taskId, actor.companyId());
Expand Down Expand Up @@ -185,7 +193,7 @@ public ApprovalResult reject(
RequestMetadata metadata
) {
bindTenant(actor);
actorAuthorizer.requireHrWrite(actor);
requireApprovalDecisionPermission(actor);
Task task = requireTask(taskId, actor.companyId());
requireTaskVersion(task, command.expectedVersion());
ApprovalRequest approval = requirePendingApproval(taskId, actor.companyId());
Expand Down Expand Up @@ -321,7 +329,7 @@ public TaskActionResult complete(
task.contentRevision(),
task.criticalFingerprint()
);
boolean evidencePresent = evidenceRepository.existsByTaskIdAndCompanyId(taskId, actor.companyId());
boolean evidencePresent = hasRequiredEvidence(task);
Instant now = Instant.now(clock);
TaskStatus previous = task.complete(
approved,
Expand Down Expand Up @@ -494,6 +502,39 @@ private List<ApprovalRequest> invalidateActiveApprovals(
return active;
}

private void requireApprovalDecisionPermission(ActorContext actor) {
actorAuthorizer.requireHrWrite(actor);
ApprovalPolicy approvalPolicy = companySettingsRepository
.findByCompanyId(actor.companyId())
.orElseThrow(() -> new IllegalStateException(
"Persisted company settings are missing for company "
+ actor.companyId()
))
.approvalPolicy();
if (actor.roles().stream().noneMatch(approvalPolicy::permits)) {
throw new ApiException(ErrorCode.ACCESS_DENIED);
}
}

private boolean hasRequiredEvidence(Task task) {
Set<EvidenceType> recordedTypes = evidenceRepository.findTypesByTaskIdAndCompanyId(
task.taskId(),
task.companyId()
);
if (recordedTypes.isEmpty()) {
return false;
}
Set<EvidenceType> additionalRequiredTypes = companySettingsRepository
.findByCompanyId(task.companyId())
.orElseThrow(() -> new IllegalStateException(
"Persisted company settings are missing for company "
+ task.companyId()
))
.evidenceRules()
.getOrDefault(task.taskType(), Set.of());
return recordedTypes.containsAll(additionalRequiredTypes);
}

private Task requireTask(UUID taskId, UUID companyId) {
return taskRepository.findByIdAndCompanyId(taskId, companyId)
.orElseThrow(() -> new ApiException(TaskErrorCode.TASK_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.fowoco.server.approval.application.port;

import com.fowoco.server.approval.domain.Evidence;
import com.fowoco.server.approval.domain.EvidenceType;
import java.util.Set;
import java.util.UUID;

public interface EvidenceRepository {

Evidence save(Evidence evidence);

boolean existsByTaskIdAndCompanyId(UUID taskId, UUID companyId);
Set<EvidenceType> findTypesByTaskIdAndCompanyId(UUID taskId, UUID companyId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.fowoco.server.approval.application.port.EvidenceRepository;
import com.fowoco.server.approval.domain.Evidence;
import com.fowoco.server.approval.domain.EvidenceType;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Repository;

Expand All @@ -20,7 +22,7 @@ public Evidence save(Evidence evidence) {
}

@Override
public boolean existsByTaskIdAndCompanyId(UUID taskId, UUID companyId) {
return repository.existsByTaskIdAndCompanyId(taskId, companyId);
public Set<EvidenceType> findTypesByTaskIdAndCompanyId(UUID taskId, UUID companyId) {
return Set.copyOf(repository.findTypesByTaskIdAndCompanyId(taskId, companyId));
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
package com.fowoco.server.approval.infrastructure.persistence;

import com.fowoco.server.approval.domain.EvidenceType;
import java.util.Set;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

interface SpringDataEvidenceJpaRepository extends JpaRepository<EvidenceJpaEntity, UUID> {

boolean existsByTaskIdAndCompanyId(UUID taskId, UUID companyId);
@Query("""
SELECT DISTINCT evidence.evidenceType
FROM EvidenceJpaEntity evidence
WHERE evidence.taskId = :taskId
AND evidence.companyId = :companyId
""")
Set<EvidenceType> findTypesByTaskIdAndCompanyId(
@Param("taskId") UUID taskId,
@Param("companyId") UUID companyId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,15 @@ public List<AuditEventResponse> taskActivities(@PathVariable UUID taskId) {
@Operation(
operationId = "searchAuditEvents",
summary = "사업장 감사 이벤트 검색",
description = "ADMIN만 자신의 사업장 범위에서 필터와 불투명 cursor로 검색할 수 있습니다."
description = "회사 audit_visibility 정책이 허용하는 ADMIN 또는 HR이 자신의 사업장 범위에서 검색합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "감사 이벤트 페이지"),
@ApiResponse(responseCode = "400", ref = "#/components/responses/BadRequest"),
@ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden")
})
@GetMapping(path = "/audit-events", produces = MediaType.APPLICATION_JSON_VALUE)
@PreAuthorize("hasRole('ADMIN')")
@PreAuthorize("hasAnyRole('ADMIN', 'HR')")
public AuditPageResponse search(
@RequestParam(name = "actor_type", required = false) ActorType actorType,
@RequestParam(required = false) AuditAction action,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import com.fowoco.server.common.error.ApiException;
import com.fowoco.server.common.error.ErrorCode;
import com.fowoco.server.common.security.TenantDatabaseContext;
import com.fowoco.server.settings.application.port.CompanySettingsRepository;
import com.fowoco.server.settings.domain.AuditVisibility;
import com.fowoco.server.task.application.error.TaskErrorCode;
import com.fowoco.server.task.application.port.TaskRepository;
import java.time.Instant;
Expand All @@ -29,19 +31,22 @@ public class AuditQueryService {
private final TenantDatabaseContext tenantDatabaseContext;
private final TaskRepository taskRepository;
private final AuditEventRepository auditRepository;
private final CompanySettingsRepository companySettingsRepository;
private final AuditCursorCodec cursorCodec;

public AuditQueryService(
ActorAuthorizer actorAuthorizer,
TenantDatabaseContext tenantDatabaseContext,
TaskRepository taskRepository,
AuditEventRepository auditRepository,
CompanySettingsRepository companySettingsRepository,
AuditCursorCodec cursorCodec
) {
this.actorAuthorizer = actorAuthorizer;
this.tenantDatabaseContext = tenantDatabaseContext;
this.taskRepository = taskRepository;
this.auditRepository = auditRepository;
this.companySettingsRepository = companySettingsRepository;
this.cursorCodec = cursorCodec;
}

Expand Down Expand Up @@ -70,7 +75,7 @@ public AuditPageResult search(
ActorContext actor
) {
bindTenant(actor);
actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN);
requireAuditSearchPermission(actor);
if (createdFrom != null && createdTo != null && createdFrom.isAfter(createdTo)) {
throw new ApiException(ErrorCode.INVALID_REQUEST);
}
Expand Down Expand Up @@ -102,6 +107,20 @@ private String normalizeTraceId(String traceId) {
return traceId == null || traceId.isBlank() ? null : traceId.trim();
}

private void requireAuditSearchPermission(ActorContext actor) {
actorAuthorizer.requireAnyRole(actor, UserRole.ADMIN, UserRole.HR);
AuditVisibility visibility = companySettingsRepository
.findByCompanyId(actor.companyId())
.orElseThrow(() -> new IllegalStateException(
"Persisted company settings are missing for company "
+ actor.companyId()
))
.auditVisibility();
if (actor.roles().stream().noneMatch(visibility::permits)) {
throw new ApiException(ErrorCode.ACCESS_DENIED);
}
}

private void bindTenant(ActorContext actor) {
tenantDatabaseContext.setCompanyIdForCurrentTransaction(actor.companyId());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public enum AuditAction {
WORKER_IMPORT_ROWS_UPDATED,
WORKER_IMPORT_COMMITTED,
WORKER_IMPORT_RETRIED,
SETTINGS_UPDATED,
DOCUMENT_OCR_REQUESTED,
DOCUMENT_OCR_COMPLETED,
DOCUMENT_OCR_FAILED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ public enum AuditTargetType {
WORKER_LINK,
USER_ACCOUNT,
WORKER_IMPORT,
COMPANY_SETTINGS,
DOCUMENT_OCR_RUN
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.fowoco.server.auth.application;

import com.fowoco.server.auth.domain.AccountStatus;
import com.fowoco.server.auth.domain.UserRole;
import java.util.Objects;
import java.util.UUID;

public record CompanyMemberAccount(
UUID userId,
String displayName,
UserRole role,
AccountStatus status
) {

public CompanyMemberAccount {
Objects.requireNonNull(userId, "userId must not be null");
if (displayName == null || displayName.isBlank()) {
throw new IllegalArgumentException("displayName must not be blank");
}
displayName = displayName.strip();
Objects.requireNonNull(role, "role must not be null");
Objects.requireNonNull(status, "status must not be null");
}

public boolean active() {
return status == AccountStatus.ACTIVE;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.fowoco.server.common.id.UuidGenerator;
import com.fowoco.server.common.security.TenantDatabaseContext;
import com.fowoco.server.company.application.port.CompanyRepository;
import com.fowoco.server.company.application.port.CompanySettingsProvisioner;
import com.fowoco.server.company.domain.Company;
import java.time.Clock;
import java.time.Instant;
Expand All @@ -32,6 +33,7 @@
public class SignupService {

private final CompanyRepository companyRepository;
private final CompanySettingsProvisioner companySettingsProvisioner;
private final UserAccountRepository userAccountRepository;
private final UserAgreementConsentRepository consentRepository;
private final AuthTenantBootstrap authTenantBootstrap;
Expand All @@ -45,6 +47,7 @@ public class SignupService {

public SignupService(
CompanyRepository companyRepository,
CompanySettingsProvisioner companySettingsProvisioner,
UserAccountRepository userAccountRepository,
UserAgreementConsentRepository consentRepository,
AuthTenantBootstrap authTenantBootstrap,
Expand All @@ -57,6 +60,7 @@ public SignupService(
Clock clock
) {
this.companyRepository = companyRepository;
this.companySettingsProvisioner = companySettingsProvisioner;
this.userAccountRepository = userAccountRepository;
this.consentRepository = consentRepository;
this.authTenantBootstrap = authTenantBootstrap;
Expand Down Expand Up @@ -95,6 +99,7 @@ public SignupResult signup(SignupCommand command) {
);

companyRepository.insert(company);
companySettingsProvisioner.provisionDefaults(company.companyId(), now);
try {
userAccountRepository.insert(initialAdmin);
} catch (DataIntegrityViolationException exception) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.fowoco.server.auth.application.port;

import com.fowoco.server.auth.application.CompanyMemberAccount;
import com.fowoco.server.auth.domain.UserRole;
import java.util.List;
import java.util.UUID;

public interface CompanyMemberDirectory {

List<CompanyMemberAccount> findByCompanyId(
UUID companyId,
UserRole role,
boolean activeOnly
);
}
Loading
Loading