Skip to content

intent: share: hands an issued document to an external reader through a revocable link (#8613) - #8617

Merged
delchev merged 1 commit into
masterfrom
issue-8613-intent-share-link
Nov 13, 2026
Merged

delchev merged 1 commit into
masterfrom
issue-8613-intent-share-link

Conversation

@delchev

@delchev delchev commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

What

share: on an entity declares that the issued copy of its records may be opened by a reader without an account, through a link a user creates on the record.

- name: SalesInvoice
  share:
    when: "status == ISSUED"     # required
    copy: SalesInvoiceCopy       # required: a function: Snapshot child
    roles: [SalesInvoiceShare]   # required key; [] admits nobody
    expiresAfterDays: 0          # optional, 0 = permanent

No block, no link: all generated share code sits under #if($share), and no setting makes an undeclared entity shareable.

How

  • Parser (IntentParser.validateShare) refuses the block without when:, without a copy: that is a Snapshot composition child of the same printable document, without roles:, with a role permissions does not declare, with expiresAfterDays outside 0..3650, and on a personal entity (own personal: true relation or a composition ancestor's).
  • New module engine-share
    • Links live per tenant in DIRIGIBLE_SHARE_LINKS; each open is a row in DIRIGIBLE_SHARE_LINK_OPENS (time, link, remote address, user agent).
    • The token is 256 CSPRNG bits, base64url, stored only as SHA-256 and returned once. It is unrelated to the record's uuid.
    • GET /public/share/{token} serves the one stored file fixed when the link was created. Unknown, expired, revoked, other-tenant and file-gone tokens all get the same empty 404. It reads and creates no session.
    • Throttling is in memory, per address and per link, with a back-off after repeated misses (429 + Retry-After).
    • Nothing is cached, so a revoke takes effect on the next request on every node.
  • Leaving the when: state (guardrail 10) is checked at open time. The generated repository implements ShareSubject, and the public route asks it on every open. A record found outside the state has its link revoked for good (revokedBy: system). I chose this over hooking each repository write path, of which there are five, some generated conditionally.
  • Generated code: GET|POST /{id}/share-links and DELETE /{id}/share-links/{linkId} on the entity's controller, gated by the declared roles only, plus a "Share links" section on the form and document record pages (one shared shareLinksPanel.js). Not generated on the my/partner surfaces.
  • Tenant settings: DIRIGIBLE_DOCUMENTS_SHARE_ENABLED (off switch) and DIRIGIBLE_DOCUMENTS_SHARE_MAX_EXPIRY_DAYS (only shortens). Instance settings: DIRIGIBLE_DOCUMENTS_SHARE_RATE_LIMIT_PER_MINUTE, DIRIGIBLE_DOCUMENTS_SHARE_MISS_LIMIT.
  • TOKEN_GROUPS: one host serves every tenant and an anonymous request has no tenant, so there the token is <tenantId>.<secret> and the route switches into that tenant for the lookup. Under SUBDOMAIN the host decides, and another tenant's token is simply not in the table.

Deviations from the issue

Verified

  • ShareIntentTest (14): the parse refusals for guardrails 3, 4 and 11.
  • ShareLinkServiceTest (15, real H2 table) and ShareRateLimiterTest (4).
  • IntentShareLinkIT against the published application, over HTTP: a draft is refused; a role-less user gets 403 on all three endpoints; a role holder creates a link; an anonymous open returns the copy's bytes with no Set-Cookie; the record's uuid and the link id as token give 404; the token on another tenant's host gives 404; an entity without share: has no route; revoke, expiry and void each give 404; repeated misses give 429.
  • IntentEmissionCoverageIT with share: added to its fixture (generated repository, controller, page, and none of it on an undeclared entity).
  • Also green locally: IntentEngineIT, ModelGenerationIT, EndpointAuthorizationIT, HarmoniaContractIT, HarmoniaRecordActionsIT, TenantConfigurationIT.
  • Unit suites of commons-config, core-configurations, engine-share, engine-intent, ide-template, api-modules-java; formatter:validate on the whole reactor with the cache wiped; the release javadoc build of the changed modules.

Not verified

  • The Share links section was not opened in a browser. Its markup is asserted in generated output and passes HarmoniaContractIT, but no Selenide test clicks through it.
  • All ITs ran on H2 only, not PostgreSQL.
  • The TOKEN_GROUPS path is covered by the unit test only; no Keycloak IT.

Docs: IntentFile/intent-specification#238, IntentFile/intentfile.github.io#85, dirigible-io/dirigible-io.github.io#292 (merge after this one).

Fixes #8613

🤖 Generated with Claude Code

… a revocable link (#8613)

An entity's share: { when, copy, roles, expiresAfterDays } declares that the
stored copy of its records may be opened without an account, through a link a
user creates on the record. Off unless declared, per entity: the parser refuses
the block without a when:, without a function: Snapshot copy of a printable
document, without roles: (an empty list admits nobody), and on a personal
entity.

The new engine-share module keeps the links per tenant, the token as a hash
only (256 random bits, unrelated to the record's uuid), and serves
GET /public/share/<token>: one stored file fixed at creation, the same empty
404 for every token that does not answer, throttled per address and per link.
Whether the record is still in its when: state is asked of the generated
repository on every open, so a void, a workflow step or a delete all end the
link, which is then revoked for good.

Generated: the repository's ShareSubject, three link endpoints on the entity's
controller behind the declared roles, and a Share links section on the record
pages.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
* @return the stored file, 404 when the token does not answer, 429 when the caller is throttled
*/
@GetMapping("/{token}")
ResponseEntity<byte[]> open(@PathVariable("token") String token, HttpServletRequest request) {
}

private static void create(Connection connection, String table, String sql) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
Map<String, Object> share = new LinkedHashMap<>();
share.put("when", visibleWhenCondition(declared.getWhen()));
share.put("whenTerms", terms);
share.put("copyEntity", copy.getName());
private static void create(Connection connection, String table, String sql) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.executeUpdate();
LOGGER.info("Created per-tenant share-link table using sql [{}]", sql);
@delchev
delchev merged commit 05df340 into master Nov 13, 2026
15 of 16 checks passed
@delchev
delchev deleted the issue-8613-intent-share-link branch November 13, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants