Commit 4879fd7d866642ef208b56ca220f988c68badb89
1 parent
28e1aa38
refactor: rip out multi-tenancy, vibe_erp is now single-tenant per instance
Design change: vibe_erp deliberately does NOT support multiple companies in one process. Each running instance serves exactly one company against an isolated Postgres database. Hosting many customers means provisioning many independent instances, not multiplexing them. Why: most ERP/EBC customers will not accept a SaaS where their data shares a database with other companies. The single-tenant-per-instance model is what the user actually wants the product to look like, and it dramatically simplifies the framework. What changed: - CLAUDE.md guardrail #5 rewritten from "multi-tenant from day one" to "single-tenant per instance, isolated database" - api.v1: removed TenantId value class entirely; removed tenantId from Entity, AuditedEntity, Principal, DomainEvent, RequestContext, TaskContext, IdentityApi.UserRef, Repository - platform-persistence: deleted TenantContext, HibernateTenantResolver, TenantAwareJpaTransactionManager, TenancyJpaConfiguration; removed @TenantId and tenant_id column from AuditedJpaEntity - platform-bootstrap: deleted TenantResolutionFilter; dropped vibeerp.instance.mode and default-tenant from properties; added vibeerp.instance.company-name; added VibeErpApplication @EnableJpaRepositories and @EntityScan so PBC repositories outside the main package are wired; added GlobalExceptionHandler that maps IllegalArgumentException → 400 and NoSuchElementException → 404 (RFC 7807 ProblemDetail) - pbc-identity: removed tenant_id from User, repository, controller, DTOs, IdentityApiAdapter; updated UserService duplicate-username message and the matching test - distribution: dropped multiTenancy=DISCRIMINATOR and tenant_identifier_resolver from application.yaml; configured Spring Boot mainClass on the springBoot extension (not just bootJar) so bootRun works - Liquibase: rewrote platform-init changelog to drop platform__tenant and the tenant_id columns on every metadata__* table; rewrote pbc-identity init to drop tenant_id columns, the (tenant_id, *) composite indexes, and the per-table RLS policies - IdentifiersTest replaced with Id<T> tests since the TenantId tests no longer apply Verified end-to-end against a real Postgres via docker-compose: POST /api/v1/identity/users → 201 Created GET /api/v1/identity/users → list works GET /api/v1/identity/users/X → fetch by id works POST duplicate username → 400 Bad Request (was 500) PATCH bogus id → 404 Not Found (was 500) PATCH alice → 200 OK DELETE alice → 204, alice now disabled All 18 unit tests pass.
Showing
29 changed files
with
353 additions
and
717 deletions
CLAUDE.md
| ... | ... | @@ -19,7 +19,7 @@ These rules exist because the whole point of the project is reusability across c |
| 19 | 19 | 2. **Workflows are data, not code.** State machines, approval chains, document routing, and form definitions must be declarative (config / DB / DSL) so a new customer can be onboarded by editing definitions, not by editing source. |
| 20 | 20 | 3. **Extensibility seams come first.** Before adding any feature, identify the extension point (hook, plug-in interface, event, custom field, scripting hook) it should hang off of. If no seam exists yet, design the seam first, then implement the reference customer's behavior on top of it. |
| 21 | 21 | 4. **The reference customer is a test, not a requirement.** When implementing something inspired by `raw/业务流程设计文档/`, ask: "Could a different printing shop with different rules also be expressed here without code changes?" If no, redesign. |
| 22 | -5. **Multi-tenant from day one in spirit.** Even if deployment is single-tenant, data models and APIs should not assume one company, one workflow, or one form layout. | |
| 22 | +5. **Single-tenant per instance, isolated database.** vibe_erp is deliberately NOT multi-tenant. One running instance serves exactly one company against an isolated Postgres database. Hosting many customers means provisioning many independent instances (each with its own DB), not multiplexing them in one process. There are no `tenant_id` columns, no row-level tenant filtering, no Row-Level Security, no `TenantContext`, and no per-request tenant resolution anywhere in the framework. Customer isolation is a deployment concern, not a code concern. Data models and APIs may freely assume "one company, one process, one DB" — but they must still allow per-customer customization through configuration, plug-ins, and metadata so the SAME image runs for any customer. | |
| 23 | 23 | 6. **Global / i18n from day one.** This codebase is the foundation of an ERP/EBC product intended to be sold worldwide. No hard-coded user-facing strings, no hard-coded currency, date format, time zone, number format, address shape, tax model, or language. All such concerns must go through localization, formatting, and configuration layers — even in the very first prototype. The reference docs being in Chinese does **not** mean Chinese is the primary or default locale; it is just one supported locale among many. |
| 24 | 24 | 7. **Clean Core.** Borrowed from SAP S/4HANA's vocabulary: extensions **never modify the core**. The core is stable, generic, and upgrade-safe; everything customer-specific lives in plug-ins or in metadata rows. Extensions are graded A/B/C/D by upgrade safety: |
| 25 | 25 | - **A** = Tier-1 metadata only (custom fields, forms, workflows, rules) — always upgrade-safe |
| ... | ... | @@ -55,7 +55,7 @@ The architecture has been brainstormed, validated against 2026 ERP/EBC SOTA, and |
| 55 | 55 | - **Backend:** Kotlin on the JVM, Spring Boot, single fat-JAR / single Docker image |
| 56 | 56 | - **Workflow engine:** Embedded Flowable (BPMN 2.0) |
| 57 | 57 | - **Persistence:** PostgreSQL (the only mandatory external dependency) |
| 58 | -- **Multi-tenancy:** row-level `tenant_id` + Postgres Row-Level Security (defense in depth, same code path for self-host and hosted) | |
| 58 | +- **Multi-tenancy:** intentionally none. Single-tenant per instance, one isolated Postgres database per customer (see guardrail #5) | |
| 59 | 59 | - **Custom fields:** JSONB `ext` column on every business table, described by `metadata__custom_field` rows; GIN-indexed |
| 60 | 60 | - **Plug-in framework:** PF4J + Spring Boot child contexts (classloader isolation per plug-in) |
| 61 | 61 | - **i18n:** ICU MessageFormat (ICU4J) + Spring `MessageSource` | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/core/Identifiers.kt
| ... | ... | @@ -6,10 +6,12 @@ import java.util.UUID |
| 6 | 6 | * Strongly-typed identifier wrapping a UUID. |
| 7 | 7 | * |
| 8 | 8 | * Using `Id<User>` instead of raw `UUID` everywhere prevents the classic |
| 9 | - * mistake of passing a tenant id where a user id was expected — the compiler | |
| 10 | - * catches it. The wrapper is a value class so it has zero runtime overhead. | |
| 9 | + * mistake of passing one entity's id where another was expected — the | |
| 10 | + * compiler catches it. The wrapper is a value class so it has zero | |
| 11 | + * runtime overhead. | |
| 11 | 12 | * |
| 12 | - * @param T phantom type parameter that names what kind of entity this id refers to. | |
| 13 | + * @param T phantom type parameter that names what kind of entity this id | |
| 14 | + * refers to. | |
| 13 | 15 | */ |
| 14 | 16 | @JvmInline |
| 15 | 17 | value class Id<T>(val value: UUID) { |
| ... | ... | @@ -20,27 +22,3 @@ value class Id<T>(val value: UUID) { |
| 20 | 22 | fun <T> random(): Id<T> = Id(UUID.randomUUID()) |
| 21 | 23 | } |
| 22 | 24 | } |
| 23 | - | |
| 24 | -/** | |
| 25 | - * A tenant identifier. | |
| 26 | - * | |
| 27 | - * Self-hosted single-customer deployments use a single tenant whose id is the | |
| 28 | - * literal string "default". Hosted multi-tenant deployments have one [TenantId] | |
| 29 | - * per customer organization. | |
| 30 | - */ | |
| 31 | -@JvmInline | |
| 32 | -value class TenantId(val value: String) { | |
| 33 | - init { | |
| 34 | - require(value.isNotBlank()) { "TenantId must not be blank" } | |
| 35 | - require(value.length <= 64) { "TenantId must be at most 64 characters" } | |
| 36 | - require(value.all { it.isLetterOrDigit() || it == '-' || it == '_' }) { | |
| 37 | - "TenantId may only contain letters, digits, '-', or '_' (got '$value')" | |
| 38 | - } | |
| 39 | - } | |
| 40 | - | |
| 41 | - override fun toString(): String = value | |
| 42 | - | |
| 43 | - companion object { | |
| 44 | - val DEFAULT = TenantId("default") | |
| 45 | - } | |
| 46 | -} | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/Entity.kt
| 1 | 1 | package org.vibeerp.api.v1.entity |
| 2 | 2 | |
| 3 | 3 | import org.vibeerp.api.v1.core.Id |
| 4 | -import org.vibeerp.api.v1.core.TenantId | |
| 5 | 4 | |
| 6 | 5 | /** |
| 7 | 6 | * Marker interface for every domain entity that lives in the vibe_erp data model. |
| 8 | 7 | * |
| 9 | 8 | * Why this exists at all: |
| 10 | - * - It enforces, at the type level, that every entity carries a tenant id. | |
| 11 | - * Multi-tenancy is an architectural guardrail (CLAUDE.md #5) and the cheapest | |
| 12 | - * place to enforce it is the type system. A repository signature like | |
| 13 | - * `Repository<T : Entity>` cannot be instantiated for a type that "forgot" | |
| 14 | - * its tenant id. | |
| 15 | - * - It gives the runtime a single hook for tenant filtering, JSONB `ext` | |
| 16 | - * handling, and audit interception without each PBC reinventing it. | |
| 9 | + * - It gives the runtime a single hook for JSONB `ext` handling, audit | |
| 10 | + * interception, and metadata-driven custom fields without each PBC | |
| 11 | + * reinventing it. | |
| 17 | 12 | * - It is intentionally minimal: no equality, no hashCode, no lifecycle |
| 18 | 13 | * callbacks. Those are concerns of `platform.persistence`, not the public |
| 19 | 14 | * contract. Adding them later is a breaking change; leaving them out is not. |
| 20 | 15 | * |
| 16 | + * **Single-tenant per instance.** vibe_erp is deliberately single-tenant per | |
| 17 | + * deployment: one running instance serves exactly one company against an | |
| 18 | + * isolated database. There is no `tenantId` field on this interface and no | |
| 19 | + * tenant filtering anywhere in the framework. Hosting many customers means | |
| 20 | + * provisioning many independent instances, not multiplexing them in one | |
| 21 | + * process. See CLAUDE.md guardrail #5 for the rationale. | |
| 22 | + * | |
| 21 | 23 | * Plug-ins implement this on their own data classes. They never see the |
| 22 | 24 | * underlying JPA `@Entity` annotation — that lives in the platform's internal |
| 23 | 25 | * mapping layer. |
| ... | ... | @@ -25,7 +27,6 @@ import org.vibeerp.api.v1.core.TenantId |
| 25 | 27 | * ``` |
| 26 | 28 | * data class Plate( |
| 27 | 29 | * override val id: Id<Plate>, |
| 28 | - * override val tenantId: TenantId, | |
| 29 | 30 | * val gauge: Quantity, |
| 30 | 31 | * ) : Entity |
| 31 | 32 | * ``` |
| ... | ... | @@ -39,12 +40,4 @@ interface Entity { |
| 39 | 40 | * parameter to themselves. |
| 40 | 41 | */ |
| 41 | 42 | val id: Id<*> |
| 42 | - | |
| 43 | - /** | |
| 44 | - * The tenant that owns this row. Required on every business entity, even in | |
| 45 | - * single-tenant self-hosted deployments (where the value is | |
| 46 | - * [TenantId.DEFAULT]) — there is intentionally one code path for both | |
| 47 | - * deployment shapes. | |
| 48 | - */ | |
| 49 | - val tenantId: TenantId | |
| 50 | 43 | } | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/event/DomainEvent.kt
| 1 | 1 | package org.vibeerp.api.v1.event |
| 2 | 2 | |
| 3 | 3 | import org.vibeerp.api.v1.core.Id |
| 4 | -import org.vibeerp.api.v1.core.TenantId | |
| 5 | 4 | import java.time.Instant |
| 6 | 5 | |
| 7 | 6 | /** |
| ... | ... | @@ -12,21 +11,23 @@ import java.time.Instant |
| 12 | 11 | * other only through events or through `api.v1.ext.<pbc>` interfaces. If |
| 13 | 12 | * events were untyped, every consumer would have to know every producer's |
| 14 | 13 | * class, which is exactly the coupling we are trying to avoid. |
| 15 | - * - The standard envelope (`eventId`, `tenantId`, `occurredAt`, | |
| 16 | - * `aggregateType`, `aggregateId`) is what the platform's outbox table, | |
| 17 | - * audit log, and future Kafka/NATS bridge all rely on. Plug-ins that | |
| 18 | - * forget any of these fields cannot misroute or replay events. | |
| 14 | + * - The standard envelope (`eventId`, `occurredAt`, `aggregateType`, | |
| 15 | + * `aggregateId`) is what the platform's outbox table, audit log, and | |
| 16 | + * future Kafka/NATS bridge all rely on. Plug-ins that forget any of these | |
| 17 | + * fields cannot misroute or replay events. | |
| 18 | + * | |
| 19 | + * **No tenant id.** vibe_erp is single-tenant per instance — the entire | |
| 20 | + * process belongs to one company — so events do not need to carry a tenant | |
| 21 | + * discriminator. The hosting environment (Postgres database, Kafka cluster, | |
| 22 | + * future event archive) is itself per-instance. | |
| 19 | 23 | * |
| 20 | 24 | * Note that this is `interface`, not `sealed interface`: plug-ins must be |
| 21 | 25 | * free to *extend* the hierarchy with their own concrete event classes. |
| 22 | - * "Sealed" here means "every plug-in event must extend this sealed envelope", | |
| 23 | - * not "the set of envelope shapes is closed". A truly closed hierarchy would | |
| 24 | - * make plug-ins impossible. | |
| 26 | + * A truly closed hierarchy would make plug-ins impossible. | |
| 25 | 27 | * |
| 26 | 28 | * ``` |
| 27 | 29 | * data class PlateApproved( |
| 28 | 30 | * override val eventId: Id<DomainEvent> = Id.random(), |
| 29 | - * override val tenantId: TenantId, | |
| 30 | 31 | * override val occurredAt: Instant = Instant.now(), |
| 31 | 32 | * val plateId: Id<*>, |
| 32 | 33 | * ) : DomainEvent { |
| ... | ... | @@ -40,9 +41,6 @@ interface DomainEvent { |
| 40 | 41 | /** Unique id for this event instance. Used for idempotent consumer logic and outbox dedup. */ |
| 41 | 42 | val eventId: Id<DomainEvent> |
| 42 | 43 | |
| 43 | - /** Tenant the event belongs to. Carried explicitly because background consumers run outside any HTTP request. */ | |
| 44 | - val tenantId: TenantId | |
| 45 | - | |
| 46 | 44 | /** When the event occurred (UTC). Set by the producer, not by the bus, so replays preserve original timing. */ |
| 47 | 45 | val occurredAt: Instant |
| 48 | 46 | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/identity/IdentityApi.kt
| 1 | 1 | package org.vibeerp.api.v1.ext.identity |
| 2 | 2 | |
| 3 | 3 | import org.vibeerp.api.v1.core.Id |
| 4 | -import org.vibeerp.api.v1.core.TenantId | |
| 5 | 4 | import org.vibeerp.api.v1.security.PrincipalId |
| 6 | 5 | |
| 7 | 6 | /** |
| ... | ... | @@ -26,9 +25,9 @@ import org.vibeerp.api.v1.security.PrincipalId |
| 26 | 25 | interface IdentityApi { |
| 27 | 26 | |
| 28 | 27 | /** |
| 29 | - * Look up a user by their unique username within the current tenant. | |
| 30 | - * Returns `null` when no such user exists or when the calling principal | |
| 31 | - * lacks permission to read user records. | |
| 28 | + * Look up a user by their unique username. Returns `null` when no such | |
| 29 | + * user exists or when the calling principal lacks permission to read | |
| 30 | + * user records. | |
| 32 | 31 | */ |
| 33 | 32 | fun findUserByUsername(username: String): UserRef? |
| 34 | 33 | |
| ... | ... | @@ -50,8 +49,7 @@ interface IdentityApi { |
| 50 | 49 | * a deliberate api.v1 change and triggers a security review. |
| 51 | 50 | * |
| 52 | 51 | * @property id Stable user id. |
| 53 | - * @property username Login handle. Unique within [tenantId]. | |
| 54 | - * @property tenantId Owning tenant. | |
| 52 | + * @property username Login handle. Unique across the instance. | |
| 55 | 53 | * @property displayName Human-readable name for UI rendering. Falls back to |
| 56 | 54 | * [username] when the user has not set one. |
| 57 | 55 | * @property enabled Whether the account is active. A disabled user can still |
| ... | ... | @@ -61,7 +59,6 @@ interface IdentityApi { |
| 61 | 59 | data class UserRef( |
| 62 | 60 | val id: Id<UserRef>, |
| 63 | 61 | val username: String, |
| 64 | - val tenantId: TenantId, | |
| 65 | 62 | val displayName: String, |
| 66 | 63 | val enabled: Boolean, |
| 67 | 64 | ) | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/http/RequestContext.kt
| 1 | 1 | package org.vibeerp.api.v1.http |
| 2 | 2 | |
| 3 | -import org.vibeerp.api.v1.core.TenantId | |
| 4 | 3 | import org.vibeerp.api.v1.security.Principal |
| 5 | 4 | import java.util.Locale |
| 6 | 5 | |
| ... | ... | @@ -13,10 +12,15 @@ import java.util.Locale |
| 13 | 12 | * inside api.v1 would have to bridge classloaders to find the host's |
| 14 | 13 | * state. An injected context is cleaner, testable, and forces the |
| 15 | 14 | * plug-in author to declare a dependency on the values they use. |
| 16 | - * - It is the single answer to "who, where, when, which language, which | |
| 17 | - * request" that every other api.v1 surface (permission check, translator, | |
| 15 | + * - It is the single answer to "who, when, which language, which request" | |
| 16 | + * that every other api.v1 surface (permission check, translator, | |
| 18 | 17 | * repository, event bus) consults under the covers. |
| 19 | 18 | * |
| 19 | + * **No tenant id.** vibe_erp is single-tenant per instance — the entire | |
| 20 | + * process belongs to one company — so the request context does not need to | |
| 21 | + * carry a tenant. The instance's company identity comes from configuration | |
| 22 | + * (`vibeerp.instance.company-name`) and is global to the process. | |
| 23 | + * | |
| 20 | 24 | * The host provides one [RequestContext] per HTTP request and per workflow |
| 21 | 25 | * task execution. Background jobs receive a context whose [principal] is a |
| 22 | 26 | * [org.vibeerp.api.v1.security.Principal.System] and whose [requestId] is |
| ... | ... | @@ -27,14 +31,6 @@ interface RequestContext { |
| 27 | 31 | /** The authenticated caller. Never `null`; anonymous endpoints get a system principal. */ |
| 28 | 32 | fun principal(): Principal |
| 29 | 33 | |
| 30 | - /** | |
| 31 | - * The tenant the request is operating in. Always equal to | |
| 32 | - * `principal().tenantId` in normal flows, but separated for clarity in | |
| 33 | - * "act as" / impersonation scenarios where the two could legitimately | |
| 34 | - * differ. | |
| 35 | - */ | |
| 36 | - fun tenantId(): TenantId | |
| 37 | - | |
| 38 | 34 | /** The resolved request locale. Same value [org.vibeerp.api.v1.i18n.LocaleProvider.current] would return. */ |
| 39 | 35 | fun locale(): Locale |
| 40 | 36 | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/persistence/Repository.kt
| ... | ... | @@ -4,20 +4,21 @@ import org.vibeerp.api.v1.core.Id |
| 4 | 4 | import org.vibeerp.api.v1.entity.Entity |
| 5 | 5 | |
| 6 | 6 | /** |
| 7 | - * Generic, tenant-scoped repository contract for any [Entity]. | |
| 7 | + * Generic repository contract for any [Entity]. | |
| 8 | 8 | * |
| 9 | 9 | * Why this lives in api.v1: |
| 10 | 10 | * - Plug-ins must be able to persist their own entities without depending on |
| 11 | 11 | * Spring Data, JPA, or any platform internal class. The platform binds this |
| 12 | 12 | * interface to a Hibernate-backed implementation per entity at startup. |
| 13 | - * - It is the cleanest place to enforce the tenant guarantee: **none of these | |
| 14 | - * methods accept a `tenantId`**. The tenant is resolved from the ambient | |
| 15 | - * [org.vibeerp.api.v1.http.RequestContext] (or, in background jobs, from the | |
| 16 | - * job's tenant binding) by the platform. A plug-in cannot read or write | |
| 17 | - * cross-tenant data through this contract — that is the whole point. | |
| 18 | 13 | * - It is generic on `T : Entity` so the type system rejects "wrong-entity" |
| 19 | 14 | * mistakes at compile time. |
| 20 | 15 | * |
| 16 | + * **Single-tenant per instance.** vibe_erp does no tenant filtering anywhere | |
| 17 | + * in the framework — the entire process serves one company against one | |
| 18 | + * isolated database. Customer isolation happens at the *deployment* level | |
| 19 | + * (one running instance per customer), not at the row level. See CLAUDE.md | |
| 20 | + * guardrail #5. | |
| 21 | + * | |
| 21 | 22 | * Methods are deliberately few. Anything more sophisticated (joins, |
| 22 | 23 | * projections, native SQL) is a smell at this layer — model the use case as a |
| 23 | 24 | * domain service or a query object instead. Adding methods later (with default |
| ... | ... | @@ -26,9 +27,7 @@ import org.vibeerp.api.v1.entity.Entity |
| 26 | 27 | interface Repository<T : Entity> { |
| 27 | 28 | |
| 28 | 29 | /** |
| 29 | - * Find by primary key. Returns `null` when no row exists for the current | |
| 30 | - * tenant — even if a row with that id exists for *another* tenant. Cross- | |
| 31 | - * tenant reads are unobservable through this API. | |
| 30 | + * Find by primary key. Returns `null` when no row exists. | |
| 32 | 31 | */ |
| 33 | 32 | fun findById(id: Id<T>): T? |
| 34 | 33 | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/security/Principal.kt
| 1 | 1 | package org.vibeerp.api.v1.security |
| 2 | 2 | |
| 3 | 3 | import org.vibeerp.api.v1.core.Id |
| 4 | -import org.vibeerp.api.v1.core.TenantId | |
| 5 | 4 | |
| 6 | 5 | /** |
| 7 | 6 | * Type alias for a principal identifier. |
| 8 | 7 | * |
| 9 | - * Distinct phantom-typed alias of [Id] so signatures like `createdBy: PrincipalId` | |
| 10 | - * are self-documenting in IDE hovers and so the compiler refuses to mix a | |
| 11 | - * `PrincipalId` with, say, an `Id<Order>`. | |
| 8 | + * Distinct phantom-typed alias of [Id] so signatures like | |
| 9 | + * `createdBy: PrincipalId` are self-documenting in IDE hovers and so the | |
| 10 | + * compiler refuses to mix a `PrincipalId` with, say, an `Id<Order>`. | |
| 12 | 11 | */ |
| 13 | 12 | typealias PrincipalId = Id<Principal> |
| 14 | 13 | |
| ... | ... | @@ -28,6 +27,12 @@ typealias PrincipalId = Id<Principal> |
| 28 | 27 | * plug-in-to-plug-in calls and for bookkeeping rows the plug-in writes |
| 29 | 28 | * without a user request (e.g. a periodic sync from an external system). |
| 30 | 29 | * |
| 30 | + * **Single-tenant per instance.** Principals do NOT carry a tenant id — | |
| 31 | + * the entire vibe_erp instance is owned by exactly one company, so there | |
| 32 | + * is no tenant for the principal to belong to. The company identity comes | |
| 33 | + * from configuration (`vibeerp.instance.company-name`) and is the same for | |
| 34 | + * every principal in the process. | |
| 35 | + * | |
| 31 | 36 | * Sealed because the framework needs to switch on the full set in audit and |
| 32 | 37 | * permission code; new principal kinds are deliberate api.v1 changes. |
| 33 | 38 | */ |
| ... | ... | @@ -37,20 +42,11 @@ sealed interface Principal { |
| 37 | 42 | val id: PrincipalId |
| 38 | 43 | |
| 39 | 44 | /** |
| 40 | - * The tenant the principal is acting in. A [User] always has one. A | |
| 41 | - * [System] principal uses [TenantId.DEFAULT] for global jobs and a | |
| 42 | - * specific tenant for per-tenant jobs. A [PluginPrincipal] uses the | |
| 43 | - * tenant of the operation it is performing. | |
| 44 | - */ | |
| 45 | - val tenantId: TenantId | |
| 46 | - | |
| 47 | - /** | |
| 48 | 45 | * A real human user (or an AI agent acting on a human's behalf, which the |
| 49 | 46 | * framework intentionally does not distinguish at the principal level). |
| 50 | 47 | */ |
| 51 | 48 | data class User( |
| 52 | 49 | override val id: PrincipalId, |
| 53 | - override val tenantId: TenantId, | |
| 54 | 50 | val username: String, |
| 55 | 51 | ) : Principal { |
| 56 | 52 | init { |
| ... | ... | @@ -64,7 +60,6 @@ sealed interface Principal { |
| 64 | 60 | */ |
| 65 | 61 | data class System( |
| 66 | 62 | override val id: PrincipalId, |
| 67 | - override val tenantId: TenantId, | |
| 68 | 63 | val name: String, |
| 69 | 64 | ) : Principal { |
| 70 | 65 | init { |
| ... | ... | @@ -75,7 +70,6 @@ sealed interface Principal { |
| 75 | 70 | /** A plug-in acting under its own identity. */ |
| 76 | 71 | data class PluginPrincipal( |
| 77 | 72 | override val id: PrincipalId, |
| 78 | - override val tenantId: TenantId, | |
| 79 | 73 | val pluginId: String, |
| 80 | 74 | ) : Principal { |
| 81 | 75 | init { | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/workflow/TaskHandler.kt
| 1 | 1 | package org.vibeerp.api.v1.workflow |
| 2 | 2 | |
| 3 | -import org.vibeerp.api.v1.core.TenantId | |
| 4 | 3 | import org.vibeerp.api.v1.security.Principal |
| 5 | 4 | import java.util.Locale |
| 6 | 5 | |
| ... | ... | @@ -71,17 +70,6 @@ interface TaskContext { |
| 71 | 70 | fun set(name: String, value: Any?) |
| 72 | 71 | |
| 73 | 72 | /** |
| 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 | 73 | * The principal whose action triggered this task instance. For |
| 86 | 74 | * automatically scheduled tasks (timer-driven, signal-driven), this |
| 87 | 75 | * is a system principal — never null. | ... | ... |
api/api-v1/src/test/kotlin/org/vibeerp/api/v1/core/IdentifiersTest.kt
| ... | ... | @@ -3,40 +3,46 @@ package org.vibeerp.api.v1.core |
| 3 | 3 | import assertk.assertFailure |
| 4 | 4 | import assertk.assertThat |
| 5 | 5 | import assertk.assertions.isEqualTo |
| 6 | +import assertk.assertions.isNotEqualTo | |
| 6 | 7 | import org.junit.jupiter.api.Test |
| 8 | +import java.util.UUID | |
| 7 | 9 | |
| 10 | +/** Phantom-typed [Id] sanity checks. */ | |
| 8 | 11 | class IdentifiersTest { |
| 9 | 12 | |
| 10 | - @Test | |
| 11 | - fun `TenantId rejects blank`() { | |
| 12 | - assertFailure { TenantId("") } | |
| 13 | - assertFailure { TenantId(" ") } | |
| 14 | - } | |
| 13 | + private class Foo | |
| 14 | + private class Bar | |
| 15 | 15 | |
| 16 | 16 | @Test |
| 17 | - fun `TenantId rejects too-long values`() { | |
| 18 | - val tooLong = "a".repeat(65) | |
| 19 | - assertFailure { TenantId(tooLong) } | |
| 17 | + fun `Id of valid uuid string round-trips`() { | |
| 18 | + val raw = "11111111-2222-3333-4444-555555555555" | |
| 19 | + val id: Id<Foo> = Id.of(raw) | |
| 20 | + assertThat(id.value).isEqualTo(UUID.fromString(raw)) | |
| 21 | + assertThat(id.toString()).isEqualTo(raw) | |
| 20 | 22 | } |
| 21 | 23 | |
| 22 | 24 | @Test |
| 23 | - fun `TenantId rejects invalid characters`() { | |
| 24 | - assertFailure { TenantId("acme corp") } // space | |
| 25 | - assertFailure { TenantId("acme.corp") } // dot | |
| 26 | - assertFailure { TenantId("acme/corp") } // slash | |
| 27 | - assertFailure { TenantId("acme!") } // punctuation | |
| 25 | + fun `Id of invalid uuid string fails`() { | |
| 26 | + assertFailure { Id.of<Foo>("not-a-uuid") } | |
| 28 | 27 | } |
| 29 | 28 | |
| 30 | 29 | @Test |
| 31 | - fun `TenantId accepts the default tenant id`() { | |
| 32 | - assertThat(TenantId("default").value).isEqualTo("default") | |
| 33 | - assertThat(TenantId.DEFAULT.value).isEqualTo("default") | |
| 30 | + fun `Id random produces distinct ids`() { | |
| 31 | + val a = Id.random<Foo>() | |
| 32 | + val b = Id.random<Foo>() | |
| 33 | + assertThat(a).isNotEqualTo(b) | |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | 36 | @Test |
| 37 | - fun `TenantId accepts hyphens digits and underscores`() { | |
| 38 | - assertThat(TenantId("tenant-1").value).isEqualTo("tenant-1") | |
| 39 | - assertThat(TenantId("acme_2").value).isEqualTo("acme_2") | |
| 40 | - assertThat(TenantId("ACME").value).isEqualTo("ACME") | |
| 37 | + fun `Id of two different phantom types are still equal when their UUIDs match`() { | |
| 38 | + // The phantom parameter is erased at runtime; equality is by UUID. | |
| 39 | + // This test exists to document that the type-safety of `Id<T>` is a | |
| 40 | + // *compile-time* guarantee, not a runtime one. The compiler refuses | |
| 41 | + // to mix `Id<Foo>` with `Id<Bar>`, but reflection or unchecked casts | |
| 42 | + // can still smuggle them — value classes do not box at runtime. | |
| 43 | + val uuid = UUID.randomUUID() | |
| 44 | + val a = Id<Foo>(uuid) | |
| 45 | + val b = Id<Bar>(uuid) | |
| 46 | + assertThat(a.value).isEqualTo(b.value) | |
| 41 | 47 | } |
| 42 | 48 | } | ... | ... |
distribution/build.gradle.kts
| ... | ... | @@ -37,10 +37,17 @@ dependencies { |
| 37 | 37 | testImplementation(libs.spring.boot.starter.test) |
| 38 | 38 | } |
| 39 | 39 | |
| 40 | +// Configure Spring Boot's main class once, on the `springBoot` extension. | |
| 41 | +// This is the only place that drives BOTH bootJar and bootRun — setting | |
| 42 | +// mainClass on `tasks.bootJar` alone leaves `bootRun` unable to resolve | |
| 43 | +// the entry point because :distribution has no Kotlin sources of its own. | |
| 44 | +springBoot { | |
| 45 | + mainClass.set("org.vibeerp.platform.bootstrap.VibeErpApplicationKt") | |
| 46 | +} | |
| 47 | + | |
| 40 | 48 | // The fat-jar produced here is what the Dockerfile copies into the |
| 41 | 49 | // runtime image as /app/vibe-erp.jar. |
| 42 | 50 | tasks.bootJar { |
| 43 | - mainClass.set("org.vibeerp.platform.bootstrap.VibeErpApplicationKt") | |
| 44 | 51 | archiveFileName.set("vibe-erp.jar") |
| 45 | 52 | } |
| 46 | 53 | ... | ... |
distribution/src/main/resources/application.yaml
| ... | ... | @@ -8,6 +8,10 @@ |
| 8 | 8 | # Customer overrides live in /opt/vibe-erp/config/vibe-erp.yaml on the |
| 9 | 9 | # mounted volume. Plug-in configuration lives in metadata__plugin_config, |
| 10 | 10 | # never here. |
| 11 | +# | |
| 12 | +# vibe_erp is single-tenant per instance: one running process serves | |
| 13 | +# exactly one company against an isolated database. There is no | |
| 14 | +# multi-tenancy configuration here, by design. | |
| 11 | 15 | |
| 12 | 16 | spring: |
| 13 | 17 | application: |
| ... | ... | @@ -22,18 +26,6 @@ spring: |
| 22 | 26 | hibernate: |
| 23 | 27 | ddl-auto: validate |
| 24 | 28 | 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 | |
| 37 | 29 | liquibase: |
| 38 | 30 | change-log: classpath:db/changelog/master.xml |
| 39 | 31 | |
| ... | ... | @@ -49,8 +41,11 @@ management: |
| 49 | 41 | |
| 50 | 42 | vibeerp: |
| 51 | 43 | instance: |
| 52 | - mode: ${VIBEERP_INSTANCE_MODE:self-hosted} | |
| 53 | - default-tenant: ${VIBEERP_DEFAULT_TENANT:default} | |
| 44 | + # Display name of the company that owns this instance. Shown in branding, | |
| 45 | + # report headers, and audit logs. There is exactly ONE company per | |
| 46 | + # running instance — provisioning a second customer means deploying a | |
| 47 | + # second instance with its own database. | |
| 48 | + company-name: ${VIBEERP_COMPANY_NAME:vibe_erp instance} | |
| 54 | 49 | plugins: |
| 55 | 50 | directory: ${VIBEERP_PLUGINS_DIR:/opt/vibe-erp/plugins} |
| 56 | 51 | auto-load: true | ... | ... |
distribution/src/main/resources/db/changelog/pbc-identity/001-identity-init.xml
| ... | ... | @@ -9,23 +9,17 @@ |
| 9 | 9 | |
| 10 | 10 | Owns: identity__user, identity__role, identity__user_role. |
| 11 | 11 | |
| 12 | + vibe_erp is single-tenant per instance: one running process serves | |
| 13 | + exactly one company against an isolated database. There are no | |
| 14 | + tenant_id columns and no Row-Level Security policies on these | |
| 15 | + tables — customer isolation happens at the deployment level. | |
| 16 | + | |
| 12 | 17 | Conventions enforced for every business table in vibe_erp: |
| 13 | 18 | • UUID primary key |
| 14 | - • tenant_id varchar(64) NOT NULL | |
| 15 | 19 | • Audit columns: created_at, created_by, updated_at, updated_by |
| 16 | 20 | • Optimistic-locking version column |
| 17 | 21 | • ext jsonb NOT NULL DEFAULT '{}' for key-user custom fields |
| 18 | 22 | • GIN index on ext for fast custom-field queries |
| 19 | - • Composite index on (tenant_id, ...) for tenant-scoped lookups | |
| 20 | - • Postgres Row-Level Security enabled, with a simple per-tenant | |
| 21 | - policy bound to the GUC `vibeerp.current_tenant` set by the | |
| 22 | - platform on every transaction (see TODO below) | |
| 23 | - | |
| 24 | - TODO(v0.2): the platform's `RlsTransactionHook` is not yet | |
| 25 | - implemented, so the RLS policy in this file uses | |
| 26 | - `current_setting('vibeerp.current_tenant', true)` and is enabled | |
| 27 | - but not yet enforced (NO FORCE). When the hook lands, change to | |
| 28 | - FORCE ROW LEVEL SECURITY in a follow-up changeset. | |
| 29 | 23 | --> |
| 30 | 24 | |
| 31 | 25 | <changeSet id="identity-init-001" author="vibe_erp"> |
| ... | ... | @@ -33,7 +27,6 @@ |
| 33 | 27 | <sql> |
| 34 | 28 | CREATE TABLE identity__user ( |
| 35 | 29 | id uuid PRIMARY KEY, |
| 36 | - tenant_id varchar(64) NOT NULL, | |
| 37 | 30 | username varchar(128) NOT NULL, |
| 38 | 31 | display_name varchar(256) NOT NULL, |
| 39 | 32 | email varchar(320), |
| ... | ... | @@ -45,8 +38,8 @@ |
| 45 | 38 | updated_by varchar(128) NOT NULL, |
| 46 | 39 | version bigint NOT NULL DEFAULT 0 |
| 47 | 40 | ); |
| 48 | - CREATE UNIQUE INDEX identity__user_tenant_username_uk | |
| 49 | - ON identity__user (tenant_id, username); | |
| 41 | + CREATE UNIQUE INDEX identity__user_username_uk | |
| 42 | + ON identity__user (username); | |
| 50 | 43 | CREATE INDEX identity__user_ext_gin |
| 51 | 44 | ON identity__user USING GIN (ext jsonb_path_ops); |
| 52 | 45 | </sql> |
| ... | ... | @@ -56,24 +49,10 @@ |
| 56 | 49 | </changeSet> |
| 57 | 50 | |
| 58 | 51 | <changeSet id="identity-init-002" author="vibe_erp"> |
| 59 | - <comment>Enable Row-Level Security on identity__user (advisory until RlsTransactionHook lands)</comment> | |
| 60 | - <sql> | |
| 61 | - ALTER TABLE identity__user ENABLE ROW LEVEL SECURITY; | |
| 62 | - CREATE POLICY identity__user_tenant_isolation ON identity__user | |
| 63 | - USING (tenant_id = current_setting('vibeerp.current_tenant', true)); | |
| 64 | - </sql> | |
| 65 | - <rollback> | |
| 66 | - DROP POLICY IF EXISTS identity__user_tenant_isolation ON identity__user; | |
| 67 | - ALTER TABLE identity__user DISABLE ROW LEVEL SECURITY; | |
| 68 | - </rollback> | |
| 69 | - </changeSet> | |
| 70 | - | |
| 71 | - <changeSet id="identity-init-003" author="vibe_erp"> | |
| 72 | 52 | <comment>Create identity__role table</comment> |
| 73 | 53 | <sql> |
| 74 | 54 | CREATE TABLE identity__role ( |
| 75 | 55 | id uuid PRIMARY KEY, |
| 76 | - tenant_id varchar(64) NOT NULL, | |
| 77 | 56 | code varchar(64) NOT NULL, |
| 78 | 57 | name varchar(256) NOT NULL, |
| 79 | 58 | description text, |
| ... | ... | @@ -84,25 +63,21 @@ |
| 84 | 63 | updated_by varchar(128) NOT NULL, |
| 85 | 64 | version bigint NOT NULL DEFAULT 0 |
| 86 | 65 | ); |
| 87 | - CREATE UNIQUE INDEX identity__role_tenant_code_uk | |
| 88 | - ON identity__role (tenant_id, code); | |
| 66 | + CREATE UNIQUE INDEX identity__role_code_uk | |
| 67 | + ON identity__role (code); | |
| 89 | 68 | CREATE INDEX identity__role_ext_gin |
| 90 | 69 | ON identity__role USING GIN (ext jsonb_path_ops); |
| 91 | - ALTER TABLE identity__role ENABLE ROW LEVEL SECURITY; | |
| 92 | - CREATE POLICY identity__role_tenant_isolation ON identity__role | |
| 93 | - USING (tenant_id = current_setting('vibeerp.current_tenant', true)); | |
| 94 | 70 | </sql> |
| 95 | 71 | <rollback> |
| 96 | 72 | DROP TABLE identity__role; |
| 97 | 73 | </rollback> |
| 98 | 74 | </changeSet> |
| 99 | 75 | |
| 100 | - <changeSet id="identity-init-004" author="vibe_erp"> | |
| 76 | + <changeSet id="identity-init-003" author="vibe_erp"> | |
| 101 | 77 | <comment>Create identity__user_role join table</comment> |
| 102 | 78 | <sql> |
| 103 | 79 | CREATE TABLE identity__user_role ( |
| 104 | 80 | id uuid PRIMARY KEY, |
| 105 | - tenant_id varchar(64) NOT NULL, | |
| 106 | 81 | user_id uuid NOT NULL REFERENCES identity__user(id) ON DELETE CASCADE, |
| 107 | 82 | role_id uuid NOT NULL REFERENCES identity__role(id) ON DELETE CASCADE, |
| 108 | 83 | created_at timestamptz NOT NULL, |
| ... | ... | @@ -112,10 +87,7 @@ |
| 112 | 87 | version bigint NOT NULL DEFAULT 0 |
| 113 | 88 | ); |
| 114 | 89 | CREATE UNIQUE INDEX identity__user_role_uk |
| 115 | - ON identity__user_role (tenant_id, user_id, role_id); | |
| 116 | - ALTER TABLE identity__user_role ENABLE ROW LEVEL SECURITY; | |
| 117 | - CREATE POLICY identity__user_role_tenant_isolation ON identity__user_role | |
| 118 | - USING (tenant_id = current_setting('vibeerp.current_tenant', true)); | |
| 90 | + ON identity__user_role (user_id, role_id); | |
| 119 | 91 | </sql> |
| 120 | 92 | <rollback> |
| 121 | 93 | DROP TABLE identity__user_role; | ... | ... |
distribution/src/main/resources/db/changelog/platform/000-platform-init.xml
| ... | ... | @@ -2,410 +2,219 @@ |
| 2 | 2 | <!-- |
| 3 | 3 | vibe_erp — platform init changelog. |
| 4 | 4 | |
| 5 | - Creates the cross-cutting platform tables: a tenant table, an audit | |
| 6 | - table, and the metadata__* registry tables described in architecture | |
| 7 | - spec section 8 ("The metadata store"). | |
| 5 | + Creates the cross-cutting platform tables: an audit log and the | |
| 6 | + metadata__* registry tables described in architecture spec section 8 | |
| 7 | + ("The metadata store"). | |
| 8 | 8 | |
| 9 | - These are intentionally minimal v0.1 stubs: id + tenant_id + source + | |
| 10 | - timestamps + (where useful) a payload jsonb. Real columns are added by | |
| 11 | - later changesets in the PBCs that own them. pbc-identity owns the User | |
| 9 | + These are intentionally minimal v0.1 stubs: id + source + timestamps | |
| 10 | + + (where useful) a payload jsonb. Real columns are added by later | |
| 11 | + changesets in the PBCs that own them. pbc-identity owns the User | |
| 12 | 12 | schema and ships its own changelog. |
| 13 | 13 | |
| 14 | - NOTE: Postgres Row-Level Security policies are NOT enabled here. RLS | |
| 15 | - policies are added in a dedicated changeset in the v1.0 cut, so that | |
| 16 | - they can be authored alongside the application-level @TenantId filter | |
| 17 | - (architecture spec section 8 — "Tenant isolation"). The build pattern | |
| 18 | - for that changeset will mirror the changesets below. | |
| 14 | + vibe_erp is single-tenant per instance: one running process serves | |
| 15 | + exactly one company against an isolated database. There are no | |
| 16 | + tenant_id columns and no Row-Level Security policies anywhere in | |
| 17 | + this changelog — customer isolation happens at the deployment level | |
| 18 | + (one running instance per customer). | |
| 19 | 19 | --> |
| 20 | 20 | <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" |
| 21 | 21 | xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
| 22 | 22 | xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog |
| 23 | 23 | https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd"> |
| 24 | 24 | |
| 25 | - <!-- ─── platform__tenant ─────────────────────────────────────────── --> | |
| 26 | - <changeSet id="platform-init-001" author="vibe_erp"> | |
| 27 | - <createTable tableName="platform__tenant"> | |
| 28 | - <column name="id" type="uuid"> | |
| 29 | - <constraints primaryKey="true" nullable="false"/> | |
| 30 | - </column> | |
| 31 | - <column name="tenant_id" type="varchar(64)"> | |
| 32 | - <constraints nullable="false"/> | |
| 33 | - </column> | |
| 34 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 35 | - <constraints nullable="false"/> | |
| 36 | - </column> | |
| 37 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 38 | - <constraints nullable="false"/> | |
| 39 | - </column> | |
| 40 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 41 | - <constraints nullable="false"/> | |
| 42 | - </column> | |
| 43 | - </createTable> | |
| 44 | - <rollback> | |
| 45 | - <dropTable tableName="platform__tenant"/> | |
| 46 | - </rollback> | |
| 47 | - </changeSet> | |
| 48 | - | |
| 49 | 25 | <!-- ─── platform__audit ──────────────────────────────────────────── --> |
| 50 | - <changeSet id="platform-init-002" author="vibe_erp"> | |
| 26 | + <changeSet id="platform-init-001" author="vibe_erp"> | |
| 51 | 27 | <createTable tableName="platform__audit"> |
| 52 | - <column name="id" type="uuid"> | |
| 53 | - <constraints primaryKey="true" nullable="false"/> | |
| 54 | - </column> | |
| 55 | - <column name="tenant_id" type="varchar(64)"> | |
| 56 | - <constraints nullable="false"/> | |
| 57 | - </column> | |
| 58 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 59 | - <constraints nullable="false"/> | |
| 60 | - </column> | |
| 61 | - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb"> | |
| 62 | - <constraints nullable="false"/> | |
| 63 | - </column> | |
| 64 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 65 | - <constraints nullable="false"/> | |
| 66 | - </column> | |
| 67 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 68 | - <constraints nullable="false"/> | |
| 69 | - </column> | |
| 28 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 29 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 30 | + <column name="payload" type="jsonb"/> | |
| 31 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 32 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 70 | 33 | </createTable> |
| 71 | - <rollback> | |
| 72 | - <dropTable tableName="platform__audit"/> | |
| 73 | - </rollback> | |
| 34 | + <createIndex tableName="platform__audit" indexName="platform__audit_source_idx"> | |
| 35 | + <column name="source"/> | |
| 36 | + </createIndex> | |
| 37 | + <rollback><dropTable tableName="platform__audit"/></rollback> | |
| 74 | 38 | </changeSet> |
| 75 | 39 | |
| 76 | 40 | <!-- ─── metadata__entity ─────────────────────────────────────────── --> |
| 77 | - <changeSet id="platform-init-003" author="vibe_erp"> | |
| 41 | + <changeSet id="platform-init-002" author="vibe_erp"> | |
| 78 | 42 | <createTable tableName="metadata__entity"> |
| 79 | - <column name="id" type="uuid"> | |
| 80 | - <constraints primaryKey="true" nullable="false"/> | |
| 81 | - </column> | |
| 82 | - <column name="tenant_id" type="varchar(64)"> | |
| 83 | - <constraints nullable="false"/> | |
| 84 | - </column> | |
| 85 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 86 | - <constraints nullable="false"/> | |
| 87 | - </column> | |
| 88 | - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb"> | |
| 89 | - <constraints nullable="false"/> | |
| 90 | - </column> | |
| 91 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 92 | - <constraints nullable="false"/> | |
| 93 | - </column> | |
| 94 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 95 | - <constraints nullable="false"/> | |
| 96 | - </column> | |
| 43 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 44 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 45 | + <column name="payload" type="jsonb"/> | |
| 46 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 47 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 97 | 48 | </createTable> |
| 98 | - <createIndex tableName="metadata__entity" indexName="idx_metadata__entity__tenant_source"> | |
| 99 | - <column name="tenant_id"/> | |
| 49 | + <createIndex tableName="metadata__entity" indexName="metadata__entity_source_idx"> | |
| 100 | 50 | <column name="source"/> |
| 101 | 51 | </createIndex> |
| 102 | - <rollback> | |
| 103 | - <dropTable tableName="metadata__entity"/> | |
| 104 | - </rollback> | |
| 52 | + <rollback><dropTable tableName="metadata__entity"/></rollback> | |
| 105 | 53 | </changeSet> |
| 106 | 54 | |
| 107 | 55 | <!-- ─── metadata__custom_field ───────────────────────────────────── --> |
| 108 | - <changeSet id="platform-init-004" author="vibe_erp"> | |
| 56 | + <changeSet id="platform-init-003" author="vibe_erp"> | |
| 109 | 57 | <createTable tableName="metadata__custom_field"> |
| 110 | - <column name="id" type="uuid"> | |
| 111 | - <constraints primaryKey="true" nullable="false"/> | |
| 112 | - </column> | |
| 113 | - <column name="tenant_id" type="varchar(64)"> | |
| 114 | - <constraints nullable="false"/> | |
| 115 | - </column> | |
| 116 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 117 | - <constraints nullable="false"/> | |
| 118 | - </column> | |
| 119 | - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb"> | |
| 120 | - <constraints nullable="false"/> | |
| 121 | - </column> | |
| 122 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 123 | - <constraints nullable="false"/> | |
| 124 | - </column> | |
| 125 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 126 | - <constraints nullable="false"/> | |
| 127 | - </column> | |
| 58 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 59 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 60 | + <column name="payload" type="jsonb"/> | |
| 61 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 62 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 128 | 63 | </createTable> |
| 129 | - <createIndex tableName="metadata__custom_field" indexName="idx_metadata__custom_field__tenant_source"> | |
| 130 | - <column name="tenant_id"/> | |
| 64 | + <createIndex tableName="metadata__custom_field" indexName="metadata__custom_field_source_idx"> | |
| 131 | 65 | <column name="source"/> |
| 132 | 66 | </createIndex> |
| 133 | - <rollback> | |
| 134 | - <dropTable tableName="metadata__custom_field"/> | |
| 135 | - </rollback> | |
| 67 | + <rollback><dropTable tableName="metadata__custom_field"/></rollback> | |
| 136 | 68 | </changeSet> |
| 137 | 69 | |
| 138 | 70 | <!-- ─── metadata__form ───────────────────────────────────────────── --> |
| 139 | - <changeSet id="platform-init-005" author="vibe_erp"> | |
| 71 | + <changeSet id="platform-init-004" author="vibe_erp"> | |
| 140 | 72 | <createTable tableName="metadata__form"> |
| 141 | - <column name="id" type="uuid"> | |
| 142 | - <constraints primaryKey="true" nullable="false"/> | |
| 143 | - </column> | |
| 144 | - <column name="tenant_id" type="varchar(64)"> | |
| 145 | - <constraints nullable="false"/> | |
| 146 | - </column> | |
| 147 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 148 | - <constraints nullable="false"/> | |
| 149 | - </column> | |
| 150 | - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb"> | |
| 151 | - <constraints nullable="false"/> | |
| 152 | - </column> | |
| 153 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 154 | - <constraints nullable="false"/> | |
| 155 | - </column> | |
| 156 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 157 | - <constraints nullable="false"/> | |
| 158 | - </column> | |
| 73 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 74 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 75 | + <column name="payload" type="jsonb"/> | |
| 76 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 77 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 78 | + </createTable> | |
| 79 | + <createIndex tableName="metadata__form" indexName="metadata__form_source_idx"> | |
| 80 | + <column name="source"/> | |
| 81 | + </createIndex> | |
| 82 | + <rollback><dropTable tableName="metadata__form"/></rollback> | |
| 83 | + </changeSet> | |
| 84 | + | |
| 85 | + <!-- ─── metadata__list_view ──────────────────────────────────────── --> | |
| 86 | + <changeSet id="platform-init-005" author="vibe_erp"> | |
| 87 | + <createTable tableName="metadata__list_view"> | |
| 88 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 89 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 90 | + <column name="payload" type="jsonb"/> | |
| 91 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 92 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 159 | 93 | </createTable> |
| 160 | - <createIndex tableName="metadata__form" indexName="idx_metadata__form__tenant_source"> | |
| 161 | - <column name="tenant_id"/> | |
| 94 | + <createIndex tableName="metadata__list_view" indexName="metadata__list_view_source_idx"> | |
| 162 | 95 | <column name="source"/> |
| 163 | 96 | </createIndex> |
| 164 | - <rollback> | |
| 165 | - <dropTable tableName="metadata__form"/> | |
| 166 | - </rollback> | |
| 97 | + <rollback><dropTable tableName="metadata__list_view"/></rollback> | |
| 167 | 98 | </changeSet> |
| 168 | 99 | |
| 169 | 100 | <!-- ─── metadata__workflow ───────────────────────────────────────── --> |
| 170 | 101 | <changeSet id="platform-init-006" author="vibe_erp"> |
| 171 | 102 | <createTable tableName="metadata__workflow"> |
| 172 | - <column name="id" type="uuid"> | |
| 173 | - <constraints primaryKey="true" nullable="false"/> | |
| 174 | - </column> | |
| 175 | - <column name="tenant_id" type="varchar(64)"> | |
| 176 | - <constraints nullable="false"/> | |
| 177 | - </column> | |
| 178 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 179 | - <constraints nullable="false"/> | |
| 180 | - </column> | |
| 181 | - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb"> | |
| 182 | - <constraints nullable="false"/> | |
| 183 | - </column> | |
| 184 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 185 | - <constraints nullable="false"/> | |
| 186 | - </column> | |
| 187 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 188 | - <constraints nullable="false"/> | |
| 189 | - </column> | |
| 103 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 104 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 105 | + <column name="payload" type="jsonb"/> | |
| 106 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 107 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 190 | 108 | </createTable> |
| 191 | - <createIndex tableName="metadata__workflow" indexName="idx_metadata__workflow__tenant_source"> | |
| 192 | - <column name="tenant_id"/> | |
| 109 | + <createIndex tableName="metadata__workflow" indexName="metadata__workflow_source_idx"> | |
| 193 | 110 | <column name="source"/> |
| 194 | 111 | </createIndex> |
| 195 | - <rollback> | |
| 196 | - <dropTable tableName="metadata__workflow"/> | |
| 197 | - </rollback> | |
| 112 | + <rollback><dropTable tableName="metadata__workflow"/></rollback> | |
| 198 | 113 | </changeSet> |
| 199 | 114 | |
| 200 | 115 | <!-- ─── metadata__rule ───────────────────────────────────────────── --> |
| 201 | 116 | <changeSet id="platform-init-007" author="vibe_erp"> |
| 202 | 117 | <createTable tableName="metadata__rule"> |
| 203 | - <column name="id" type="uuid"> | |
| 204 | - <constraints primaryKey="true" nullable="false"/> | |
| 205 | - </column> | |
| 206 | - <column name="tenant_id" type="varchar(64)"> | |
| 207 | - <constraints nullable="false"/> | |
| 208 | - </column> | |
| 209 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 210 | - <constraints nullable="false"/> | |
| 211 | - </column> | |
| 212 | - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb"> | |
| 213 | - <constraints nullable="false"/> | |
| 214 | - </column> | |
| 215 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 216 | - <constraints nullable="false"/> | |
| 217 | - </column> | |
| 218 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 219 | - <constraints nullable="false"/> | |
| 220 | - </column> | |
| 118 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 119 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 120 | + <column name="payload" type="jsonb"/> | |
| 121 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 122 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 221 | 123 | </createTable> |
| 222 | - <createIndex tableName="metadata__rule" indexName="idx_metadata__rule__tenant_source"> | |
| 223 | - <column name="tenant_id"/> | |
| 124 | + <createIndex tableName="metadata__rule" indexName="metadata__rule_source_idx"> | |
| 224 | 125 | <column name="source"/> |
| 225 | 126 | </createIndex> |
| 226 | - <rollback> | |
| 227 | - <dropTable tableName="metadata__rule"/> | |
| 228 | - </rollback> | |
| 127 | + <rollback><dropTable tableName="metadata__rule"/></rollback> | |
| 229 | 128 | </changeSet> |
| 230 | 129 | |
| 231 | 130 | <!-- ─── metadata__permission ─────────────────────────────────────── --> |
| 232 | 131 | <changeSet id="platform-init-008" author="vibe_erp"> |
| 233 | 132 | <createTable tableName="metadata__permission"> |
| 234 | - <column name="id" type="uuid"> | |
| 235 | - <constraints primaryKey="true" nullable="false"/> | |
| 236 | - </column> | |
| 237 | - <column name="tenant_id" type="varchar(64)"> | |
| 238 | - <constraints nullable="false"/> | |
| 239 | - </column> | |
| 240 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 241 | - <constraints nullable="false"/> | |
| 242 | - </column> | |
| 243 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 244 | - <constraints nullable="false"/> | |
| 245 | - </column> | |
| 246 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 247 | - <constraints nullable="false"/> | |
| 248 | - </column> | |
| 133 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 134 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 135 | + <column name="payload" type="jsonb"/> | |
| 136 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 137 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 249 | 138 | </createTable> |
| 250 | - <createIndex tableName="metadata__permission" indexName="idx_metadata__permission__tenant_source"> | |
| 251 | - <column name="tenant_id"/> | |
| 139 | + <createIndex tableName="metadata__permission" indexName="metadata__permission_source_idx"> | |
| 252 | 140 | <column name="source"/> |
| 253 | 141 | </createIndex> |
| 254 | - <rollback> | |
| 255 | - <dropTable tableName="metadata__permission"/> | |
| 256 | - </rollback> | |
| 142 | + <rollback><dropTable tableName="metadata__permission"/></rollback> | |
| 257 | 143 | </changeSet> |
| 258 | 144 | |
| 259 | 145 | <!-- ─── metadata__role_permission ────────────────────────────────── --> |
| 260 | 146 | <changeSet id="platform-init-009" author="vibe_erp"> |
| 261 | 147 | <createTable tableName="metadata__role_permission"> |
| 262 | - <column name="id" type="uuid"> | |
| 263 | - <constraints primaryKey="true" nullable="false"/> | |
| 264 | - </column> | |
| 265 | - <column name="tenant_id" type="varchar(64)"> | |
| 266 | - <constraints nullable="false"/> | |
| 267 | - </column> | |
| 268 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 269 | - <constraints nullable="false"/> | |
| 270 | - </column> | |
| 271 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 272 | - <constraints nullable="false"/> | |
| 273 | - </column> | |
| 274 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 275 | - <constraints nullable="false"/> | |
| 276 | - </column> | |
| 148 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 149 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 150 | + <column name="payload" type="jsonb"/> | |
| 151 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 152 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 277 | 153 | </createTable> |
| 278 | - <createIndex tableName="metadata__role_permission" indexName="idx_metadata__role_permission__tenant_source"> | |
| 279 | - <column name="tenant_id"/> | |
| 154 | + <createIndex tableName="metadata__role_permission" indexName="metadata__role_permission_source_idx"> | |
| 280 | 155 | <column name="source"/> |
| 281 | 156 | </createIndex> |
| 282 | - <rollback> | |
| 283 | - <dropTable tableName="metadata__role_permission"/> | |
| 284 | - </rollback> | |
| 157 | + <rollback><dropTable tableName="metadata__role_permission"/></rollback> | |
| 285 | 158 | </changeSet> |
| 286 | 159 | |
| 287 | 160 | <!-- ─── metadata__menu ───────────────────────────────────────────── --> |
| 288 | 161 | <changeSet id="platform-init-010" author="vibe_erp"> |
| 289 | 162 | <createTable tableName="metadata__menu"> |
| 290 | - <column name="id" type="uuid"> | |
| 291 | - <constraints primaryKey="true" nullable="false"/> | |
| 292 | - </column> | |
| 293 | - <column name="tenant_id" type="varchar(64)"> | |
| 294 | - <constraints nullable="false"/> | |
| 295 | - </column> | |
| 296 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 297 | - <constraints nullable="false"/> | |
| 298 | - </column> | |
| 299 | - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb"> | |
| 300 | - <constraints nullable="false"/> | |
| 301 | - </column> | |
| 302 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 303 | - <constraints nullable="false"/> | |
| 304 | - </column> | |
| 305 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 306 | - <constraints nullable="false"/> | |
| 307 | - </column> | |
| 163 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 164 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 165 | + <column name="payload" type="jsonb"/> | |
| 166 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 167 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 308 | 168 | </createTable> |
| 309 | - <createIndex tableName="metadata__menu" indexName="idx_metadata__menu__tenant_source"> | |
| 310 | - <column name="tenant_id"/> | |
| 169 | + <createIndex tableName="metadata__menu" indexName="metadata__menu_source_idx"> | |
| 311 | 170 | <column name="source"/> |
| 312 | 171 | </createIndex> |
| 313 | - <rollback> | |
| 314 | - <dropTable tableName="metadata__menu"/> | |
| 315 | - </rollback> | |
| 172 | + <rollback><dropTable tableName="metadata__menu"/></rollback> | |
| 316 | 173 | </changeSet> |
| 317 | 174 | |
| 318 | 175 | <!-- ─── metadata__report ─────────────────────────────────────────── --> |
| 319 | 176 | <changeSet id="platform-init-011" author="vibe_erp"> |
| 320 | 177 | <createTable tableName="metadata__report"> |
| 321 | - <column name="id" type="uuid"> | |
| 322 | - <constraints primaryKey="true" nullable="false"/> | |
| 323 | - </column> | |
| 324 | - <column name="tenant_id" type="varchar(64)"> | |
| 325 | - <constraints nullable="false"/> | |
| 326 | - </column> | |
| 327 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 328 | - <constraints nullable="false"/> | |
| 329 | - </column> | |
| 330 | - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb"> | |
| 331 | - <constraints nullable="false"/> | |
| 332 | - </column> | |
| 333 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 334 | - <constraints nullable="false"/> | |
| 335 | - </column> | |
| 336 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 337 | - <constraints nullable="false"/> | |
| 338 | - </column> | |
| 178 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 179 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 180 | + <column name="payload" type="jsonb"/> | |
| 181 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 182 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 339 | 183 | </createTable> |
| 340 | - <createIndex tableName="metadata__report" indexName="idx_metadata__report__tenant_source"> | |
| 341 | - <column name="tenant_id"/> | |
| 184 | + <createIndex tableName="metadata__report" indexName="metadata__report_source_idx"> | |
| 342 | 185 | <column name="source"/> |
| 343 | 186 | </createIndex> |
| 344 | - <rollback> | |
| 345 | - <dropTable tableName="metadata__report"/> | |
| 346 | - </rollback> | |
| 187 | + <rollback><dropTable tableName="metadata__report"/></rollback> | |
| 347 | 188 | </changeSet> |
| 348 | 189 | |
| 349 | 190 | <!-- ─── metadata__translation ────────────────────────────────────── --> |
| 350 | 191 | <changeSet id="platform-init-012" author="vibe_erp"> |
| 351 | 192 | <createTable tableName="metadata__translation"> |
| 352 | - <column name="id" type="uuid"> | |
| 353 | - <constraints primaryKey="true" nullable="false"/> | |
| 354 | - </column> | |
| 355 | - <column name="tenant_id" type="varchar(64)"> | |
| 356 | - <constraints nullable="false"/> | |
| 357 | - </column> | |
| 358 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 359 | - <constraints nullable="false"/> | |
| 360 | - </column> | |
| 361 | - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb"> | |
| 362 | - <constraints nullable="false"/> | |
| 363 | - </column> | |
| 364 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 365 | - <constraints nullable="false"/> | |
| 366 | - </column> | |
| 367 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 368 | - <constraints nullable="false"/> | |
| 369 | - </column> | |
| 193 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 194 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 195 | + <column name="payload" type="jsonb"/> | |
| 196 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 197 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 370 | 198 | </createTable> |
| 371 | - <createIndex tableName="metadata__translation" indexName="idx_metadata__translation__tenant_source"> | |
| 372 | - <column name="tenant_id"/> | |
| 199 | + <createIndex tableName="metadata__translation" indexName="metadata__translation_source_idx"> | |
| 373 | 200 | <column name="source"/> |
| 374 | 201 | </createIndex> |
| 375 | - <rollback> | |
| 376 | - <dropTable tableName="metadata__translation"/> | |
| 377 | - </rollback> | |
| 202 | + <rollback><dropTable tableName="metadata__translation"/></rollback> | |
| 378 | 203 | </changeSet> |
| 379 | 204 | |
| 380 | 205 | <!-- ─── metadata__plugin_config ──────────────────────────────────── --> |
| 381 | 206 | <changeSet id="platform-init-013" author="vibe_erp"> |
| 382 | 207 | <createTable tableName="metadata__plugin_config"> |
| 383 | - <column name="id" type="uuid"> | |
| 384 | - <constraints primaryKey="true" nullable="false"/> | |
| 385 | - </column> | |
| 386 | - <column name="tenant_id" type="varchar(64)"> | |
| 387 | - <constraints nullable="false"/> | |
| 388 | - </column> | |
| 389 | - <column name="source" type="varchar(32)" defaultValue="core"> | |
| 390 | - <constraints nullable="false"/> | |
| 391 | - </column> | |
| 392 | - <column name="payload" type="jsonb" defaultValueComputed="'{}'::jsonb"> | |
| 393 | - <constraints nullable="false"/> | |
| 394 | - </column> | |
| 395 | - <column name="created_at" type="timestamptz" defaultValueComputed="now()"> | |
| 396 | - <constraints nullable="false"/> | |
| 397 | - </column> | |
| 398 | - <column name="updated_at" type="timestamptz" defaultValueComputed="now()"> | |
| 399 | - <constraints nullable="false"/> | |
| 400 | - </column> | |
| 208 | + <column name="id" type="uuid"><constraints primaryKey="true" nullable="false"/></column> | |
| 209 | + <column name="source" type="varchar(32)" defaultValue="core"><constraints nullable="false"/></column> | |
| 210 | + <column name="payload" type="jsonb"/> | |
| 211 | + <column name="created_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 212 | + <column name="updated_at" type="timestamptz" defaultValueComputed="now()"><constraints nullable="false"/></column> | |
| 401 | 213 | </createTable> |
| 402 | - <createIndex tableName="metadata__plugin_config" indexName="idx_metadata__plugin_config__tenant_source"> | |
| 403 | - <column name="tenant_id"/> | |
| 214 | + <createIndex tableName="metadata__plugin_config" indexName="metadata__plugin_config_source_idx"> | |
| 404 | 215 | <column name="source"/> |
| 405 | 216 | </createIndex> |
| 406 | - <rollback> | |
| 407 | - <dropTable tableName="metadata__plugin_config"/> | |
| 408 | - </rollback> | |
| 217 | + <rollback><dropTable tableName="metadata__plugin_config"/></rollback> | |
| 409 | 218 | </changeSet> |
| 410 | 219 | |
| 411 | 220 | </databaseChangeLog> | ... | ... |
local-vibeerp/config/vibe-erp.yaml
| ... | ... | @@ -6,8 +6,11 @@ |
| 6 | 6 | # operators can copy it as a starting point for a real deployment. |
| 7 | 7 | |
| 8 | 8 | instance: |
| 9 | - mode: self-hosted | |
| 10 | - default-tenant: default | |
| 9 | + # Display name of the company that owns this instance. | |
| 10 | + # vibe_erp is single-tenant per instance: one running process serves | |
| 11 | + # exactly one company against an isolated database. Provisioning a | |
| 12 | + # second customer means deploying a second instance. | |
| 13 | + company-name: Acme Printing Co. | |
| 11 | 14 | |
| 12 | 15 | database: |
| 13 | 16 | url: jdbc:postgresql://db:5432/vibeerp | ... | ... |
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/application/UserService.kt
| ... | ... | @@ -32,7 +32,7 @@ class UserService( |
| 32 | 32 | |
| 33 | 33 | fun create(command: CreateUserCommand): User { |
| 34 | 34 | require(!users.existsByUsername(command.username)) { |
| 35 | - "username '${command.username}' is already taken in this tenant" | |
| 35 | + "username '${command.username}' is already taken" | |
| 36 | 36 | } |
| 37 | 37 | val user = User( |
| 38 | 38 | username = command.username, | ... | ... |
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/domain/User.kt
| ... | ... | @@ -2,7 +2,6 @@ package org.vibeerp.pbc.identity.domain |
| 2 | 2 | |
| 3 | 3 | import jakarta.persistence.Column |
| 4 | 4 | import jakarta.persistence.Entity |
| 5 | -import jakarta.persistence.Id | |
| 6 | 5 | import jakarta.persistence.Table |
| 7 | 6 | import org.hibernate.annotations.JdbcTypeCode |
| 8 | 7 | import org.hibernate.type.SqlTypes |
| ... | ... | @@ -17,9 +16,12 @@ import org.vibeerp.platform.persistence.audit.AuditedJpaEntity |
| 17 | 16 | * This is the entire point of the modular monolith — PBCs get power, plug-ins |
| 18 | 17 | * get safety. |
| 19 | 18 | * |
| 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. | |
| 19 | + * Audit columns come from [AuditedJpaEntity] and are filled in by the | |
| 20 | + * platform's JPA listener. PBC code never writes to them directly. | |
| 21 | + * | |
| 22 | + * vibe_erp is single-tenant per instance — the entire database belongs to one | |
| 23 | + * company — so there is no `tenant_id` column on this entity and no tenant | |
| 24 | + * filtering anywhere in this PBC. | |
| 23 | 25 | * |
| 24 | 26 | * Custom fields: the `ext` JSONB column is the metadata-driven custom-field |
| 25 | 27 | * store (architecture spec section 8). A key user can add fields to this |
| ... | ... | @@ -58,5 +60,5 @@ class User( |
| 58 | 60 | var ext: String = "{}" |
| 59 | 61 | |
| 60 | 62 | override fun toString(): String = |
| 61 | - "User(id=$id, tenantId=$tenantId, username='$username')" | |
| 63 | + "User(id=$id, username='$username')" | |
| 62 | 64 | } | ... | ... |
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/ext/IdentityApiAdapter.kt
| ... | ... | @@ -3,7 +3,6 @@ package org.vibeerp.pbc.identity.ext |
| 3 | 3 | import org.springframework.stereotype.Component |
| 4 | 4 | import org.springframework.transaction.annotation.Transactional |
| 5 | 5 | import org.vibeerp.api.v1.core.Id |
| 6 | -import org.vibeerp.api.v1.core.TenantId | |
| 7 | 6 | import org.vibeerp.api.v1.ext.identity.IdentityApi |
| 8 | 7 | import org.vibeerp.api.v1.ext.identity.UserRef |
| 9 | 8 | import org.vibeerp.api.v1.security.PrincipalId |
| ... | ... | @@ -21,7 +20,6 @@ import org.vibeerp.pbc.identity.infrastructure.UserJpaRepository |
| 21 | 20 | * |
| 22 | 21 | * **What this adapter MUST NOT do:** |
| 23 | 22 | * • leak `org.vibeerp.pbc.identity.domain.User` to callers |
| 24 | - * • bypass tenant filtering (caller's tenant context is honored automatically) | |
| 25 | 23 | * • return PII fields that aren't already on `UserRef` |
| 26 | 24 | * |
| 27 | 25 | * If a caller needs more fields, the answer is to grow `UserRef` deliberately |
| ... | ... | @@ -42,7 +40,6 @@ class IdentityApiAdapter( |
| 42 | 40 | private fun User.toRef(): UserRef = UserRef( |
| 43 | 41 | id = Id<UserRef>(this.id), |
| 44 | 42 | username = this.username, |
| 45 | - tenantId = TenantId(this.tenantId), | |
| 46 | 43 | displayName = this.displayName, |
| 47 | 44 | enabled = this.enabled, |
| 48 | 45 | ) | ... | ... |
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/http/UserController.kt
| ... | ... | @@ -29,12 +29,13 @@ import java.util.UUID |
| 29 | 29 | * PBC, and so plug-in endpoints (which mount under `/api/v1/plugins/...`) |
| 30 | 30 | * cannot collide with core endpoints. |
| 31 | 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. | |
| 32 | + * vibe_erp is single-tenant per instance — the entire process serves one | |
| 33 | + * company against an isolated database — so this controller does no tenant | |
| 34 | + * filtering and exposes no `tenantId` parameter. Customer isolation happens | |
| 35 | + * at the deployment level (one running instance per customer). | |
| 35 | 36 | * |
| 36 | 37 | * 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 | + * the controller answers any request. | |
| 38 | 39 | */ |
| 39 | 40 | @RestController |
| 40 | 41 | @RequestMapping("/api/v1/identity/users") |
| ... | ... | @@ -102,7 +103,6 @@ data class UpdateUserRequest( |
| 102 | 103 | |
| 103 | 104 | data class UserResponse( |
| 104 | 105 | val id: UUID, |
| 105 | - val tenantId: String, | |
| 106 | 106 | val username: String, |
| 107 | 107 | val displayName: String, |
| 108 | 108 | val email: String?, |
| ... | ... | @@ -111,7 +111,6 @@ data class UserResponse( |
| 111 | 111 | |
| 112 | 112 | private fun User.toResponse() = UserResponse( |
| 113 | 113 | id = this.id, |
| 114 | - tenantId = this.tenantId, | |
| 115 | 114 | username = this.username, |
| 116 | 115 | displayName = this.displayName, |
| 117 | 116 | email = this.email, | ... | ... |
pbc/pbc-identity/src/main/kotlin/org/vibeerp/pbc/identity/infrastructure/UserJpaRepository.kt
| ... | ... | @@ -8,14 +8,11 @@ import java.util.UUID |
| 8 | 8 | /** |
| 9 | 9 | * Spring Data JPA repository for [User]. |
| 10 | 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. | |
| 11 | + * vibe_erp is single-tenant per instance — the entire database belongs to | |
| 12 | + * one company — so this repository does no tenant filtering. Querying for a | |
| 13 | + * user gives back the row if it exists, regardless of who is asking. | |
| 14 | + * Customer isolation happens at the *deployment* level: each customer gets | |
| 15 | + * their own running instance and their own Postgres database. | |
| 19 | 16 | */ |
| 20 | 17 | @Repository |
| 21 | 18 | interface UserJpaRepository : JpaRepository<User, UUID> { | ... | ... |
pbc/pbc-identity/src/test/kotlin/org/vibeerp/pbc/identity/application/UserServiceTest.kt
platform/platform-bootstrap/build.gradle.kts
| ... | ... | @@ -21,13 +21,13 @@ kotlin { |
| 21 | 21 | |
| 22 | 22 | dependencies { |
| 23 | 23 | api(project(":api:api-v1")) |
| 24 | - api(project(":platform:platform-persistence")) // for TenantContext used by the HTTP filter | |
| 25 | 24 | implementation(libs.kotlin.stdlib) |
| 26 | 25 | implementation(libs.kotlin.reflect) |
| 27 | 26 | implementation(libs.jackson.module.kotlin) |
| 28 | 27 | |
| 29 | 28 | implementation(libs.spring.boot.starter) |
| 30 | 29 | implementation(libs.spring.boot.starter.web) |
| 30 | + implementation(libs.spring.boot.starter.data.jpa) // for @EnableJpaRepositories on VibeErpApplication | |
| 31 | 31 | implementation(libs.spring.boot.starter.validation) |
| 32 | 32 | implementation(libs.spring.boot.starter.actuator) |
| 33 | 33 | ... | ... |
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/VibeErpApplication.kt
| 1 | 1 | package org.vibeerp.platform.bootstrap |
| 2 | 2 | |
| 3 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication |
| 4 | +import org.springframework.boot.autoconfigure.domain.EntityScan | |
| 4 | 5 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan |
| 5 | 6 | import org.springframework.boot.runApplication |
| 6 | 7 | import org.springframework.context.annotation.ComponentScan |
| 8 | +import org.springframework.data.jpa.repository.config.EnableJpaRepositories | |
| 7 | 9 | |
| 8 | 10 | /** |
| 9 | 11 | * Entry point for the vibe_erp framework. |
| ... | ... | @@ -13,8 +15,13 @@ import org.springframework.context.annotation.ComponentScan |
| 13 | 15 | * • all core PBCs that have been registered as Gradle dependencies of :distribution |
| 14 | 16 | * • the plug-in loader, which scans an external `./plugins/` directory |
| 15 | 17 | * |
| 16 | - * The package scan is anchored at `org.vibeerp` so every PBC and platform module | |
| 17 | - * is picked up by Spring as long as its Kotlin packages live under that root. | |
| 18 | + * **The four `@*Scan` annotations are critical for modular monolith setups.** | |
| 19 | + * Spring Boot's defaults only scan the main application's own package | |
| 20 | + * (`org.vibeerp.platform.bootstrap`), but every PBC lives in its own package | |
| 21 | + * (`org.vibeerp.pbc.identity`, `org.vibeerp.pbc.catalog`, …). Without the | |
| 22 | + * explicit `org.vibeerp` root, Spring would refuse to wire PBC repositories | |
| 23 | + * and entities at startup. PBCs are added by listing them in | |
| 24 | + * `:distribution`'s build.gradle.kts, not by editing this file. | |
| 18 | 25 | * |
| 19 | 26 | * **What does NOT live in this process:** |
| 20 | 27 | * • the React web client (separate build, served by reverse proxy) |
| ... | ... | @@ -24,6 +31,8 @@ import org.springframework.context.annotation.ComponentScan |
| 24 | 31 | @SpringBootApplication |
| 25 | 32 | @ComponentScan(basePackages = ["org.vibeerp"]) |
| 26 | 33 | @ConfigurationPropertiesScan(basePackages = ["org.vibeerp"]) |
| 34 | +@EnableJpaRepositories(basePackages = ["org.vibeerp"]) | |
| 35 | +@EntityScan(basePackages = ["org.vibeerp"]) | |
| 27 | 36 | class VibeErpApplication |
| 28 | 37 | |
| 29 | 38 | fun main(args: Array<String>) { | ... | ... |
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/config/VibeErpProperties.kt
| ... | ... | @@ -9,7 +9,7 @@ import java.util.Locale |
| 9 | 9 | * |
| 10 | 10 | * The set of keys here is **closed and documented**: plug-ins do NOT extend |
| 11 | 11 | * this tree. Per-plug-in configuration lives in the `metadata__plugin_config` |
| 12 | - * table, where it is tenant-scoped and editable in the UI. | |
| 12 | + * table, editable through the web UI. | |
| 13 | 13 | * |
| 14 | 14 | * Reference: architecture spec section 10 ("The single config file"). |
| 15 | 15 | */ |
| ... | ... | @@ -22,10 +22,15 @@ data class VibeErpProperties( |
| 22 | 22 | ) |
| 23 | 23 | |
| 24 | 24 | data class InstanceProperties( |
| 25 | - /** "self-hosted" or "hosted". Self-hosted single-customer is the v0.1 default. */ | |
| 26 | - val mode: String = "self-hosted", | |
| 27 | - /** Tenant id used when [mode] is "self-hosted". Always "default" unless overridden. */ | |
| 28 | - val defaultTenant: String = "default", | |
| 25 | + /** | |
| 26 | + * Display name of the company that owns this instance. | |
| 27 | + * | |
| 28 | + * vibe_erp is single-tenant per instance: one running process serves | |
| 29 | + * exactly one company against an isolated database. The company name | |
| 30 | + * appears in branding, report headers, and audit logs. Provisioning a | |
| 31 | + * second customer means deploying a second instance with its own DB. | |
| 32 | + */ | |
| 33 | + val companyName: String = "vibe_erp instance", | |
| 29 | 34 | /** External base URL of this instance, used for absolute links and OIDC. Optional. */ |
| 30 | 35 | val baseUrl: String? = null, |
| 31 | 36 | ) | ... | ... |
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/GlobalExceptionHandler.kt
0 → 100644
| 1 | +package org.vibeerp.platform.bootstrap.web | |
| 2 | + | |
| 3 | +import org.slf4j.LoggerFactory | |
| 4 | +import org.springframework.http.HttpStatus | |
| 5 | +import org.springframework.http.ProblemDetail | |
| 6 | +import org.springframework.web.bind.annotation.ExceptionHandler | |
| 7 | +import org.springframework.web.bind.annotation.RestControllerAdvice | |
| 8 | +import java.time.Instant | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Process-wide exception → HTTP status translator. | |
| 12 | + * | |
| 13 | + * The framework has two principles for failure mapping: | |
| 14 | + * | |
| 15 | + * 1. **Expected failures get a typed status code.** A duplicate username, | |
| 16 | + * a missing entity, an invalid argument — these are part of normal | |
| 17 | + * business flow and should never surface as a 500. Mapping them here | |
| 18 | + * keeps every PBC controller free of try/catch noise. | |
| 19 | + * | |
| 20 | + * 2. **Unexpected failures stay 500.** Anything not handled below is a | |
| 21 | + * real bug or an infrastructure failure (DB down, NPE, …) — those | |
| 22 | + * need to be logged with a stack trace and surfaced as a generic | |
| 23 | + * "internal error" so an operator can pick them up from the logs. | |
| 24 | + * | |
| 25 | + * The response shape uses Spring's `ProblemDetail` (RFC 7807), which is | |
| 26 | + * what every modern API client and OpenAPI tool understands out of the box. | |
| 27 | + */ | |
| 28 | +@RestControllerAdvice | |
| 29 | +class GlobalExceptionHandler { | |
| 30 | + | |
| 31 | + private val log = LoggerFactory.getLogger(GlobalExceptionHandler::class.java) | |
| 32 | + | |
| 33 | + /** | |
| 34 | + * `require(...)` failures and explicit `IllegalArgumentException` from | |
| 35 | + * domain code map to 400 Bad Request. Domain code uses these for | |
| 36 | + * "the caller asked for something that does not make sense" — invalid | |
| 37 | + * combinations, duplicate keys, malformed inputs the JSR-380 validation | |
| 38 | + * layer didn't catch. | |
| 39 | + */ | |
| 40 | + @ExceptionHandler(IllegalArgumentException::class) | |
| 41 | + fun handleIllegalArgument(ex: IllegalArgumentException): ProblemDetail = | |
| 42 | + problem(HttpStatus.BAD_REQUEST, ex.message ?: "invalid argument") | |
| 43 | + | |
| 44 | + /** | |
| 45 | + * Domain-layer "I asked for X but it isn't here" → 404 Not Found. | |
| 46 | + * Application services use `NoSuchElementException` consistently for | |
| 47 | + * this; mapping it here keeps controllers from manually wrapping every | |
| 48 | + * `findById().orElseThrow { ... }` in a try/catch. | |
| 49 | + */ | |
| 50 | + @ExceptionHandler(NoSuchElementException::class) | |
| 51 | + fun handleNotFound(ex: NoSuchElementException): ProblemDetail = | |
| 52 | + problem(HttpStatus.NOT_FOUND, ex.message ?: "not found") | |
| 53 | + | |
| 54 | + /** | |
| 55 | + * Last-resort fallback. Anything not handled above is logged with a | |
| 56 | + * full stack trace and surfaced as a generic 500 to the caller. The | |
| 57 | + * detail message is intentionally vague so we don't leak internals. | |
| 58 | + */ | |
| 59 | + @ExceptionHandler(Throwable::class) | |
| 60 | + fun handleAny(ex: Throwable): ProblemDetail { | |
| 61 | + log.error("Unhandled exception bubbled to GlobalExceptionHandler", ex) | |
| 62 | + return problem(HttpStatus.INTERNAL_SERVER_ERROR, "internal error") | |
| 63 | + } | |
| 64 | + | |
| 65 | + private fun problem(status: HttpStatus, message: String): ProblemDetail = | |
| 66 | + ProblemDetail.forStatusAndDetail(status, message).apply { | |
| 67 | + setProperty("timestamp", Instant.now().toString()) | |
| 68 | + } | |
| 69 | +} | ... | ... |
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/TenantResolutionFilter.kt deleted
| 1 | -package org.vibeerp.platform.bootstrap.web | |
| 2 | - | |
| 3 | -import jakarta.servlet.FilterChain | |
| 4 | -import jakarta.servlet.http.HttpServletRequest | |
| 5 | -import jakarta.servlet.http.HttpServletResponse | |
| 6 | -import org.springframework.boot.context.properties.ConfigurationProperties | |
| 7 | -import org.springframework.core.Ordered | |
| 8 | -import org.springframework.core.annotation.Order | |
| 9 | -import org.springframework.stereotype.Component | |
| 10 | -import org.springframework.web.filter.OncePerRequestFilter | |
| 11 | -import org.vibeerp.api.v1.core.TenantId | |
| 12 | -import org.vibeerp.platform.persistence.tenancy.TenantContext | |
| 13 | - | |
| 14 | -/** | |
| 15 | - * HTTP filter that binds the request's tenant id to [TenantContext] for the | |
| 16 | - * duration of the request and clears it on the way out. | |
| 17 | - * | |
| 18 | - * Lives in `platform-bootstrap` (not `platform-persistence`) because the | |
| 19 | - * persistence layer must not depend on the servlet API or Spring Web — that | |
| 20 | - * would couple JPA code to HTTP and forbid running PBCs from background | |
| 21 | - * jobs or message consumers. | |
| 22 | - * | |
| 23 | - * Resolution order: | |
| 24 | - * 1. `X-Tenant-Id` header (used by trusted internal callers and tests) | |
| 25 | - * 2. `tenant` claim on the JWT (production path, once auth is wired up) | |
| 26 | - * 3. The configured fallback tenant when running in self-hosted single-tenant mode | |
| 27 | - * | |
| 28 | - * The third path is what makes self-hosted single-customer use the SAME | |
| 29 | - * code path as hosted multi-tenant: the request always sees a tenant in | |
| 30 | - * `TenantContext`, the only difference is whether it came from the JWT | |
| 31 | - * or from configuration. PBC code never branches on `mode`. | |
| 32 | - */ | |
| 33 | -@Component | |
| 34 | -@Order(Ordered.HIGHEST_PRECEDENCE + 10) | |
| 35 | -class TenantResolutionFilter( | |
| 36 | - private val tenancyProperties: TenancyProperties, | |
| 37 | -) : OncePerRequestFilter() { | |
| 38 | - | |
| 39 | - override fun doFilterInternal( | |
| 40 | - request: HttpServletRequest, | |
| 41 | - response: HttpServletResponse, | |
| 42 | - filterChain: FilterChain, | |
| 43 | - ) { | |
| 44 | - val tenantId = resolveTenant(request) | |
| 45 | - TenantContext.runAs(tenantId) { | |
| 46 | - filterChain.doFilter(request, response) | |
| 47 | - } | |
| 48 | - } | |
| 49 | - | |
| 50 | - private fun resolveTenant(request: HttpServletRequest): TenantId { | |
| 51 | - val header = request.getHeader("X-Tenant-Id") | |
| 52 | - if (header != null && header.isNotBlank()) { | |
| 53 | - return TenantId(header) | |
| 54 | - } | |
| 55 | - // TODO(v0.2): read JWT 'tenant' claim once pbc-identity ships auth. | |
| 56 | - return TenantId(tenancyProperties.fallbackTenant) | |
| 57 | - } | |
| 58 | -} | |
| 59 | - | |
| 60 | -@Component | |
| 61 | -@ConfigurationProperties(prefix = "vibeerp.tenancy") | |
| 62 | -data class TenancyProperties( | |
| 63 | - /** | |
| 64 | - * Tenant id to use when no header or JWT claim is present. | |
| 65 | - * Defaults to "default" — the canonical id for self-hosted single-tenant. | |
| 66 | - */ | |
| 67 | - var fallbackTenant: String = "default", | |
| 68 | -) |
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/audit/AuditedJpaEntity.kt
| ... | ... | @@ -2,12 +2,11 @@ package org.vibeerp.platform.persistence.audit |
| 2 | 2 | |
| 3 | 3 | import jakarta.persistence.Column |
| 4 | 4 | import jakarta.persistence.EntityListeners |
| 5 | +import jakarta.persistence.Id | |
| 5 | 6 | import jakarta.persistence.MappedSuperclass |
| 6 | 7 | import jakarta.persistence.PrePersist |
| 7 | 8 | import jakarta.persistence.PreUpdate |
| 8 | 9 | import jakarta.persistence.Version |
| 9 | -import org.hibernate.annotations.TenantId | |
| 10 | -import org.vibeerp.platform.persistence.tenancy.TenantContext | |
| 11 | 10 | import java.time.Instant |
| 12 | 11 | import java.util.UUID |
| 13 | 12 | |
| ... | ... | @@ -16,10 +15,16 @@ import java.util.UUID |
| 16 | 15 | * |
| 17 | 16 | * Provides, for free, on every business table: |
| 18 | 17 | * • `id` (UUID primary key) |
| 19 | - * • `tenant_id` (multi-tenant discriminator, NOT NULL, never written manually) | |
| 20 | 18 | * • `created_at`, `created_by`, `updated_at`, `updated_by` (audit columns) |
| 21 | 19 | * • `version` (optimistic-locking column for safe concurrent edits) |
| 22 | 20 | * |
| 21 | + * **Single-tenant per instance.** vibe_erp is a single-tenant framework: one | |
| 22 | + * running process serves exactly one company against an isolated database. | |
| 23 | + * There is no `tenant_id` column on this superclass and no tenant filtering | |
| 24 | + * anywhere in the platform. Hosting multiple customers means provisioning | |
| 25 | + * multiple instances, not multiplexing them in one process — see CLAUDE.md | |
| 26 | + * guardrail #5 for the rationale. | |
| 27 | + * | |
| 23 | 28 | * **What this is NOT:** |
| 24 | 29 | * • This is INTERNAL to the platform layer. PBC code that lives in |
| 25 | 30 | * `pbc-*` extends this directly, but plug-ins do NOT — plug-ins extend |
| ... | ... | @@ -34,21 +39,10 @@ import java.util.UUID |
| 34 | 39 | @EntityListeners(AuditedJpaEntityListener::class) |
| 35 | 40 | abstract class AuditedJpaEntity { |
| 36 | 41 | |
| 42 | + @Id | |
| 37 | 43 | @Column(name = "id", updatable = false, nullable = false, columnDefinition = "uuid") |
| 38 | 44 | open var id: UUID = UUID.randomUUID() |
| 39 | 45 | |
| 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 | |
| 49 | - @Column(name = "tenant_id", updatable = false, nullable = false, length = 64) | |
| 50 | - open var tenantId: String = "" | |
| 51 | - | |
| 52 | 46 | @Column(name = "created_at", updatable = false, nullable = false) |
| 53 | 47 | open var createdAt: Instant = Instant.EPOCH |
| 54 | 48 | |
| ... | ... | @@ -67,11 +61,11 @@ abstract class AuditedJpaEntity { |
| 67 | 61 | } |
| 68 | 62 | |
| 69 | 63 | /** |
| 70 | - * JPA entity listener that fills in tenant id and audit columns at save time. | |
| 64 | + * JPA entity listener that fills in audit columns at save time. | |
| 71 | 65 | * |
| 72 | - * Reads tenant from `TenantContext` (set by an HTTP filter) and the principal | |
| 73 | - * from a yet-to-exist `PrincipalContext` — for v0.1 the principal is hard-coded | |
| 74 | - * to `__system__` because pbc-identity's auth flow lands in v0.2. | |
| 66 | + * Reads the principal from a yet-to-exist `PrincipalContext` — for v0.1 the | |
| 67 | + * principal is hard-coded to `__system__` because pbc-identity's auth flow | |
| 68 | + * lands in v0.2. | |
| 75 | 69 | */ |
| 76 | 70 | class AuditedJpaEntityListener { |
| 77 | 71 | |
| ... | ... | @@ -79,21 +73,7 @@ class AuditedJpaEntityListener { |
| 79 | 73 | fun onCreate(entity: Any) { |
| 80 | 74 | if (entity is AuditedJpaEntity) { |
| 81 | 75 | val now = Instant.now() |
| 82 | - val tenant = TenantContext.currentOrNull() | |
| 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 | - | |
| 95 | 76 | val principal = currentPrincipal() |
| 96 | - entity.tenantId = tenant.value | |
| 97 | 77 | entity.createdAt = now |
| 98 | 78 | entity.createdBy = principal |
| 99 | 79 | entity.updatedAt = now | ... | ... |
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/HibernateTenantResolver.kt deleted
| 1 | -package org.vibeerp.platform.persistence.tenancy | |
| 2 | - | |
| 3 | -import org.hibernate.context.spi.CurrentTenantIdentifierResolver | |
| 4 | -import org.springframework.stereotype.Component | |
| 5 | - | |
| 6 | -/** | |
| 7 | - * Hibernate hook that returns the tenant id for the current Hibernate session. | |
| 8 | - * | |
| 9 | - * Wired into Hibernate via `spring.jpa.properties.hibernate.multiTenancy=DISCRIMINATOR` | |
| 10 | - * (set in platform-persistence's auto-config). Hibernate then automatically | |
| 11 | - * filters every query and every save by tenant — the application code never | |
| 12 | - * writes `WHERE tenant_id = ?` manually. | |
| 13 | - * | |
| 14 | - * This is the FIRST of two walls against cross-tenant data leaks. The | |
| 15 | - * SECOND wall is Postgres Row-Level Security policies on every business | |
| 16 | - * table, applied by `RlsTransactionHook` on every transaction. A bug in | |
| 17 | - * either wall is contained by the other; both walls must be wrong at | |
| 18 | - * the same time for a leak to occur. | |
| 19 | - * | |
| 20 | - * Reference: architecture spec section 8 ("Tenant isolation"). | |
| 21 | - */ | |
| 22 | -@Component | |
| 23 | -class HibernateTenantResolver : CurrentTenantIdentifierResolver<String> { | |
| 24 | - | |
| 25 | - override fun resolveCurrentTenantIdentifier(): String = | |
| 26 | - TenantContext.currentOrNull()?.value | |
| 27 | - // We return a sentinel rather than throwing here because | |
| 28 | - // Hibernate calls this resolver during schema validation at | |
| 29 | - // startup, before any HTTP request has set a tenant. | |
| 30 | - ?: "__no_tenant__" | |
| 31 | - | |
| 32 | - override fun validateExistingCurrentSessions(): Boolean = true | |
| 33 | -} |
platform/platform-persistence/src/main/kotlin/org/vibeerp/platform/persistence/tenancy/TenantContext.kt deleted
| 1 | -package org.vibeerp.platform.persistence.tenancy | |
| 2 | - | |
| 3 | -import org.vibeerp.api.v1.core.TenantId | |
| 4 | - | |
| 5 | -/** | |
| 6 | - * Per-thread holder of the current request's tenant. | |
| 7 | - * | |
| 8 | - * Set by an HTTP filter on the way in (from JWT claim, header, or | |
| 9 | - * subdomain), read by Hibernate's tenant resolver and by Postgres RLS | |
| 10 | - * via a `SET LOCAL` statement at the start of every transaction. | |
| 11 | - * | |
| 12 | - * **Why a ThreadLocal and not a request-scoped Spring bean?** | |
| 13 | - * Hibernate's `CurrentTenantIdentifierResolver` is called from inside | |
| 14 | - * the Hibernate session, which is not always inside a Spring request | |
| 15 | - * scope (e.g., during background jobs and event handlers). A ThreadLocal | |
| 16 | - * is the lowest common denominator that works across all those code paths. | |
| 17 | - * | |
| 18 | - * **For background jobs:** the job scheduler explicitly calls | |
| 19 | - * [runAs] to bind a tenant for the duration of the job, then clears it. | |
| 20 | - * Never let a job thread inherit a stale tenant from a previous run. | |
| 21 | - */ | |
| 22 | -object TenantContext { | |
| 23 | - | |
| 24 | - private val current = ThreadLocal<TenantId?>() | |
| 25 | - | |
| 26 | - fun set(tenantId: TenantId) { | |
| 27 | - current.set(tenantId) | |
| 28 | - } | |
| 29 | - | |
| 30 | - fun clear() { | |
| 31 | - current.remove() | |
| 32 | - } | |
| 33 | - | |
| 34 | - fun currentOrNull(): TenantId? = current.get() | |
| 35 | - | |
| 36 | - fun current(): TenantId = | |
| 37 | - currentOrNull() ?: error( | |
| 38 | - "TenantContext.current() called outside a tenant-bound scope. " + | |
| 39 | - "Either an HTTP filter forgot to set the tenant, or a background job " + | |
| 40 | - "is running without explicitly calling TenantContext.runAs(...)." | |
| 41 | - ) | |
| 42 | - | |
| 43 | - /** | |
| 44 | - * Run [block] with [tenantId] bound to the current thread, restoring | |
| 45 | - * the previous value (which is usually null) afterwards. | |
| 46 | - */ | |
| 47 | - fun <T> runAs(tenantId: TenantId, block: () -> T): T { | |
| 48 | - val previous = current.get() | |
| 49 | - current.set(tenantId) | |
| 50 | - try { | |
| 51 | - return block() | |
| 52 | - } finally { | |
| 53 | - if (previous == null) current.remove() else current.set(previous) | |
| 54 | - } | |
| 55 | - } | |
| 56 | -} |