Tenant users: application-user registry with a provisioning callback, owner invitations and Settings → Users (#7498) - #7500
Merged
Conversation
…ant-provisioning API (#7498) A new module, core-tenants-users, keeps the users of each tenant of an application: DIRIGIBLE_APPLICATION_USERS (one row per tenant and email - status, latest request, who invited and who last changed it, last sign-in) and DIRIGIBLE_APPLICATION_USER_ROLES (one row per granted role, with granted-by/at and the granting request). Plain entities in the SystemDB with an explicit tenant id, like the tenant registry; they do not extend Auditable, whose auditor stamps a fixed technical name, because who invited and who changed a user are people. PUT|GET /services/tenant-provisioning/tenants/{tenantId}/users lets an external provisioning system record what it did for a user - INVITED, ASSIGNED or FAILED. It exists exactly when the tenant provisioning API does, behind the same roles (TenantProvisioningRoles is now public so the constants are shared, not copied), and answers with that API's {status, error, message} body. The merge rules make it idempotent: a role row is added only when absent and keeps its first grant; a status is never lowered (ACTIVE > INVITED > ASSIGNED > PENDING/FAILED), and a person who already entered the tenant becomes ACTIVE; a FAILED of another request is ignored, and FAILED marks the user failed only while no role is granted. Six Liquibase changesets (two tables, two unique constraints, the cascading foreign key, the tenant index). Unit tests for every merge rule; ApplicationUsersProvisioningApiIT drives the callback as a TENANT_PROVISIONER-only client; ApplicationUsersProvisioningApiDisabledIT pins that the endpoint is absent without the API. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ment request (#7498) services/security/tenant-users lets the owners of a tenant see its users and invite another person: GET /context (whether to offer the section), GET (the users), POST {email, role} (invite), POST /{id}/resend. It exists only with DIRIGIBLE_TENANT_USERS_ENABLED, and TenantUsersConfigValidator refuses to start without TOKEN_GROUPS, a DIRIGIBLE_TENANT_USERS_REQUEST_QUEUE and the owner role (DIRIGIBLE_TENANT_USERS_OWNER_ROLE, default Owner) among DIRIGIBLE_TENANT_USERS_ROLES (default Owner,User); it warns when invitations would land on the embedded broker. The gate is in code (TenantUsersAccess), because the owner role is configuration: the caller's authorities must hold ROLE_<owner role> - never UserFacade.isInRole, which passes every developer and administrator. Administrators and operators may read, not invite; the default tenant is refused; the tenant is always the session's selected tenant, never a request field. Both POSTs accept JSON only, the CSRF defence the tenant selection endpoint relies on too. An invitation records the user row first - PENDING, or the new request on an existing row, one request in flight per user, a granted role not asked for twice - then publishes the user.assignment.requested v1 envelope with MessageProducer; a failed publish is compensated and answered 503. Resend republishes the same message id while the request is unanswered. Unit tests for the access rules, the validator and the invitation service; TenantUsersIT drives a fabricated OIDC owner under TOKEN_GROUPS and reads the envelope off the queue; TenantUsersDisabledIT pins the feature off by default. .claude/docs/tenants.md gains a "Tenant users" section. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tion users turn ACTIVE (#7498) TenantSelectionManager.selectTenant - the one place the picker and the auto-selection of a user's only tenant go through - now publishes a TenantEnteredEvent (core-tenants: tenant id, principal, the identity token's email claim). Neither the bearer path (enterTenant) nor the per-request consistency check publishes it, so a listener can read it as a sign-in into the tenant. TenantUserSignInListener (only with DIRIGIBLE_TENANT_USERS_ENABLED) matches the user by email - the claim, else an email-shaped principal - stamps lastSignInAt and makes an INVITED or ASSIGNED user ACTIVE. It runs in its own transaction and inside a try/catch: bookkeeping must never break a sign-in. A PENDING user is stamped but stays PENDING; the provisioning callback then makes them ACTIVE, because a success on a row that has signed in yields ACTIVE. TenantSelectionManagerTest +2 (announced once, not on refusal); TenantUserSignInListenerTest; TenantUsersIT gains the sign-in case. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No browser test could sign in with tenant groups until now: every one signs in with basic auth, which lands in the default tenant, so the TOKEN_GROUPS user interface - the tenant chip, and the Users section an Owner sees - had no end-to-end coverage. KeycloakTestContainer (tests-framework) runs one quay.io/keycloak/keycloak:26.0 per JVM and builds its realm through the admin REST API: an import that declares clientScopes replaces the built-in scopes and breaks every login, while an empty realm keeps them (offline_access included, which the platform requests). A Group Membership mapper writes plain group names into dirigible_groups, and the client's redirect URI is set to the application's actual port once it is known - Keycloak refuses a wildcard port. KeycloakUserInterfaceIntegrationTest boots Dirigible with the keycloak profile and TOKEN_GROUPS against it, with provisionTenant / grant / signInAndOpen helpers. KeycloakTokenGroupsSignInIT: an Owner of one tenant signs in through Keycloak, is placed in the tenant, and the tenant chip names it. Requires Docker; tagged ui through its parent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…sers (#7498) The application shell's Settings gains a fourth built-in entry, Users, for the owners of the current tenant: who belongs to the tenant, where each person stands (Pending, Invited, Assigned, Active, Failed - with the latest request, who invited and who last changed each person, and the last sign-in), and an invitation for another person with one of the configured roles. A request unanswered for fifteen minutes is marked as such and can be sent again with the same id. The section is ONE fragment, application-core/shell/views/_tenant-users.html, rendered from the shared tenantUsers store (shell/js/stores/tenantUsers.js) - the tenant chip's pattern - so the generated application shell can mount the same file. The store reads the context endpoint (a 404 means the feature is off) and offers the entry only to a holder of the owner role; refusals are shown from the endpoint's reason, never its raw text. Strings in en-US and bg-BG. The Settings footer entry used to require an application SETTING perspective, which also hid the three older built-ins on an instance without one; it now opens for the Users section too. Shared code reaches the store through Alpine.store('tenantUsers') checks, because pages generated before it never load it. Browser tests on the Keycloak harness: TenantUsersSettingsIT (an owner signs in, opens Users, invites a person and sees the request pending; a member of the same tenant is not offered the section) and TenantUsersSettingsHiddenIT (feature off). HarmoniaContractIT scans the new fragment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tings fragment (#7498) Every regenerated application shell carries the platform's Users section: index.html.template loads the tenantUsers store, and settings.html.template gains a "Tenant" list header with the Users entry and a pane rendering the shared fragment. The shared settingsPage loads the store when Users is selected, guarding its absence on pages generated before it. Settings is now routed in every generated shell. A model without a SETTING entity used to have no Settings route and no footer entry at all; the footer entry is now emitted always and, for such a model, shown only while the Users section is offered. Only bare $store references reach the Velocity templates. GeneratedShellTenantUsersIT renders both branches - sales-order.model (no SETTING entity) and DependsOnHarmoniaIT's edm.model (two) - and asserts the store, the route, the guarded or plain footer entry and the Users entry and pane. A browser case on a generated project was not possible here: the model deployer generates over REST with basic auth, which the keycloak profile switches off; the fragment's behaviour is the one TenantUsersSettingsIT drives in the application shell. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…7498) The user row held one "latest request" (requestId, requestedRole, requestState, requestedAt, errorMessage), so a person could have only one request in flight and a second role's failure overwrote the first's trail. ApplicationUserRole now carries the role's whole life: state REQUESTED -> GRANTED | FAILED, the latest request (id, requested by/at), granted by/at (the first grant kept) and the failure text. The user row keeps the person: status, invited by/at, updated by/at, last sign-in. - merge rules per role: a FAILED for a granted role or an older request of that role is ignored; the user is FAILED only while no role is granted or still requested - invitations: 409 REQUEST_PENDING / ROLE_ALREADY_GRANTED per role, other roles may be requested side by side; compensation removes a new role row or restores a re-requested one - resend is POST /{id}/roles/{role}/resend - the Users section shows each role's state, with Resend per stale role - the callback body is unchanged Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- log injection: values that come from a request body, a path variable or configuration (email, role, request id, tenant id, queue, owner role) go through LogSanitizer before they reach a log line - a null invitation body is refused explicitly with INVALID_EMAIL instead of relying on an earlier guard the analysis cannot follow - resend drops its unused body parameter: consumes = JSON already refuses a cross-site form post with 415, now pinned in TenantUsersIT Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ts use @RolesAllowed (#7498) The owner role and the grantable roles were configuration (DIRIGIBLE_TENANT_USERS_OWNER_ROLE, DIRIGIBLE_TENANT_USERS_ROLES), which is why the gate had to read authorities in code. They are fixed application roles, so they become constants and the endpoints are protected like every other service. - ApplicationRoles (core-base, beside Roles): OWNER, USER, ALL - the two DirigibleConfig keys and their validator checks are removed - TenantUsersEndpoint: list is @RolesAllowed Owner/DEVELOPER/ADMINISTRATOR/ OPERATOR, invite and resend Owner/DEVELOPER/ADMINISTRATOR - developers and administrators now manage users like an owner, as elsewhere in the platform - an AccessDeniedException is answered 403 NOT_A_TENANT_OWNER in the endpoints' own shape; TenantUsersAccess keeps the default-tenant refusal and computes the context's canManage/canRead from the same role sets - TenantUsersIT pins a member's invite refused by the annotation Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
) CodeQL java/unused-parameter: @ExceptionHandler(AccessDeniedException.class) already names the type, and the body needs nothing from the exception. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Under
TOKEN_GROUPSa tenant's membership lives at the identity provider as groups<tenantId>.<appId>.<role>, and an application can only ask for a new member. Three things were missing:Fixes #7498 (which supersedes #7429).
What
Commits 1–6 are one per piece, in the order below. Three review follow-ups sit on top:
refactor(tenants): requests and their outcomes live on the role rowsmoved the pending request from the user row onto the role rows. Its result is what the text below describes.fix(tenants): address CodeQL findingspasses request-derived and configuration values throughLogSanitizerbefore logging them, refuses a null invitation body explicitly, and drops resend's unused body parameter.refactor(tenants): application roles are constants; the owner endpoints use @RolesAllowedremoved the owner-role and grantable-roles configuration keys. The owner endpoints are now protected like every other service.1. The users registry and the callback (
core-tenants-users, new module)DIRIGIBLE_APPLICATION_USERSholds one row per tenant and email: the status (PENDING,INVITED,ASSIGNED,ACTIVE,FAILED), who invited and who last changed each person, and the last sign-in.DIRIGIBLE_APPLICATION_USER_ROLESholds one row per user and role for the role's whole life:state(REQUESTED→GRANTED|FAILED);Every request and outcome lands on the row of the role it is about, so different roles can be requested side by side.
Both are SystemDB tables with an explicit tenant id, and every lookup is scoped by it. Six Liquibase changesets.
The entities do not extend
Auditable. Its auditor stamps a fixed technical name, while who invited or changed a user is a person.PUT|GET /services/tenant-provisioning/tenants/{tenantId}/usersrecordsINVITED,ASSIGNEDorFAILED. It exists exactly when the tenant provisioning API does, behind the same roles and that API's{status, error, message}body.TenantProvisioningRolesis now public so the role constants are shared, not copied.The merge rules make a replayed outcome harmless:
GRANTED, and a role already granted keeps its first who and when;ACTIVE > INVITED > ASSIGNED > PENDING/FAILED), and a person who already signed in becomesACTIVE;FAILEDfor a granted role, or for an older request of that role, is ignored;FAILED, and the user only while no role is granted or still requested.2. The owner endpoints (
/services/security/tenant-users)GET /context,GET(list),POST(invite),POST /{id}/roles/{role}/resend. They exist only withDIRIGIBLE_TENANT_USERS_ENABLED.TOKEN_GROUPSand a request queue. It warns when invitations would land on the embedded broker.ApplicationRolesin core-base, next toRoles, holdsOWNER,USERandALL.@RolesAllowed:Owner,DEVELOPER,ADMINISTRATORandOPERATOR;Owner,DEVELOPERandADMINISTRATOR, so developers and administrators manage users as they do elsewhere.NOT_A_TENANT_OWNER, in the same shape as the other errors. The default tenant is refused with 409DEFAULT_TENANT.consumes), so a cross-site form post gets 415.REQUESTEDfirst, then publishes theuser.assignment.requestedv1 envelope.REQUEST_PENDING), and a granted one is not requested again (409ROLE_ALREADY_GRANTED).REQUESTED.3. Sign-in tracking
TenantSelectionManager.selectTenantpublishes aTenantEnteredEvent(in core-tenants). That method is the one place the picker and the auto-selection of a user's only tenant go through.ACTIVE. It runs in its own transaction and never throws.4. A Keycloak test harness
KeycloakTestContainerruns one Keycloak per test run and builds its realm through the admin REST API.KeycloakUserInterfaceIntegrationTestboots Dirigible with the keycloak profile underTOKEN_GROUPSagainst it.uishard.5. Settings → Users in the application shell
application-core/shell/views/_tenant-users.htmland thetenantUsersstore), the tenant chip's pattern.en-USandbg-BG.6. The generated application shell mounts the same section, and Settings is now routed in every generated shell. Before this, a model without a SETTING entity had no Settings at all.
Behaviour
DIRIGIBLE_TENANT_USERS_ENABLEDoffOwnerof the selected tenant, a developer or an administratorOwnerroleDEFAULT_TENANTTests
ApplicationUserServiceTest(every merge rule, plus the per-role invite, resend and undo rules),TenantUserInvitationServiceTest,TenantUsersAccessTest,TenantUsersConfigValidatorTest,TenantUserSignInListenerTest, and two new cases inTenantSelectionManagerTest.ApplicationUsersProvisioningApiIT/…DisabledITTenantUsersIT: a fabricated OIDC owner underTOKEN_GROUPS; the envelope is read off the queue; the 409s, the 403 for a member's list and invite, and the 415 for invite and resend; a second role requested side by side; per-role resend; the callback; sign-in toACTIVETenantUsersDisabledITGeneratedShellTenantUsersIT: both template branches on rendered pagesKeycloakTokenGroupsSignInIT,TenantUsersSettingsIT(an owner invites and sees the request pending; a member is not offered the section),TenantUsersSettingsHiddenIT.HarmoniaContractIT,ShellRuntimeVintageIT,TenantSelectionIT,TenantProvisioningApiIT,TenantWidgetIT,MultitenancyHarmoniaITand the multitenant-mode ITs.mvn install -P integration-tests -pl tests/tests-integrations -D selenide.headless=true \ -Dit.test="ApplicationUsersProvisioningApiIT,TenantUsersIT,TenantUsersDisabledIT,GeneratedShellTenantUsersIT,KeycloakTokenGroupsSignInIT,TenantUsersSettingsIT,TenantUsersSettingsHiddenIT,HarmoniaContractIT,ShellRuntimeVintageIT"Not in scope
🤖 Generated with Claude Code