Commit 40f01805c8ce3486cb5b4224e06418065dbb6573

Authored by vibe_erp
1 parent 85dcf0d6

feat(pbc-identity): User entity end-to-end (entity, repo, service, REST controll…

…er, IdentityApi adapter, unit tests)
pbc/pbc-identity/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.kotlin.jpa)
  5 + alias(libs.plugins.spring.dependency.management)
  6 +}
  7 +
  8 +description = "vibe_erp pbc-identity — users, roles, tenants, permissions. INTERNAL Packaged Business Capability."
  9 +
  10 +java {
  11 + toolchain {
  12 + languageVersion.set(JavaLanguageVersion.of(21))
  13 + }
  14 +}
  15 +
  16 +kotlin {
  17 + jvmToolchain(21)
  18 + compilerOptions {
  19 + freeCompilerArgs.add("-Xjsr305=strict")
  20 + }
  21 +}
  22 +
  23 +allOpen {
  24 + annotation("jakarta.persistence.Entity")
  25 + annotation("jakarta.persistence.MappedSuperclass")
  26 + annotation("jakarta.persistence.Embeddable")
  27 +}
  28 +
  29 +// CRITICAL: pbc-identity may depend on api-v1 and platform-* but NEVER on
  30 +// another pbc-*. The root build.gradle.kts enforces this; if you add a
  31 +// `:pbc:pbc-foo` dependency below, the build will refuse to load.
  32 +dependencies {
  33 + api(project(":api:api-v1"))
  34 + implementation(project(":platform:platform-persistence"))
  35 + implementation(project(":platform:platform-bootstrap"))
  36 +
  37 + implementation(libs.kotlin.stdlib)
  38 + implementation(libs.kotlin.reflect)
  39 +
  40 + implementation(libs.spring.boot.starter)
  41 + implementation(libs.spring.boot.starter.web)
  42 + implementation(libs.spring.boot.starter.data.jpa)
  43 + implementation(libs.spring.boot.starter.validation)
  44 + implementation(libs.jackson.module.kotlin)
  45 +
  46 + testImplementation(libs.spring.boot.starter.test)
  47 + testImplementation(libs.junit.jupiter)
  48 + testImplementation(libs.assertk)
  49 + testImplementation(libs.mockk)
  50 + testImplementation(libs.testcontainers.postgres)
  51 + testImplementation(libs.testcontainers.junit.jupiter)
  52 +}
  53 +
  54 +tasks.test {
  55 + useJUnitPlatform()
  56 +}
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/UserService.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.application
  2 +
  3 +import org.springframework.stereotype.Service
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.pbc.identity.domain.User
  6 +import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
  7 +import java.util.UUID
  8 +
  9 +/**
  10 + * Application service for user CRUD.
  11 + *
  12 + * This is the boundary between HTTP controllers and the domain. Anything
  13 + * a controller needs to do — list, fetch, create, update, disable — goes
  14 + * through here. Controllers do NOT inject the repository directly; that's
  15 + * what keeps them testable and what gives transaction boundaries one
  16 + * obvious place to live.
  17 + */
  18 +@Service
  19 +@Transactional
  20 +class UserService(
  21 + private val users: UserJpaRepository,
  22 +) {
  23 +
  24 + @Transactional(readOnly = true)
  25 + fun list(): List<User> = users.findAll()
  26 +
  27 + @Transactional(readOnly = true)
  28 + fun findById(id: UUID): User? = users.findById(id).orElse(null)
  29 +
  30 + @Transactional(readOnly = true)
  31 + fun findByUsername(username: String): User? = users.findByUsername(username)
  32 +
  33 + fun create(command: CreateUserCommand): User {
  34 + require(!users.existsByUsername(command.username)) {
  35 + "username '${command.username}' is already taken in this tenant"
  36 + }
  37 + val user = User(
  38 + username = command.username,
  39 + displayName = command.displayName,
  40 + email = command.email,
  41 + enabled = command.enabled,
  42 + )
  43 + return users.save(user)
  44 + }
  45 +
  46 + fun update(id: UUID, command: UpdateUserCommand): User {
  47 + val user = users.findById(id).orElseThrow {
  48 + NoSuchElementException("user not found: $id")
  49 + }
  50 + command.displayName?.let { user.displayName = it }
  51 + command.email?.let { user.email = it }
  52 + command.enabled?.let { user.enabled = it }
  53 + return user // managed by JPA, flushed on transaction commit
  54 + }
  55 +
  56 + fun disable(id: UUID) {
  57 + val user = users.findById(id).orElseThrow {
  58 + NoSuchElementException("user not found: $id")
  59 + }
  60 + user.enabled = false
  61 + }
  62 +}
  63 +
  64 +data class CreateUserCommand(
  65 + val username: String,
  66 + val displayName: String,
  67 + val email: String?,
  68 + val enabled: Boolean = true,
  69 +)
  70 +
  71 +data class UpdateUserCommand(
  72 + val displayName: String? = null,
  73 + val email: String? = null,
  74 + val enabled: Boolean? = null,
  75 +)
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/domain/User.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.Id
  6 +import jakarta.persistence.Table
  7 +import org.hibernate.annotations.JdbcTypeCode
  8 +import org.hibernate.type.SqlTypes
  9 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  10 +
  11 +/**
  12 + * The canonical User entity for the identity PBC.
  13 + *
  14 + * Why a real Hibernate `@Entity` instead of going through `Repository<User>`
  15 + * from api.v1: PBCs live INSIDE the platform layer and are allowed to use
  16 + * Hibernate directly. Only **plug-ins** are restricted to the api.v1 surface.
  17 + * This is the entire point of the modular monolith — PBCs get power, plug-ins
  18 + * get safety.
  19 + *
  20 + * Multi-tenancy: `tenant_id` and audit columns come from [AuditedJpaEntity]
  21 + * and are filled in by the platform's JPA listener. PBC code never writes
  22 + * to those columns directly.
  23 + *
  24 + * Custom fields: the `ext` JSONB column is the metadata-driven custom-field
  25 + * store (architecture spec section 8). A key user can add fields to this
  26 + * entity through the web UI without a migration; queries against custom
  27 + * fields use the GIN index defined in the Liquibase changeset.
  28 + */
  29 +@Entity
  30 +@Table(name = "identity__user")
  31 +class User(
  32 + username: String,
  33 + displayName: String,
  34 + email: String?,
  35 + enabled: Boolean = true,
  36 +) : AuditedJpaEntity() {
  37 +
  38 + @Column(name = "username", nullable = false, length = 128)
  39 + var username: String = username
  40 +
  41 + @Column(name = "display_name", nullable = false, length = 256)
  42 + var displayName: String = displayName
  43 +
  44 + @Column(name = "email", nullable = true, length = 320)
  45 + var email: String? = email
  46 +
  47 + @Column(name = "enabled", nullable = false)
  48 + var enabled: Boolean = enabled
  49 +
  50 + /**
  51 + * JSONB column for key-user-defined custom fields. Mapped as a String at
  52 + * the JPA layer to keep the dependency on Hibernate's JSON mapping out
  53 + * of api.v1; the application layer parses/serializes via Jackson when
  54 + * the form-rendering layer demands it.
  55 + */
  56 + @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
  57 + @JdbcTypeCode(SqlTypes.JSON)
  58 + var ext: String = "{}"
  59 +
  60 + override fun toString(): String =
  61 + "User(id=$id, tenantId=$tenantId, username='$username')"
  62 +}
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/ext/IdentityApiAdapter.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.ext
  2 +
  3 +import org.springframework.stereotype.Component
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.api.v1.core.Id
  6 +import org.vibeerp.api.v1.core.TenantId
  7 +import org.vibeerp.api.v1.ext.identity.IdentityApi
  8 +import org.vibeerp.api.v1.ext.identity.UserRef
  9 +import org.vibeerp.pbc.identity.domain.User
  10 +import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
  11 +import java.util.UUID
  12 +
  13 +/**
  14 + * Concrete [IdentityApi] implementation. The ONLY thing other PBCs and
  15 + * plug-ins are allowed to inject for "give me a user reference".
  16 + *
  17 + * Why this file exists at all: see the rationale on [IdentityApi]. The
  18 + * Gradle build forbids cross-PBC dependencies; this adapter is the
  19 + * runtime fulfillment of the api.v1 contract that lets the rest of the
  20 + * world ask identity questions without importing identity types.
  21 + *
  22 + * **What this adapter MUST NOT do:**
  23 + * • leak `org.vibeerp.pbc.identity.domain.User` to callers
  24 + * • bypass tenant filtering (caller's tenant context is honored automatically)
  25 + * • return PII fields that aren't already on `UserRef`
  26 + *
  27 + * If a caller needs more fields, the answer is to grow `UserRef` deliberately
  28 + * (an api.v1 change with a major-version cost), not to hand them the entity.
  29 + */
  30 +@Component
  31 +@Transactional(readOnly = true)
  32 +class IdentityApiAdapter(
  33 + private val users: UserJpaRepository,
  34 +) : IdentityApi {
  35 +
  36 + override fun findUserByUsername(username: String): UserRef? =
  37 + users.findByUsername(username)?.toRef()
  38 +
  39 + override fun findUserById(id: Id<*>): UserRef? =
  40 + users.findById(id.value).orElse(null)?.toRef()
  41 +
  42 + private fun User.toRef(): UserRef = UserRef(
  43 + id = Id<UserRef>(this.id),
  44 + username = this.username,
  45 + tenantId = TenantId(this.tenantId),
  46 + displayName = this.displayName,
  47 + enabled = this.enabled,
  48 + )
  49 +}
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/UserController.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.http
  2 +
  3 +import jakarta.validation.Valid
  4 +import jakarta.validation.constraints.Email
  5 +import jakarta.validation.constraints.NotBlank
  6 +import jakarta.validation.constraints.Size
  7 +import org.springframework.http.HttpStatus
  8 +import org.springframework.http.ResponseEntity
  9 +import org.springframework.web.bind.annotation.DeleteMapping
  10 +import org.springframework.web.bind.annotation.GetMapping
  11 +import org.springframework.web.bind.annotation.PatchMapping
  12 +import org.springframework.web.bind.annotation.PathVariable
  13 +import org.springframework.web.bind.annotation.PostMapping
  14 +import org.springframework.web.bind.annotation.RequestBody
  15 +import org.springframework.web.bind.annotation.RequestMapping
  16 +import org.springframework.web.bind.annotation.ResponseStatus
  17 +import org.springframework.web.bind.annotation.RestController
  18 +import org.vibeerp.pbc.identity.application.CreateUserCommand
  19 +import org.vibeerp.pbc.identity.application.UpdateUserCommand
  20 +import org.vibeerp.pbc.identity.application.UserService
  21 +import org.vibeerp.pbc.identity.domain.User
  22 +import java.util.UUID
  23 +
  24 +/**
  25 + * REST API for the identity PBC's User aggregate.
  26 + *
  27 + * Mounted at `/api/v1/identity/users`. The path follows the convention
  28 + * `/api/v1/<pbc-name>/<resource>` so the surface naturally namespaces by
  29 + * PBC, and so plug-in endpoints (which mount under `/api/v1/plugins/...`)
  30 + * cannot collide with core endpoints.
  31 + *
  32 + * Tenant id is bound automatically by `TenantResolutionFilter` upstream of
  33 + * this controller — there is no `tenantId` parameter on any endpoint and
  34 + * there never will be. Cross-tenant access is impossible by construction.
  35 + *
  36 + * Authentication is deferred to v0.2 (pbc-identity's auth flow). For v0.1
  37 + * the controller answers any request that gets past the tenant filter.
  38 + */
  39 +@RestController
  40 +@RequestMapping("/api/v1/identity/users")
  41 +class UserController(
  42 + private val userService: UserService,
  43 +) {
  44 +
  45 + @GetMapping
  46 + fun list(): List<UserResponse> =
  47 + userService.list().map { it.toResponse() }
  48 +
  49 + @GetMapping("/{id}")
  50 + fun get(@PathVariable id: UUID): ResponseEntity<UserResponse> {
  51 + val user = userService.findById(id) ?: return ResponseEntity.notFound().build()
  52 + return ResponseEntity.ok(user.toResponse())
  53 + }
  54 +
  55 + @PostMapping
  56 + @ResponseStatus(HttpStatus.CREATED)
  57 + fun create(@RequestBody @Valid request: CreateUserRequest): UserResponse =
  58 + userService.create(
  59 + CreateUserCommand(
  60 + username = request.username,
  61 + displayName = request.displayName,
  62 + email = request.email,
  63 + enabled = request.enabled ?: true,
  64 + ),
  65 + ).toResponse()
  66 +
  67 + @PatchMapping("/{id}")
  68 + fun update(
  69 + @PathVariable id: UUID,
  70 + @RequestBody @Valid request: UpdateUserRequest,
  71 + ): UserResponse =
  72 + userService.update(
  73 + id,
  74 + UpdateUserCommand(
  75 + displayName = request.displayName,
  76 + email = request.email,
  77 + enabled = request.enabled,
  78 + ),
  79 + ).toResponse()
  80 +
  81 + @DeleteMapping("/{id}")
  82 + @ResponseStatus(HttpStatus.NO_CONTENT)
  83 + fun disable(@PathVariable id: UUID) {
  84 + userService.disable(id)
  85 + }
  86 +}
  87 +
  88 +// ─── DTOs ────────────────────────────────────────────────────────────
  89 +
  90 +data class CreateUserRequest(
  91 + @field:NotBlank @field:Size(max = 128) val username: String,
  92 + @field:NotBlank @field:Size(max = 256) val displayName: String,
  93 + @field:Email @field:Size(max = 320) val email: String?,
  94 + val enabled: Boolean? = true,
  95 +)
  96 +
  97 +data class UpdateUserRequest(
  98 + @field:Size(max = 256) val displayName: String? = null,
  99 + @field:Email @field:Size(max = 320) val email: String? = null,
  100 + val enabled: Boolean? = null,
  101 +)
  102 +
  103 +data class UserResponse(
  104 + val id: UUID,
  105 + val tenantId: String,
  106 + val username: String,
  107 + val displayName: String,
  108 + val email: String?,
  109 + val enabled: Boolean,
  110 +)
  111 +
  112 +private fun User.toResponse() = UserResponse(
  113 + id = this.id,
  114 + tenantId = this.tenantId,
  115 + username = this.username,
  116 + displayName = this.displayName,
  117 + email = this.email,
  118 + enabled = this.enabled,
  119 +)
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/infrastructure/UserJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.identity.domain.User
  6 +import java.util.UUID
  7 +
  8 +/**
  9 + * Spring Data JPA repository for [User].
  10 + *
  11 + * Tenant filtering happens automatically: Hibernate's
  12 + * [org.vibeerp.platform.persistence.tenancy.HibernateTenantResolver] sees the
  13 + * tenant from `TenantContext` and adds the discriminator predicate to every
  14 + * generated query. Code in this file never writes `WHERE tenant_id = ?`.
  15 + *
  16 + * The Postgres Row-Level Security policy on `identity__user` is the second
  17 + * wall — even if Hibernate's filter were misconfigured, the database would
  18 + * still refuse to return cross-tenant rows.
  19 + */
  20 +@Repository
  21 +interface UserJpaRepository : JpaRepository<User, UUID> {
  22 +
  23 + fun findByUsername(username: String): User?
  24 +
  25 + fun existsByUsername(username: String): Boolean
  26 +}
pbc/pbc-identity/src/test/kotlin/org/vibeerp/pbc/identity/application/UserServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.identity.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.hasMessage
  6 +import assertk.assertions.isEqualTo
  7 +import assertk.assertions.isInstanceOf
  8 +import assertk.assertions.isNotNull
  9 +import assertk.assertions.isNull
  10 +import io.mockk.every
  11 +import io.mockk.mockk
  12 +import io.mockk.slot
  13 +import io.mockk.verify
  14 +import org.junit.jupiter.api.BeforeEach
  15 +import org.junit.jupiter.api.Test
  16 +import org.vibeerp.pbc.identity.domain.User
  17 +import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository
  18 +import java.util.Optional
  19 +import java.util.UUID
  20 +
  21 +/**
  22 + * Pure unit tests for [UserService] — no Spring, no database. Reach into
  23 + * the JPA repository through a mock.
  24 + *
  25 + * Why mock the repository instead of using Testcontainers Postgres here:
  26 + * • these tests run in milliseconds in every developer's IDE
  27 + * • the repository's correctness (multi-tenant filtering, RLS) is
  28 + * covered by an integration test elsewhere — `UserPersistenceIT`
  29 + * (lands in v0.2 alongside the platform-persistence test harness)
  30 + */
  31 +class UserServiceTest {
  32 +
  33 + private lateinit var users: UserJpaRepository
  34 + private lateinit var service: UserService
  35 +
  36 + @BeforeEach
  37 + fun setUp() {
  38 + users = mockk(relaxed = false)
  39 + service = UserService(users)
  40 + }
  41 +
  42 + @Test
  43 + fun `create rejects duplicate username in the same tenant`() {
  44 + every { users.existsByUsername("alice") } returns true
  45 +
  46 + assertFailure {
  47 + service.create(
  48 + CreateUserCommand(
  49 + username = "alice",
  50 + displayName = "Alice",
  51 + email = null,
  52 + ),
  53 + )
  54 + }
  55 + .isInstanceOf(IllegalArgumentException::class)
  56 + .hasMessage("username 'alice' is already taken in this tenant")
  57 + }
  58 +
  59 + @Test
  60 + fun `create persists a new user when the username is free`() {
  61 + val saved = slot<User>()
  62 + every { users.existsByUsername("bob") } returns false
  63 + every { users.save(capture(saved)) } answers { saved.captured }
  64 +
  65 + val result = service.create(
  66 + CreateUserCommand(
  67 + username = "bob",
  68 + displayName = "Bob the Builder",
  69 + email = "bob@example.com",
  70 + ),
  71 + )
  72 +
  73 + assertThat(result.username).isEqualTo("bob")
  74 + assertThat(result.displayName).isEqualTo("Bob the Builder")
  75 + assertThat(result.email).isEqualTo("bob@example.com")
  76 + assertThat(result.enabled).isEqualTo(true)
  77 + verify(exactly = 1) { users.save(any()) }
  78 + }
  79 +
  80 + @Test
  81 + fun `findByUsername returns null when no user exists`() {
  82 + every { users.findByUsername("ghost") } returns null
  83 +
  84 + assertThat(service.findByUsername("ghost")).isNull()
  85 + }
  86 +
  87 + @Test
  88 + fun `update mutates only the supplied fields`() {
  89 + val id = UUID.randomUUID()
  90 + val existing = User(
  91 + username = "carol",
  92 + displayName = "Carol",
  93 + email = "carol@old.example",
  94 + enabled = true,
  95 + ).also { it.id = id }
  96 + every { users.findById(id) } returns Optional.of(existing)
  97 +
  98 + val updated = service.update(
  99 + id = id,
  100 + command = UpdateUserCommand(displayName = "Carol Danvers"),
  101 + )
  102 +
  103 + assertThat(updated.displayName).isEqualTo("Carol Danvers")
  104 + assertThat(updated.email).isEqualTo("carol@old.example") // untouched
  105 + assertThat(updated.enabled).isEqualTo(true) // untouched
  106 + }
  107 +
  108 + @Test
  109 + fun `disable flips the enabled flag without deleting the row`() {
  110 + val id = UUID.randomUUID()
  111 + val existing = User("dave", "Dave", null, enabled = true).also { it.id = id }
  112 + every { users.findById(id) } returns Optional.of(existing)
  113 +
  114 + service.disable(id)
  115 +
  116 + assertThat(existing.enabled).isEqualTo(false)
  117 + }
  118 +
  119 + @Test
  120 + fun `update throws NoSuchElementException when the user is missing`() {
  121 + val id = UUID.randomUUID()
  122 + every { users.findById(id) } returns Optional.empty()
  123 +
  124 + assertFailure { service.update(id, UpdateUserCommand(displayName = "Eve")) }
  125 + .isInstanceOf(NoSuchElementException::class)
  126 + }
  127 +}