Commit 28e1aa38008bf342577c6ca8ae767cd1cbc8b1f0
1 parent
d8aa39eb
fix: address v0.1 acceptance review (1 BLOCKER + 6 IMPORTANT/NIT findings)
BLOCKER: wire Hibernate multi-tenancy - application.yaml: set hibernate.tenant_identifier_resolver and hibernate.multiTenancy=DISCRIMINATOR so HibernateTenantResolver is actually installed into the SessionFactory - AuditedJpaEntity.tenantId: add @org.hibernate.annotations.TenantId so every PBC entity inherits the discriminator - AuditedJpaEntityListener.onCreate: throw if a caller pre-set tenantId to a different value than the current TenantContext, instead of silently overwriting (defense against cross-tenant write bugs) IMPORTANT: dependency hygiene - pbc-identity no longer depends on platform-bootstrap (wrong direction; bootstrap assembles PBCs at the top of the stack) - root build.gradle.kts: tighten the architectural-rule enforcement to also reject :pbc:* -> platform-bootstrap; switch plug-in detection from a fragile pathname heuristic to an explicit extra["vibeerp.module-kind"] = "plugin" marker; reference plug-in declares the marker IMPORTANT: api.v1 surface additions (all non-breaking) - Repository: documented closed exception set; new PersistenceExceptions.kt declares OptimisticLockConflictException, UniqueConstraintViolationException, EntityValidationException, and EntityNotFoundException so plug-ins never see Hibernate types - TaskContext: now exposes tenantId(), principal(), locale(), correlationId() so workflow handlers (which run outside an HTTP request) can pass tenant-aware calls back into api.v1 - EventBus: subscribe() now returns a Subscription with close() so long-lived subscribers can deregister explicitly; added a subscribe(topic: String, ...) overload for cross-classloader event routing where Class<E> equality is unreliable - IdentityApi.findUserById: tightened from Id<*> to PrincipalId so the type system rejects "wrong-id-kind" mistakes at the cross-PBC boundary NITs: - HealthController.kt -> MetaController.kt (file name now matches the class name); added TODO(v0.2) for reading implementationVersion from the Spring Boot BuildProperties bean
Showing
12 changed files
with
275 additions
and
25 deletions
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/EventBus.kt
| @@ -31,11 +31,46 @@ interface EventBus { | @@ -31,11 +31,46 @@ interface EventBus { | ||
| 31 | fun publish(event: DomainEvent) | 31 | fun publish(event: DomainEvent) |
| 32 | 32 | ||
| 33 | /** | 33 | /** |
| 34 | - * Register a listener for events of [eventType] (and its subtypes). | 34 | + * Register a typed listener for events of [eventType] (and its subtypes). |
| 35 | * | 35 | * |
| 36 | - * The listener stays registered until the plug-in is stopped, at which | ||
| 37 | - * point the platform tears it down automatically — plug-ins do not | ||
| 38 | - * need (and should not have) a manual `unsubscribe` method. | 36 | + * Plug-ins can ignore the returned [Subscription] — when the plug-in is |
| 37 | + * stopped, the platform tears down all of its listeners automatically. | ||
| 38 | + * Core PBCs and tooling that legitimately need to drop a subscription | ||
| 39 | + * mid-life (feature toggles, completed sagas, per-tenant subscriptions) | ||
| 40 | + * call `subscription.close()` to deregister explicitly. | ||
| 39 | */ | 41 | */ |
| 40 | - fun <E : DomainEvent> subscribe(eventType: Class<E>, listener: EventListener<E>) | 42 | + fun <E : DomainEvent> subscribe( |
| 43 | + eventType: Class<E>, | ||
| 44 | + listener: EventListener<E>, | ||
| 45 | + ): Subscription | ||
| 46 | + | ||
| 47 | + /** | ||
| 48 | + * Register a listener keyed on the event's [DomainEvent.aggregateType] | ||
| 49 | + * string instead of its concrete Java class. | ||
| 50 | + * | ||
| 51 | + * Why this overload exists: cross-classloader event listening cannot rely | ||
| 52 | + * on `Class<E>` equality, because two plug-ins compiling against the | ||
| 53 | + * same `OrderCreated` event class will see two different `Class` objects | ||
| 54 | + * loaded by two different classloaders. The string topic is the only | ||
| 55 | + * stable identifier across the boundary. | ||
| 56 | + * | ||
| 57 | + * @param topic the [DomainEvent.aggregateType] (or topic-style) string | ||
| 58 | + * to match. Convention: `<plugin-or-pbc>.<aggregate>` (e.g. | ||
| 59 | + * `orders_sales.Order`, `printingshop.Plate`). | ||
| 60 | + */ | ||
| 61 | + fun subscribe( | ||
| 62 | + topic: String, | ||
| 63 | + listener: EventListener<DomainEvent>, | ||
| 64 | + ): Subscription | ||
| 65 | + | ||
| 66 | + /** | ||
| 67 | + * Handle returned by `subscribe`, used to deregister explicitly. | ||
| 68 | + * | ||
| 69 | + * Implements [AutoCloseable] so `use { ... }` patterns work in tests | ||
| 70 | + * and short-lived listeners. | ||
| 71 | + */ | ||
| 72 | + interface Subscription : AutoCloseable { | ||
| 73 | + /** Deregister this listener. Idempotent: calling twice is a no-op. */ | ||
| 74 | + override fun close() | ||
| 75 | + } | ||
| 41 | } | 76 | } |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/identity/IdentityApi.kt
| @@ -2,6 +2,7 @@ package org.vibeerp.api.v1.ext.identity | @@ -2,6 +2,7 @@ package org.vibeerp.api.v1.ext.identity | ||
| 2 | 2 | ||
| 3 | import org.vibeerp.api.v1.core.Id | 3 | import org.vibeerp.api.v1.core.Id |
| 4 | import org.vibeerp.api.v1.core.TenantId | 4 | import org.vibeerp.api.v1.core.TenantId |
| 5 | +import org.vibeerp.api.v1.security.PrincipalId | ||
| 5 | 6 | ||
| 6 | /** | 7 | /** |
| 7 | * Cross-PBC facade for the identity bounded context. | 8 | * Cross-PBC facade for the identity bounded context. |
| @@ -32,11 +33,12 @@ interface IdentityApi { | @@ -32,11 +33,12 @@ interface IdentityApi { | ||
| 32 | fun findUserByUsername(username: String): UserRef? | 33 | fun findUserByUsername(username: String): UserRef? |
| 33 | 34 | ||
| 34 | /** | 35 | /** |
| 35 | - * Look up a user by id. The id is `Id<*>` rather than `Id<UserRef>` so | ||
| 36 | - * callers do not have to know the exact entity type — they pass the id | ||
| 37 | - * they have, and the implementation does the matching internally. | 36 | + * Look up a user by their principal id. The id is typed as |
| 37 | + * [PrincipalId] (which is `Id<Principal>`) so the compiler refuses to | ||
| 38 | + * accept, e.g., an `Id<Order>` here. Callers that have a generic id | ||
| 39 | + * from an audit row construct a `PrincipalId(rawUuid)` deliberately. | ||
| 38 | */ | 40 | */ |
| 39 | - fun findUserById(id: Id<*>): UserRef? | 41 | + fun findUserById(id: PrincipalId): UserRef? |
| 40 | } | 42 | } |
| 41 | 43 | ||
| 42 | /** | 44 | /** |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/PersistenceExceptions.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.persistence | ||
| 2 | + | ||
| 3 | +/** | ||
| 4 | + * The closed set of exceptions that [Repository] is allowed to raise. | ||
| 5 | + * | ||
| 6 | + * Why a small custom hierarchy in api.v1 instead of letting JPA exceptions | ||
| 7 | + * leak through: | ||
| 8 | + * | ||
| 9 | + * • Plug-ins live in their own classloader and cannot import JPA or | ||
| 10 | + * Hibernate types — those packages are not on the parent classloader's | ||
| 11 | + * exported list. A `jakarta.persistence.OptimisticLockException` thrown | ||
| 12 | + * across the plug-in/host boundary would surface as a wrapped runtime | ||
| 13 | + * error the plug-in cannot meaningfully `catch`. | ||
| 14 | + * | ||
| 15 | + * • Plug-in authors need a stable, semver-governed contract for "what | ||
| 16 | + * can go wrong when I save this thing?". JPA exception names and | ||
| 17 | + * hierarchies have shifted across Hibernate majors more than once. | ||
| 18 | + * | ||
| 19 | + * • Optimistic-locking conflicts and unique-constraint violations are | ||
| 20 | + * **expected** failure modes (the kind of thing the [Result] doctrine | ||
| 21 | + * applies to). They aren't bugs; they're business outcomes that callers | ||
| 22 | + * legitimately want to handle. | ||
| 23 | + * | ||
| 24 | + * The platform translates underlying JPA / Hibernate exceptions to these | ||
| 25 | + * types at the persistence boundary. Anything else (the database is down, | ||
| 26 | + * the connection pool is exhausted, …) bubbles up as the generic | ||
| 27 | + * [PersistenceException] and is treated as infrastructure failure. | ||
| 28 | + * | ||
| 29 | + * **Adding a new subtype** is a non-breaking change within api.v1.x as long | ||
| 30 | + * as the new type extends [PersistenceException] (so existing `catch` | ||
| 31 | + * blocks still match). **Removing or renaming** a subtype is a major bump. | ||
| 32 | + */ | ||
| 33 | +sealed class PersistenceException( | ||
| 34 | + message: String, | ||
| 35 | + cause: Throwable? = null, | ||
| 36 | +) : RuntimeException(message, cause) | ||
| 37 | + | ||
| 38 | +/** | ||
| 39 | + * The save was rejected because another transaction concurrently updated | ||
| 40 | + * the same row. The caller's in-memory copy is stale; reload, re-apply the | ||
| 41 | + * intent, and try again — or surface a "someone else changed this" UX. | ||
| 42 | + * | ||
| 43 | + * Backed by Hibernate's `OptimisticLockException` / `StaleObjectStateException`. | ||
| 44 | + */ | ||
| 45 | +class OptimisticLockConflictException( | ||
| 46 | + val entityName: String, | ||
| 47 | + val entityId: String, | ||
| 48 | + cause: Throwable? = null, | ||
| 49 | +) : PersistenceException( | ||
| 50 | + "Optimistic lock conflict on $entityName id=$entityId — the row was modified by another transaction", | ||
| 51 | + cause, | ||
| 52 | +) | ||
| 53 | + | ||
| 54 | +/** | ||
| 55 | + * A unique constraint was violated by the save. [constraint] is the index | ||
| 56 | + * or constraint name as reported by the database (e.g. | ||
| 57 | + * `identity__user_tenant_username_uk`). The platform surfaces the raw name | ||
| 58 | + * because plug-ins legitimately want to map specific constraints to | ||
| 59 | + * specific user-facing messages. | ||
| 60 | + */ | ||
| 61 | +class UniqueConstraintViolationException( | ||
| 62 | + val entityName: String, | ||
| 63 | + val constraint: String, | ||
| 64 | + cause: Throwable? = null, | ||
| 65 | +) : PersistenceException( | ||
| 66 | + "Unique constraint '$constraint' violated on $entityName", | ||
| 67 | + cause, | ||
| 68 | +) | ||
| 69 | + | ||
| 70 | +/** | ||
| 71 | + * The save failed validation against entity invariants enforced by the | ||
| 72 | + * platform (custom-field constraints, jakarta.validation annotations, etc.) | ||
| 73 | + * [errors] is a per-field map of message keys (NOT translated strings) so | ||
| 74 | + * the caller can render them in any locale. | ||
| 75 | + */ | ||
| 76 | +class EntityValidationException( | ||
| 77 | + val entityName: String, | ||
| 78 | + val errors: Map<String, String>, | ||
| 79 | +) : PersistenceException( | ||
| 80 | + "Validation failed on $entityName: ${errors.entries.joinToString(", ") { "${it.key}=${it.value}" }}", | ||
| 81 | +) | ||
| 82 | + | ||
| 83 | +/** | ||
| 84 | + * The targeted entity does not exist for the current tenant. Distinct from | ||
| 85 | + * `findById` returning `null`: this is for operations that semantically | ||
| 86 | + * require the row to exist (update, delete, increment, …). | ||
| 87 | + */ | ||
| 88 | +class EntityNotFoundException( | ||
| 89 | + val entityName: String, | ||
| 90 | + val entityId: String, | ||
| 91 | +) : PersistenceException( | ||
| 92 | + "$entityName with id=$entityId not found in current tenant", | ||
| 93 | +) |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Repository.kt
| @@ -43,12 +43,26 @@ interface Repository<T : Entity> { | @@ -43,12 +43,26 @@ interface Repository<T : Entity> { | ||
| 43 | * Insert or update. Returning the saved entity (rather than `Unit`) gives | 43 | * Insert or update. Returning the saved entity (rather than `Unit`) gives |
| 44 | * the platform a place to populate generated columns (audit timestamps, | 44 | * the platform a place to populate generated columns (audit timestamps, |
| 45 | * generated ids) before the plug-in sees them again. | 45 | * generated ids) before the plug-in sees them again. |
| 46 | + * | ||
| 47 | + * The closed set of expected failure modes is declared in | ||
| 48 | + * [PersistenceException]. The platform translates underlying JPA / | ||
| 49 | + * Hibernate exceptions to those types at the persistence boundary so | ||
| 50 | + * plug-ins never see Hibernate types crossing their classloader. | ||
| 51 | + * | ||
| 52 | + * @throws OptimisticLockConflictException when another transaction | ||
| 53 | + * concurrently updated the same row. | ||
| 54 | + * @throws UniqueConstraintViolationException when a unique index would | ||
| 55 | + * be violated. | ||
| 56 | + * @throws EntityValidationException when entity invariants fail. | ||
| 46 | */ | 57 | */ |
| 47 | fun save(entity: T): T | 58 | fun save(entity: T): T |
| 48 | 59 | ||
| 49 | /** | 60 | /** |
| 50 | * Delete by id. Returns `true` if a row was deleted, `false` if no | 61 | * Delete by id. Returns `true` if a row was deleted, `false` if no |
| 51 | * matching row existed for the current tenant. | 62 | * matching row existed for the current tenant. |
| 63 | + * | ||
| 64 | + * Does NOT throw [EntityNotFoundException] — use `findById` first if | ||
| 65 | + * you need "must exist" semantics. | ||
| 52 | */ | 66 | */ |
| 53 | fun delete(id: Id<T>): Boolean | 67 | fun delete(id: Id<T>): Boolean |
| 54 | } | 68 | } |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/TaskHandler.kt
| 1 | package org.vibeerp.api.v1.workflow | 1 | package org.vibeerp.api.v1.workflow |
| 2 | 2 | ||
| 3 | +import org.vibeerp.api.v1.core.TenantId | ||
| 4 | +import org.vibeerp.api.v1.security.Principal | ||
| 5 | +import java.util.Locale | ||
| 6 | + | ||
| 3 | /** | 7 | /** |
| 4 | * Implementation contract for a single workflow task type. | 8 | * Implementation contract for a single workflow task type. |
| 5 | * | 9 | * |
| @@ -65,4 +69,37 @@ interface TaskContext { | @@ -65,4 +69,37 @@ interface TaskContext { | ||
| 65 | * Passing `null` removes the variable. | 69 | * Passing `null` removes the variable. |
| 66 | */ | 70 | */ |
| 67 | fun set(name: String, value: Any?) | 71 | fun set(name: String, value: Any?) |
| 72 | + | ||
| 73 | + /** | ||
| 74 | + * Owning tenant of the workflow instance this task belongs to. | ||
| 75 | + * | ||
| 76 | + * Workflow tasks do NOT run inside an HTTP request, so the usual | ||
| 77 | + * `RequestContext` injection is unavailable. The platform binds the | ||
| 78 | + * task's tenant context for the duration of [TaskHandler.execute] and | ||
| 79 | + * exposes it here so handlers can pass tenant-aware calls back into | ||
| 80 | + * api.v1 services. | ||
| 81 | + */ | ||
| 82 | + fun tenantId(): TenantId | ||
| 83 | + | ||
| 84 | + /** | ||
| 85 | + * The principal whose action triggered this task instance. For | ||
| 86 | + * automatically scheduled tasks (timer-driven, signal-driven), this | ||
| 87 | + * is a system principal — never null. | ||
| 88 | + */ | ||
| 89 | + fun principal(): Principal | ||
| 90 | + | ||
| 91 | + /** | ||
| 92 | + * The locale to use for any user-facing strings produced by this task | ||
| 93 | + * (notification titles, audit messages, etc.). Resolved from the | ||
| 94 | + * triggering user, the workflow definition, or the tenant default, | ||
| 95 | + * in that order. | ||
| 96 | + */ | ||
| 97 | + fun locale(): Locale | ||
| 98 | + | ||
| 99 | + /** | ||
| 100 | + * Correlation id for this task execution. The same id appears in the | ||
| 101 | + * structured logs and the audit log so an operator can trace a task | ||
| 102 | + * end-to-end. | ||
| 103 | + */ | ||
| 104 | + fun correlationId(): String | ||
| 68 | } | 105 | } |
build.gradle.kts
| @@ -11,19 +11,33 @@ allprojects { | @@ -11,19 +11,33 @@ allprojects { | ||
| 11 | version = providers.gradleProperty("vibeerp.version").get() | 11 | version = providers.gradleProperty("vibeerp.version").get() |
| 12 | } | 12 | } |
| 13 | 13 | ||
| 14 | -// Enforce the architecture-level dependency rule (CLAUDE.md guardrail #9): | 14 | +// Enforce the architecture-level dependency rules (CLAUDE.md guardrail #9): |
| 15 | // | 15 | // |
| 16 | -// • PBCs may NEVER depend on other PBCs. | ||
| 17 | -// Cross-PBC interaction goes through api.v1 service interfaces or the | ||
| 18 | -// event bus. | 16 | +// 1. PBCs may NEVER depend on other PBCs. |
| 17 | +// Cross-PBC interaction goes through api.v1.ext.<pbc> service | ||
| 18 | +// interfaces or the event bus. | ||
| 19 | // | 19 | // |
| 20 | -// • Plug-ins (and the reference plug-in) may ONLY depend on :api:api-v1. | ||
| 21 | -// They never see :platform:* or :pbc:* internals. | 20 | +// 2. PBCs may NEVER depend on `:platform:platform-bootstrap`. |
| 21 | +// Bootstrap is the application entry point that ASSEMBLES PBCs; | ||
| 22 | +// depending on it inverts the layering. PBCs depend on platform | ||
| 23 | +// runtime modules (persistence, plugins, i18n, etc.) only. | ||
| 22 | // | 24 | // |
| 23 | -// The build fails at configuration time if either rule is violated. | 25 | +// 3. Plug-ins (modules carrying the `vibeerp.module-kind=plugin` ext |
| 26 | +// property) may ONLY depend on `:api:api-v1`. They never see | ||
| 27 | +// `:platform:*` or `:pbc:*` internals. | ||
| 28 | +// | ||
| 29 | +// All three rules fail the build at configuration time. | ||
| 30 | +// | ||
| 31 | +// We use an explicit `extra["vibeerp.module-kind"]` marker rather than a | ||
| 32 | +// pathname heuristic so that adding a plug-in under a different directory | ||
| 33 | +// (e.g. `customer-plugins/foo/`) does not silently bypass the rule, and | ||
| 34 | +// so that `:platform:platform-plugins` (which contains the substring | ||
| 35 | +// "plugin-" in its path) is correctly classified as a runtime module, not | ||
| 36 | +// a plug-in. | ||
| 24 | gradle.projectsEvaluated { | 37 | gradle.projectsEvaluated { |
| 25 | rootProject.allprojects.forEach { p -> | 38 | rootProject.allprojects.forEach { p -> |
| 26 | val ownPath: String = p.path | 39 | val ownPath: String = p.path |
| 40 | + val moduleKind: String? = p.extra.properties["vibeerp.module-kind"] as String? | ||
| 27 | 41 | ||
| 28 | val checkedConfigs = listOf("api", "implementation", "compileOnly", "compileClasspath") | 42 | val checkedConfigs = listOf("api", "implementation", "compileOnly", "compileClasspath") |
| 29 | val projectDeps = p.configurations | 43 | val projectDeps = p.configurations |
| @@ -32,10 +46,12 @@ gradle.projectsEvaluated { | @@ -32,10 +46,12 @@ gradle.projectsEvaluated { | ||
| 32 | cfg.dependencies.filterIsInstance<org.gradle.api.artifacts.ProjectDependency>() | 46 | cfg.dependencies.filterIsInstance<org.gradle.api.artifacts.ProjectDependency>() |
| 33 | } | 47 | } |
| 34 | 48 | ||
| 49 | + // Rule 1 & 2 — PBC dependency hygiene | ||
| 35 | if (ownPath.startsWith(":pbc:")) { | 50 | if (ownPath.startsWith(":pbc:")) { |
| 36 | projectDeps.forEach { dep -> | 51 | projectDeps.forEach { dep -> |
| 37 | @Suppress("DEPRECATION") | 52 | @Suppress("DEPRECATION") |
| 38 | val depPath = dep.dependencyProject.path | 53 | val depPath = dep.dependencyProject.path |
| 54 | + | ||
| 39 | if (depPath.startsWith(":pbc:") && depPath != ownPath) { | 55 | if (depPath.startsWith(":pbc:") && depPath != ownPath) { |
| 40 | throw GradleException( | 56 | throw GradleException( |
| 41 | "Architectural violation in $ownPath: depends on $depPath. " + | 57 | "Architectural violation in $ownPath: depends on $depPath. " + |
| @@ -43,12 +59,18 @@ gradle.projectsEvaluated { | @@ -43,12 +59,18 @@ gradle.projectsEvaluated { | ||
| 43 | "interfaces (org.vibeerp.api.v1.ext.<pbc>) or the event bus." | 59 | "interfaces (org.vibeerp.api.v1.ext.<pbc>) or the event bus." |
| 44 | ) | 60 | ) |
| 45 | } | 61 | } |
| 62 | + if (depPath == ":platform:platform-bootstrap") { | ||
| 63 | + throw GradleException( | ||
| 64 | + "Architectural violation in $ownPath: depends on $depPath. " + | ||
| 65 | + "PBCs MUST NOT depend on platform-bootstrap (the application " + | ||
| 66 | + "entry point). Depend on platform runtime modules only." | ||
| 67 | + ) | ||
| 68 | + } | ||
| 46 | } | 69 | } |
| 47 | } | 70 | } |
| 48 | 71 | ||
| 49 | - val isPlugin = ownPath.startsWith(":reference-customer:") || | ||
| 50 | - ownPath.contains("plugin-") | ||
| 51 | - if (isPlugin) { | 72 | + // Rule 3 — Plug-in dependency hygiene (driven by an explicit marker) |
| 73 | + if (moduleKind == "plugin") { | ||
| 52 | projectDeps.forEach { dep -> | 74 | projectDeps.forEach { dep -> |
| 53 | @Suppress("DEPRECATION") | 75 | @Suppress("DEPRECATION") |
| 54 | val depPath = dep.dependencyProject.path | 76 | val depPath = dep.dependencyProject.path |
distribution/src/main/resources/application.yaml
| @@ -22,6 +22,18 @@ spring: | @@ -22,6 +22,18 @@ spring: | ||
| 22 | hibernate: | 22 | hibernate: |
| 23 | ddl-auto: validate | 23 | ddl-auto: validate |
| 24 | open-in-view: false | 24 | open-in-view: false |
| 25 | + # Multi-tenant wall #1 (the application-layer wall): | ||
| 26 | + # Hibernate's DISCRIMINATOR strategy means every query and every save | ||
| 27 | + # is automatically filtered/written with the tenant id from | ||
| 28 | + # `HibernateTenantResolver`, which reads `TenantContext` (set per-request | ||
| 29 | + # by `TenantResolutionFilter`). Wall #2 — Postgres Row-Level Security — | ||
| 30 | + # is enforced by the `RlsTransactionHook` (planned: implementation-plan | ||
| 31 | + # P1.1) once it lands. Both walls are required by CLAUDE.md guardrail #5; | ||
| 32 | + # disabling either is a release-blocker. | ||
| 33 | + properties: | ||
| 34 | + hibernate: | ||
| 35 | + tenant_identifier_resolver: org.vibeerp.platform.persistence.tenancy.HibernateTenantResolver | ||
| 36 | + multiTenancy: DISCRIMINATOR | ||
| 25 | liquibase: | 37 | liquibase: |
| 26 | change-log: classpath:db/changelog/master.xml | 38 | change-log: classpath:db/changelog/master.xml |
| 27 | 39 |
pbc/pbc-identity/build.gradle.kts
| @@ -26,13 +26,19 @@ allOpen { | @@ -26,13 +26,19 @@ allOpen { | ||
| 26 | annotation("jakarta.persistence.Embeddable") | 26 | annotation("jakarta.persistence.Embeddable") |
| 27 | } | 27 | } |
| 28 | 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. | 29 | +// CRITICAL: pbc-identity may depend on api-v1 and platform-* runtime modules |
| 30 | +// (persistence, plugins, i18n, etc.) but NEVER on platform-bootstrap, and | ||
| 31 | +// NEVER on another pbc-*. | ||
| 32 | +// | ||
| 33 | +// • No cross-PBC: enforced by the root build.gradle.kts. | ||
| 34 | +// • No dependency on platform-bootstrap: bootstrap is the application | ||
| 35 | +// entry point that ASSEMBLES PBCs at the top of the stack. Depending | ||
| 36 | +// on it inverts the layering — every change to the HTTP filter or the | ||
| 37 | +// properties bean would force a recompile of every PBC, and a future | ||
| 38 | +// PBC graph would tangle into an unfixable cycle. | ||
| 32 | dependencies { | 39 | dependencies { |
| 33 | api(project(":api:api-v1")) | 40 | api(project(":api:api-v1")) |
| 34 | implementation(project(":platform:platform-persistence")) | 41 | implementation(project(":platform:platform-persistence")) |
| 35 | - implementation(project(":platform:platform-bootstrap")) | ||
| 36 | 42 | ||
| 37 | implementation(libs.kotlin.stdlib) | 43 | implementation(libs.kotlin.stdlib) |
| 38 | implementation(libs.kotlin.reflect) | 44 | implementation(libs.kotlin.reflect) |
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/ext/IdentityApiAdapter.kt
| @@ -6,9 +6,9 @@ import org.vibeerp.api.v1.core.Id | @@ -6,9 +6,9 @@ import org.vibeerp.api.v1.core.Id | ||
| 6 | import org.vibeerp.api.v1.core.TenantId | 6 | import org.vibeerp.api.v1.core.TenantId |
| 7 | import org.vibeerp.api.v1.ext.identity.IdentityApi | 7 | import org.vibeerp.api.v1.ext.identity.IdentityApi |
| 8 | import org.vibeerp.api.v1.ext.identity.UserRef | 8 | import org.vibeerp.api.v1.ext.identity.UserRef |
| 9 | +import org.vibeerp.api.v1.security.PrincipalId | ||
| 9 | import org.vibeerp.pbc.identity.domain.User | 10 | import org.vibeerp.pbc.identity.domain.User |
| 10 | import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository | 11 | import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository |
| 11 | -import java.util.UUID | ||
| 12 | 12 | ||
| 13 | /** | 13 | /** |
| 14 | * Concrete [IdentityApi] implementation. The ONLY thing other PBCs and | 14 | * Concrete [IdentityApi] implementation. The ONLY thing other PBCs and |
| @@ -36,7 +36,7 @@ class IdentityApiAdapter( | @@ -36,7 +36,7 @@ class IdentityApiAdapter( | ||
| 36 | override fun findUserByUsername(username: String): UserRef? = | 36 | override fun findUserByUsername(username: String): UserRef? = |
| 37 | users.findByUsername(username)?.toRef() | 37 | users.findByUsername(username)?.toRef() |
| 38 | 38 | ||
| 39 | - override fun findUserById(id: Id<*>): UserRef? = | 39 | + override fun findUserById(id: PrincipalId): UserRef? = |
| 40 | users.findById(id.value).orElse(null)?.toRef() | 40 | users.findById(id.value).orElse(null)?.toRef() |
| 41 | 41 | ||
| 42 | private fun User.toRef(): UserRef = UserRef( | 42 | private fun User.toRef(): UserRef = UserRef( |
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/HealthController.kt renamed to platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/MetaController.kt
| @@ -20,6 +20,10 @@ class MetaController { | @@ -20,6 +20,10 @@ class MetaController { | ||
| 20 | fun info(): Map<String, Any> = mapOf( | 20 | fun info(): Map<String, Any> = mapOf( |
| 21 | "name" to "vibe-erp", | 21 | "name" to "vibe-erp", |
| 22 | "apiVersion" to "v1", | 22 | "apiVersion" to "v1", |
| 23 | + // TODO(v0.2): read implementationVersion from the Spring Boot | ||
| 24 | + // BuildProperties bean once `springBoot { buildInfo() }` is wired | ||
| 25 | + // up in the distribution module. Hard-coding a fallback here is | ||
| 26 | + // fine for v0.1 but will silently drift from `gradle.properties`. | ||
| 23 | "implementationVersion" to (javaClass.`package`.implementationVersion ?: "0.1.0-SNAPSHOT"), | 27 | "implementationVersion" to (javaClass.`package`.implementationVersion ?: "0.1.0-SNAPSHOT"), |
| 24 | ) | 28 | ) |
| 25 | } | 29 | } |
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt
| @@ -6,6 +6,7 @@ import jakarta.persistence.MappedSuperclass | @@ -6,6 +6,7 @@ import jakarta.persistence.MappedSuperclass | ||
| 6 | import jakarta.persistence.PrePersist | 6 | import jakarta.persistence.PrePersist |
| 7 | import jakarta.persistence.PreUpdate | 7 | import jakarta.persistence.PreUpdate |
| 8 | import jakarta.persistence.Version | 8 | import jakarta.persistence.Version |
| 9 | +import org.hibernate.annotations.TenantId | ||
| 9 | import org.vibeerp.platform.persistence.tenancy.TenantContext | 10 | import org.vibeerp.platform.persistence.tenancy.TenantContext |
| 10 | import java.time.Instant | 11 | import java.time.Instant |
| 11 | import java.util.UUID | 12 | import java.util.UUID |
| @@ -36,6 +37,15 @@ abstract class AuditedJpaEntity { | @@ -36,6 +37,15 @@ abstract class AuditedJpaEntity { | ||
| 36 | @Column(name = "id", updatable = false, nullable = false, columnDefinition = "uuid") | 37 | @Column(name = "id", updatable = false, nullable = false, columnDefinition = "uuid") |
| 37 | open var id: UUID = UUID.randomUUID() | 38 | open var id: UUID = UUID.randomUUID() |
| 38 | 39 | ||
| 40 | + /** | ||
| 41 | + * Tenant discriminator. Hibernate's `@TenantId` makes this column the | ||
| 42 | + * automatic filter for every query and save: the value is sourced from | ||
| 43 | + * `HibernateTenantResolver`, which in turn reads `TenantContext`. PBC | ||
| 44 | + * code MUST NOT write to this property directly — the JPA listener | ||
| 45 | + * fills it in on `@PrePersist`. Attempting to set it to a value other | ||
| 46 | + * than the current tenant before save throws (see [AuditedJpaEntityListener]). | ||
| 47 | + */ | ||
| 48 | + @TenantId | ||
| 39 | @Column(name = "tenant_id", updatable = false, nullable = false, length = 64) | 49 | @Column(name = "tenant_id", updatable = false, nullable = false, length = 64) |
| 40 | open var tenantId: String = "" | 50 | open var tenantId: String = "" |
| 41 | 51 | ||
| @@ -71,6 +81,17 @@ class AuditedJpaEntityListener { | @@ -71,6 +81,17 @@ class AuditedJpaEntityListener { | ||
| 71 | val now = Instant.now() | 81 | val now = Instant.now() |
| 72 | val tenant = TenantContext.currentOrNull() | 82 | val tenant = TenantContext.currentOrNull() |
| 73 | ?: error("Cannot persist ${entity::class.simpleName}: no tenant in context") | 83 | ?: error("Cannot persist ${entity::class.simpleName}: no tenant in context") |
| 84 | + | ||
| 85 | + // Defense against the "buggy caller pre-set tenantId to the wrong | ||
| 86 | + // value" failure mode flagged in the v0.1 acceptance review. Silent | ||
| 87 | + // overwrite would mask a real bug; throwing here surfaces it. | ||
| 88 | + if (entity.tenantId.isNotBlank() && entity.tenantId != tenant.value) { | ||
| 89 | + error( | ||
| 90 | + "Cannot persist ${entity::class.simpleName}: entity tenantId='${entity.tenantId}' " + | ||
| 91 | + "but current TenantContext is '${tenant.value}'. Cross-tenant write attempt?" | ||
| 92 | + ) | ||
| 93 | + } | ||
| 94 | + | ||
| 74 | val principal = currentPrincipal() | 95 | val principal = currentPrincipal() |
| 75 | entity.tenantId = tenant.value | 96 | entity.tenantId = tenant.value |
| 76 | entity.createdAt = now | 97 | entity.createdAt = now |
reference-customer/plugin-printing-shop/build.gradle.kts
| @@ -4,6 +4,10 @@ plugins { | @@ -4,6 +4,10 @@ plugins { | ||
| 4 | 4 | ||
| 5 | description = "vibe_erp reference plug-in: a hello-world printing-shop customer expressed entirely through api.v1." | 5 | description = "vibe_erp reference plug-in: a hello-world printing-shop customer expressed entirely through api.v1." |
| 6 | 6 | ||
| 7 | +// Marker that the root build.gradle.kts uses to apply the plug-in dependency | ||
| 8 | +// rule (no `:platform:*`, no `:pbc:*`, only `:api:api-v1`). | ||
| 9 | +extra["vibeerp.module-kind"] = "plugin" | ||
| 10 | + | ||
| 7 | java { | 11 | java { |
| 8 | toolchain { | 12 | toolchain { |
| 9 | languageVersion.set(JavaLanguageVersion.of(21)) | 13 | languageVersion.set(JavaLanguageVersion.of(21)) |