Global search that only shows what you may open: one service, per-source permissions, an extension hook

The header search was a demo box with no data behind it. On 12 October it became a real search over users, roles, permissions and Knowledge Base documents, where each source is gated by its own permission and a package can add a source in one call.

Until 12 October 2026 the search box in the Flightpack header was a demo. It had a nice dropdown and no data behind it. Ticket FP-180 replaced it with a real search, and the interesting part was not the matching. It was deciding who may see which result.

What it searches

Four sources: users, roles, permissions and Knowledge Base documents. Typing in the header shows a short list of suggestions, five at most. Pressing enter opens a results page, which can show everything or one source at a time, with paging of up to 50 results per page. A term must be at least two characters and at most one hundred, and the server enforces both, not only the browser.

One service, and permission per source

A single SearchService owns the whole thing. Each source is registered with the permission needed to search it: users need users.view, roles need roles.view, permissions need permissions.view. Before searching, the service asks which sources the signed-in user may use, and searches only those. A person who cannot open the user list cannot find a user through the search box either. Search is a second door into the same rooms, so it uses the same locks.

source      asked of the caller
users       users.view
roles       roles.view
permissions permissions.view
documents   the Knowledge Base levels the caller may read

This is the rule I would copy first. Many search boxes are built as one big query and filtered afterwards, and the filter is where the leak is. Deciding the sources first means there is nothing to filter out.

Repositories, not queries in the service

The service does not write SQL. Each repository gained one small search method, and the service maps the paged result to a common shape: a title, a type and the path to open. The same layering as everywhere else, described in the architecture on one page, so the matching rules for users stay in the user repository where the next developer will look for them.

A package can add a source in one call

The service has a registration method that takes a name, a search closure and the permission it needs. A package that owns data, for example a tracker of tickets, adds its own source from its service provider and appears in the dropdown and on the results page with no change to the core. The permission is part of the registration, so a package cannot forget it. This is the same idea as the rest of the package contract: the core offers a place to plug in, and the plug carries its own rules.

The route and its counter

All search routes sit behind sign-in and a limit of 60 requests a minute. The limit has its own counter named search, which matters because a search box is the one control a person hits many times in a row, and a shared counter would lock them out of unrelated screens. The story of that mistake is in the post on throttle counters. The new routes were also added to the route allowlist that the security checks read, so an endpoint nobody listed cannot appear.

What we left out on purpose

The ticket also mentioned bookmarks, navigation badges and a breadcrumb store. We did not port them. They belong to station and device features of the product the pattern came from, and the core has neither. The ticket says so in its notes, which is the part of the record I trust most: what was left out, and why.

What I would tell another small team

Decide which sources a person may search before you search them, and make every new source register its permission when it registers itself. A search box that cannot show what you may not open is worth more than one that ranks well.

Retrospective post. Written on 27 October 2026 from our project records and dated to the day the work happened.
Net Advice
Loading