From 893633a3ce8dfb425eafc4067a76d0b867022327 Mon Sep 17 00:00:00 2001 From: Cavin Date: Wed, 5 Aug 2026 17:17:28 +0300 Subject: [PATCH] feat(auth): introduce centralized authorization and access scopes - Add AccessScope to separate role permissions from data visibility - Introduce AuthorizationService as the central authorization layer - Refactor UserService and VisitorService to use centralized access checks - Enable global resource access for SUPER_ADMIN while preserving site-scoped restrictions - Update VisitorSpecification and repository queries to support scoped filtering - Refine DashboardService and ReportService to respect access scope - Fix JWT email claim generation - Remove deprecated AuthenticatedUser DTO and simplify authentication models --- .../gatelog/backend/auth/AccessScope.kt | 37 +++++ ...troller.kt => AuthenticationController.kt} | 8 +- ...uthService.kt => AuthenticationService.kt} | 2 +- .../backend/auth/AuthorizationService.kt | 157 ++++++++++++++++++ .../gatelog/backend/auth/JwtTokenProvider.kt | 2 +- .../gatelog/backend/auth/dto/AuthDtos.kt | 11 -- .../gatelog/backend/auth/dto/AuthRequests.kt | 2 +- .../common/exception/DomainExceptions.kt | 10 +- .../backend/dashboard/DashboardService.kt | 114 +++++++------ .../gatelog/backend/reports/ReportService.kt | 61 +++---- .../gatelog/backend/users/UserService.kt | 97 +++-------- .../backend/visitors/VisitorRepository.kt | 47 +++++- .../backend/visitors/VisitorService.kt | 77 +++++---- .../backend/visitors/VisitorSpecification.kt | 29 ++-- 14 files changed, 429 insertions(+), 225 deletions(-) create mode 100644 backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AccessScope.kt rename backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/{AuthController.kt => AuthenticationController.kt} (79%) rename backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/{AuthService.kt => AuthenticationService.kt} (99%) create mode 100644 backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthorizationService.kt delete mode 100644 backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/dto/AuthDtos.kt diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AccessScope.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AccessScope.kt new file mode 100644 index 0000000..23a2bdd --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AccessScope.kt @@ -0,0 +1,37 @@ +package io.github.devcavin.gatelog.backend.auth + +import java.util.UUID + +/** + * Represents the data visibility scope for an authenticated user. + * + * GLOBAL - user can access resources across all sites (SUPER_ADMIN) + * + * SITE - user can only access resources belonging to their own site (MANAGER, STAFF) + * + * This separates the concept of "what a user can do" (role) + * from "which resources they can see" (scope), making authorization + * decisions explicit and centralized rather than inferred from site FK. + */ + +sealed class AccessScope { + /** No site boundary - SUPER_ADMIN sees everything */ + data object Global : AccessScope() + + /** Restricted to a single site - MANAGER and STAFF */ + data class Site(val siteId: UUID) : AccessScope() + + /** True when this scope covers the given siteId */ + fun covers(siteId: UUID): Boolean = when (this) { + is Global -> true + is Site -> this.siteId == siteId + } + + /** Returns the siteId if site-scoped, null if global */ + val siteIdOrNull: UUID? get() = when (this) { + is Global -> null + is Site -> this.siteId + } + + val isGlobal: Boolean get() = this is Global +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthController.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthenticationController.kt similarity index 79% rename from backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthController.kt rename to backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthenticationController.kt index 3200844..10d820a 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthController.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthenticationController.kt @@ -12,22 +12,22 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/auth") -class AuthController(private val authService: AuthService) { +class AuthenticationController(private val authenticationService: AuthenticationService) { @PostMapping("/login") fun login(@Valid @RequestBody request: LoginRequest): ResponseEntity { - val response = authService.login(request) + val response = authenticationService.login(request) return ResponseEntity.ok(response) } @PostMapping("/refresh") fun refresh(@Valid @RequestBody request: RefreshTokenRequest): ResponseEntity { - val response = authService.refresh(request.token) + val response = authenticationService.refresh(request.refreshToken) return ResponseEntity.ok(response) } @PostMapping("/logout") fun logout(@Valid @RequestBody request: RefreshTokenRequest): ResponseEntity { - authService.logout(request.token) + authenticationService.logout(request.refreshToken) return ResponseEntity.noContent().build() } } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthenticationService.kt similarity index 99% rename from backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthService.kt rename to backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthenticationService.kt index 7ead96c..708e4b4 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthenticationService.kt @@ -13,7 +13,7 @@ import java.time.OffsetDateTime import java.util.* @Service -class AuthService( +class AuthenticationService( private val userRepository: UserRepository, private val refreshTokenRepository: RefreshTokenRepository, private val passwordEncoder: PasswordEncoder, diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthorizationService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthorizationService.kt new file mode 100644 index 0000000..34bca19 --- /dev/null +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/AuthorizationService.kt @@ -0,0 +1,157 @@ +package io.github.devcavin.gatelog.backend.auth + +import io.github.devcavin.gatelog.backend.common.exception.ResourceNotFoundException +import io.github.devcavin.gatelog.backend.common.exception.AccessDeniedException +import io.github.devcavin.gatelog.backend.users.User +import io.github.devcavin.gatelog.backend.visitors.Visitor +import org.springframework.security.authorization.AuthorizationDeniedException +import org.springframework.stereotype.Service +import java.util.* + +@Service +class AuthorizationService { + /** + * Derives the access scope for a user based on their role. + * This is the single source of truth for scope decisions. + */ + + fun scopeFor(user: User): AccessScope { + return when (user.role.name) { + "SUPER_ADMIN" -> AccessScope.Global + else -> AccessScope.Site(user.site.id!!) + } + } + + /** + * Asserts the user's scope covers the given siteId. + * Throws AuthorizationDeniedException if the scope does not cover it. + */ + + fun assertCovers(user: User, siteId: UUID) { + val scope = scopeFor(user) + + if (!scope.covers(siteId)) { + throw AuthorizationDeniedException( + "Authorization denied for user" + ) + } + } + + /** + * Asserts the user's scope covers the visitor's site. + * Throws ResourceNotFoundException for site-scoped users seeing + * resources from another site - avoids leaking resource existence. + */ + + fun assertCanAccessVisitor(user: User, visitor: Visitor) { + val scope = scopeFor(user) + + if (!scope.covers(visitor.site.id!!)) throw ResourceNotFoundException( + "Visitor", + visitor.id!! + ) + } + + /** + * Returns a siteId filter appropriate for list/search queries. + * Global scope returns null - callers omit the filter entirely. + * Site scope returns the user's siteId - callers apply it. + */ + + fun siteFilterFor(user: User): UUID? = scopeFor(user).siteIdOrNull + + /** + * Enforces who can create a user with the given role at the given site. + * SUPER_ADMIN - unrestricted. + * MANAGER - Staff only, at their own site. + * STAFF - cannot create users. + */ + + fun assertCanCreateUser( + requestedBy: User, + targetRoleName: String, + targetSiteId: UUID + ) { + when (val scope = scopeFor(requestedBy)) { + is AccessScope.Global -> Unit + + is AccessScope.Site -> { + if (requestedBy.role.name != "MANAGER") + throw AccessDeniedException("Insufficient privileges to create users") + + if (targetRoleName != "STAFF") + throw AccessDeniedException("Managers can only create Staff accounts") + + if (targetSiteId != scope.siteId) + throw AccessDeniedException("Managers can only create users at their own site") + } + } + } + + /** + * Enforces who can update a user's details and which role they can assign. + * SUPER_ADMIN - unrestricted. + * MANAGER - Staff at their own site, cannot elevate beyond Staff. + */ + + fun assertCanUpdateUser( + requestedBy: User, + target: User, + newRoleName: String + ) { + when (val scope = scopeFor(requestedBy)) { + is AccessScope.Global -> Unit + + is AccessScope.Site -> { + if (target.site.id != scope.siteId) + throw AccessDeniedException("User does not belong to your site") + + if (target.role.name != "STAFF") + throw AccessDeniedException("Managers can only update Staff accounts") + + if (newRoleName != "STAFF") + throw AccessDeniedException("Managers cannot change role beyond Staff") + } + } + } + + /** + * Enforces who can deactivate a user. + * SUPER_ADMIN - unrestricted. + * MANAGER - Staff at their own site only. + */ + + fun assertCanDeactivateUser(requestedBy: User, target: User) { + when (val scope = scopeFor(requestedBy)) { + + is AccessScope.Global -> Unit + + is AccessScope.Site -> { + if (target.site.id != scope.siteId) + throw AccessDeniedException("User does not belong to your site") + if (target.role.name != "STAFF") + throw AccessDeniedException("Managers can only deactivate Staff accounts") + } + } + } + + /** + * Enforces visibility - who can see a given user record. + * SUPER_ADMIN - can see any user. + * MANAGER - Staff at their own site only. + */ + fun assertCanViewUser(requestedBy: User, target: User) { + when (val scope = scopeFor(requestedBy)) { + + is AccessScope.Global -> Unit + + is AccessScope.Site -> { + if (target.site.id != scope.siteId) + throw ResourceNotFoundException("User", target.id!!) + if (target.role.name != "STAFF") + throw AccessDeniedException("Managers can only view Staff accounts") + } + } + } + +} \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/JwtTokenProvider.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/JwtTokenProvider.kt index 8726e7d..6a49cf7 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/JwtTokenProvider.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/JwtTokenProvider.kt @@ -21,7 +21,7 @@ class JwtTokenProvider(private val jwtProperties: JwtProperties) { return Jwts.builder() .subject(userId.toString()) - .claim("claim", email) + .claim("email", email) .claim("role", role) .issuedAt(now) .expiration(expiry) diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/dto/AuthDtos.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/dto/AuthDtos.kt deleted file mode 100644 index 9e729ec..0000000 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/dto/AuthDtos.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.github.devcavin.gatelog.backend.auth.dto - -import java.util.UUID - -data class AuthenticatedUser( - val id: UUID, - val name: String, - val email: String, - val role: String, - val siteId: UUID -) diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/dto/AuthRequests.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/dto/AuthRequests.kt index 24d98aa..41f09ff 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/dto/AuthRequests.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/auth/dto/AuthRequests.kt @@ -14,5 +14,5 @@ data class LoginRequest( data class RefreshTokenRequest( @field:NotBlank - val token: String + val refreshToken: String ) \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/common/exception/DomainExceptions.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/common/exception/DomainExceptions.kt index 02ab01b..33fbaf4 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/common/exception/DomainExceptions.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/common/exception/DomainExceptions.kt @@ -6,22 +6,22 @@ import org.springframework.security.authentication.BadCredentialsException sealed class DomainException(message: String) : RuntimeException(message) // 401 — bad credentials or invalid/expired tokens -sealed class UnauthorizedException(message: String): io.github.devcavin.gatelog.backend.common.exception.DomainException(message) +sealed class UnauthorizedException(message: String): DomainException(message) class InvalidCredentialsException : BadCredentialsException("Invalid username or password") class InvalidRefreshTokenException : BadCredentialsException("Invalid or expired refresh token") // 403 - authenticated but !permitted -class AccountDisabledException : io.github.devcavin.gatelog.backend.common.exception.DomainException("Account is disabled") +class AccountDisabledException : DomainException("Account is disabled") // 404 - resource !found -class ResourceNotFoundException(resource: String, id: Any) : io.github.devcavin.gatelog.backend.common.exception.DomainException("Resource $resource not found: $id") +class ResourceNotFoundException(resource: String, id: Any) : DomainException("Resource $resource not found: $id") // 409 - conflicts with existing state/resources, etc... -class ConflictException(message: String) : io.github.devcavin.gatelog.backend.common.exception.DomainException(message) +class ConflictException(message: String) : DomainException(message) // 422 - semantically invalid request (e.g. checking out already checked out visitor) -class InvalidStateException(message: String) : io.github.devcavin.gatelog.backend.common.exception.DomainException(message) +class InvalidStateException(message: String) : DomainException(message) class AccessDeniedException(message: String) : RuntimeException(message) \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/dashboard/DashboardService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/dashboard/DashboardService.kt index ae7018f..3863df9 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/dashboard/DashboardService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/dashboard/DashboardService.kt @@ -1,5 +1,6 @@ package io.github.devcavin.gatelog.backend.dashboard +import io.github.devcavin.gatelog.backend.auth.AuthorizationService import io.github.devcavin.gatelog.backend.dashboard.dto.DashboardFeed import io.github.devcavin.gatelog.backend.dashboard.dto.DashboardSummary import io.github.devcavin.gatelog.backend.users.User @@ -13,81 +14,100 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.OffsetDateTime import java.time.ZoneOffset +import java.util.UUID @Service class DashboardService( private val visitorRepository: VisitorRepository, private val visitorStatusRepository: VisitStatusRepository, - + private val authorizationService: AuthorizationService, @Value("\${gatelog.scheduler.overdue-threshold-hours:2}") private val overdueThresholdHours: Long, ) { @Transactional(readOnly = true) fun getFeed(requestedBy: User): DashboardFeed { - val siteId = requestedBy.site.id!! + val scope = authorizationService.scopeFor(requestedBy) val now = OffsetDateTime.now(ZoneOffset.UTC) val startOfDay = now.toLocalDate().atStartOfDay().atOffset(ZoneOffset.UTC) val endOfDay = startOfDay.plusDays(1) val overdueThreshold = now.minusHours(overdueThresholdHours) - val checkedInStatus = visitorStatusRepository.findByName("CHECKED_IN")!! + val checkedInStatus = visitorStatusRepository.findByName("CHECKED_IN")!! val checkedOutStatus = visitorStatusRepository.findByName("CHECKED_OUT")!! - val overdueStatus = visitorStatusRepository.findByName("OVERDUE")!! + val overdueStatus = visitorStatusRepository.findByName("OVERDUE")!! - // summary counts bar - val currentlyOnPremises = visitorRepository.countBySiteIdAndVisitStatus( - siteId = siteId, - visitStatus = checkedInStatus - ) + // siteId is null for SUPER_ADMIN (Global scope) + // siteId is set for MANAGER and STAFF (Site scope) + val siteId: UUID? = scope.siteIdOrNull + + val currentlyOnPremises = if (siteId != null) { + visitorRepository.countBySiteIdAndVisitStatus(siteId, checkedInStatus) + visitorRepository.countBySiteIdAndVisitStatus(siteId, overdueStatus) + } else { + visitorRepository.countByVisitStatus(checkedInStatus) + visitorRepository.countByVisitStatus(overdueStatus) + } - val checkedInToday = visitorRepository.findAllCheckedInToday( - siteId = siteId, - startOfDay = startOfDay, - endOfDay = endOfDay, - pageable = PageRequest.of(0, 1) - ).totalElements + val checkedInToday = if (siteId != null) { + visitorRepository.findAllCheckedInToday( + siteId, startOfDay, endOfDay, PageRequest.of(0, 1) + ).totalElements + } else { + visitorRepository.countCheckedInTodayGlobal(startOfDay, endOfDay) + } - val checkedOutToday = visitorRepository.findAllBySiteIdAndVisitStatus( - siteId = siteId, - visitStatus = checkedOutStatus, - pageable = PageRequest.of(0, 1) - ).totalElements + val checkedOutToday = if (siteId != null) { + visitorRepository.countBySiteIdAndVisitStatusAndCheckOutTimeBetween(siteId, checkedOutStatus, startOfDay, endOfDay) + } else { + visitorRepository.countByVisitStatusAndCheckOutTimeBetween(checkedOutStatus, startOfDay, endOfDay) + } - val overdueCount = visitorRepository.findAllBySiteIdAndVisitStatus( - siteId = siteId, - visitStatus = overdueStatus, - pageable = PageRequest.of(0, 1) - ).totalElements + val overdueCount = if (siteId != null) { + visitorRepository.findAllBySiteIdAndVisitStatus( + siteId, overdueStatus, PageRequest.of(0, 1) + ).totalElements + } else { + visitorRepository.countByVisitStatus(overdueStatus) + } - val activeVisitors = visitorRepository.findAllBySiteIdAndVisitStatus( - siteId = siteId, - visitStatus = checkedInStatus, - pageable = PageRequest.of(0, 10, - Sort.by(Sort.Direction.DESC, "checkInTime")) - ).content.map { it.toResponse() } + val activeVisitors = if (siteId != null) { + visitorRepository.findAllBySiteIdAndVisitStatus( + siteId, checkedInStatus, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "checkInTime")) + ).content + } else { + visitorRepository.findAllByVisitStatus( + checkedInStatus, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "checkInTime")) + ).content + } - val overdueVisitors = visitorRepository.findAllOverdue( - siteId = siteId, - threshold = overdueThreshold - ).map { it.toResponse() } + val overdueVisitors = if (siteId != null) { + visitorRepository.findAllOverdue(siteId, overdueThreshold) + } else { + visitorRepository.findAllOverdueGlobal(overdueThreshold) + } - val recentlyCheckedOut = visitorRepository.findAllBySiteIdAndVisitStatus( - siteId = siteId, - visitStatus = checkedOutStatus, - pageable = PageRequest.of(0, 10, - Sort.by(Sort.Direction.DESC, "checkOutTime")) - ).content.map { it.toResponse() } + val recentlyCheckedOut = if (siteId != null) { + visitorRepository.findAllBySiteIdAndVisitStatus( + siteId, checkedOutStatus, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "checkOutTime")) + ).content + } else { + visitorRepository.findAllByVisitStatus( + checkedOutStatus, + PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "checkOutTime")) + ).content + } return DashboardFeed( summary = DashboardSummary( currentlyOnPremises = currentlyOnPremises, - checkedInToday = checkedInToday, - checkedOutToday = checkedOutToday, - overdueCount = overdueCount + checkedInToday = checkedInToday, + checkedOutToday = checkedOutToday, + overdueCount = overdueCount ), - activeVisitors = activeVisitors, - overdueVisitors = overdueVisitors, - recentlyCheckedOut = recentlyCheckedOut + activeVisitors = activeVisitors.map { it.toResponse() }, + overdueVisitors = overdueVisitors.map { it.toResponse() }, + recentlyCheckedOut = recentlyCheckedOut.map { it.toResponse() } ) } } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/reports/ReportService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/reports/ReportService.kt index d1d58c9..148024a 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/reports/ReportService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/reports/ReportService.kt @@ -1,5 +1,6 @@ package io.github.devcavin.gatelog.backend.reports +import io.github.devcavin.gatelog.backend.auth.AuthorizationService import io.github.devcavin.gatelog.backend.users.User import io.github.devcavin.gatelog.backend.visitors.Visitor import io.github.devcavin.gatelog.backend.visitors.VisitorRepository @@ -15,61 +16,63 @@ import java.time.format.DateTimeFormatter @Service class ReportService( - private val visitorRepository: VisitorRepository + private val visitorRepository: VisitorRepository, + private val authorizationService: AuthorizationService ) { - private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC) + + private val formatter = DateTimeFormatter + .ofPattern("yyyy-MM-dd HH:mm:ss") + .withZone(ZoneOffset.UTC) @Transactional(readOnly = true) fun exportVisitorsCsv( requestedBy: User, - searchParams: VisitorSearchParams + params: VisitorSearchParams ): ByteArray { - val spec = VisitorSpecification.search( - siteId = requestedBy.site.id!!, - params = searchParams - ) - + val scope = authorizationService.scopeFor(requestedBy) + val spec = VisitorSpecification.search(scope, params) val visitors = visitorRepository.findAll(spec) - return buildCsv(visitors) } private fun buildCsv(visitors: List): ByteArray { - val output = ByteArrayOutputStream() - val writer = PrintWriter(output) + val out = ByteArrayOutputStream() + val writer = PrintWriter(out) writer.println( - csvRow ( + csvRow( "ID", "Name", "Phone", "Visitor Type", "Purpose", "Status", "Zone", "Host", - "Registered By", "Check In", "Check Out", "Duration (minutes)" - ) + "Registered By", "Site", + "Check In", "Check Out", "Duration (minutes)" + ) ) - visitors.forEach { visitor -> - val durationInMinutes = visitor.checkOutTime?.let { - Duration.between(visitor.checkInTime, it).toMinutes().toString() + visitors.forEach { v -> + val duration = v.checkOutTime?.let { + Duration.between(v.checkInTime, it).toMinutes().toString() } ?: "" writer.println( csvRow( - visitor.id.toString(), - visitor.name, - visitor.phone, - visitor.visitorType, - visitor.purpose, - visitor.visitStatus.name, - visitor.zone?.name ?: "", - visitor.createdBy.name, - formatter.format(visitor.checkInTime), - visitor.checkOutTime?.let { formatter.format(it) } ?: "", - durationInMinutes + v.id.toString(), + v.name, + v.phone, + v.visitorType, + v.purpose, + v.visitStatus.name, + v.zone?.name ?: "", + v.createdBy.name, + v.site.name, + formatter.format(v.checkInTime), + v.checkOutTime?.let { formatter.format(it) } ?: "", + duration ) ) } writer.flush() - return output.toByteArray() + return out.toByteArray() } private fun csvRow(vararg fields: String): String = diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/users/UserService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/users/UserService.kt index 7eed3b8..305b327 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/users/UserService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/users/UserService.kt @@ -1,5 +1,7 @@ package io.github.devcavin.gatelog.backend.users +import io.github.devcavin.gatelog.backend.auth.AccessScope +import io.github.devcavin.gatelog.backend.auth.AuthorizationService import io.github.devcavin.gatelog.backend.common.exception.ConflictException import io.github.devcavin.gatelog.backend.common.exception.InvalidCredentialsException import io.github.devcavin.gatelog.backend.common.exception.InvalidStateException @@ -21,6 +23,7 @@ class UserService( private val userRepository: UserRepository, private val roleRepository: RoleRepository, private val siteRepository: SiteRepository, + private val authorizationService: AuthorizationService, private val passwordEncoder: PasswordEncoder ) { @Transactional @@ -29,7 +32,7 @@ class UserService( val targetRole = roleRepository.findByName(request.roleName) ?: throw ResourceNotFoundException("Role", request.roleName) - enforceCreationRules(requestedBy, targetRole.name, request.siteId) + authorizationService.assertCanCreateUser(requestedBy, targetRole.name, request.siteId) val site = siteRepository.findById(request.siteId).orElseThrow { ResourceNotFoundException("Site", request.siteId) } @@ -47,25 +50,32 @@ class UserService( @Transactional(readOnly = true) fun getAll(requestedBy: User): List { - - return when (requestedBy.role.name) { - "SUPER_ADMIN" -> userRepository.findAllWithRole().map { it.toResponse() } - "MANAGER" -> userRepository - .findAllBySiteIdWithRole(requestedBy.site.id!!) + return when (val scope = authorizationService.scopeFor(requestedBy)) { + is AccessScope.Global -> userRepository + .findAllWithRole() + .map { it.toResponse() } + is AccessScope.Site -> userRepository + .findAllBySiteIdWithRole(scope.siteId) .filter { it.role.name == "STAFF" } .map { it.toResponse() } - else -> emptyList() } } @Transactional(readOnly = true) fun getById(requestedBy: User, userId: UUID): UserResponse { - val user = userRepository.findByIdWithRole(userId) + val target = userRepository.findById(userId) .orElseThrow { ResourceNotFoundException("User", userId) } + when (val scope = authorizationService.scopeFor(requestedBy)) { + is AccessScope.Global -> Unit // SUPER_ADMIN sees any user + is AccessScope.Site -> { + if (target.site.id != scope.siteId) + throw ResourceNotFoundException("User", userId) + if (target.role.name != "STAFF") + throw AccessDeniedException("Managers can only view Staff accounts") + } + } - enforceVisibilityRules(requestedBy, user) - - return user.toResponse() + return target.toResponse() } @Transactional @@ -77,8 +87,8 @@ class UserService( val target = userRepository.findById(userId) .orElseThrow { ResourceNotFoundException("User", userId) } - enforceVisibilityRules(requestedBy, target) - enforceUpdateRules(requestedBy, target, request.roleName) + authorizationService.assertCanViewUser(requestedBy, target) + authorizationService.assertCanUpdateUser(requestedBy, target, request.roleName) if (request.email != target.email && userRepository.existsByEmail(request.email) @@ -104,8 +114,8 @@ class UserService( val target = userRepository.findById(userId) .orElseThrow { ResourceNotFoundException("User", userId) } - enforceVisibilityRules(requestedBy, target) - enforceDeactivationRules(requestedBy, target) + authorizationService.assertCanViewUser(requestedBy, target) + authorizationService.assertCanDeactivateUser(requestedBy, target) target.isActive = false return userRepository.save(target).toResponse() @@ -115,7 +125,7 @@ class UserService( fun activate(requestedBy: User, userId: UUID): UserResponse { val target = userRepository.findById(userId) .orElseThrow { ResourceNotFoundException("User", userId) } - enforceVisibilityRules(requestedBy, target) + authorizationService.assertCanViewUser(requestedBy, target) target.isActive = true return userRepository.save(target).toResponse() } @@ -134,59 +144,4 @@ class UserService( requestedBy.passwordHash = passwordEncoder.encode(request.newPassword) return userRepository.save(requestedBy).toResponse() } - - private fun enforceCreationRules( - requestedBy: User, - targetRoleName: String, - targetSiteId: UUID - ) { - when (requestedBy.role.name) { - "SUPER_ADMIN" -> Unit - "MANAGER" -> { - if (targetRoleName != "STAFF") throw AccessDeniedException("Managers can only create STAFF accounts") - if (targetSiteId != requestedBy.site.id) throw AccessDeniedException("Managers can only create users at their own site") - } - else -> throw AccessDeniedException("Insufficient privileges to create users") - } - } - - private fun enforceDeactivationRules(requestedBy: User, target: User) { - when (requestedBy.role.name) { - "SUPER_ADMIN" -> Unit - "MANAGER" -> { - if (target.role.name != "STAFF") - throw AccessDeniedException("Managers can only deactivate Staff accounts") - } - else -> throw AccessDeniedException("Insufficient privilege to deactivate users") - } - } - - private fun enforceUpdateRules( - requestedBy: User, - target: User, - newRoleName: String - ) { - when (requestedBy.role.name) { - "SUPER_ADMIN" -> Unit - "MANAGER" -> { - if (target.role.name != "STAFF") - throw AccessDeniedException("Managers can only update Staff accounts") - if (newRoleName != "STAFF") - throw AccessDeniedException("Managers cannot change role beyond Staff") - } - else -> throw AccessDeniedException("Insufficient privilege to update users") - } - } - - private fun enforceVisibilityRules(requestedBy: User, target: User) { - if (requestedBy.role.name == "SUPER_ADMIN") return - - if (requestedBy.site.id != target.site.id) { - throw AccessDeniedException("User doesnt belong to your site") - } - - if (requestedBy.role.name == "MANAGER" && target.role.name != "STAFF") { - throw AccessDeniedException("Managers can only view staff accounts") - } - } } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRepository.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRepository.kt index cc07a1d..8e23d4b 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRepository.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorRepository.kt @@ -41,7 +41,7 @@ interface VisitorRepository : JpaRepository, JpaSpecificationExec pageable: Pageable ): Page - // overdue visitors — checked in but no checkout past threshold + // overdue visitors - checked in but no checkout past threshold @Query( """ SELECT v FROM Visitor v @@ -72,7 +72,7 @@ interface VisitorRepository : JpaRepository, JpaSpecificationExec overdueStatus: VisitStatus ): Int - // count by status — dashboard stats + // count by status - dashboard stats fun countBySiteIdAndVisitStatus( siteId: UUID, visitStatus: VisitStatus @@ -80,5 +80,46 @@ interface VisitorRepository : JpaRepository, JpaSpecificationExec fun countBySiteIdAndVisitorProfileId(siteId: UUID, profileId: UUID): Long - // fun findAll(specification: Specification, pageable: Pageable): Page + @Query(""" + SELECT v FROM Visitor v + WHERE v.visitStatus = :visitStatus + """) + fun findAllByVisitStatus( + visitStatus: VisitStatus, + pageable: Pageable + ): Page + + @Query(""" + SELECT v FROM Visitor v + WHERE v.visitStatus.name = 'CHECKED_IN' + AND v.checkInTime <= :threshold + """) + fun findAllOverdueGlobal( + threshold: OffsetDateTime + ): List + + @Query(""" + SELECT COUNT(v) FROM Visitor v + WHERE v.checkInTime >= :startOfDay + AND v.checkInTime < :endOfDay + """) + fun countCheckedInTodayGlobal( + startOfDay: OffsetDateTime, + endOfDay: OffsetDateTime + ): Long + + fun countByVisitStatus(visitStatus: VisitStatus): Long + + fun countBySiteIdAndVisitStatusAndCheckOutTimeBetween( + siteId: UUID, + visitStatus: VisitStatus, + start: OffsetDateTime, + end: OffsetDateTime + ): Long + + fun countByVisitStatusAndCheckOutTimeBetween( + visitStatus: VisitStatus, + start: OffsetDateTime, + end: OffsetDateTime + ): Long } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorService.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorService.kt index 353b5df..4d26005 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorService.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorService.kt @@ -1,16 +1,11 @@ package io.github.devcavin.gatelog.backend.visitors +import io.github.devcavin.gatelog.backend.auth.AuthorizationService import io.github.devcavin.gatelog.backend.common.exception.ConflictException import io.github.devcavin.gatelog.backend.common.exception.InvalidStateException import io.github.devcavin.gatelog.backend.common.exception.ResourceNotFoundException import io.github.devcavin.gatelog.backend.users.User -import io.github.devcavin.gatelog.backend.visitors.dto.RegisterVisitorRequest -import io.github.devcavin.gatelog.backend.visitors.dto.ReturningVisitorResponse -import io.github.devcavin.gatelog.backend.visitors.dto.UpdateVisitorProfileRequest -import io.github.devcavin.gatelog.backend.visitors.dto.VisitorProfileResponse -import io.github.devcavin.gatelog.backend.visitors.dto.VisitorResponse -import io.github.devcavin.gatelog.backend.visitors.dto.VisitorSearchParams -import io.github.devcavin.gatelog.backend.visitors.dto.toResponse +import io.github.devcavin.gatelog.backend.visitors.dto.* import io.github.devcavin.gatelog.backend.zones.ZoneRepository import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable @@ -23,31 +18,31 @@ import java.util.* @Service class VisitorService( private val visitorRepository: VisitorRepository, - private val visitorProfileRepository: VisitorProfileRepository, private val visitStatusRepository: VisitStatusRepository, - private val zoneRepository: ZoneRepository + private val zoneRepository: ZoneRepository, + private val visitorProfileRepository: VisitorProfileRepository, + private val authorizationService: AuthorizationService ) { @Transactional - fun register( - requestedBy: User, - request: RegisterVisitorRequest - ): VisitorResponse { + fun register(requestedBy: User, request: RegisterVisitorRequest): VisitorResponse { val checkedInStatus = visitStatusRepository.findByName("CHECKED_IN") ?: throw ResourceNotFoundException("VisitStatus", "CHECKED_IN") + // registration always scoped to the registering user's own site + val registrationSiteId = requestedBy.site.id!! + val zone = request.zoneId.let { zoneRepository.findById(it) .orElseThrow { ResourceNotFoundException("Zone", it) } .also { z -> - if (z.site.id != requestedBy.site.id) + if (z.site.id != registrationSiteId) throw AccessDeniedException("Zone does not belong to your site") } } - // find or create visitor profile by phone + site val profile = visitorProfileRepository - .findBySiteIdAndPhoneNumber(requestedBy.site.id!!, request.phone) + .findBySiteIdAndPhoneNumber(registrationSiteId, request.phone) ?: visitorProfileRepository.save( VisitorProfile( name = request.name, @@ -75,8 +70,7 @@ class VisitorService( fun getById(requestedBy: User, visitorId: UUID): VisitorResponse { val visitor = visitorRepository.findById(visitorId) .orElseThrow { ResourceNotFoundException("Visitor", visitorId) } - - enforcesSiteBoundary(requestedBy, visitor.site.id!!) + authorizationService.assertCanAccessVisitor(requestedBy, visitor) return visitor.toResponse() } @@ -85,11 +79,12 @@ class VisitorService( val visitor = visitorRepository.findById(visitorId) .orElseThrow { ResourceNotFoundException("Visitor", visitorId) } - enforcesSiteBoundary(requestedBy, visitor.site.id!!) + authorizationService.assertCanAccessVisitor(requestedBy, visitor) if (visitor.visitStatus.name != "CHECKED_IN") { throw InvalidStateException( - "Visitor is already ${visitor.visitStatus.name.lowercase().replace('_', ' ')}" + "Visitor is already ${visitor.visitStatus.name + .lowercase().replace('_', ' ')}" ) } @@ -98,7 +93,6 @@ class VisitorService( visitor.visitStatus = checkedOutStatus visitor.checkOutTime = OffsetDateTime.now() - return visitorRepository.save(visitor).toResponse() } @@ -108,24 +102,26 @@ class VisitorService( params: VisitorSearchParams, pageable: Pageable ): Page { - val spec = VisitorSpecification.search(requestedBy.site.id!!, params) + val scope = authorizationService.scopeFor(requestedBy) + val spec = VisitorSpecification.search(scope, params) return visitorRepository.findAll(spec, pageable).map { it.toResponse() } } - @Transactional(readOnly = true) fun findReturningVisitor( requestedBy: User, phone: String ): ReturningVisitorResponse? { + // returning visitor lookup is always site-scoped + // even SUPER_ADMIN registers visitors at their own site + val siteId = requestedBy.site.id!! + val profile = visitorProfileRepository - .findBySiteIdAndPhoneNumber(requestedBy.site.id!!, phone) + .findBySiteIdAndPhoneNumber(siteId, phone) ?: return null val lastVisit = visitorRepository - .findTopBySiteIdAndPhoneOrderByCheckInTimeDesc( - requestedBy.site.id!!, phone - ) + .findTopBySiteIdAndPhoneOrderByCheckInTimeDesc(siteId, phone) return ReturningVisitorResponse( name = profile.name, @@ -136,14 +132,6 @@ class VisitorService( ) } - private fun enforcesSiteBoundary(requestedBy: User, visitorSiteId: UUID) { - if (requestedBy.role.name != "SUPER_ADMIN" && - requestedBy.site.id != visitorSiteId - ) { - throw AccessDeniedException("Visitor does not belong to your site") - } - } - @Transactional fun updateProfile( requestedBy: User, @@ -153,6 +141,7 @@ class VisitorService( val profile = visitorProfileRepository.findById(profileId) .orElseThrow { ResourceNotFoundException("VisitorProfile", profileId) } + // profile updates always scoped to the user's own site if (profile.site.id != requestedBy.site.id) { throw AccessDeniedException("Profile does not belong to your site") } @@ -163,7 +152,7 @@ class VisitorService( ) ) { throw ConflictException( - "Phone number is already registered at this site" + "Phone '${request.phoneNumber}' already registered at this site" ) } @@ -182,4 +171,20 @@ class VisitorService( visitCount = visitCount.toInt() ) } + + private fun Visitor.toResponse() = VisitorResponse( + id = id!!, + name = name, + phone = phone, + visitorType = visitorType, + purpose = purpose, + status = visitStatus.name, + siteId = site.id!!, + zoneId = zone?.id, + zoneName = zone?.name, + createdById = createdBy.id!!, + createdByName = createdBy.name, + checkInTime = checkInTime, + checkOutTime = checkOutTime + ) } \ No newline at end of file diff --git a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorSpecification.kt b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorSpecification.kt index 86f6564..dfc87a3 100644 --- a/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorSpecification.kt +++ b/backend/src/main/kotlin/io/github/devcavin/gatelog/backend/visitors/VisitorSpecification.kt @@ -1,5 +1,6 @@ package io.github.devcavin.gatelog.backend.visitors +import io.github.devcavin.gatelog.backend.auth.AccessScope import io.github.devcavin.gatelog.backend.visitors.dto.VisitorSearchParams import jakarta.persistence.criteria.Predicate import org.springframework.data.jpa.domain.Specification @@ -8,44 +9,40 @@ import java.util.UUID object VisitorSpecification { fun search( - siteId: UUID, + scope: AccessScope, params: VisitorSearchParams ): Specification = Specification { root, _, cb -> val predicates = mutableListOf() - predicates.add(cb.equal(root.get("site").get("id"), siteId)) - - params.name?.takeIf { it.isNotBlank() }?.let { + /** + * site filter - only applied for site-scoped access + * SUPER_ADMIN with Global scope skips this entirely + */ + if (scope is AccessScope.Site) { predicates.add( - cb.like(cb.lower(root.get("name")), "%${it.lowercase()}%") + cb.equal(root.get("site").get("id"), scope.siteId) ) } + params.name?.takeIf { it.isNotBlank() }?.let { + predicates.add(cb.like(cb.lower(root.get("name")), "%${it.lowercase()}%")) + } params.phone?.takeIf { it.isNotBlank() }?.let { predicates.add(cb.like(root.get("phone"), "%$it%")) } - params.visitorType?.takeIf { it.isNotBlank() }?.let { predicates.add(cb.equal(root.get("visitorType"), it)) } - params.zoneId?.let { - predicates.add( - cb.equal(root.get("zone").get("id"), it) - ) + predicates.add(cb.equal(root.get("zone").get("id"), it)) } - params.status?.takeIf { it.isNotBlank() }?.let { - predicates.add( - cb.equal(root.get("visitStatus").get("name"), it) - ) + predicates.add(cb.equal(root.get("visitStatus").get("name"), it)) } - params.from?.let { predicates.add(cb.greaterThanOrEqualTo(root.get("checkInTime"), it)) } - params.to?.let { predicates.add(cb.lessThanOrEqualTo(root.get("checkInTime"), it)) }