fix: correct ACL mapping, indexed URL, credential exposure and crawl lifecycle - #27
Merged
marevol merged 9 commits intoAug 31, 2026
Merged
Conversation
A type=anyone Drive permission means "anyone with the link". Fess identifies the
anonymous user with the guest role (role.search.guest.permissions), so the
previous getSearchRoleByUser("guest") produced a user-typed role and publicly
shared files were invisible to anonymous searches.
A type=anyone permission also carries no emailAddress, so the branch was
unreachable behind the null-value guard. Resolve anyone before that guard.
A type=domain Drive permission carries the domain in the domain field and leaves
emailAddress null, so the previous getEmailAddress()-based branch was dead code
and domain-wide shares were dropped from the indexed ACL entirely.
Resolve them through the new domain_permission_format parameter, which defaults
to {group}{domain}.
getDomainPermission returned the {domain}-substituted format string verbatim,
so with the default format the indexed role was the literal text
"{group}example.com" rather than a role any user's search-time roles could
match. That moved D-3 from "grant dropped entirely" to "grant indexed in a
form nothing can match", which is not a real fix.
Pass the substituted value through PermissionHelper#encode(), the same call
default_permissions already goes through a few lines away in this class. The
{user}/{group}/{role} tokens in domain_permission_format are now input
notation for encode(), consistent with default_permissions, and the method
returns null instead of an unusable role when encode() cannot resolve the
format.
The indexed URL defaulted to webContentLink, a direct-download link, and fell back to a uc?export=download URL. Search results therefore pointed at downloads instead of the Drive viewer, which is why every documented script mapping had to override url with file.web_view_link. Use webViewLink, falling back to https://drive.google.com/open?id=<id>. Existing indexes carry the old URL; a full re-crawl is required to pick up the new value.
Drive rejects files.get for an item that lives on a shared drive unless supportsAllDrives=true is set, so content downloads from shared drives could fail. Route the download through a small request factory that sets the flag. files.export has no supportsAllDrives parameter in the Drive v3 API, so extractFileText only gains a note explaining why the flag is absent there.
processFile runs on the crawler thread pool but wrote the stats key into the DataStoreParams instance shared by every thread, so with number_of_threads > 1 concurrent files overwrote each other's stats key and the callback could be handed another file's key. Copy the params per call and use the copy for the stats key, the script context, the permissions lookup and the index callback.
processFile seeded the script evaluation context with the whole parameter map and removed nothing, so a mapping such as digest=private_key indexed the service account RSA private key into the search index, where anyone with search access could read it. Remove private_key, private_key_id and client_email from the context before it reaches convertValue, mirroring what GitDataStore does for username/password. The three keys live in one named constant, SECRET_PARAM_KEYS, so a later secret parameter has an obvious place to be added.
The size guard ran after getFileContents, so a file above max_size was still downloaded from Drive and run through Tika before being discarded. Check File#getSize() first and fall back to the post-extraction check only for Google native formats, which report no size.
AbstractDataStore#alive was never read, so the admin UI's force stop (DataIndexHelper -> stopCrawling -> DataStore#stop) had no effect: every remaining file was still queued and indexed. Check alive when dispatching each file and again at the top of processFile, and make the hardcoded 60 second awaitTermination configurable through thread_pool_timeout_seconds.
marevol
force-pushed
the
fix/acl-and-url-correctness
branch
from
August 31, 2026 14:01
c1377a4 to
b6b603c
Compare
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.
Summary
Eight correctness defects in the Drive datastore, each pinned by a test written first. One is a
credential-disclosure issue; two silently dropped access-control grants; one made crawls
uninterruptible.
Depends on #26 — please merge that first.
The defects
1. Publicly-shared files were unreachable by anonymous users (
fix:)getPermission(type, value)opened withif (value == null) return null;and was called withpermission.getEmailAddress(). Atype=anyonepermission carries no email address, so theanyonebranch was unreachable dead code — public files indexed with that grant dropped.It now resolves before the null guard and maps to the anonymous role via
getSearchRoleByRole("guest"), which is the role a real guest session actually carries.2. Domain-shared files lost their ACL entirely (
fix:)A
type=domainpermission carries its value ingetDomain(), notgetEmailAddress(), so thisbranch was unreachable for the same reason. It now reads the right field, and the role format is
configurable via a new
domain_permission_formatparameter (default{group}{domain}).The formatted value is passed through
PermissionHelper.encode(), the same waydefault_permissionsalready is in this class. Without that the index would receive the literalstring
{group}example.com— a value no user's search-time role can match, which would have turned"grant dropped" into "grant unmatched" rather than fixing anything.
3. The service account's private key was reachable from crawl scripts (
fix:) — securityprocessFileseeded the script-evaluation context with the entire parameter map and strippednothing, so a crawl script such as
digest=private_keywould index the service account's RSAprivate key into the search index, readable by anyone with search access.
private_key,private_key_idandclient_emailare now removed before the context is used, viaa named
SECRET_PARAM_KEYSconstant so a future secret parameter has one obvious place to be added.The test proves the leak is closed behaviourally — a script referencing
private_keyresolves tonull — rather than counting map keys.
4. Indexed URL is now the browser-openable link (
fix:)getUrlreturnedwebContentLink, a direct-download link, falling back to auc?id=...&export=downloadURL. Neither opens the document. Every example in the documentationoverrode it with
url=file.web_view_link, which was the tell. The chain is nowwebViewLink→https://drive.google.com/open?id=<id>→ null.5. Crawler stats key raced across threads (
fix:)The stats key was put on the shared parameter map, so with
number_of_threads > 1the threadsclobbered each other. It now goes on a per-thread copy; the shared map is no longer mutated.
6. Oversized files were downloaded and parsed before being rejected (
fix:)max_sizewas only checked after the content had been fetched and run through Tika. The size Drivereports is now checked first. Google-native formats report no size, so those still fall back to the
post-extraction check — skipping them outright would lose all Docs, Sheets and Slides content.
7. Crawls could not be stopped (
fix:)AbstractDataStore.alivewas never read, so the admin console's force-stop(
isForceStop()→stopCrawling()→dataStore.stop()) had no effect. It is now checked in thedispatch loop and at the top of
processFile, and the hardcoded 60-second shutdown wait becomes athread_pool_timeout_secondsparameter.Known limitation: stopping prevents new work from being dispatched, but
GSuiteClient.getFilesstill walks the remaining pages of the listing because
list.execute()blocks. On a large Drive aforce-stop therefore takes effect later than an operator might expect. Interrupting the client's own
paging belongs with the rate-limit work that rewrites that loop, and is deliberately not done here
rather than rewriting
getFilestwice.8. Shared-drive downloads could fail (
fix:)files.getnever setsupportsAllDrives. It does now.Note
Drive.Files.Exporthas no such parameter — onlyDrive.Files.Getdoes — so the export pathused for Google-native formats cannot carry the flag. A comment on
extractFileTextrecords why.Verification
That run includes the
javadoc:jarstep. Test count across the stack so far: 0 → 41 → 42 → 50 → 79.Behaviour changes for existing users
webViewLink. Existing indexes carry the oldURL, so a full re-crawl is needed to pick up the new value.
were effectively invisible to anonymous or domain users will start appearing for them — this is
the fix working, but it is a visible change in search results.
domain_permission_format,thread_pool_timeout_seconds.