You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
intent: a tenant member cannot invite another member - the model declares the roles and the departure, but nothing declares the screen that joins them #7429
A multitenant application resolves the caller's tenant and role from identity-provider groups named <tenantId>.<appId>.<role> (DIRIGIBLE_TENANT_RESOLUTION_STRATEGY=TOKEN_GROUPS). permissions: already declares those role names, and generation already turns them into <intent>.roles, the role of every perspective.extension, and the isInAnyRole(...) guards in the generated controllers.
What no application can declare is the one screen a tenant's owner needs: invite another person into this tenant. The model names the roles; outbound: can carry the request to whatever manages identities; nothing joins them into a page.
It cannot be closed by the platform's own user API, and that is the point. POST /services/security/users is @RolesAllowed({"ADMINISTRATOR", "OPERATOR"}) and creates a user with a password — and in TOKEN_GROUPS mode authorities are recomputed from the identity provider's groups on every request, so a locally created user with locally assigned roles is ignored. Membership lives at the identity provider. The application can only ask for it, which is exactly what outbound: is for.
Why the workaround is worse than the gap
Today an application models the invitation as an ordinary entity and lets a departure carry it:
It works, and it is wrong in four ways that every such application repeats:
an action is modelled as a record, so the tenant gets a CRUD list of past invitations presented as master data, with edit and delete on rows that mean nothing once sent;
there is no send affordance and no confirmation — the user saves a row and is told a row was saved;
role is a free-text field with no relationship to the permissions: block that defines which roles exist, so a typo is discovered by the consumer, not by the form;
the invitation rows accumulate in the tenant's own schema for no reason anyone asked for.
What is asked
Let the model declare the screen. A shape, offered for the maintainers to settle:
membership:
invite:
by: Owner # who may open it - must be a role declared in permissions:grants: [Owner, User] # the roles it may grant - likewise declaredto: { queue: "global:acme.membership-requests" }payload:
type: "membership.requested"version: 1messageId: "{uuid}"tenantId: "{tenant}"appId: "@config:DIRIGIBLE_APP_ID"email: email # <- the screen's own inputrole: role # <- the role chosen from grants:requestedBy: "{user}"requestedAt: "{now}"
The one genuinely new capability is the two marked lines.outbound:'s payload resolves against a record — a literal, a direct field, or a one-hop relation.field. A screen has no record, so its inputs have to be addressable the same way. Everything else in that block already exists: the token vocabulary ({uuid}, {now}, {tenant}, {user}), @config:KEY, the to: { queue | topic } target and its global: prefix, and the publisher generated for a departure (#6767, #6768).
ui: false is the opt-out, matching dashboard: on a report and button: on a generates: a tri-state Boolean defaulting to on, so declaring membership.invite gives you the screen unless you say otherwise, and turning it off keeps the declaration without the page.
A small convenience worth considering alongside it: an {app} token. appId is the one field above that needs @config:DIRIGIBLE_APP_ID when the platform already knows the value.
What it generates
a perspective on application-perspectives whose role is the by: role, so the entry appears for the members who may use it;
a Harmonia page: an email field, a role select populated from grants:, a submit button, and success and failure surfaced through the shell's notification store rather than as a raw error;
an endpoint behind the same role that publishes the declared envelope through the existing publisher mechanism — no new transport.
Acceptance criteria
A member holding by: sees the entry; a member holding neither by: nor DEVELOPER/ADMINISTRATOR does not receive it at all — ExtensionService.findByExtensionPoint filters server-side, and those two roles bypass the role gate by design.
The submit endpoint carries its own role gate. The extension role hides a menu entry; it protects nothing. Without a gate on the endpoint any authenticated user of the application could post to it.
Submitting publishes exactly one message carrying the declared envelope, with {tenant} resolved to the tenant the caller has selected and the two input values carried verbatim.
A refused or failed publish is reported in the UI through App.services.apiErrors, never as a raw e.message.
role reaches the wire with the casing declared in grants: — a consumer matching a business key exactly is the normal case.
ui: false keeps the declaration and drops the page; a by: or grants: role that permissions: does not declare is a Generate issue; an absent membership: changes nothing.
Out of scope
Accepting an invitation, creating the user and granting the group — that belongs to the identity provider and to whatever consumes the message.
Any change to POST /services/security/users; it is the wrong mechanism here, per the first section.
Any UI in the platform's operator shells (/admin/, /builder/, /database/, /monitoring/), all of which are gated to platform roles.
Any change to TOKEN_GROUPS resolution, and the transport, which outbound: already owns.
Notes
Which shell. The Administration shell is the intuitive home and is the wrong one: admin.access constrains /admin/ to ADMINISTRATOR, so a tenant owner is refused at the HTTP layer before any extension role is consulted. The generator already contributes a per-project admin perspective (ui/admin/ → application-admin-perspectives, kind: 'ADMIN'), so the surface exists — the gate is what makes it unsuitable for a tenant member. The application shell is where the model's own perspectives already live, and its resources are Public with each perspective gating itself.
This proposes a new app.intent key, which is a change to the vendor-neutral intent DSL and so belongs to the specification's proposal process rather than to this repository alone. Raising it here first is deliberate: the shape should be proven by an implementation before it is specified. The objection it will meet upstream is the precedent that "the country a deployment (or a tenant of it) is in is a platform-level attribute, not part of the intent" — so, to be explicit: this construct does not describe tenancy, or which tenant, or how membership is stored. It declares that this application offers its members a way to invite others, which is an application capability in the same family as view: or dashboard:. If maintainers read it the other way, the fallback home is the project's .settings file, whose access: { generate: true } switch exists for exactly that "deployment decision, not domain property" reason.
What is undeclarable
A multitenant application resolves the caller's tenant and role from identity-provider groups named
<tenantId>.<appId>.<role>(DIRIGIBLE_TENANT_RESOLUTION_STRATEGY=TOKEN_GROUPS).permissions:already declares those role names, and generation already turns them into<intent>.roles, theroleof everyperspective.extension, and theisInAnyRole(...)guards in the generated controllers.What no application can declare is the one screen a tenant's owner needs: invite another person into this tenant. The model names the roles;
outbound:can carry the request to whatever manages identities; nothing joins them into a page.It cannot be closed by the platform's own user API, and that is the point.
POST /services/security/usersis@RolesAllowed({"ADMINISTRATOR", "OPERATOR"})and creates a user with a password — and inTOKEN_GROUPSmode authorities are recomputed from the identity provider's groups on every request, so a locally created user with locally assigned roles is ignored. Membership lives at the identity provider. The application can only ask for it, which is exactly whatoutbound:is for.Why the workaround is worse than the gap
Today an application models the invitation as an ordinary entity and lets a departure carry it:
It works, and it is wrong in four ways that every such application repeats:
roleis a free-text field with no relationship to thepermissions:block that defines which roles exist, so a typo is discovered by the consumer, not by the form;What is asked
Let the model declare the screen. A shape, offered for the maintainers to settle:
The one genuinely new capability is the two marked lines.
outbound:'s payload resolves against a record — a literal, a direct field, or a one-hoprelation.field. A screen has no record, so its inputs have to be addressable the same way. Everything else in that block already exists: the token vocabulary ({uuid},{now},{tenant},{user}),@config:KEY, theto: { queue | topic }target and itsglobal:prefix, and the publisher generated for a departure (#6767, #6768).ui: falseis the opt-out, matchingdashboard:on a report andbutton:on agenerates: a tri-stateBooleandefaulting to on, so declaringmembership.invitegives you the screen unless you say otherwise, and turning it off keeps the declaration without the page.A small convenience worth considering alongside it: an
{app}token.appIdis the one field above that needs@config:DIRIGIBLE_APP_IDwhen the platform already knows the value.What it generates
application-perspectiveswhoseroleis theby:role, so the entry appears for the members who may use it;grants:, a submit button, and success and failure surfaced through the shell's notification store rather than as a raw error;Acceptance criteria
by:sees the entry; a member holding neitherby:norDEVELOPER/ADMINISTRATORdoes not receive it at all —ExtensionService.findByExtensionPointfilters server-side, and those two roles bypass therolegate by design.rolehides a menu entry; it protects nothing. Without a gate on the endpoint any authenticated user of the application could post to it.{tenant}resolved to the tenant the caller has selected and the two input values carried verbatim.App.services.apiErrors, never as a rawe.message.rolereaches the wire with the casing declared ingrants:— a consumer matching a business key exactly is the normal case.ui: falsekeeps the declaration and drops the page; aby:orgrants:role thatpermissions:does not declare is a Generate issue; an absentmembership:changes nothing.Out of scope
POST /services/security/users; it is the wrong mechanism here, per the first section./admin/,/builder/,/database/,/monitoring/), all of which are gated to platform roles.TOKEN_GROUPSresolution, and the transport, whichoutbound:already owns.Notes
admin.accessconstrains/admin/toADMINISTRATOR, so a tenant owner is refused at the HTTP layer before any extensionroleis consulted. The generator already contributes a per-project admin perspective (ui/admin/→application-admin-perspectives,kind: 'ADMIN'), so the surface exists — the gate is what makes it unsuitable for a tenant member. The application shell is where the model's own perspectives already live, and its resources arePublicwith each perspective gating itself.Administration surfaceitem is adjacent but not this.app.intentkey, which is a change to the vendor-neutral intent DSL and so belongs to the specification's proposal process rather than to this repository alone. Raising it here first is deliberate: the shape should be proven by an implementation before it is specified. The objection it will meet upstream is the precedent that "the country a deployment (or a tenant of it) is in is a platform-level attribute, not part of the intent" — so, to be explicit: this construct does not describe tenancy, or which tenant, or how membership is stored. It declares that this application offers its members a way to invite others, which is an application capability in the same family asview:ordashboard:. If maintainers read it the other way, the fallback home is the project's.settingsfile, whoseaccess: { generate: true }switch exists for exactly that "deployment decision, not domain property" reason.