Skip to content

feat(index): authorize a "<project>.entities" index against its base project - #2311

Draft
pirhoo wants to merge 16 commits into
mainfrom
refactor/2199-namespaced-index-authorization
Draft

feat(index): authorize a "<project>.entities" index against its base project#2311
pirhoo wants to merge 16 commits into
mainfrom
refactor/2199-namespaced-index-authorization

Conversation

@pirhoo

@pirhoo pirhoo commented Aug 4, 2026

Copy link
Copy Markdown
Member

Lets a user who can access a project also reach that project's <project>.entities index through the search proxy. Part of #2194.

  • refactor: allow a dot between index-name segments, never leading, so the elasticsearch system indices stay unreachable
  • refactor: map an index back to its project by stripping a known .entities suffix before the grant check, exact match on the remainder
  • fix: record async-search ownership against the base project, so a poll re-checks the string its submit authorized
  • fix: use matches() rather than find() in the index-name check, so a name with a trailing line terminator no longer validates
  • fix: require a grant on the index's base project before creating it, closing, or opening it, so a namespaced index doesn't create an index-creation or index-closure path with no grant check at all; also refuses a blanket _all close/open
  • fix: createIndex, _close, and _open authorize each index in a comma-separated list individually, same as the search proxy already did, so a multi-index request (e.g. snapshot restore) still works instead of always 403ing
  • fix: guard project names against a dot only rather than the full project-name pattern, so names like my_project that the REST API previously accepted still work, while a dotted name (the one hazard the suffix rule depends on) is still rejected on both POST and PUT create, in the same order
  • fix: also reject a project name starting with an underscore, so a project can't be named _all and inherit access to every index in the cluster
  • fix: mode-gate createIndex the same way _close and _open already are, so it's unreachable in SERVER mode
  • test: a user reaches their own .entities index, and is refused another project's, an unknown suffix, a prefix match, and a foreign index piggybacked on a granted one

What this PR does not do

Scope boundaries up front, so a reviewer doesn't have to derive them from the diff.

  • Pre-existing, not fixed here: esHead, esOptions and the _snapshot/* endpoints still reach Elasticsearch with caller input no grant check has seen. Tracked as fix: search proxy reaches Elasticsearch without a grant check on HEAD, OPTIONS and index creation #2312, with the per-endpoint detail. esHead's exposure is unchanged by this PR: it never called checkIndices, so a dotted path always reached it.
  • Introduced here, on purpose: a search over p,p.entities records p twice in the async-search ownership list, previously unreachable since the old grammar rejected the dot and the request never validated. Harmless, the ownership check is allMatch, so deduplicating would add a line that changes no behavior. Also createIndexPreflight now advertises a PUT that the new mode gate refuses in SERVER mode (cosmetic, the PUT 403s as intended).
  • Left to feat: <project>.entities index and ExtractedEntity type #2205: the .entities constant stays private to IndexAccessVerifier until a second consumer exists.
  • Deliberately narrower than Project.NAME_PATTERN: the name guard rejects a dot and a leading underscore only, so POST /api/project still accepts names the admin service rejects. Aligning the two contracts is a separate backward-compatibility decision.
  • Residual risk: the suffix rule authorizes on name shape, not proven ownership. In a shared cluster, a pre-existing third-party index named <project>.entities would be readable by anyone granted <project>. What keeps that from being self-inflicted is the mode gate on createIndex plus the project-name guard; nothing here vouches for indices Datashare did not create.

Closes #2199

pirhoo added 4 commits August 4, 2026 12:14
…project

The search proxy granted an index only on an exact match against a project
name, so a user granted "myproject" was refused on "myproject.entities":
the name validator rejected the dot, and no project carried that name.

Index names now allow a dot between segments but never a leading one, so
the elasticsearch system indices stay out of reach, and the grant check
maps an index back to its project by stripping a known ".entities" suffix.
Suffix, never prefix, or a grant on "myproject" would leak into
"myproject-other". Async-search ownership records the base project too, so
a poll re-checks the string its submit authorized.

Closes #2199
Java's $ in a ^...$ pattern also matches before a final line terminator,
so find() let "foo\n" through where the format is meant to be rejected.
matches() anchors the whole string with no such gap.

Also corrects a backwards comment on the prefix-match assertion, and adds
a mixed-list case (a granted index alongside an unrelated one) to the
same refusal test.
checkIndices no longer rejects every dot, so it stopped doubling as the
de-facto project-name validator that the REST creation paths relied on:
a project named "victim.entities" would have passed creation and become
readable by every member of project "victim".

projectCreate and the create branch of projectUpdate now validate against
Project.NAME_PATTERN, same as the existing admin-service and CLI paths.
The update-existing branch is untouched, so a legacy project whose name
predates the regex can still be updated.
@pirhoo
pirhoo force-pushed the refactor/2199-namespaced-index-authorization branch from 224120c to 4775ead Compare August 4, 2026 12:18
@pirhoo pirhoo changed the title refactor(index): authorize a "<project>.entities" index against its base project feat(index): authorize a "<project>.entities" index against its base project Aug 4, 2026
@pirhoo
pirhoo marked this pull request as draft August 4, 2026 12:41
pirhoo added 12 commits August 4, 2026 13:36
…oc overclaim

IndexResource.recordAsyncSearchOwnership re-derived the same comma-split-then-map
baseProjects transform that IndexAccessVerifier.isAuthorizedRequest already did on
the same string checkPath just split. A submit and the poll re-checking it must
stay on the same project set, so a single IndexAccessVerifier.baseProjects(String)
replaces both, removing the now-unused Arrays.stream import from IndexResource.

Also corrects the checkIndices grammar's javadoc: a leading-dot ban alone does not
keep the elasticsearch system indices out of reach, since "_all" and other
"_"-leading names still match it. The grant check downstream is what stops those.
isAuthorizedRequest's grant clause is (isMethodGet || isSearchPath || isCountPath ||
isAsyncSearchPath); every checkPath assertion here used a GET-mocked context, so
each was satisfied by the first term alone and never exercised isSearchPath. A
regression dropping the OR's tail would have left this class green.

contextFor now takes the method explicitly. The positive .entities assertion runs
as a POST so isSearchPath is what authorizes it, and a new case covers a POST to
a non-search path on an otherwise-granted index still being refused.
…ng it

PUT /api/index/:index had no grant check at all: any authenticated user could
create (and thereby read) any index by name, including "victim.entities",
which our new suffix rule then serves to everyone granted "victim".

createIndex now requires ForbiddenException.requireGranted on the checked
index's baseProject, the same centralised gate other project-scoped
endpoints use. A user still creates their own "<id>-datashare" index (and
its .entities companion) at first login; test_put_create_local_index_in_local_mode's
LOCAL-mode fixture is updated to grant the index it creates, since LOCAL mode's
grants come from the (mocked) project repository, not a bypass.
Both endpoints checked the mode but never a grant, so any authenticated user
could close or open any index by name. Worse, "_all" passes the index-name
grammar, so POST /api/index/_all/_close could close every index in the
cluster, including .kibana and .security.

Both now require ForbiddenException.requireGranted on the checked index's
baseProject, same as createIndex. "_all" is refused because it is never a
granted project.
…order

Project.NAME_PATTERN (^[a-z0-9][a-z0-9-]{1,63}$) rejects far more than a dot:
no underscore, no single-character name, nothing over 64 characters. Enforcing
it here broke names the REST API previously accepted, e.g. "my_project", which
is not this endpoint's contract to enforce.

Both projectCreate and projectUpdate's create branch now reject only a name
containing a dot, the one invariant the .entities suffix rule actually depends
on. projectUpdate checks it before dataDirVerifier, same order as projectCreate,
so a doubly-invalid body gets the same status regardless of verb. The
@apiresponse docs for both endpoints now mention the dot, not just emptiness.
…mma list

All three called baseProject on the whole checked segment, not per index. A
comma-separated list (which checkIndices already accepts, e.g. "bar,foo.entities")
has no project name equal to it, so baseProject("a,b") returned "a,b" unchanged
and requireGranted always refused it: every multi-index _close/_open/createIndex
request 403'd regardless of grants.

datashare-client's snapshot-restore flow joins a snapshot's indices with a comma
and calls closeIndex/openIndex with the joined string, so this broke multi-index
snapshot restore. Now uses baseProjects(checkedIndex).forEach(requireGranted),
checking each index's project individually, same as the search proxy already
does at IndexAccessVerifier.isAuthorizedRequest. "_all" is still refused: it maps
to itself and no project is ever named "_all".
It called no configure(...) and passed only because the static @ClassRule
server retained whatever routes/filter an earlier test in the class had left
behind (a BasicAuthFilter granting cecile-datashare to cecile, in whichever
run). That was harmless before createIndex checked the caller at all; now
that the grant is load-bearing, an unrelated change reordering test methods
could break this test in a way that looks nothing like its actual cause.
The dot-only guard left a project nameable "_all". Granted to a user, that
name would make isGranted("_all") true, and GET /api/index/search/_all/_search
would read every index in the cluster including .kibana and .security -
also defeating the _close/_open grant check's assumption that "_all" is
never a granted project.

Both projectCreate and projectUpdate's create branch now also reject a
name starting with an underscore, with its own message so a client learns
which rule was violated. Underscores elsewhere in the name, 1-character
names, and long names are still accepted, same as before this change:
this is not a return to the full Project.NAME_PATTERN.
createIndex was the only one of the three grant-gated endpoints in this file
with no modeVerifier.checkAllowedMode call, so it stayed reachable in SERVER
mode while _close and _open did not. Added the same LOCAL/EMBEDDED gate as
the first statement, and the matching @apiresponse 403 doc.

datashare-client defines api.createIndex but has no production call site for
it, only a unit test of the wrapper, so no client flow depends on SERVER-mode
access to this endpoint.
…th ends

The reopen after test_close_index_allows_comma_separated_list_of_granted_projects's
close assertion was a plain follow-on statement: a failing close assertion would
skip it, leaving es.getIndexNames()[1] and [2] closed for the rest of the class,
the exact cascade a full-class run already caught once. Wrapped the reopen in a
finally so it runs on every path; changes no behavior.

test_close_and_open_refuse_comma_separated_list_with_one_ungranted_project put
the ungranted index last in both its _close and _open assertions, so a mutation
checking only the final index in a comma list would still pass. Added the
reversed ordering (ungranted first). New assertion on already-correct behavior,
so no red-green cycle: it passed on the first run, as expected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: proxy authorization for namespaced indices

1 participant