From 0c6f1c41f37668ed3abc2801ddd5554e9006e529 Mon Sep 17 00:00:00 2001 From: vaschenkoda Date: Fri, 10 Jul 2026 16:28:46 +0300 Subject: [PATCH 1/3] roles: decompose the role skill and close the UI-login invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the overloaded jmix-create-resource-role — which covered the access model, resource roles AND row-level roles under one misleading name — into: - jmix-role-based-access (new umbrella): the access model (resource roles are additive/no-deny; row-level roles are restrictive), security scope (UI/API), the mandatory ui.loginToUi login invariant, and DEBUG-logging diagnostics for silent access denials. Read before creating any role. - jmix-create-resource-role: resource roles only; adds minimum-policy mapping (an action like confirm/approve = READ+UPDATE, not ALL), a gotcha against hand-rolling role checks via a raw GrantedAuthority (use AccessManager with CrudEntityContext/EntityAttributeContext), and a non-admin login self-check. - jmix-create-row-level-role (new): @RowLevelRole with JPQL and predicate policies, incl. per-row write gating — previously buried under the resource-role name and hard to discover. Why: when driving an LLM agent to build apps on the pack, domain-only roles repeatedly could not log into the UI (generic "Login failed") because ui.loginToUi / the built-in ui-minimal role was never mentioned — the single most commonly missed defect. The umbrella now states it as a mandatory invariant with a self-check. Identifiers verified against Jmix 2.8.2 jars. AGENTS.md skill routing and .studio/skills-manifest.json updated accordingly. Co-Authored-By: Claude Opus 4.8 --- .studio/skills-manifest.json | 4 +- content/AGENTS.md | 8 +- .../skills/jmix-create-resource-role/SKILL.md | 74 +++++++------ .../jmix-create-row-level-role/SKILL.md | 103 ++++++++++++++++++ .../skills/jmix-role-based-access/SKILL.md | 84 ++++++++++++++ 5 files changed, 235 insertions(+), 38 deletions(-) create mode 100644 content/skills/jmix-create-row-level-role/SKILL.md create mode 100644 content/skills/jmix-role-based-access/SKILL.md diff --git a/.studio/skills-manifest.json b/.studio/skills-manifest.json index bfe050a..00debe1 100644 --- a/.studio/skills-manifest.json +++ b/.studio/skills-manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sha256": "41f41a9daf9573ed28eae2c87c23f2624489a52a3809b9792ac1701921390bca", + "sha256": "55637a285dfb0a8b533789bafea152f2a505c11c3c1208a7988f4b8dcf3a9a50", "skills": [ "jmix-add-dialog-detail-flow", "jmix-add-entity-event-listener", @@ -15,9 +15,11 @@ "jmix-create-liquibase-changelog", "jmix-create-list-view", "jmix-create-resource-role", + "jmix-create-row-level-role", "jmix-create-service", "jmix-create-test", "jmix-ide-static-analysis", + "jmix-role-based-access", "jmix-verify-api-symbol", "jmix-verify-bootrun" ], diff --git a/content/AGENTS.md b/content/AGENTS.md index 6fd475f..c50f22e 100644 --- a/content/AGENTS.md +++ b/content/AGENTS.md @@ -92,7 +92,9 @@ READ the most specific skill for each artifact: - Detail dialog from a button/action, OR master-row selection → filtered child grid: `jmix-add-dialog-detail-flow` - Entity lifecycle/event business logic: `jmix-add-entity-event-listener` - Database schema: `jmix-create-liquibase-changelog` -- Resource roles: `jmix-create-resource-role` +- Role-based access — model, security scope, `ui.loginToUi` login invariant (READ FIRST before any role): `jmix-role-based-access` +- Resource role — WHAT a user can do (entity/attribute/view/menu policies): `jmix-create-resource-role` +- Row-level role — WHICH rows a user sees (JPQL/predicate policies): `jmix-create-row-level-role` - User-visible text / entity-enum captions: `jmix-add-i18n-keys` - Tests: `jmix-create-test` - Fetch plans / unfetched-reference / N+1 tuning: `jmix-configure-fetch-plan` @@ -106,7 +108,9 @@ For each new persistent entity, run through: `jmix-create-entity` + `jmix-add-i18n-keys`. For a user-facing entity, also add a list and/or detail view (`jmix-create-list-view`, `jmix-create-detail-view`) and a view policy in every role that can open them — **including dialog-only detail views opened -from a composition table**. +from a composition table**. Any user who logs into the UI also needs +`ui-minimal` / `ui.loginToUi` (the most commonly missed defect) — see +`jmix-role-based-access`. Service- or listener-level defaulting does NOT relieve the entity from defaulting required fields on initial persist — defaults must work through diff --git a/content/skills/jmix-create-resource-role/SKILL.md b/content/skills/jmix-create-resource-role/SKILL.md index 76f49be..9740e81 100644 --- a/content/skills/jmix-create-resource-role/SKILL.md +++ b/content/skills/jmix-create-resource-role/SKILL.md @@ -1,16 +1,19 @@ --- name: jmix-create-resource-role -description: Create or update Jmix resource roles with entity, attribute, view, and menu policies, including the CREATE-implies-MODIFY rule. +description: Create or update a Jmix @ResourceRole with entity, attribute, view, and menu policies (CREATE-implies-MODIFY, minimum-policy mapping). See jmix-role-based-access for the model, security scope, and the ui.loginToUi login invariant. --- # Create Resource Role -Use this skill when adding or changing Jmix security access. +Use this skill to create/update a `@ResourceRole` — WHAT a user can do (entity, +attribute, view, and menu policies). First read `jmix-role-based-access` for the +model, security scope, and the mandatory `ui.loginToUi` login invariant. For +row-level (WHICH rows) roles, see `jmix-create-row-level-role`. -The model is ADDITIVE / no-deny: if any assigned role grants access the user has -it, and there is no deny-policy. A role interface may `extend` several role -interfaces of the SAME kind to compose their policies (a role cannot mix -`@ResourceRole` and `@RowLevelRole`). +**Do not forget the login invariant:** a user with only this domain role CANNOT +log into the UI without `ui.loginToUi` — assign the built-in `ui-minimal` role too +(or add `@SpecificPolicy(resources = "ui.loginToUi")` + a `MainView` `@ViewPolicy`). +See `jmix-role-based-access`. ## TOP RULE — CREATE implies MODIFY @@ -42,13 +45,16 @@ read-only even at creation (e.g. auto-generated audit fields), exclude them from ## Requirement wording → policy actions Map the EXACT wording of the requirement to entity-policy actions. Re-read the -requirement for the entity BEFORE writing the policy block. +requirement for the entity BEFORE writing the policy block. Grant the MINIMUM the +workflow needs — a role that only reviews/confirms a record needs `READ`+`UPDATE`, +NOT `ALL`. | Requirement wording (about an entity) | EntityPolicyAction | |-------------------------------------------------|----------------------------| | "view only", "read only" | `READ` | | "view and create", "create only" | `READ`, `CREATE` | | "view, edit" | `READ`, `UPDATE` | +| "confirm/approve/reject", "change status only" | `READ`, `UPDATE` (not `ALL`) | | "view, create, delete" (no update) | `READ`, `CREATE`, `DELETE` | | "full CRUD", "manage", "all operations" | `ALL` | | "cannot be updated", "immutable" | do NOT include `UPDATE` | @@ -82,33 +88,11 @@ A `@ViewPolicy` that lists parent list+detail but omits the child detail will pa compilation and fail at runtime when the user clicks "+" inside the parent's composition table. -## Row-Level roles +## Row-level roles -Row-level roles are a separate first-class concept from resource roles: a resource -role grants *what* you can do, a row-level role restricts *which rows* you see. They -live in their own interface annotated with `@RowLevelRole` and never mix with -`@ResourceRole`. - -- `@JpqlRowLevelPolicy(entityClass = ..., where = "...")` filters at the database - level. Use `{E}` as the entity alias and `:current_user_*` params (e.g. - `:current_user_username`). -- `@PredicateRowLevelPolicy(entityClass = ..., actions = {...})` filters in-memory; - the method returns a `RowLevelPredicate` / `RowLevelBiPredicate`. Use for logic - that JPQL cannot express and for non-read operations. - -Gotcha: a JPQL policy only affects the root entity of a loaded graph. If the same -entity is also loaded as a *collection* inside another entity's graph, define BOTH -a `@JpqlRowLevelPolicy` and a `@PredicateRowLevelPolicy` for it to keep access -consistent. - -```java -@RowLevelRole(name = "Own Orders Only", code = "app_OwnOrdersOnly") -public interface OwnOrdersOnlyRole { - @JpqlRowLevelPolicy(entityClass = Order.class, - where = "{E}.createdBy = :current_user_username") - void orderPolicy(); -} -``` +Row-level roles (WHICH rows a user can access) are a SEPARATE concept — see +`jmix-create-row-level-role`. A single interface cannot mix `@ResourceRole` and +`@RowLevelRole`. ## Mechanical self-check before finishing @@ -125,10 +109,23 @@ your own code: `` GROUP id does NOT grant its items. 3. **Every reachable view has a `@ViewPolicy` entry.** Include composition-dialog detail views opened from a parent grid even though they have no menu item. +4. **UI login works for every non-admin role.** A user with only domain roles + cannot log into the UI without `ui.loginToUi` (via a `ui-minimal` assignment or + a `@SpecificPolicy` on the role). Verify a seeded NON-admin user actually reaches + `MainView`, not "Login failed" — testing/verifying as `admin` (full access) masks + this class of defect entirely. See `jmix-role-based-access`. To assert a permission in Java at runtime, inject `AccessManager` and call -`applyRegisteredConstraints(...)` on a context (e.g. `EntityOperationContext`), then -check `isPermitted()`. +`applyRegisteredConstraints(...)` on a context (e.g. `CrudEntityContext` for entity +CRUD, `EntityAttributeContext` for an attribute), then check `isPermitted()`. + +To show/hide a UI action or button by the current user's role or permission, use +that `AccessManager` check (or a view-level `@ViewPolicy`). Do NOT hand-roll a role +check by reading `SecurityContextHolder`/`GrantedAuthority` strings and comparing to +a raw role code: the granted authority is not the bare code (Jmix prefixes/maps it), +so such a check silently fails — the action stays hidden even for authorized users +and no error is raised. This defect compiles and passes green tests; it surfaces +only when a real non-admin user opens the view. ## Steps @@ -172,6 +169,11 @@ public interface EmployeeRole { } ``` +This role grants domain access only. A user assigned just `EmployeeRole` still +CANNOT log into the UI — assign the built-in `ui-minimal` role too (or add +`@SpecificPolicy(resources = "ui.loginToUi")` + a `MainView` `@ViewPolicy` here). +See `jmix-role-based-access`. + ## Role Matrix | Surface | Required? | Policy | @@ -209,3 +211,5 @@ group itself and the project's security checks use that group id. - View policies only for list views while create/edit dialogs use detail views. - Menu policy for a parent group when the user needs access to concrete menu items. - Menu policy for views that are not menu entries. +- A domain role for a UI user without a `ui-minimal` assignment or `ui.loginToUi` (cannot log in — see `jmix-role-based-access`). +- Hand-rolled role checks via `SecurityContextHolder`/`GrantedAuthority` (use `AccessManager`). diff --git a/content/skills/jmix-create-row-level-role/SKILL.md b/content/skills/jmix-create-row-level-role/SKILL.md new file mode 100644 index 0000000..58698b4 --- /dev/null +++ b/content/skills/jmix-create-row-level-role/SKILL.md @@ -0,0 +1,103 @@ +--- +name: jmix-create-row-level-role +description: Create Jmix row-level roles (@RowLevelRole) that restrict WHICH rows a user can access — read AND create/update/delete per instance — via JPQL and predicate policies. See jmix-role-based-access for the model. +--- + +# Create Row-Level Role + +Use this skill to restrict access at the ROW (entity-instance) level — not only +which rows a user can READ/see, but also which rows they can CREATE, UPDATE, or +DELETE (e.g. "see all orders but edit only your own"). Resource roles +(`jmix-create-resource-role`) grant type-level CRUD; a row-level role narrows it to +specific instances. For the overall model, scope, and the UI-login invariant, see +`jmix-role-based-access`. + +A row-level role lives in its OWN interface annotated with `@RowLevelRole` and +never mixes with `@ResourceRole` — a role cannot be both kinds. + +## Policy types + +- `@JpqlRowLevelPolicy(entityClass = ..., where = "...")` filters at the DATABASE + level and affects LOADING only (read): it adds a where/join clause so restricted + rows are never returned. Use `{E}` as the entity alias and `:current_user_*` + params (e.g. `:current_user_username`). Best for read filtering the DB can express. +- `@PredicateRowLevelPolicy(entityClass = ..., actions = {...})` is tested per + instance and covers ANY of `READ`, `CREATE`, `UPDATE`, `DELETE` — this is how you + gate WRITE operations per row (e.g. read all rows but UPDATE/DELETE only owned + ones). The method returns a `RowLevelPredicate` / `RowLevelBiPredicate`; use it + for logic JPQL cannot express and for any non-read action. + +## Gotcha — JPQL policy only covers the root entity of a loaded graph + +A `@JpqlRowLevelPolicy` filters the entity only when it is the ROOT of a load. If +the same entity is ALSO loaded as a *collection* inside another entity's graph, the +JPQL policy does not apply there. Define BOTH a `@JpqlRowLevelPolicy` and a +`@PredicateRowLevelPolicy` for that entity to keep access consistent across both +load paths. + +## Template + +```java +import io.jmix.security.role.annotation.RowLevelRole; +import io.jmix.security.role.annotation.JpqlRowLevelPolicy; + +@RowLevelRole(name = "Own Orders Only", code = "app_OwnOrdersOnly") +public interface OwnOrdersOnlyRole { + @JpqlRowLevelPolicy(entityClass = Order.class, + where = "{E}.createdBy = :current_user_username") + void orderPolicy(); +} +``` + +Note: a JPQL policy method is `void` (the annotation carries the `where`). A +PREDICATE policy method is a `default` method that RETURNS the predicate. + +## Predicate policy example — gating writes per row + +A predicate policy gates a specific ACTION per instance. To let a user read all +orders but modify only their own, apply it to `UPDATE`/`DELETE`. Use +`RowLevelBiPredicate` when you need the current user: + +```java +@PredicateRowLevelPolicy(entityClass = Order.class, + actions = {RowLevelPolicyAction.UPDATE, RowLevelPolicyAction.DELETE}) +default RowLevelBiPredicate onlyOwnOrdersEditable() { + return (order, applicationContext) -> { + CurrentAuthentication auth = applicationContext.getBean(CurrentAuthentication.class); + return order.getCreatedBy() != null + && order.getCreatedBy().equals(auth.getUser().getUsername()); + }; +} +``` + +When the current user is not needed, return a plain `RowLevelPredicate`: + +```java +@PredicateRowLevelPolicy(entityClass = Order.class, actions = {RowLevelPolicyAction.READ}) +default RowLevelPredicate notArchived() { + return order -> !Boolean.TRUE.equals(order.getArchived()); +} +``` + +`RowLevelPolicyAction` (`io.jmix.security.model.RowLevelPolicyAction`) has values +`READ`, `CREATE`, `UPDATE`, `DELETE`. Verify the `RowLevelPredicate` / +`RowLevelBiPredicate` import via `jmix-verify-api-symbol`. + +## Steps + +1. Create the `@RowLevelRole` interface (separate from any `@ResourceRole`). +2. Add a `@JpqlRowLevelPolicy` for DB-level read filtering, using `{E}` and + `:current_user_*` params. +3. Add a `@PredicateRowLevelPolicy` where JPQL cannot express the rule, or where the + entity is also loaded as a nested collection (see the gotcha above). +4. Assign the row-level role to users alongside their resource role(s). + +## Forbidden + +- Mixing `@RowLevelRole` and `@ResourceRole` policies in one interface. +- A JPQL policy alone for an entity also loaded as a nested collection (add the + predicate policy too). +- Using a row-level role to GRANT a base operation — the resource role must first + grant the action on the entity type; a row-level predicate only NARROWS an + already-granted operation to specific instances (it cannot enable an operation + the resource role denies). diff --git a/content/skills/jmix-role-based-access/SKILL.md b/content/skills/jmix-role-based-access/SKILL.md new file mode 100644 index 0000000..a0a77cb --- /dev/null +++ b/content/skills/jmix-role-based-access/SKILL.md @@ -0,0 +1,84 @@ +--- +name: jmix-role-based-access +description: Overview of Jmix role-based access — resource vs row-level roles, security scope (UI/API), and the mandatory ui.loginToUi login invariant. Read this before creating any role. +--- + +# Role-based access in Jmix + +Read this BEFORE adding or changing security, then use the specific skill: + +- WHAT a user can do (entities, attributes, views, menu) → `jmix-create-resource-role` +- WHICH rows a user can access — read and create/update/delete per row → `jmix-create-row-level-role` + +## Model + +Two role kinds with OPPOSITE effects — do not conflate them: + +- **Resource roles** (`@ResourceRole`) are ADDITIVE / no-deny: they GRANT access + (entities, attributes, views, menu). If any assigned resource role grants + something, the user has it, and there is no deny-policy. Compose them by + `extend`ing several resource-role interfaces. +- **Row-level roles** (`@RowLevelRole`) are RESTRICTIVE: they narrow access at the + ROW level — not only which rows a user can READ/see, but also which rows they can + CREATE/UPDATE/DELETE (an effective row-level deny). With no row-level role a user + has every row that resource roles permit; a row-level policy narrows that set. + +The "additive / no-deny" property applies to resource roles only — it is NOT a +statement about the whole security model. A single role interface CANNOT mix +`@ResourceRole` and `@RowLevelRole`. + +## Logging into the UI — a MANDATORY invariant (the most commonly missed defect) + +Entity/view/menu grants do NOT let a user log into the UI. Any user who logs into +the web UI needs, in addition to domain roles, the `ui.loginToUi` specific +permission and access to `MainView`. Without it the login is +rejected with a generic "Login failed" AFTER authentication succeeds — a symptom +that looks like a wrong password but is actually a missing UI-access grant. + +Pick one: +- assign the built-in **`ui-minimal`** resource role (code `"ui-minimal"`) to the + user ALONGSIDE the domain role — preferred; or +- declare on the domain role itself + `@SpecificPolicy(resources = "ui.loginToUi")` plus a `@ViewPolicy` covering + `MainView`. + +The default Full-Stack scaffold hides this: the demo `admin` is assigned +`system-full-access` (the full superuser role, which already includes UI login), +so the trap appears the moment you create YOUR OWN users with narrower roles. If +you seed users via Liquibase, assign `ui-minimal` in the same changeset next to +the domain role. Recognizable symptom: `admin` logs in but a domain-only +`manager`/`officer` gets "Login failed" with correct credentials. + +**Self-check:** verify a seeded NON-admin user actually reaches `MainView`, not +"Login failed" — testing/verifying as `admin` (full access) masks this entirely. + +## Security scope (UI vs API) + +A role has a scope — `@ResourceRole(..., scope = SecurityScope.UI)`, +`SecurityScope.API`, or both — controlling where it applies (web UI vs REST/API +clients). Use `SecurityScope.UI` for roles that gate the web interface; +`ui-minimal` is UI-scoped. Scope does NOT replace the UI-login invariant above: a +UI-scoped domain role still cannot log in without `ui.loginToUi`. + +## Diagnosing access problems (DEBUG logging) + +Access denials are SILENT in the UI — a "Login failed" message, a hidden button, +or an empty grid, with no cause shown. Turn on DEBUG logging to see the actual +denial in the console instead of guessing: + +```properties +logging.level.io.jmix.core.AccessLogger=DEBUG # entity/attribute/row denials +logging.level.io.jmix.security=DEBUG +logging.level.org.springframework.security=DEBUG # authentication / login rejection +``` + +The log then names the exact permission or row that was denied — turning a silent +"Login failed" or missing action into a concrete, fixable cause. Prefer this over +re-reading role code when a permission problem is suspected. + +## Assigning roles to users + +Roles take effect only when assigned to a user (role assignment). When seeding +users via Liquibase, insert both the domain role AND `ui-minimal` for each UI user +in the same changeset. A user with domain roles but no `ui-minimal`/`ui.loginToUi` +cannot log in. From b9755ae82a45968bc22e890de9fab759d27f3998 Mon Sep 17 00:00:00 2001 From: vaschenkoda Date: Fri, 10 Jul 2026 16:28:46 +0300 Subject: [PATCH 2/3] skills: accuracy and completeness fixes surfaced by an agentic-dev review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each item is a general framework-idiom gap or an inaccurate claim, verified against Jmix 2.8.2 jars/sources: - jmix-create-list-view: a column on a reference attribute needs the reference added to the fetch plan (this is an N+1/loading concern — defer the exact rules to jmix-configure-fetch-plan; it is NOT a "reference throws unfetched" crash, which is the local-attribute-in-a-partial-plan case). list_create / list_edit require an existing .detail view (else NoSuchViewException on first click). Added a menu.xml registration example. - jmix-create-entity: a plain @ManyToOne needs an explicit fetch = LAZY (JPA defaults to EAGER); @Column(unique = true) alone does not create the constraint (Liquibase builds the schema). - jmix-create-liquibase-changelog: added an example; the composition-child FK cascade now depends on the delete mode — soft-deleted entities cascade at the app layer (@OnDelete, no DB onDelete), hard-deleted entities MUST declare onDelete="CASCADE" on the FK or the delete FK-violates (soft delete is NOT global — it applies only to Soft-Delete-trait entities). - jmix-create-service: use .optional() for finders that may not match; added a gotcha that calling save() twice on one reference without reassigning the returned instance throws OptimisticLockException (stale @Version). - jmix-create-detail-view: corrected the entityComboBox-bound-to-enum failure wording (it requires an entity Range, not an enumeration). Why: surfaced by repeatedly building the same CRUD app with an LLM agent on the pack — these are the defects the agent hit that compile and pass a green test but fail (or degrade) at runtime. Co-Authored-By: Claude Opus 4.8 --- .../skills/jmix-create-detail-view/SKILL.md | 6 +-- content/skills/jmix-create-entity/SKILL.md | 18 ++++++++ .../jmix-create-liquibase-changelog/SKILL.md | 41 ++++++++++++++--- content/skills/jmix-create-list-view/SKILL.md | 44 +++++++++++++++++++ content/skills/jmix-create-service/SKILL.md | 16 +++++++ 5 files changed, 117 insertions(+), 8 deletions(-) diff --git a/content/skills/jmix-create-detail-view/SKILL.md b/content/skills/jmix-create-detail-view/SKILL.md index d125c4e..f5e857a 100644 --- a/content/skills/jmix-create-detail-view/SKILL.md +++ b/content/skills/jmix-create-detail-view/SKILL.md @@ -17,9 +17,9 @@ CONSTRUCTION from the WRONG/RIGHT examples below: 1. **An enum attribute is NEVER `entityComboBox`.** `entityComboBox` is for ENTITY references; binding it to an enum (with or without a - made-up `enumClass` attribute) throws `IllegalStateException: Range - is enumeration` at render. There is no `enumClass` attribute on - `entityComboBox`. For a Jmix enum property use a plain `` + made-up `enumClass` attribute) fails at render — `entityComboBox` + requires an entity Range, not an enumeration. There is no `enumClass` + attribute on `entityComboBox`. For a Jmix enum property use a plain `` or `