Commit b174cf606893e7cea05d8f87acb530d529903f96

Authored by zichun
1 parent 7ec2a40a

feat(security): annotate catalog + partners endpoints with @RequirePermission

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.
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/http/ItemController.kt
... ... @@ -19,6 +19,7 @@ import org.vibeerp.pbc.catalog.application.ItemService
19 19 import org.vibeerp.pbc.catalog.application.UpdateItemCommand
20 20 import org.vibeerp.pbc.catalog.domain.Item
21 21 import org.vibeerp.pbc.catalog.domain.ItemType
  22 +import org.vibeerp.platform.security.authz.RequirePermission
22 23 import java.util.UUID
23 24  
24 25 /**
... ... @@ -40,16 +41,19 @@ class ItemController(
40 41 ) {
41 42  
42 43 @GetMapping
  44 + @RequirePermission("catalog.item.read")
43 45 fun list(): List<ItemResponse> =
44 46 itemService.list().map { it.toResponse() }
45 47  
46 48 @GetMapping("/{id}")
  49 + @RequirePermission("catalog.item.read")
47 50 fun get(@PathVariable id: UUID): ResponseEntity<ItemResponse> {
48 51 val item = itemService.findById(id) ?: return ResponseEntity.notFound().build()
49 52 return ResponseEntity.ok(item.toResponse())
50 53 }
51 54  
52 55 @GetMapping("/by-code/{code}")
  56 + @RequirePermission("catalog.item.read")
53 57 fun getByCode(@PathVariable code: String): ResponseEntity<ItemResponse> {
54 58 val item = itemService.findByCode(code) ?: return ResponseEntity.notFound().build()
55 59 return ResponseEntity.ok(item.toResponse())
... ... @@ -57,6 +61,7 @@ class ItemController(
57 61  
58 62 @PostMapping
59 63 @ResponseStatus(HttpStatus.CREATED)
  64 + @RequirePermission("catalog.item.create")
60 65 fun create(@RequestBody @Valid request: CreateItemRequest): ItemResponse =
61 66 itemService.create(
62 67 CreateItemCommand(
... ... @@ -70,6 +75,7 @@ class ItemController(
70 75 ).toResponse()
71 76  
72 77 @PatchMapping("/{id}")
  78 + @RequirePermission("catalog.item.update")
73 79 fun update(
74 80 @PathVariable id: UUID,
75 81 @RequestBody @Valid request: UpdateItemRequest,
... ... @@ -86,6 +92,7 @@ class ItemController(
86 92  
87 93 @DeleteMapping("/{id}")
88 94 @ResponseStatus(HttpStatus.NO_CONTENT)
  95 + @RequirePermission("catalog.item.deactivate")
89 96 fun deactivate(@PathVariable id: UUID) {
90 97 itemService.deactivate(id)
91 98 }
... ...
pbc/pbc-catalog/src/main/kotlin/org/vibeerp/pbc/catalog/http/UomController.kt
... ... @@ -17,6 +17,7 @@ import org.vibeerp.pbc.catalog.application.CreateUomCommand
17 17 import org.vibeerp.pbc.catalog.application.UomService
18 18 import org.vibeerp.pbc.catalog.application.UpdateUomCommand
19 19 import org.vibeerp.pbc.catalog.domain.Uom
  20 +import org.vibeerp.platform.security.authz.RequirePermission
20 21 import java.util.UUID
21 22  
22 23 /**
... ... @@ -38,16 +39,19 @@ class UomController(
38 39 ) {
39 40  
40 41 @GetMapping
  42 + @RequirePermission("catalog.uom.read")
41 43 fun list(): List<UomResponse> =
42 44 uomService.list().map { it.toResponse() }
43 45  
44 46 @GetMapping("/{id}")
  47 + @RequirePermission("catalog.uom.read")
45 48 fun get(@PathVariable id: UUID): ResponseEntity<UomResponse> {
46 49 val uom = uomService.findById(id) ?: return ResponseEntity.notFound().build()
47 50 return ResponseEntity.ok(uom.toResponse())
48 51 }
49 52  
50 53 @GetMapping("/by-code/{code}")
  54 + @RequirePermission("catalog.uom.read")
51 55 fun getByCode(@PathVariable code: String): ResponseEntity<UomResponse> {
52 56 val uom = uomService.findByCode(code) ?: return ResponseEntity.notFound().build()
53 57 return ResponseEntity.ok(uom.toResponse())
... ... @@ -55,6 +59,7 @@ class UomController(
55 59  
56 60 @PostMapping
57 61 @ResponseStatus(HttpStatus.CREATED)
  62 + @RequirePermission("catalog.uom.create")
58 63 fun create(@RequestBody @Valid request: CreateUomRequest): UomResponse =
59 64 uomService.create(
60 65 CreateUomCommand(
... ... @@ -65,6 +70,7 @@ class UomController(
65 70 ).toResponse()
66 71  
67 72 @PatchMapping("/{id}")
  73 + @RequirePermission("catalog.uom.update")
68 74 fun update(
69 75 @PathVariable id: UUID,
70 76 @RequestBody @Valid request: UpdateUomRequest,
... ...
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/AddressController.kt
... ... @@ -19,6 +19,7 @@ import org.vibeerp.pbc.partners.application.CreateAddressCommand
19 19 import org.vibeerp.pbc.partners.application.UpdateAddressCommand
20 20 import org.vibeerp.pbc.partners.domain.Address
21 21 import org.vibeerp.pbc.partners.domain.AddressType
  22 +import org.vibeerp.platform.security.authz.RequirePermission
22 23 import java.util.UUID
23 24  
24 25 /**
... ... @@ -38,10 +39,12 @@ class AddressController(
38 39 ) {
39 40  
40 41 @GetMapping
  42 + @RequirePermission("partners.address.read")
41 43 fun list(@PathVariable partnerId: UUID): List<AddressResponse> =
42 44 addressService.listFor(partnerId).map { it.toResponse() }
43 45  
44 46 @GetMapping("/{id}")
  47 + @RequirePermission("partners.address.read")
45 48 fun get(
46 49 @PathVariable partnerId: UUID,
47 50 @PathVariable id: UUID,
... ... @@ -53,6 +56,7 @@ class AddressController(
53 56  
54 57 @PostMapping
55 58 @ResponseStatus(HttpStatus.CREATED)
  59 + @RequirePermission("partners.address.create")
56 60 fun create(
57 61 @PathVariable partnerId: UUID,
58 62 @RequestBody @Valid request: CreateAddressRequest,
... ... @@ -72,6 +76,7 @@ class AddressController(
72 76 ).toResponse()
73 77  
74 78 @PatchMapping("/{id}")
  79 + @RequirePermission("partners.address.update")
75 80 fun update(
76 81 @PathVariable partnerId: UUID,
77 82 @PathVariable id: UUID,
... ... @@ -93,6 +98,7 @@ class AddressController(
93 98  
94 99 @DeleteMapping("/{id}")
95 100 @ResponseStatus(HttpStatus.NO_CONTENT)
  101 + @RequirePermission("partners.address.delete")
96 102 fun delete(
97 103 @PathVariable partnerId: UUID,
98 104 @PathVariable id: UUID,
... ...
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/ContactController.kt
... ... @@ -18,6 +18,7 @@ import org.vibeerp.pbc.partners.application.ContactService
18 18 import org.vibeerp.pbc.partners.application.CreateContactCommand
19 19 import org.vibeerp.pbc.partners.application.UpdateContactCommand
20 20 import org.vibeerp.pbc.partners.domain.Contact
  21 +import org.vibeerp.platform.security.authz.RequirePermission
21 22 import java.util.UUID
22 23  
23 24 /**
... ... @@ -28,11 +29,12 @@ import java.util.UUID
28 29 * relationship — every operation is implicitly scoped to a specific
29 30 * partner.
30 31 *
31   - * **PII boundary.** Contact data is personal information. Once the
32   - * permission system lands (P4.3) this controller will be guarded by
33   - * `partners.contact.read` etc. Until then, plain authentication is the
34   - * only gate, and that is a known short-term posture, NOT the long-term
35   - * one — see the metadata YAML for the planned permission keys.
  32 + * **PII boundary.** Contact data is personal information. Every
  33 + * endpoint is guarded by a dedicated `partners.contact.*` permission
  34 + * so role admin can grant read/write access independently of the
  35 + * wider `partners.partner.*` permissions. This is the stronger
  36 + * posture that the earlier "TODO: annotate once P4.3 lands" note
  37 + * referred to; P4.3 is live and the annotations are now in place.
36 38 */
37 39 @RestController
38 40 @RequestMapping("/api/v1/partners/partners/{partnerId}/contacts")
... ... @@ -41,10 +43,12 @@ class ContactController(
41 43 ) {
42 44  
43 45 @GetMapping
  46 + @RequirePermission("partners.contact.read")
44 47 fun list(@PathVariable partnerId: UUID): List<ContactResponse> =
45 48 contactService.listFor(partnerId).map { it.toResponse() }
46 49  
47 50 @GetMapping("/{id}")
  51 + @RequirePermission("partners.contact.read")
48 52 fun get(
49 53 @PathVariable partnerId: UUID,
50 54 @PathVariable id: UUID,
... ... @@ -56,6 +60,7 @@ class ContactController(
56 60  
57 61 @PostMapping
58 62 @ResponseStatus(HttpStatus.CREATED)
  63 + @RequirePermission("partners.contact.create")
59 64 fun create(
60 65 @PathVariable partnerId: UUID,
61 66 @RequestBody @Valid request: CreateContactRequest,
... ... @@ -72,6 +77,7 @@ class ContactController(
72 77 ).toResponse()
73 78  
74 79 @PatchMapping("/{id}")
  80 + @RequirePermission("partners.contact.update")
75 81 fun update(
76 82 @PathVariable partnerId: UUID,
77 83 @PathVariable id: UUID,
... ... @@ -90,6 +96,7 @@ class ContactController(
90 96  
91 97 @DeleteMapping("/{id}")
92 98 @ResponseStatus(HttpStatus.NO_CONTENT)
  99 + @RequirePermission("partners.contact.deactivate")
93 100 fun deactivate(
94 101 @PathVariable partnerId: UUID,
95 102 @PathVariable id: UUID,
... ...
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/PartnerController.kt
... ... @@ -40,16 +40,19 @@ class PartnerController(
40 40 ) {
41 41  
42 42 @GetMapping
  43 + @RequirePermission("partners.partner.read")
43 44 fun list(): List<PartnerResponse> =
44 45 partnerService.list().map { it.toResponse(partnerService) }
45 46  
46 47 @GetMapping("/{id}")
  48 + @RequirePermission("partners.partner.read")
47 49 fun get(@PathVariable id: UUID): ResponseEntity<PartnerResponse> {
48 50 val partner = partnerService.findById(id) ?: return ResponseEntity.notFound().build()
49 51 return ResponseEntity.ok(partner.toResponse(partnerService))
50 52 }
51 53  
52 54 @GetMapping("/by-code/{code}")
  55 + @RequirePermission("partners.partner.read")
53 56 fun getByCode(@PathVariable code: String): ResponseEntity<PartnerResponse> {
54 57 val partner = partnerService.findByCode(code) ?: return ResponseEntity.notFound().build()
55 58 return ResponseEntity.ok(partner.toResponse(partnerService))
... ... @@ -57,6 +60,7 @@ class PartnerController(
57 60  
58 61 @PostMapping
59 62 @ResponseStatus(HttpStatus.CREATED)
  63 + @RequirePermission("partners.partner.create")
60 64 fun create(@RequestBody @Valid request: CreatePartnerRequest): PartnerResponse =
61 65 partnerService.create(
62 66 CreatePartnerCommand(
... ... @@ -73,6 +77,7 @@ class PartnerController(
73 77 ).toResponse(partnerService)
74 78  
75 79 @PatchMapping("/{id}")
  80 + @RequirePermission("partners.partner.update")
76 81 fun update(
77 82 @PathVariable id: UUID,
78 83 @RequestBody @Valid request: UpdatePartnerRequest,
... ...