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
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthResponse> {
val response = authService.login(request)
val response = authenticationService.login(request)
return ResponseEntity.ok(response)
}

@PostMapping("/refresh")
fun refresh(@Valid @RequestBody request: RefreshTokenRequest): ResponseEntity<AuthResponse> {
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<Void> {
authService.logout(request.token)
authenticationService.logout(request.refreshToken)
return ResponseEntity.noContent().build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,5 @@ data class LoginRequest(

data class RefreshTokenRequest(
@field:NotBlank
val token: String
val refreshToken: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading