8000 Feature/organization roles by yaroslav02678 · Pull Request #48738 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content

Feature/organization roles - #48738

Closed
yaroslav02678 wants to merge 1 commit into
keycloak:mainfrom
TNTU-121-Software-Engineering:feature/organization-roles
Closed

yaroslav02678 wants to merge 1 commit into
keycloak:mainfrom
TNTU-121-Software-Engineering:feature/organization-roles

Conversation

@yaroslav02678
@yaroslav02678 yaroslav02678 commented May 6, 2026 •
Copy link
Copy Markdown

[Organization] Organization-scoped Role Management Implementation

Following the design feedback from @vramik and @pedroigor, I have refactored the implementation to align with Keycloak's core architecture.

Key Architectural Changes:

  1. Storage Strategy: Instead of using role attributes, I introduced a TYPE column (0=REALM, 1=CLIENT, 2=ORGANIZATION) and an ORG_ID foreign key to the KEYCLOAK_ROLE table.
    • Added Liquibase migration: jpa-changelog-authz-org-roles.xml.
  2. Model Integration: OrganizationModel now extends RoleContainerModel. This allows organizations to natively leverage existing role infrastructure (composites, mappings, etc.).
  3. Role Provider Updates: Updated RoleProvider and JpaRealmProvider to filter roles by the new TYPE, ensuring organization roles are isolated and do not leak into realm or client role listings.
  4. Testing: Migrated all integration tests to the new test framework (tests/base/src/test/java/org/keycloak/tests/admin/organization/).
  5. Decoupling: Fixed the module dependency direction; keycloak-admin-client no longer depends on keycloak-services.

Implementation Details:

  • Full CRUD for organization-scoped roles via Admin REST API.
  • Member role assignment/revocation with membership validation.
  • Audit trail support via AdminEventBuilder.

Issue:

Closes #40585 / Related to #47326

Note to Reviewers:

The PR has been rebased on the latest main, squashed into a single commit, and properly signed off (DCO).

@yaroslav02678
Copy link
Copy Markdown
Author

@vramik Hi, I'm a programming student and my teacher has given me an assignment to try to implement this feature. I would appreciate any feedback on my solution.

@yaroslav02678
yaroslav02678 force-pushed the feature/organization-roles branch from 1f8c841 to 1a88383 Compare May 6, 2026 18:27
@yaroslav02678
yaroslav02678 requested a review from a team as a code owner May 6, 2026 18:27
@yaroslav02678
yaroslav02678 force-pushed the feature/organization-roles branch from 1a88383 to 995b522 Compare May 6, 2026 18:35
@vramik vramik left a comment
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @yaroslav02678, thanks for taking the time to work on this! Organization roles are an important feature we're actively designing.

Before I dive into technical feedback, could you clarify your intent with this PR? Specifically:

  • Are you looking for directional feedback on your approach (e.g., for your assignment/learning)?
  • Or are you aiming to iterate this PR toward merge through the full review process?

Either way is fine — I just want to set expectations so neither of us invests time in a way that doesn't serve the goal.

Design pointers

We have discussed a design for this feature within the team, at some extend, already. Here are the key points your implementation should align with:

1. Storage: Shared Table with TYPE column

Rather than using role attributes to tag roles as "organizational," the agreed approach is to add a TYPE column to the existing KEYCLOAK_ROLE table (similar to how org groups reuse KEYCLOAK_GROUP with a type). This means:

  • A schema migration adding TYPE (e.g., 0=REALM, 1=CLIENT, 2=ORGANIZATION) and an ORG_ID foreign key to KEYCLOAK_ROLE (same approach as KEYCLOAK_GROUP table uses)
  • Existing queries will need to be migrated to use the TYPE, so org roles don't leak into realm or client role listings
  • Composites across role types work naturally through the existing infrastructure

The current attribute-based approach ("organization.id" / "org.role.name" attributes, "org.[orgId].[roleName]" naming) avoids schema changes but introduces fragility, inefficient queries, and adds a maintenance burden in the long term.

2. Consider OrganizationModel extending RoleContainerModel

Keycloak's RealmModel and ClientModel both implement RoleContainerModel, which provides a standard interface for role CRUD. If OrganizationModel implements it too, you get role infrastructure (composites, mappings, default roles) largely for free. This is an open design question worth exploring.

3. Token mappers

Roles need to appear in tokens to be useful. The design includes an organization roles token mappers being able to add org-roles under the existing "organization" (by default) claim. Without this, roles exist in the DB but clients can't consume them.

4. IdP mappers

There are several IdP mappers that assign roles during brokered login, they support both realm and client roles, newly they should support org-roles as well.

5. Tests

New tests should go in the new test framework (tests/base/src/test/java/org/keycloak/tests/...), not the old Arquillian suite (testsuite/integration-arquillian/...).

6. Admin client dependency

The admin client module should not depend on keycloak-services — that inverts the dependency direction.

7. Eventually we will need also Admin UI for it

It's also possible to implement admin UI as follow up.

8. Documentaion

Just mentioning it for completness.

Happy to discuss any of these points further. Also please note, that I might not catch everything what needs attention, other things might occur "as we go". Once again thanks for your time on this.

@pedroigor
Copy link
Copy Markdown
Contributor

To complement what @vramik said, our near-term goal is to deliver #47327, so that we address the authorization story for (most) CIAM/B2B use cases to enable access checks (RBAC/GBAC) on realm/platform/company clients when accessing their protected resources.

We might not be able to put much effort, including reviews, because of this, and also because of other tasks we are running in parallel.

@yaroslav02678
Copy link
Copy Markdown
Author

Hi @vramik and the team, thank you for the detailed feedback!
To clarify my intent: this is my student assignment, but I would also love to try and bring this PR to a mergeable state. I agree with your design pointers (especially the DB schema migration and the new test framework) and will start refactoring.
I understand your current focus is on #47327, so I’ll work at my own pace and update the PR when I have significant progress. Thanks again!

@yaroslav02678
yaroslav02678 force-pushed the feature/organization-roles branch from 995b522 to e9e9f93 Compare May 13, 2026 18:29


Signed-off-by: Yaroslav Gerasimchuk <yarosmen026@gmail.com>
@yaroslav02678
yaroslav02678 force-pushed the feature/organization-roles branch from e9e9f93 to 90af72a Compare May 13, 2026 18:31
@yaroslav02678
Copy link
Copy Markdown
Author

Hi @vramik, I have completed the refactoring based on your design pointers.
The implementation now uses the TYPE column in the KEYCLOAK_ROLE table, and OrganizationModel correctly extends RoleContainerModel. I've also moved the tests to the new framework and resolved the dependency direction issues.
I have squashed the changes into a single commit and fixed the DCO sign-off. Ready for your review whenever you have a moment. Thanks for the guidance!

@vramik
vramik commented May 20, 2026
Copy link
Copy Markdown
Contributor

Hello @yaroslav02678, thanks for the update. I wanted to give you a heads up — my backlog is quite full at the moment, but I hope to take a look next week.

@vramik
vramik commented Jun 12, 2026
Copy link
Copy Markdown
Contributor

@yaroslav02678 thanks for the updated version. There are some issues I see, which needs to fixed to proceed further:


Critical Issues

1. Two conflicting implementations — the PR contradicts itself

The PR adds proper TYPE column infrastructure in RoleEntity, JpaRealmProvider, and RealmCacheSession, but the REST-facing code in JpaOrganizationProvider ignores all of it. Instead it creates org roles as regular realm roles via realm.addRole() and tags them with attributes (organization.id, org.role.name):

// JpaOrganizationProvider — attribute-based, the path the REST API actually uses
String internalName = "org." + organization.getId() + "." + name;
RoleModel role = realm.addRole(internalName);
role.setSingleAttribute(ORG_ROLE_ATTRIBUTE, organization.getId());
role.setSingleAttribute(ORG_ROLE_NAME_ATTRIBUTE, name);

Since realm.addRole() calls RoleEntity.setRealmId(), which sets type = 0 (REALM), these roles are stored as realm roles with TYPE=0, not org roles with TYPE=2. The entire TYPE column infrastructure (addOrganizationRole, getOrganizationRole, etc.) is dead code that no caller ever reaches.

Fix: Remove the attribute-based implementation from JpaOrganizationProvider. Have it delegate to session.roles().addOrganizationRole() which properly sets TYPE=2 and ORGANIZATION_ID. The getRoleById/getRoles methods should similarly use the TYPE-column queries.

2. No authorization checks — and incomplete authorization model

2a. Missing permission checks entirely

OrganizationRolesResource and OrganizationRoleResource don't receive or check AdminPermissionEvaluator. The parent OrganizationResource.roles() doesn't pass auth:

@Path("roles")
public OrganizationRolesResource roles() {
    return new OrganizationRolesResource(session, organization, adminEvent);
    // Missing: auth parameter
}

Any authenticated admin can create/delete/assign org roles regardless of their permissions.

2b. Required permission model

Existing org member operations use a dual permission model (org-level + user-level). Org role endpoints should follow the same pattern:

Operation Org check User check
Create/update/delete role auth.orgs().requireManage(org) —
List/get roles auth.orgs().requireView(org) —
Assign role to user auth.orgs().requireManage(org) map-roles on the user (FGAP) or manage-users (admin roles)
Revoke role from user auth.orgs().requireManage(org) map-roles on the user (FGAP) or manage-users (admin roles)
List users in role auth.orgs().requireView(org) auth.users().requireQuery() + filter by canView

The org-level requireView is already enforced at OrganizationsResource.get(), so role endpoints only need to escalate to requireManage for writes. The user-level check on assignment/revocation is critical — it mirrors how addMember/delete already require auth.users().requireManage(user).

2c. Org role assignments must be blocked from the user role-mappings API

Since the current attribute-based implementation stores org roles as plain realm roles, nothing prevents assigning/revoking them via PUT /{realm}/users/{userId}/role-mappings/realm. Org role assignment should only go through the organization API (/organizations/{orgId}/roles/{roleId}/users/{userId}).

With the proper TYPE=2 approach this becomes more natural — realm role-mapping endpoints should reject roles with TYPE=2, and the realm role listing queries (once consistently fixed) won't surface them. But an explicit guard is still needed for assignment-by-ID.

3. Inconsistent named query filtering — some queries still use clientRole, others use type

In RoleEntity, half the queries were migrated to role.type = N and half still use role.clientRole = false:

Query Filter
getRealmRoles type = 0
getRealmRoleIds clientRole = false
getRealmRoleByName type = 0
getRealmRoleIdByName clientRole = false

Since the attribute-based approach creates org roles with clientRole = false and type = 0, the queries still filtering on clientRole will leak org roles into realm role ID lookups. All queries must be migrated consistently to use the type column.

4. setRealmId() / setClientId() side effects are order-dependent

The PR adds state-setting side effects to setters:

public void setRealmId(String realmId) {
    this.realmId = realmId;
    this.clientRealmConstraint = realmId;
    this.type = TYPE_REALM;        // NEW
    this.clientRole = false;       // NEW
}
public void setClientId(String clientId) {
    this.clientId = clientId;
    this.clientRealmConstraint = clientId;
    this.type = TYPE_CLIENT;       // NEW
    this.clientRole = true;        // NEW
}

If any existing code calls these setters in an unexpected order, the entity ends up in a silently wrong state. The coupling between unrelated setters is fragile.


Significant Issues

5. Missing cascade delete for org roles

When an organization is deleted, its roles are not cleaned up. Neither OrganizationProvider.remove() nor any event listener removes org-scoped roles. This will leave orphaned rows in KEYCLOAK_ROLE.

6. Pagination ignored in JpaOrganizationProvider

getRoles() and searchRolesByName() accept first/max parameters but ignore them — they load all realm roles into memory and filter by attribute:

public Stream<RoleModel> getRoles(OrganizationModel organization, Integer first, Integer max) {
    return getRealm().getRolesStream()  // loads ALL realm roles
            .filter(r -> organization.getId().equals(r.getFirstAttribute(ORG_ROLE_ATTRIBUTE)));
}

With the TYPE column approach and proper named queries, pagination would be handled at the DB level.

7. getMembersWithRole() is O(n) per member

return session.users().getRoleMembersStream(getRealm(), realmRole)
        .filter(u -> isMember(organization, u));  // individual DB lookup per user

For orgs with many role members, this is quadratic in the worst case.

8. ResourceType.REALM_ROLE used for admin events

Admin events use ResourceType.REALM_ROLE, making org role events indistinguishable from realm role events in audit logs. The comment confirms this is a known shortcut: // використовуємо існуючий тип (Ukrainian: "we use the existing type"). A proper ResourceType for org roles should be added.

9. Liquibase changelog naming doesn't follow conventions

The file is named jpa-changelog-authz-org-roles.xml instead of being included in a versioned changelog (jpa-changelog-26.X.0.xml). This will cause merge conflicts and is inconsistent with all other migrations. Also missing newline at end of file.

10. No foreign key constraint on ORGANIZATION_ID

The migration adds the column but doesn't add <addForeignKeyConstraint> referencing the ORGANIZATION table. Combined with missing cascade delete (#5), this can produce inconsistent state.

11. RoleAdapter container mismatch

When org roles are created via JpaRealmProvider.addOrganizationRole(), the returned RoleAdapter passes the realm as the container, not the organization. role.getContainer() will return the realm, breaking the OrganizationAdapter.removeRole() check role.getContainer() != this.


Minor Issues

  • Unrelated CI changes: Removing the external-links job and adding MAVEN_OPTS to documentation.yml has nothing to do with org roles. Should be a separate PR.
  • Ukrainian comment in OrganizationAdapter.removeDefaultRoles(): // Логіка видалення — remove or translate.
  • Empty addDefaultRole() / removeDefaultRoles() in OrganizationAdapter — no-op implementations should throw UnsupportedOperationException rather than silently ignore calls.
  • getRealmRoles named query has two queries merged onto one line (formatting issue).
  • OrganizationRolesResource constructor: session == null null-guards are defensive but inconsistent — if session is null, the resource methods will NPE anyway.
  • Test lifecycle: Uses LifeCycle.CLASS but deletes all orgs in @BeforeEach — should use LifeCycle.METHOD or clean up in @AfterEach.
  • Missing token mapper: The agreed design includes an organization_roles claim in tokens. Not present in this PR — could be a follow-up.

@vramik
vramik commented Oct 8, 2026
Copy link
Copy Markdown
Contributor

Closing in favor of #50606

@vramik vramik closed this Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Manage Organization Roles

5 participants

0