
Make attachment access follow the record
An attachment often needs the same access restrictions as the record it supports. Wildo’s resource file route reuses that record’s read authorization, then verifies that the requested file belongs to the specified record and field.
This route keeps attachment access connected to the owning record. The global file routes apply the same record’s read authorization to an attached file and give an upload that is not yet attached only to its uploader. A public share link is the deliberate exception: a bearer grant bound to one attachment, available only on fields that allow sharing, so the application should decide field by field where that grant fits its confidentiality policy.
Example — Open an attachment through its request
A request is readable only by its assigned team. Its attachment preview uses the request’s file route, so access to another request or knowledge of a file ID does not satisfy that route’s checks.
For engineers
Use the resource file route for record permissions
The controller runs the owning resource’s read authorization before invoking handleFileServe. The handler then reads the file through its scope-aware service and verifies the exact binding. This excerpt from resource-file-upload-handler.backend.service.ts starts after input validation; comments are omitted.
const file = await this.filesService.getFile(fileId, executionContext);
const belongsToRouteResource =
!!file &&
file.linkedResourceType === resourceType &&
String(file.linkedResourceId) === String(resourceId) &&
file.linkedFieldName === fieldName;
if (!belongsToRouteResource) {
this.logDebug('File serve denied: file not linked to route resource/field', {
fileId,
resourceType,
resourceId,
fieldName,
linkedResourceType: file?.linkedResourceType,
linkedResourceId: file?.linkedResourceId,
linkedFieldName: file?.linkedFieldName,
});
throw this.errorBuilder.buildError(
ErrorType.NOT_FOUND,
executionContext,
{
customMessageReference: ErrorCustomMessageReference.FILE_NOT_FOUND,
context: { fileId }, // diagnostic (log-only)
},
);
}
Respect both readiness and removal
After ownership is established, the serve handler checks lifecycle state. Deleted or erased files are withheld as not found. Pending, scanning and other non-downloadable states produce a not-ready refusal; UPLOADED, LINKED, ORPHANED and CLEAN are the downloadable states, but a resource route still requires the exact current record and field binding. A file ID is therefore not a permanent authorization to receive bytes.
The same stream service applies response hardening and the optional thumbnail rendition after these checks. Storage credentials remain on the backend.
Choose links according to the intended boundary
The global /files/:fileId, metadata and share-minting routes share one disclosure step in file.controller.ts. A file that carries a record binding and is not orphaned goes through authorizeFileParentRead: it takes the owning resource’s declared READ routes, fills their parameters from the stored record rather than from the caller, re-creates the execution context from the original request, runs the READ authorization, and reads the current record under that context to confirm its field still contains the file. The controller then re-reads the file under that context and refuses unless its record, field and scope binding is unchanged. A file without a current binding goes through the staging lane, which admits only the authenticated principal that uploaded it; a share token’s minter is never treated as its bearer’s authority there.
A share token is the deliberate other path. It must be a file-access token bound to this file, its stored binding must match the file’s current record, field and scope, and that record must still exist in its scope and hold the file. Downloads additionally require a downloadable state, and metadata stays withheld for removed files. The recipient’s identity and the minter’s current roles are not consulted, so a share is a grant to whoever holds the link.
Use resource-derived serve links when a view must show the attachment of the record it is displaying. Treat public sharing as its own decision per field: minting requires the same record read access and the field’s sharable flag, but the resulting link is not tied to a person.
Build a preview from the displayed record
ResourceAutomaticDisplayer uses the public buildResourceFileServeBaseUrl helper from @wildo-ai/saas-frontend-lib. It supplies the current operation, resolved parent context and the displayed record’s own identifier. This matters for singleton views such as a person’s own profile: their READ address may omit an ID, while the file route still needs the actual record ID.
This excerpt shows the real consumer’s builder call. The surrounding component supplies the registry, operation and context values:
const serveBase = buildResourceFileServeBaseUrl({
registry,
resourceConfig,
operation,
resourceType,
fieldName,
recordId,
contextResourceIdentifiers,
contextualParameters,
parentResourcesRequirements: resourceContextRef.current.parentResourcesRequirements,
apiBaseUrl,
});
if (serveBase) serveBaseByField[fieldName] = serveBase;
The helper returns a base ending in /files/<fieldName>. Append the selected file ID and fetch through the authenticated client. If the record or parent context is unresolved, it returns null; wait for that context instead of substituting a global URL, which would authorize through whatever record the file is currently bound to rather than the record this view displays.
| Link used by the display | What it establishes |
|---|---|
Resource-derived serve base plus file ID | READ on the record named in the address, file scope, and exact record/field binding before bytes are served |
Global /api/v1/files/<fileId>/metadata | For an attached file, READ on the record it is currently bound to, then an unchanged file scope and binding; for an unattached upload, its uploader only. The standard displayer uses this route to load metadata |
Global /api/v1/files/<fileId> | The same authorization as the metadata route, plus the download state check |
| A public share-token URL | A bound bearer grant: the token, its record/field/scope binding, the current record still holding the file, and the file’s state — no recipient identity |
Global file routes remain registered whichever preview URL a view chooses, and for an attached file they apply the owning record’s read authorization too. The one path that does not ask who the caller is, a public share link, exists only on fields declared sharable and only for links someone with that read access minted.