Skip to content

Tenant users: application-user registry with a provisioning callback, owner invitations and Settings → Users (#7498) - #7500

Merged
iliyan-velichkov merged 10 commits into
masterfrom
issue-7498-tenant-users
Sep 25, 2026
Merged

iliyan-velichkov merged 10 commits into
masterfrom
issue-7498-tenant-users

Conversation

@iliyan-velichkov

@iliyan-velichkov iliyan-velichkov commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Why

Under TOKEN_GROUPS a 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:

  • A screen. No shell let a tenant owner invite another person.
  • A record. Nothing recorded who was invited, by whom, or what became of the request.
  • A way back. A provisioning system that grants the group had no way to tell the application the outcome.

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 rows moved the pending request from the user row onto the role rows. Its result is what the text below describes.
  • fix(tenants): address CodeQL findings passes request-derived and configuration values through LogSanitizer before 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 @RolesAllowed removed 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_USERS holds 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_ROLES holds one row per user and role for the role's whole life:

    • its state (REQUESTED → GRANTED | FAILED);
    • its latest request (id, requested by and at);
    • granted by and at;
    • the failure text.

    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}/users records INVITED, ASSIGNED or FAILED. It exists exactly when the tenant provisioning API does, behind the same roles and that API's {status, error, message} body. TenantProvisioningRoles is now public so the role constants are shared, not copied.

  • The merge rules make a replayed outcome harmless:

    • a success makes the role GRANTED, and a role already granted keeps its first who and when;
    • a status is never lowered (ACTIVE > INVITED > ASSIGNED > PENDING/FAILED), and a person who already signed in becomes ACTIVE;
    • a FAILED for a granted role, or for an older request of that role, is ignored;
    • otherwise it marks the role 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 with DIRIGIBLE_TENANT_USERS_ENABLED.
  • A validator refuses to start without TOKEN_GROUPS and a request queue. It warns when invitations would land on the embedded broker.
  • The roles are constants, not configuration: ApplicationRoles in core-base, next to Roles, holds OWNER, USER and ALL.
  • The endpoints use the standard @RolesAllowed:
    • listing is for Owner, DEVELOPER, ADMINISTRATOR and OPERATOR;
    • invite and resend are for Owner, DEVELOPER and ADMINISTRATOR, so developers and administrators manage users as they do elsewhere.
  • A refusal is 403 NOT_A_TENANT_OWNER, in the same shape as the other errors. The default tenant is refused with 409 DEFAULT_TENANT.
  • The tenant is always the session's selected tenant, never a request field. Both POSTs accept JSON only (consumes), so a cross-site form post gets 415.
  • An invitation records the role as REQUESTED first, then publishes the user.assignment.requested v1 envelope.
    • A role is requested once at a time (409 REQUEST_PENDING), and a granted one is not requested again (409 ROLE_ALREADY_GRANTED).
    • A failed publish is undone and answered 503.
    • Resend reuses the role's message id while it is still REQUESTED.

3. Sign-in tracking

  • TenantSelectionManager.selectTenant publishes a TenantEnteredEvent (in core-tenants). That method is the one place the picker and the auto-selection of a user's only tenant go through.
  • A listener matches the person by email, stamps the last sign-in, and turns an invited or assigned user ACTIVE. It runs in its own transaction and never throws.

4. A Keycloak test harness

  • No browser test could sign in with tenant groups before this.
  • KeycloakTestContainer runs one Keycloak per test run and builds its realm through the admin REST API. KeycloakUserInterfaceIntegrationTest boots Dirigible with the keycloak profile under TOKEN_GROUPS against it.
  • It needs Docker and runs in the nightly ui shard.

5. Settings → Users in the application shell

  • A built-in section rendered from one shared fragment (application-core/shell/views/_tenant-users.html and the tenantUsers store), the tenant chip's pattern.
  • It shows the people with their roles (granted, requested or failed, with who and when), status, invited and updated by/at, and last sign-in.
  • It has an invite form, and a role requested more than 15 minutes ago with no answer can be sent again.
  • Strings are in en-US and bg-BG.
  • The Settings footer entry used to require an application SETTING perspective, which also hid the three existing built-ins on an instance without one. It now also opens for this section.

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

Situation Users section
DIRIGIBLE_TENANT_USERS_ENABLED off absent (the context endpoint answers 404)
on, caller is an Owner of the selected tenant, a developer or an administrator offered: list, invite, resend
on, caller is a member without the Owner role not offered; list and invite answer 403
on, operator in a tenant may list through the API; the section is not offered
on, default tenant not offered, and the API answers 409 DEFAULT_TENANT

Tests

  • Unit: ApplicationUserServiceTest (every merge rule, plus the per-role invite, resend and undo rules), TenantUserInvitationServiceTest, TenantUsersAccessTest, TenantUsersConfigValidatorTest, TenantUserSignInListenerTest, and two new cases in TenantSelectionManagerTest.
  • Integration, HTTP:
    • ApplicationUsersProvisioningApiIT / …DisabledIT
    • TenantUsersIT: a fabricated OIDC owner under TOKEN_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 to ACTIVE
    • TenantUsersDisabledIT
    • GeneratedShellTenantUsersIT: both template branches on rendered pages
  • Integration, browser (Keycloak): KeycloakTokenGroupsSignInIT, TenantUsersSettingsIT (an owner invites and sees the request pending; a member is not offered the section), TenantUsersSettingsHiddenIT.
  • Unchanged and green: HarmoniaContractIT, ShellRuntimeVintageIT, TenantSelectionIT, TenantProvisioningApiIT, TenantWidgetIT, MultitenancyHarmoniaIT and the multitenant-mode ITs.
  • When each suite was run:
    • all of the above (66 ITs) passed locally on commits 1–6;
    • after the review commits, the affected ITs were re-run locally and pass (the users API, invitation, disabled, settings browser, Keycloak sign-in, tenant selection, generated-shell, contract and vintage ITs);
    • the PR's CI covers the rest.
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

  • Consuming the request and creating the account at the identity provider (the provisioning system's job).
  • Removing a member or changing roles.
  • A generic header or Settings extension point.
  • A browser case on a generated project. The model deployer generates over REST with basic auth, which the keycloak profile switches off.

🤖 Generated with Claude Code

iliyan-velichkov and others added 6 commits September 24, 2026 14:32
…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>
@iliyan-velichkov iliyan-velichkov changed the title Tenant users: application users registry, owner invitations and Settings → Users (#7498) Tenant users: application-user registry with a provisioning callback, owner invitations and Settings → Users (#7498) Sep 24, 2026
…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>
@iliyan-velichkov
iliyan-velichkov merged commit d4d611c into master Sep 25, 2026
10 checks passed
@iliyan-velichkov
iliyan-velichkov deleted the issue-7498-tenant-users branch September 25, 2026 08:43
@iliyan-velichkov iliyan-velichkov self-assigned this Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tenants: a tenant owner cannot invite a member or see who was invited - the application shell has no users management

2 participants