• Removes the ext-handling copy/paste that had grown across four PBCs
    (partners, inventory, orders-sales, orders-purchase). Every service
    that wrote the JSONB `ext` column was manually doing the same
    four-step sequence: validate, null-check, serialize with a local
    ObjectMapper, assign to the entity. And every response mapper was
    doing the inverse: check-if-blank, parse, cast, swallow errors.
    
    Net: ~15 lines saved per PBC, one place to change the ext contract
    later (e.g. PII redaction, audit tagging, field-level events), and
    a stable plug-in opt-in mechanism — any plug-in entity that
    implements `HasExt` automatically participates.
    
    New api.v1 surface:
    
      interface HasExt {
          val extEntityName: String     // key into metadata__custom_field
          var ext: String               // the serialized JSONB column
      }
    
    Lives in `org.vibeerp.api.v1.entity` so plug-ins can opt their own
    entities into the same validation path. Zero Spring/Jackson
    dependencies — api.v1 stays clean.
    
    Extended `ExtJsonValidator` (platform-metadata) with two helpers:
    
      fun applyTo(entity: HasExt, ext: Map<String, Any?>?)
          — null-safe; validates; writes canonical JSON to entity.ext.
            Replaces the validate + writeValueAsString + assign triplet
            in every service's create() and update().
    
      fun parseExt(entity: HasExt): Map<String, Any?>
          — returns empty map on blank/corrupt column; response
            mappers never 500 on bad data. Replaces the four identical
            parseExt local functions.
    
    ExtJsonValidator now takes an ObjectMapper via constructor
    injection (Spring Boot's auto-configured bean).
    
    Entities that now implement HasExt (override val extEntityName;
    override var ext; companion object const val ENTITY_NAME):
      - Partner (`partners.Partner` → "Partner")
      - Location (`inventory.Location` → "Location")
      - SalesOrder (`orders_sales.SalesOrder` → "SalesOrder")
      - PurchaseOrder (`orders_purchase.PurchaseOrder` → "PurchaseOrder")
    
    Deliberately NOT converted this chunk:
      - WorkOrder (pbc-production) — its ext column has no declared
        fields yet; a follow-up that adds declarations AND the
        HasExt implementation is cleaner than splitting the two.
      - JournalEntry (pbc-finance) — derived state, no ext column.
    
    Services lose:
      - The `jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()`
        field (four copies eliminated)
      - The `parseExt(entity): Map` helper function (four copies)
      - The `companion object { const val ENTITY_NAME = ... }` constant
        (moved onto the entity where it belongs)
      - The `val canonicalExt = extValidator.validate(...)` +
        `.also { it.ext = jsonMapper.writeValueAsString(canonicalExt) }`
        create pattern (replaced with one applyTo call)
      - The `if (command.ext != null) { ... }` update pattern
        (applyTo is null-safe)
    
    Unit tests: 6 new cases on ExtJsonValidatorTest cover applyTo and
    parseExt (null-safe path, happy path, failure path, blank column,
    round-trip, malformed JSON). Existing service tests just swap the
    mock setup from stubbing `validate` to stubbing `applyTo` and
    `parseExt` with no-ops.
    
    Smoke verified end-to-end against real Postgres:
      - POST /partners with valid ext (partners_credit_limit,
        partners_industry) → 201, canonical form persisted.
      - GET /partners/by-code/X → 200, ext round-trips.
      - POST with invalid enum value → 400 "value 'x' is not in
        allowed set [printing, publishing, packaging, other]".
      - POST with undeclared key → 400 "ext contains undeclared
        key(s) for 'Partner': [rogue_field]".
      - PATCH with new ext → 200, ext updated.
      - PATCH WITHOUT ext field → 200, prior ext preserved (null-safe
        applyTo).
      - POST /orders/sales-orders with no ext → 201, the create path
        via the shared helper still works.
    
    246 unit tests (+6 over 240), 18 Gradle subprojects.
    zichun authored
     
    Browse Code »
  • Closes the P4.3 permission-rollout gap for the two oldest PBCs that
    were never updated when the @RequirePermission aspect landed. The
    catalog and partners metadata YAMLs already declared all the needed
    permission keys — the controllers just weren't consuming them.
    
    Catalog
      - ItemController: list/get/getByCode → catalog.item.read;
        create → catalog.item.create; update → catalog.item.update;
        deactivate → catalog.item.deactivate.
      - UomController: list/get/getByCode → catalog.uom.read;
        create → catalog.uom.create; update → catalog.uom.update.
    
    Partners (including the PII boundary)
      - PartnerController: list/get/getByCode → partners.partner.read;
        create → partners.partner.create; update → partners.partner.update.
        (deactivate was already annotated in the P4.3 demo chunk.)
      - AddressController: all five verbs annotated with
        partners.address.{read,create,update,delete}.
      - ContactController: all five verbs annotated with
        partners.contact.{read,create,update,deactivate}. The
        "TODO once P4.3 lands" note in the class KDoc was removed; P4.3
        is live and the annotations are now in place. This is the PII
        boundary that CLAUDE.md flagged as incomplete after the original
        P4.3 rollout.
    
    No new permission keys were added — all 14 keys this touches were
    already declared in pbc-catalog/catalog.yml and
    pbc-partners/partners.yml when those PBCs were first built. The
    metadata loader has been serving them to the SPA/OpenAPI/MCP
    introspection endpoint since day one; this change just starts
    enforcing them at the controller.
    
    Smoke-tested end-to-end against real Postgres
      - Fresh DB + fresh boot.
      - Admin happy path (bootstrap admin has wildcard `admin` role):
          GET  /api/v1/catalog/items           → 200
          POST /api/v1/catalog/items           → 201 (SMOKE-1 created)
          GET  /api/v1/catalog/uoms            → 200
          POST /api/v1/partners/partners       → 201 (SMOKE-P created)
          POST /api/v1/partners/.../contacts   → 201 (contact created)
          GET  /api/v1/partners/.../contacts   → 200 (PII read)
      - Anonymous negative path (no Bearer token):
          GET  /api/v1/catalog/items           → 401
          GET  /api/v1/partners/.../contacts   → 401
      - 230 unit tests still green (annotations are purely additive,
        no existing test hit the @RequirePermission path since the
        service-level tests bypass the controller entirely).
    
    Why this is a genuine security improvement
      - Before: any authenticated user (including the eventual "Alice
        from reception", the contractor's read-only service account,
        the AI-agent MCP client) could read PII, create partners, and
        create catalog items.
      - After: those operations require explicit role-permission grants
        through metadata__role_permission. The bootstrap admin still
        has unconditional access via the wildcard admin role, so
        nothing in a fresh deployment is broken; but a real operator
        granting minimum-privilege roles now has the columns they need
        in the database to do it.
      - The contact PII boundary in particular is GDPR-relevant: before
        this change, any logged-in user could enumerate every contact's
        name + email + phone. After, only users with partners.contact.read
        can see them.
    
    What's still NOT annotated
      - pbc-inventory's Location create/update/deactivate endpoints
        (only stock.adjust and movement.create are annotated).
      - pbc-orders-sales and pbc-orders-purchase list/get/create/update
        endpoints (only the state-transition verbs are annotated).
      - pbc-identity's user admin endpoints.
      These are the next cleanup chunk. This one stays focused on
      catalog + partners because those were the two PBCs that predated
      P4.3 entirely and hadn't been touched since.
    zichun authored
     
    Browse Code »

  • The framework's authorization layer is now live. Until now, every
    authenticated user could do everything; the framework had only an
    authentication gate. This chunk adds method-level @RequirePermission
    annotations enforced by a Spring AOP aspect that consults the JWT's
    roles claim and a metadata-driven role-permission map.
    
    What landed
    -----------
    * New `Role` and `UserRole` JPA entities mapping the existing
      identity__role + identity__user_role tables (the schema was
      created in the original identity init but never wired to JPA).
      RoleJpaRepository + UserRoleJpaRepository with a JPQL query that
      returns a user's role codes in one round-trip.
    * `JwtIssuer.issueAccessToken(userId, username, roles)` now accepts a
      Set<String> of role codes and encodes them as a `roles` JWT claim
      (sorted for deterministic tests). Refresh tokens NEVER carry roles
      by design — see the rationale on `JwtIssuer.issueRefreshToken`. A
      role revocation propagates within one access-token lifetime
      (15 min default).
    * `JwtVerifier` reads the `roles` claim into `DecodedToken.roles`.
      Missing claim → empty set, NOT an error (refresh tokens, system
      tokens, and pre-P4.3 tokens all legitimately omit it).
    * `AuthService.login` now calls `userRoles.findRoleCodesByUserId(...)`
      before minting the access token. `AuthService.refresh` re-reads
      the user's roles too — so a refresh always picks up the latest
      set, since refresh tokens deliberately don't carry roles.
    * New `AuthorizationContext` ThreadLocal in `platform-security.authz`
      carrying an `AuthorizedPrincipal(id, username, roles)`. Separate
      from `PrincipalContext` (which lives in platform-persistence and
      carries only the principal id, for the audit listener). The two
      contexts coexist because the audit listener has no business
      knowing what roles a user has.
    * `PrincipalContextFilter` now populates BOTH contexts on every
      authenticated request, reading the JWT's `username` and `roles`
      claims via `Jwt.getClaimAsStringList("roles")`. The filter is the
      one and only place that knows about Spring Security types AND
      about both vibe_erp contexts; everything downstream uses just the
      Spring-free abstractions.
    * `PermissionEvaluator` Spring bean: takes a role set + permission
      key, returns boolean. Resolution chain:
      1. The literal `admin` role short-circuits to `true` for every
         key (the wildcard exists so the bootstrap admin can do
         everything from the very first boot without seeding a complete
         role-permission mapping).
      2. Otherwise consults an in-memory `Map<role, Set<permission>>`
         loaded from `metadata__role_permission` rows. The cache is
         rebuilt by `refresh()`, called from `VibeErpPluginManager`
         after the initial core load AND after every plug-in load.
      3. Empty role set is always denied. No implicit grants.
    * `@RequirePermission("...")` annotation in `platform-security.authz`.
      `RequirePermissionAspect` is a Spring AOP @Aspect with @Around
      advice that intercepts every annotated method, reads the current
      request's `AuthorizationContext`, calls
      `PermissionEvaluator.has(...)`, and either proceeds or throws
      `PermissionDeniedException`.
    * New `PermissionDeniedException` carrying the offending key.
      `GlobalExceptionHandler` maps it to HTTP 403 Forbidden with
      `"permission denied: 'partners.partner.deactivate'"` as the
      detail. The key IS surfaced to the caller (unlike the 401's
      generic "invalid credentials") because the SPA needs it to
      render a useful "your role doesn't include X" message and
      callers are already authenticated, so it's not an enumeration
      vector.
    * `BootstrapAdminInitializer` now creates the wildcard `admin`
      role on first boot and grants it to the bootstrap admin user.
    * `@RequirePermission` applied to four sensitive endpoints as the
      demo: `PartnerController.deactivate`,
      `StockBalanceController.adjust`, `SalesOrderController.confirm`,
      `SalesOrderController.cancel`. More endpoints will gain
      annotations as additional roles are introduced; v1 keeps the
      blast radius narrow.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, verified:
    * Admin login → JWT length 265 (was 241), decoded claims include
      `"roles":["admin"]`
    * Admin POST /sales-orders/{id}/confirm → 200, status DRAFT → CONFIRMED
      (admin wildcard short-circuits the permission check)
    * Inserted a 'powerless' user via raw SQL with no role assignments
      but copied the admin's password hash so login works
    * Powerless login → JWT length 247, decoded claims have NO roles
      field at all
    * Powerless POST /sales-orders/{id}/cancel → **403 Forbidden** with
      `"permission denied: 'orders.sales.cancel'"` in the body
    * Powerless DELETE /partners/{id} → **403 Forbidden** with
      `"permission denied: 'partners.partner.deactivate'"`
    * Powerless GET /sales-orders, /partners, /catalog/items → all 200
      (read endpoints have no @RequirePermission)
    * Admin regression: catalog uoms, identity users, inventory
      locations, printing-shop plates with i18n, metadata custom-fields
      endpoint — all still HTTP 2xx
    
    Build
    -----
    * `./gradlew build`: 15 subprojects, 163 unit tests (was 153),
      all green. The 10 new tests cover:
      - PermissionEvaluator: empty roles deny, admin wildcard, explicit
        role-permission grant, multi-role union, unknown role denial,
        malformed payload tolerance, currentHas with no AuthorizationContext,
        currentHas with bound context (8 tests).
      - JwtRoundTrip: roles claim round-trips through the access token,
        refresh token never carries roles even when asked (2 tests).
    
    What was deferred
    -----------------
    * **OIDC integration (P4.2)**. Built-in JWT only. The Keycloak-
      compatible OIDC client will reuse the same authorization layer
      unchanged — the roles will come from OIDC ID tokens instead of
      the local user store.
    * **Permission key validation at boot.** The framework does NOT
      yet check that every `@RequirePermission` value matches a
      declared metadata permission key. The plug-in linter is the
      natural place for that check to land later.
    * **Role hierarchy**. Roles are flat in v1; a role with permission
      X cannot inherit from another role. Adding a `parent_role` field
      on the role row is a non-breaking change later.
    * **Resource-aware permissions** ("the user owns THIS partner").
      v1 only checks the operation, not the operand. Resource-aware
      checks are post-v1.
    * **Composite (AND/OR) permission requirements**. A single key
      per call site keeps the contract simple. Composite requirements
      live in service code that calls `PermissionEvaluator.currentHas`
      directly.
    * **Role management UI / REST**. The framework can EVALUATE
      permissions but has no first-class endpoints for "create a
      role", "grant a permission to a role", "assign a role to a
      user". v1 expects these to be done via direct DB writes or via
      the future SPA's role editor (P3.x); the wiring above is
      intentionally policy-only, not management.
    zichun authored
     
    Browse Code »
  • The keystone of the framework's "key user adds fields without code"
    promise. A YAML declaration is now enough to add a typed custom field
    to an existing entity, validate it on every save, and surface it to
    the SPA / OpenAPI / AI agent. This is the bit that makes vibe_erp a
    *framework* instead of a fork-per-customer app.
    
    What landed
    -----------
    * Extended `MetadataYamlFile` with a top-level `customFields:` section
      and `CustomFieldYaml` + `CustomFieldTypeYaml` wire-format DTOs. The
      YAML uses a flat `kind: decimal / scale: 2` discriminator instead of
      Jackson polymorphic deserialization so plug-in authors don't have to
      nest type configs and api.v1's `FieldType` stays free of Jackson
      imports.
    * `MetadataLoader.doLoad` now upserts `metadata__custom_field` rows
      alongside entities/permissions/menus, with the same delete-by-source
      idempotency. `LoadResult` carries the count for the boot log.
    * `CustomFieldRegistry` reads every `metadata__custom_field` row from
      the database and builds an in-memory `Map<entityName, List<CustomField>>`
      for the validator's hot path. `refresh()` is called by
      `VibeErpPluginManager` after the initial core load AND after every
      plug-in load, so a freshly-installed plug-in's custom fields are
      immediately enforceable. ConcurrentHashMap under the hood; reads
      are lock-free.
    * `ExtJsonValidator` is the on-save half: takes (entityName, ext map),
      walks the declared fields, coerces each value to its native type,
      and returns the canonicalised map (or throws IllegalArgumentException
      with ALL violations joined for a single 400). Per-FieldType rules:
      - String: maxLength enforced.
      - Integer: accepts Number and numeric String, rejects garbage.
      - Decimal: precision/scale enforced; preserved as plain string in
        canonical form so JSON encoding doesn't lose trailing zeros.
      - Boolean: accepts true/false (case-insensitive).
      - Date / DateTime: ISO-8601 parse via java.time.
      - Uuid: java.util.UUID parse.
      - Enum: must be in declared `allowedValues`.
      - Money / Quantity / Json / Reference: pass-through (Reference target
        existence check pending the cross-PBC EntityRegistry seam).
      Unknown ext keys are rejected with the entity's name and the keys
      themselves listed. ALL violations are returned in one response, not
      failing on the first, so a form submitter fixes everything in one
      round-trip.
    * `Partner` is the first PBC entity to wire ext through the validator:
      `CreatePartnerRequest` and `UpdatePartnerRequest` accept an
      `ext: Map<String, Any?>?`; `PartnerService.create/update` calls
      `extValidator.validate("Partner", ext)` and persists the canonical
      JSON to the existing `partners__partner.ext` JSONB column;
      `PartnerResponse` parses it back so callers see what they wrote.
    * `partners.yml` now declares two custom fields on Partner —
      `partners_credit_limit` (Decimal precision=14, scale=2) and
      `partners_industry` (Enum of printing/publishing/packaging/other) —
      with English and Chinese labels. Tagged `source=core` so the
      framework has something to demo from a fresh boot.
    * New public `GET /api/v1/_meta/metadata/custom-fields/{entityName}`
      endpoint serves the api.v1 runtime view of declarations from the
      in-memory registry (so it reflects every refresh) for the SPA's form
      builder, the OpenAPI generator, and the AI agent function catalog.
      The existing `GET /api/v1/_meta/metadata` endpoint also gained a
      `customFields` list.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, verified:
    * Boot log: `CustomFieldRegistry: refreshed 2 custom fields across 1
      entities (0 malformed rows skipped)` — twice (after core load and
      after plug-in load).
    * `GET /api/v1/_meta/metadata/custom-fields/Partner` → both declarations
      with their labels.
    * `POST /api/v1/partners/partners` with `ext = {credit_limit: "50000.00",
      industry: "printing"}` → 201; the response echoes the canonical map.
    * `POST` with `ext = {credit_limit: "1.234", industry: "unicycles",
      rogue: "x"}` → 400 with all THREE violations in one body:
      "ext contains undeclared key(s) for 'Partner': [rogue]; ext.partners_credit_limit:
      decimal scale 3 exceeds declared scale 2; ext.partners_industry:
      value 'unicycles' is not in allowed set [printing, publishing,
      packaging, other]".
    * `SELECT ext FROM partners__partner WHERE code = 'CUST-EXT-OK'` →
      `{"partners_industry": "printing", "partners_credit_limit": "50000.00"}`
      — the canonical JSON is in the JSONB column verbatim.
    * Regression: catalog uoms, identity users, partners list, and the
      printing-shop plug-in's POST /plates (with i18n via Accept-Language:
      zh-CN) all still HTTP 2xx.
    
    Build
    -----
    * `./gradlew build`: 13 subprojects, 129 unit tests (was 118), all
      green. The 11 new tests cover each FieldType variant, multi-violation
      reporting, required missing rejection, unknown key rejection, and the
      null-value-dropped case.
    
    What was deferred
    -----------------
    * JPA listener auto-validation. Right now PartnerService explicitly
      calls extValidator.validate(...) before save; Item, Uom, User and
      the plug-in tables don't yet. Promoting the validator to a JPA
      PrePersist/PreUpdate listener attached to a marker interface
      HasExt is the right next step but is its own focused chunk.
    * Reference target existence check. FieldType.Reference passes
      through unchanged because the cross-PBC EntityRegistry that would
      let the validator look up "does an instance with this id exist?"
      is a separate seam landing later.
    * The customization UI (Tier 1 self-service add a field via the SPA)
      is P3.3 — the runtime enforcement is done, the editor is not.
    * Custom-field-aware permissions. The `pii: true` flag is in the
      metadata but not yet read by anything; the DSAR/erasure pipeline
      that will consume it is post-v1.0.
    zichun authored
     
    Browse Code »
  • The third real PBC. Validates the modular-monolith template against a
    parent-with-children aggregate (Partner → Addresses → Contacts), where
    the previous two PBCs only had single-table or two-independent-table
    shapes.
    
    What landed
    -----------
    * New Gradle subproject `pbc/pbc-partners` (12 modules total now).
    * Three JPA entities, all extending `AuditedJpaEntity`:
      - `Partner` — code, name, type (CUSTOMER/SUPPLIER/BOTH), tax_id,
        website, email, phone, active, ext jsonb. Single-table for both
        customers and suppliers because the role flag is a property of
        the relationship, not the organisation.
      - `Address` — partner_id FK, address_type (BILLING/SHIPPING/OTHER),
        line1/line2/city/region/postal_code/country_code (ISO 3166-1),
        is_primary. Two free address lines + structured city/region/code
        is the smallest set that round-trips through every postal system.
      - `Contact` — partner_id FK, full_name, role, email, phone, active.
        PII-tagged in metadata YAML for the future audit/export tooling.
    * Spring Data JPA repos, application services with full CRUD and the
      invariants below, REST controllers under
      `/api/v1/partners/partners` (+ nested addresses, contacts).
    * `partners-init.xml` Liquibase changelog with the three tables, FKs,
      GIN index on `partner.ext`, indexes on type/active/country.
    * New api.v1 facade `org.vibeerp.api.v1.ext.partners` with
      `PartnersApi` + `PartnerRef`. Third `ext.<pbc>` after identity and
      catalog. Inactive partners hidden at the facade boundary.
    * `PartnersApiAdapter` runtime implementation in pbc-partners, never
      leaking JPA entity types.
    * `partners.yml` metadata declaring all 3 entities, 12 permission
      keys, 1 menu entry. Picked up automatically by `MetadataLoader`.
    * 15 new unit tests across `PartnerServiceTest`, `AddressServiceTest`
      and `ContactServiceTest` (mockk-based, mirroring catalog tests).
    
    Invariants enforced in code (not blindly delegated to the DB)
    -------------------------------------------------------------
    * Partner code uniqueness — explicit check produces a 400 with a real
      message instead of a 500 from the unique-index violation.
    * Partner code is NOT updatable — every external reference uses code,
      so renaming is a data-migration concern, not an API call.
    * Partner deactivate cascades to contacts (also flipped to inactive).
      Addresses are NOT touched (no `active` column — they exist or they
      don't). Verified end-to-end against Postgres.
    * "Primary" flag is at most one per (partner, address_type). When a
      new/updated address is marked primary, all OTHER primaries of the
      same type for the same partner are demoted in the same transaction.
    * Addresses and contacts reject operations on unknown partners
      up-front to give better errors than the FK-violation.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, hit:
    * POST /api/v1/auth/login (admin) → JWT
    * POST /api/v1/partners/partners (CUSTOMER, SUPPLIER) → 201
    * GET  /api/v1/partners/partners → lists both
    * GET  /api/v1/partners/partners/by-code/CUST-ACME → resolves
    * POST /api/v1/partners/partners (dup code) → 400 with real message
    * POST .../{id}/addresses (BILLING, primary) → 201
    * POST .../{id}/contacts → 201
    * DELETE /api/v1/partners/partners/{id} → 204; partner active=false
    * GET  .../contacts → contact ALSO active=false (cascade verified)
    * GET  /api/v1/_meta/metadata/entities → 3 partners entities present
    * GET  /api/v1/_meta/metadata/permissions → 12 partners permissions
    * Regression: catalog UoMs/items, identity users, printing-shop
      plug-in plates all still HTTP 200.
    
    Build
    -----
    * `./gradlew build`: 12 subprojects, 107 unit tests, all green
      (was 11 / 92 before this commit).
    * The architectural rule still enforced: pbc-partners depends on
      api-v1 + platform-persistence + platform-security only — no
      cross-PBC dep, no platform-bootstrap dep.
    
    What was deferred
    -----------------
    * Permission enforcement on contact endpoints (P4.3). Currently plain
      authenticated; the metadata declares the planned `partners.contact.*`
      keys for when @RequirePermission lands.
    * Per-country address structure layered on top via metadata forms
      (P3.x). The current schema is the smallest universal subset.
    * `deletePartnerCompletely` — out of scope for v1; should be a
      separate "data scrub" admin tool, not a routine API call.
    zichun authored
     
    Browse Code »