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
3 changes: 3 additions & 0 deletions docs/visit.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,6 @@ Create Visit

```

## Visit Status
- `OVERDUE` - A visitor who has not checked out and has exceeded the configured expected visit duration.

20 changes: 13 additions & 7 deletions src/main/kotlin/io/github/devcavin/gatelog/auth/AccessScope.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import java.util.UUID
/**
* Represents the data visibility scope for an authenticated user.
*
* GLOBAL - user can access resources across all sites (SUPER_ADMIN)
* GLOBAL - user can access resources across all sites (ADMIN)
*
* SITE - user can only access resources belonging to their own site (MANAGER, STAFF)
*
Expand All @@ -15,7 +15,7 @@ import java.util.UUID
*/

sealed class AccessScope {
/** No site boundary - SUPER_ADMIN sees everything */
/** No site boundary - ADMIN sees everything */
data object Global : AccessScope()

/** Restricted to a single site - MANAGER and STAFF */
Expand All @@ -27,9 +27,15 @@ sealed class AccessScope {
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
}
/**
* Returns the site boundary for this scope.
*
* Site scope returns its site ID.
* Global scope returns null because no site filter is required.
*/
val siteIdOrNull: UUID?
get() = when (this) {
is Global -> null
is Site -> siteId
}
}
196 changes: 119 additions & 77 deletions src/main/kotlin/io/github/devcavin/gatelog/auth/AuthorizationService.kt
Original file line number Diff line number Diff line change
@@ -1,68 +1,70 @@
package io.github.devcavin.gatelog.auth

import io.github.devcavin.gatelog.common.exception.ResourceNotFoundException
import io.github.devcavin.gatelog.common.exception.AccessDeniedException
import io.github.devcavin.gatelog.common.exception.ResourceNotFoundException
import io.github.devcavin.gatelog.sites.Site
import io.github.devcavin.gatelog.users.User
import io.github.devcavin.gatelog.visitors.Visit
import org.springframework.stereotype.Service
import java.util.*
import java.util.UUID

@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
"MANAGER", "STAFF" -> AccessScope.Site(user.site.id!!)
else -> throw AccessDeniedException("Unsupported user role")
"ADMIN" -> AccessScope.Global

"MANAGER", "STAFF" ->
AccessScope.Site(requireSiteId(user))

else ->
throw AccessDeniedException("Unsupported user role")
}
}

/**
* Asserts the user's scope covers the given siteId.
* Throws AuthorizationDeniedException if the scope does not cover it.
* Asserts that the user's scope covers the given site.
*/

fun assertCovers(user: User, siteId: UUID) {
fun assertCovers(
user: User,
siteId: UUID
) {
if (!scopeFor(user).covers(siteId)) {
throw AccessDeniedException("Authorization denied")
}
}

/**
* 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.
* Asserts that the user's scope covers the visit's site.
*
* Site-scoped users receive a not-found response when attempting
* to access a visit belonging to another site, preventing resource
* existence from being leaked.
*/

fun assertCanAccessVisitor(user: User, visitor: Visit) {
if (!scopeFor(user).covers(requireSiteId(visitor.site))) {
throw ResourceNotFoundException("Visitor", requireNotNull(visitor.id))
fun assertCanAccessVisit(
user: User,
visit: Visit
) {
if (!scopeFor(user).covers(requireSiteId(visit.site))) {
throw ResourceNotFoundException(
"Visit",
requireNotNull(visit.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

fun canAccessSite(user: User, siteId: UUID): Boolean = scopeFor(user).covers(siteId)

/**
* 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.
*
* ADMIN - unrestricted.
* MANAGER - Staff only, at their own site.
* STAFF - cannot create users.
*/

fun assertCanCreateUser(
requestedBy: User,
targetRoleName: String,
Expand All @@ -72,24 +74,33 @@ class AuthorizationService {
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")
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.
*
* ADMIN - unrestricted.
* MANAGER - Staff at their own site, cannot elevate beyond Staff.
*/

fun assertCanUpdateUser(
requestedBy: User,
target: User,
Expand All @@ -99,64 +110,95 @@ class AuthorizationService {
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")
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.
*
* ADMIN - unrestricted.
* MANAGER - Staff at their own site only.
*/

fun assertCanDeactivateUser(requestedBy: User, target: User) {
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")
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.
* Enforces visibility of a user record.
*
* ADMIN - can see any user.
* MANAGER - Staff at their own site only.
*/
fun assertCanViewUser(requestedBy: User, target: User) {
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", requireNotNull(target.id))

if (target.role.name != "STAFF")
throw AccessDeniedException("Managers can only view staff accounts")
if (target.site.id != scope.siteId) {
throw ResourceNotFoundException(
"User",
requireNotNull(target.id)
)
}

if (target.role.name != "STAFF") {
throw AccessDeniedException(
"Managers can only view staff accounts"
)
}
}
}
}

private fun requireSiteId(user: User) : UUID {
return user.site.id ?: throw AccessDeniedException("User is not associated with a site")
}

private fun requireSiteId(site: Site) : UUID {
return site.id ?: throw AccessDeniedException("Resource is not associated with a site")
}
private fun requireSiteId(user: User): UUID =
user.site.id
?: throw AccessDeniedException(
"User is not associated with a site"
)

private fun requireSiteId(site: Site): UUID =
site.id
?: throw AccessDeniedException(
"Resource is not associated with a site"
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.github.devcavin.gatelog.common.service

import io.github.devcavin.gatelog.common.exception.ResourceNotFoundException
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID

abstract class BaseEntityService<T>(
private val repository: JpaRepository<T, UUID>,
private val resourceName: String
) {

protected fun findEntityById(id: UUID): T =
repository.findById(id)
.orElseThrow {
ResourceNotFoundException(resourceName, id)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package io.github.devcavin.gatelog.common.time

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
import java.time.ZoneOffset

@Configuration
class TimeConfig {
@Bean
fun clock(): Clock =
Clock.system(ZoneOffset.UTC)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.github.devcavin.gatelog.common.time

import org.springframework.stereotype.Component
import java.time.Clock
import java.time.OffsetDateTime

@Component
class TimeProvider(private val clock: Clock) {

fun timeNow() : OffsetDateTime = OffsetDateTime.now(clock)
}
Loading
Loading