Repository navigation
intent: share: hands an issued document to an external reader through a revocable link (#8613) - #8617
Merged
Merged
Conversation
… 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); |
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.
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.No block, no link: all generated share code sits under
#if($share), and no setting makes an undeclared entity shareable.How
IntentParser.validateShare) refuses the block withoutwhen:, without acopy:that is a Snapshot composition child of the same printable document, withoutroles:, with a rolepermissionsdoes not declare, withexpiresAfterDaysoutside 0..3650, and on a personal entity (ownpersonal: truerelation or a composition ancestor's).engine-shareDIRIGIBLE_SHARE_LINKS; each open is a row inDIRIGIBLE_SHARE_LINK_OPENS(time, link, remote address, user agent).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.Retry-After).when:state (guardrail 10) is checked at open time. The generated repository implementsShareSubject, 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.GET|POST /{id}/share-linksandDELETE /{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 sharedshareLinksPanel.js). Not generated on the my/partner surfaces.DIRIGIBLE_DOCUMENTS_SHARE_ENABLED(off switch) andDIRIGIBLE_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. UnderSUBDOMAINthe host decides, and another tenant's token is simply not in the table.Deviations from the issue
{shareUrl}in notify bodies is not in this PR. A send bound to the issuing transition usually runs before the copy is minted, so it needs its own ordering and failure contract. Filed as intent: {shareUrl} in a notify body - mail a share link instead of (or next to) the attachment (follow-up of #8613) #8616.Verified
ShareIntentTest(14): the parse refusals for guardrails 3, 4 and 11.ShareLinkServiceTest(15, real H2 table) andShareRateLimiterTest(4).IntentShareLinkITagainst 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 noSet-Cookie; the record'suuidand the link id as token give 404; the token on another tenant's host gives 404; an entity withoutshare:has no route; revoke, expiry and void each give 404; repeated misses give 429.IntentEmissionCoverageITwithshare:added to its fixture (generated repository, controller, page, and none of it on an undeclared entity).IntentEngineIT,ModelGenerationIT,EndpointAuthorizationIT,HarmoniaContractIT,HarmoniaRecordActionsIT,TenantConfigurationIT.commons-config,core-configurations,engine-share,engine-intent,ide-template,api-modules-java;formatter:validateon the whole reactor with the cache wiped; the release javadoc build of the changed modules.Not verified
HarmoniaContractIT, but no Selenide test clicks through it.TOKEN_GROUPSpath 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