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