ACL Management — integration guide#

The existing page in this section covers PalmyraPermissionEvaluator. This page is the how-do-I-stand-up-ACL-from-scratch walk-through — the schema, the AclController endpoints that ship with the extension, and how GroupHandler joins users to groups.

Step 1 — add the dependency#

implementation 'com.palmyralabs.palmyra.extn:palmyra-dbacl-mgmt:1.4.4'

Maven group .extn; Java package com.palmyralabs.palmyra.ext.aclmgmt.

Import AclMgmtConfiguration alongside PalmyraSpringConfiguration in your @SpringBootApplication:

@SpringBootApplication
@Import({ PalmyraSpringConfiguration.class, AclMgmtConfiguration.class })
public class AppMain { ... }

Step 2 — the ACL schema#

The extension manages five tables plus a user table you already have. Provision them via your migration tooling (Flyway / Liquibase / DevBootstrap SQL).

Table Managed by Purpose
xpm_user JPA entity (UserEntity) Users; augmented with random / salt / lock_expire columns if you also use palmyra-dbpwd-mgmt
xpm_group JPA entity (GroupEntity) Groups
xpm_acl_user JPA (@ManyToMany join on GroupEntity.users) User ↔ group membership
xpm_menu JPA entity (MenuEntity) Side-menu tree (hierarchical via parent self-FK)
xpm_acl_menu JPA (@ManyToMany join) Which groups can see which menu entries; carries a mask column
xpm_acl_class JDBC only (no JPA entity) Logical permission classes — one row per @Permission(value="...") name
xpm_acl_permission JDBC only Per-class operation codes (e.g. CUD, RS, Unlock)
xpm_acl_group_permission JDBC only group × permission → mask — the grant table the evaluator consults

The JPA-backed tables are auto-created by Hibernate ddl-auto=update when the extension’s entities are loaded. The JDBC-only tables need explicit DDL — see Step 3.

Step 3 — seed the ACL tables#

DDL for the three JDBC-only tables (MariaDB / MySQL syntax; adjust for your dialect):

CREATE TABLE xpm_acl_class (
    id          int          NOT NULL AUTO_INCREMENT,
    class_code  varchar(64)  NOT NULL,
    class_name  varchar(128) NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uq_xpm_acl_class_code (class_code)
);

CREATE TABLE xpm_acl_permission (
    id            int          NOT NULL AUTO_INCREMENT,
    class_id      int          NOT NULL,
    code          varchar(32)  NOT NULL,
    name          varchar(128) NOT NULL,
    display_order int          DEFAULT 0,
    PRIMARY KEY (id),
    UNIQUE KEY uq_xpm_acl_permission (class_id, code)
);

CREATE TABLE xpm_acl_group_permission (
    id            bigint      NOT NULL AUTO_INCREMENT,
    group_id      int         NOT NULL,
    permission_id int         NOT NULL,
    mask          int         DEFAULT 0,
    created_by    varchar(50) NOT NULL,
    last_upd_by   varchar(50) NOT NULL,
    created_on    datetime    NOT NULL,
    last_upd_on   datetime    NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uq_xpm_acl_group_permission (group_id, permission_id)
);

Seed rows — one xpm_acl_class row per domain type that a handler enforces @Permission("...") on, one xpm_acl_permission row per operation code. Example:

INSERT INTO xpm_acl_class (class_code, class_name) VALUES
  ('XpmUser',     'User Management'),
  ('XpmGroup',    'Group & ACL Management'),
  ('MrcpPatient', 'Patient');

INSERT INTO xpm_acl_permission (class_id, code, name, display_order) VALUES
  ((SELECT id FROM xpm_acl_class WHERE class_code='XpmUser'),     'CUD',    'Create/Update/Delete', 1),
  ((SELECT id FROM xpm_acl_class WHERE class_code='XpmUser'),     'RS',     'Read/List',            2),
  ((SELECT id FROM xpm_acl_class WHERE class_code='XpmUser'),     'Unlock', 'Unlock User',          3),
  ((SELECT id FROM xpm_acl_class WHERE class_code='XpmGroup'),    'CUD',    'Create/Update/Delete', 1),
  ((SELECT id FROM xpm_acl_class WHERE class_code='XpmGroup'),    'RS',     'Read/List',            2),
  ((SELECT id FROM xpm_acl_class WHERE class_code='MrcpPatient'), 'CUD',    'Create/Update/Delete', 1),
  ((SELECT id FROM xpm_acl_class WHERE class_code='MrcpPatient'), 'RS',     'Read/List',            2);

Then create a default admin group and grant every permission with mask=1:

INSERT INTO xpm_group (name, description, active, created_by, last_upd_by, created_on, last_upd_on)
VALUES ('AppAdmin', 'Admin group', 1, 'system', 'system', NOW(), NOW());

INSERT INTO xpm_acl_group_permission (group_id, permission_id, mask, created_by, last_upd_by, created_on, last_upd_on)
SELECT g.id, p.id, 1, 'system', 'system', NOW(), NOW()
FROM xpm_group g
CROSS JOIN xpm_acl_permission p
WHERE g.name = 'AppAdmin';

Finally map a user into the admin group:

INSERT INTO xpm_acl_user (group_id, user_id, active, created_by, last_upd_by, created_on, last_upd_on)
VALUES (
  (SELECT id FROM xpm_group WHERE name = 'AppAdmin'),
  (SELECT id FROM xpm_user  WHERE login_name = 'admin'),
  1, 'system', 'system', NOW(), NOW()
);

Step 4 — declare permissions on your handlers#

Once the classes / permissions exist in the DB, any handler annotated with @Permission gets gated by the evaluator:

@Component
@Permission(
    value  = "MrcpPatient",
    create = "CUD",
    update = "CUD",
    delete = "CUD",
    query  = "RS",
    read   = "RS"
)
@CrudMapping(mapping = "/patient", type = PatientModel.class, secondaryMapping = "/patient/{id}")
public class PatientHandler extends AbstractAclHandler
        implements QueryHandler, ReadHandler, CreateHandler, UpdateHandler, DeleteHandler {
    // Framework calls evaluator.hasPermission("MrcpPatient", "CUD") on write paths,
    // ("MrcpPatient", "RS") on reads, against the current user's groups.
}

Every non-matching call returns HTTP 403. Class codes must match the xpm_acl_class.class_code column — a typo is a silent “access denied”.

Endpoints shipped by the extension#

palmyra-dbacl-mgmt auto-registers a handful of handlers. Available endpoints (mounted under palmyra.servlet.prefix-path, so typically /api/palmyra/…):

Endpoint Handler Purpose
GET /acl/menu/listAll AclController.getSideMenu Side menu for the current user — returns rows with id, name, display_label, path, code, action, parent, CSV-joined children, display_order. Built for AsyncTreeMenu on the frontend.
GET /acl/permission AclController.getUserPermission Flat list of permissions (classCode, code, mask) for the logged-in user — handy to cache on the client for UI-level grant checks.
GET /admin/acl/group/{groupId}/menuList AclController.getMenuList Tree of all menus with the mask each carries for the given group — for the group-permission editor UI.
PUT /admin/acl/group/{groupId} AclController.saveMenuGroup Persist changes from the menu editor. Gated on hasPermission('XpmGroup', 'CUD').
PUT /admin/acl/permission/group/{groupId} AclController.saveGroupPermission Toggle permission grants on a group. Gated on hasPermission('XpmGroup', 'AclEdit').
GET /admin/acl/permission/group/{groupId} AclController.getGroupPermission Read grants for a group. Gated on hasPermission('XpmGroup', 'AclRead').
GET /admin/acl/group + CRUD on {id} GroupHandler Palmyra @CrudMapping handler — create / read / query / delete groups. No UpdateHandler — update goes through saveMenuGroup.
GET /admin/acl/user/{userId}/groups QueryGroupsByUserHandler Groups the given user belongs to.
GET /admin/acl/group/{groupId}/users QueryUsersByGroupHandler Users in the given group.
GET /admin/acl/user/{userId}/groups/lookup QueryGroupsLookupByUserHandler Lookup-only variant (id + name) — for ServerLookup dropdowns.
GET /admin/acl/group/{groupId}/users/lookup QueryUsersLookupByGroupHandler Same, other direction.

No user CRUD is shipped. The extension provides groups but not users — you wire your own @CrudMapping over the xpm_user table (or UserManagementHandler if porting from ada_project).

Permission evaluation path#

  1. Handler is invoked; @Permission(value="X", create="CUD") says “for writes, the user needs X:CUD”.
  2. PalmyraHandlerAclEvaluator resolves the current user → their groups → the group_permission rows → looks for (class_code="X", code="CUD", mask > 0).
  3. On match, the request proceeds. On miss, a EndPointForbiddenException is thrown → mapped to HTTP 403.

@PreAuthorize("hasPermission('X', 'CUD')") on a Spring @RestController goes through the same evaluator — so declarative ACL on controllers and on @CrudMapping handlers share one policy surface.

See also#