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,6 +19,7 @@ import org.vibeerp.pbc.catalog.application.ItemService
19 import org.vibeerp.pbc.catalog.application.UpdateItemCommand 19 import org.vibeerp.pbc.catalog.application.UpdateItemCommand
20 import org.vibeerp.pbc.catalog.domain.Item 20 import org.vibeerp.pbc.catalog.domain.Item
21 import org.vibeerp.pbc.catalog.domain.ItemType 21 import org.vibeerp.pbc.catalog.domain.ItemType
  22 +import org.vibeerp.platform.security.authz.RequirePermission
22 import java.util.UUID 23 import java.util.UUID
23 24
24 /** 25 /**
@@ -40,16 +41,19 @@ class ItemController( @@ -40,16 +41,19 @@ class ItemController(
40 ) { 41 ) {
41 42
42 @GetMapping 43 @GetMapping
  44 + @RequirePermission("catalog.item.read")
43 fun list(): List<ItemResponse> = 45 fun list(): List<ItemResponse> =
44 itemService.list().map { it.toResponse() } 46 itemService.list().map { it.toResponse() }
45 47
46 @GetMapping("/{id}") 48 @GetMapping("/{id}")
  49 + @RequirePermission("catalog.item.read")
47 fun get(@PathVariable id: UUID): ResponseEntity<ItemResponse> { 50 fun get(@PathVariable id: UUID): ResponseEntity<ItemResponse> {
48 val item = itemService.findById(id) ?: return ResponseEntity.notFound().build() 51 val item = itemService.findById(id) ?: return ResponseEntity.notFound().build()
49 return ResponseEntity.ok(item.toResponse()) 52 return ResponseEntity.ok(item.toResponse())
50 } 53 }
51 54
52 @GetMapping("/by-code/{code}") 55 @GetMapping("/by-code/{code}")
  56 + @RequirePermission("catalog.item.read")
53 fun getByCode(@PathVariable code: String): ResponseEntity<ItemResponse> { 57 fun getByCode(@PathVariable code: String): ResponseEntity<ItemResponse> {
54 val item = itemService.findByCode(code) ?: return ResponseEntity.notFound().build() 58 val item = itemService.findByCode(code) ?: return ResponseEntity.notFound().build()
55 return ResponseEntity.ok(item.toResponse()) 59 return ResponseEntity.ok(item.toResponse())
@@ -57,6 +61,7 @@ class ItemController( @@ -57,6 +61,7 @@ class ItemController(
57 61
58 @PostMapping 62 @PostMapping
59 @ResponseStatus(HttpStatus.CREATED) 63 @ResponseStatus(HttpStatus.CREATED)
  64 + @RequirePermission("catalog.item.create")
60 fun create(@RequestBody @Valid request: CreateItemRequest): ItemResponse = 65 fun create(@RequestBody @Valid request: CreateItemRequest): ItemResponse =
61 itemService.create( 66 itemService.create(
62 CreateItemCommand( 67 CreateItemCommand(
@@ -70,6 +75,7 @@ class ItemController( @@ -70,6 +75,7 @@ class ItemController(
70 ).toResponse() 75 ).toResponse()
71 76
72 @PatchMapping("/{id}") 77 @PatchMapping("/{id}")
  78 + @RequirePermission("catalog.item.update")
73 fun update( 79 fun update(
74 @PathVariable id: UUID, 80 @PathVariable id: UUID,
75 @RequestBody @Valid request: UpdateItemRequest, 81 @RequestBody @Valid request: UpdateItemRequest,
@@ -86,6 +92,7 @@ class ItemController( @@ -86,6 +92,7 @@ class ItemController(
86 92
87 @DeleteMapping("/{id}") 93 @DeleteMapping("/{id}")
88 @ResponseStatus(HttpStatus.NO_CONTENT) 94 @ResponseStatus(HttpStatus.NO_CONTENT)
  95 + @RequirePermission("catalog.item.deactivate")
89 fun deactivate(@PathVariable id: UUID) { 96 fun deactivate(@PathVariable id: UUID) {
90 itemService.deactivate(id) 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,6 +17,7 @@ import org.vibeerp.pbc.catalog.application.CreateUomCommand
17 import org.vibeerp.pbc.catalog.application.UomService 17 import org.vibeerp.pbc.catalog.application.UomService
18 import org.vibeerp.pbc.catalog.application.UpdateUomCommand 18 import org.vibeerp.pbc.catalog.application.UpdateUomCommand
19 import org.vibeerp.pbc.catalog.domain.Uom 19 import org.vibeerp.pbc.catalog.domain.Uom
  20 +import org.vibeerp.platform.security.authz.RequirePermission
20 import java.util.UUID 21 import java.util.UUID
21 22
22 /** 23 /**
@@ -38,16 +39,19 @@ class UomController( @@ -38,16 +39,19 @@ class UomController(
38 ) { 39 ) {
39 40
40 @GetMapping 41 @GetMapping
  42 + @RequirePermission("catalog.uom.read")
41 fun list(): List<UomResponse> = 43 fun list(): List<UomResponse> =
42 uomService.list().map { it.toResponse() } 44 uomService.list().map { it.toResponse() }
43 45
44 @GetMapping("/{id}") 46 @GetMapping("/{id}")
  47 + @RequirePermission("catalog.uom.read")
45 fun get(@PathVariable id: UUID): ResponseEntity<UomResponse> { 48 fun get(@PathVariable id: UUID): ResponseEntity<UomResponse> {
46 val uom = uomService.findById(id) ?: return ResponseEntity.notFound().build() 49 val uom = uomService.findById(id) ?: return ResponseEntity.notFound().build()
47 return ResponseEntity.ok(uom.toResponse()) 50 return ResponseEntity.ok(uom.toResponse())
48 } 51 }
49 52
50 @GetMapping("/by-code/{code}") 53 @GetMapping("/by-code/{code}")
  54 + @RequirePermission("catalog.uom.read")
51 fun getByCode(@PathVariable code: String): ResponseEntity<UomResponse> { 55 fun getByCode(@PathVariable code: String): ResponseEntity<UomResponse> {
52 val uom = uomService.findByCode(code) ?: return ResponseEntity.notFound().build() 56 val uom = uomService.findByCode(code) ?: return ResponseEntity.notFound().build()
53 return ResponseEntity.ok(uom.toResponse()) 57 return ResponseEntity.ok(uom.toResponse())
@@ -55,6 +59,7 @@ class UomController( @@ -55,6 +59,7 @@ class UomController(
55 59
56 @PostMapping 60 @PostMapping
57 @ResponseStatus(HttpStatus.CREATED) 61 @ResponseStatus(HttpStatus.CREATED)
  62 + @RequirePermission("catalog.uom.create")
58 fun create(@RequestBody @Valid request: CreateUomRequest): UomResponse = 63 fun create(@RequestBody @Valid request: CreateUomRequest): UomResponse =
59 uomService.create( 64 uomService.create(
60 CreateUomCommand( 65 CreateUomCommand(
@@ -65,6 +70,7 @@ class UomController( @@ -65,6 +70,7 @@ class UomController(
65 ).toResponse() 70 ).toResponse()
66 71
67 @PatchMapping("/{id}") 72 @PatchMapping("/{id}")
  73 + @RequirePermission("catalog.uom.update")
68 fun update( 74 fun update(
69 @PathVariable id: UUID, 75 @PathVariable id: UUID,
70 @RequestBody @Valid request: UpdateUomRequest, 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,6 +19,7 @@ import org.vibeerp.pbc.partners.application.CreateAddressCommand
19 import org.vibeerp.pbc.partners.application.UpdateAddressCommand 19 import org.vibeerp.pbc.partners.application.UpdateAddressCommand
20 import org.vibeerp.pbc.partners.domain.Address 20 import org.vibeerp.pbc.partners.domain.Address
21 import org.vibeerp.pbc.partners.domain.AddressType 21 import org.vibeerp.pbc.partners.domain.AddressType
  22 +import org.vibeerp.platform.security.authz.RequirePermission
22 import java.util.UUID 23 import java.util.UUID
23 24
24 /** 25 /**
@@ -38,10 +39,12 @@ class AddressController( @@ -38,10 +39,12 @@ class AddressController(
38 ) { 39 ) {
39 40
40 @GetMapping 41 @GetMapping
  42 + @RequirePermission("partners.address.read")
41 fun list(@PathVariable partnerId: UUID): List<AddressResponse> = 43 fun list(@PathVariable partnerId: UUID): List<AddressResponse> =
42 addressService.listFor(partnerId).map { it.toResponse() } 44 addressService.listFor(partnerId).map { it.toResponse() }
43 45
44 @GetMapping("/{id}") 46 @GetMapping("/{id}")
  47 + @RequirePermission("partners.address.read")
45 fun get( 48 fun get(
46 @PathVariable partnerId: UUID, 49 @PathVariable partnerId: UUID,
47 @PathVariable id: UUID, 50 @PathVariable id: UUID,
@@ -53,6 +56,7 @@ class AddressController( @@ -53,6 +56,7 @@ class AddressController(
53 56
54 @PostMapping 57 @PostMapping
55 @ResponseStatus(HttpStatus.CREATED) 58 @ResponseStatus(HttpStatus.CREATED)
  59 + @RequirePermission("partners.address.create")
56 fun create( 60 fun create(
57 @PathVariable partnerId: UUID, 61 @PathVariable partnerId: UUID,
58 @RequestBody @Valid request: CreateAddressRequest, 62 @RequestBody @Valid request: CreateAddressRequest,
@@ -72,6 +76,7 @@ class AddressController( @@ -72,6 +76,7 @@ class AddressController(
72 ).toResponse() 76 ).toResponse()
73 77
74 @PatchMapping("/{id}") 78 @PatchMapping("/{id}")
  79 + @RequirePermission("partners.address.update")
75 fun update( 80 fun update(
76 @PathVariable partnerId: UUID, 81 @PathVariable partnerId: UUID,
77 @PathVariable id: UUID, 82 @PathVariable id: UUID,
@@ -93,6 +98,7 @@ class AddressController( @@ -93,6 +98,7 @@ class AddressController(
93 98
94 @DeleteMapping("/{id}") 99 @DeleteMapping("/{id}")
95 @ResponseStatus(HttpStatus.NO_CONTENT) 100 @ResponseStatus(HttpStatus.NO_CONTENT)
  101 + @RequirePermission("partners.address.delete")
96 fun delete( 102 fun delete(
97 @PathVariable partnerId: UUID, 103 @PathVariable partnerId: UUID,
98 @PathVariable id: UUID, 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,6 +18,7 @@ import org.vibeerp.pbc.partners.application.ContactService
18 import org.vibeerp.pbc.partners.application.CreateContactCommand 18 import org.vibeerp.pbc.partners.application.CreateContactCommand
19 import org.vibeerp.pbc.partners.application.UpdateContactCommand 19 import org.vibeerp.pbc.partners.application.UpdateContactCommand
20 import org.vibeerp.pbc.partners.domain.Contact 20 import org.vibeerp.pbc.partners.domain.Contact
  21 +import org.vibeerp.platform.security.authz.RequirePermission
21 import java.util.UUID 22 import java.util.UUID
22 23
23 /** 24 /**
@@ -28,11 +29,12 @@ import java.util.UUID @@ -28,11 +29,12 @@ import java.util.UUID
28 * relationship — every operation is implicitly scoped to a specific 29 * relationship — every operation is implicitly scoped to a specific
29 * partner. 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 @RestController 39 @RestController
38 @RequestMapping("/api/v1/partners/partners/{partnerId}/contacts") 40 @RequestMapping("/api/v1/partners/partners/{partnerId}/contacts")
@@ -41,10 +43,12 @@ class ContactController( @@ -41,10 +43,12 @@ class ContactController(
41 ) { 43 ) {
42 44
43 @GetMapping 45 @GetMapping
  46 + @RequirePermission("partners.contact.read")
44 fun list(@PathVariable partnerId: UUID): List<ContactResponse> = 47 fun list(@PathVariable partnerId: UUID): List<ContactResponse> =
45 contactService.listFor(partnerId).map { it.toResponse() } 48 contactService.listFor(partnerId).map { it.toResponse() }
46 49
47 @GetMapping("/{id}") 50 @GetMapping("/{id}")
  51 + @RequirePermission("partners.contact.read")
48 fun get( 52 fun get(
49 @PathVariable partnerId: UUID, 53 @PathVariable partnerId: UUID,
50 @PathVariable id: UUID, 54 @PathVariable id: UUID,
@@ -56,6 +60,7 @@ class ContactController( @@ -56,6 +60,7 @@ class ContactController(
56 60
57 @PostMapping 61 @PostMapping
58 @ResponseStatus(HttpStatus.CREATED) 62 @ResponseStatus(HttpStatus.CREATED)
  63 + @RequirePermission("partners.contact.create")
59 fun create( 64 fun create(
60 @PathVariable partnerId: UUID, 65 @PathVariable partnerId: UUID,
61 @RequestBody @Valid request: CreateContactRequest, 66 @RequestBody @Valid request: CreateContactRequest,
@@ -72,6 +77,7 @@ class ContactController( @@ -72,6 +77,7 @@ class ContactController(
72 ).toResponse() 77 ).toResponse()
73 78
74 @PatchMapping("/{id}") 79 @PatchMapping("/{id}")
  80 + @RequirePermission("partners.contact.update")
75 fun update( 81 fun update(
76 @PathVariable partnerId: UUID, 82 @PathVariable partnerId: UUID,
77 @PathVariable id: UUID, 83 @PathVariable id: UUID,
@@ -90,6 +96,7 @@ class ContactController( @@ -90,6 +96,7 @@ class ContactController(
90 96
91 @DeleteMapping("/{id}") 97 @DeleteMapping("/{id}")
92 @ResponseStatus(HttpStatus.NO_CONTENT) 98 @ResponseStatus(HttpStatus.NO_CONTENT)
  99 + @RequirePermission("partners.contact.deactivate")
93 fun deactivate( 100 fun deactivate(
94 @PathVariable partnerId: UUID, 101 @PathVariable partnerId: UUID,
95 @PathVariable id: UUID, 102 @PathVariable id: UUID,
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/PartnerController.kt
@@ -40,16 +40,19 @@ class PartnerController( @@ -40,16 +40,19 @@ class PartnerController(
40 ) { 40 ) {
41 41
42 @GetMapping 42 @GetMapping
  43 + @RequirePermission("partners.partner.read")
43 fun list(): List<PartnerResponse> = 44 fun list(): List<PartnerResponse> =
44 partnerService.list().map { it.toResponse(partnerService) } 45 partnerService.list().map { it.toResponse(partnerService) }
45 46
46 @GetMapping("/{id}") 47 @GetMapping("/{id}")
  48 + @RequirePermission("partners.partner.read")
47 fun get(@PathVariable id: UUID): ResponseEntity<PartnerResponse> { 49 fun get(@PathVariable id: UUID): ResponseEntity<PartnerResponse> {
48 val partner = partnerService.findById(id) ?: return ResponseEntity.notFound().build() 50 val partner = partnerService.findById(id) ?: return ResponseEntity.notFound().build()
49 return ResponseEntity.ok(partner.toResponse(partnerService)) 51 return ResponseEntity.ok(partner.toResponse(partnerService))
50 } 52 }
51 53
52 @GetMapping("/by-code/{code}") 54 @GetMapping("/by-code/{code}")
  55 + @RequirePermission("partners.partner.read")
53 fun getByCode(@PathVariable code: String): ResponseEntity<PartnerResponse> { 56 fun getByCode(@PathVariable code: String): ResponseEntity<PartnerResponse> {
54 val partner = partnerService.findByCode(code) ?: return ResponseEntity.notFound().build() 57 val partner = partnerService.findByCode(code) ?: return ResponseEntity.notFound().build()
55 return ResponseEntity.ok(partner.toResponse(partnerService)) 58 return ResponseEntity.ok(partner.toResponse(partnerService))
@@ -57,6 +60,7 @@ class PartnerController( @@ -57,6 +60,7 @@ class PartnerController(
57 60
58 @PostMapping 61 @PostMapping
59 @ResponseStatus(HttpStatus.CREATED) 62 @ResponseStatus(HttpStatus.CREATED)
  63 + @RequirePermission("partners.partner.create")
60 fun create(@RequestBody @Valid request: CreatePartnerRequest): PartnerResponse = 64 fun create(@RequestBody @Valid request: CreatePartnerRequest): PartnerResponse =
61 partnerService.create( 65 partnerService.create(
62 CreatePartnerCommand( 66 CreatePartnerCommand(
@@ -73,6 +77,7 @@ class PartnerController( @@ -73,6 +77,7 @@ class PartnerController(
73 ).toResponse(partnerService) 77 ).toResponse(partnerService)
74 78
75 @PatchMapping("/{id}") 79 @PatchMapping("/{id}")
  80 + @RequirePermission("partners.partner.update")
76 fun update( 81 fun update(
77 @PathVariable id: UUID, 82 @PathVariable id: UUID,
78 @RequestBody @Valid request: UpdatePartnerRequest, 83 @RequestBody @Valid request: UpdatePartnerRequest,