Features Apograph CMS on GitHub

Segments plugin

Segments plugin @apograph/segments-server@apograph/segments-admin

Deciding who may read a published entry, enforced on every protocol.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

Reader entitlements on the public content API. A segment is an audience; an entry carries an allow list and a deny list of them; a reader arrives carrying tags. Everything else follows from one predicate.

This is the plugin for member-only articles, per-customer documentation, staged regional launches and anything else where “published” and “visible to everybody” are not the same statement.

Installing it changes nothing

With no segment created, the catalogue is empty, the read scope returns nothing, no SQL fragment is emitted, and a public read costs exactly what it did before. That is the state every existing installation is in, and it is the first property the plugin keeps.

Install

npm install @apograph/segments-server @apograph/segments-admin

Both are installed in every app create-apograph-app generates.

Register

// apps/server/src/plugins.ts
import { SegmentsPlugin } from '@apograph/segments-server';

SegmentsPlugin(config.plugins.segments);
// apps/admin/src/plugins.ts
import { SegmentsPlugin } from '@apograph/segments-admin';

SegmentsPlugin();

Register the server half after ContentPlugin — it binds content’s read-scope port. The admin half fills the Content Library’s entry-header and entry-tab slots, so it reads after ContentPlugin(); its own directory page is independent of that order.

Configuration

One setting, and it is the line this feature needs per install:

segments: {
    resolver: {
        resolve: async (request) => readTagsFrom(request)
    }
}

The resolver receives the request, so a JWT claim, a header your CDN sets, or a lookup against a billing system are all equally reachable. It reads no environment variables.

Leaving it out is a working configuration. Every reader is then anonymous: unrestricted content serves and restricted content does not. It fails in the safe direction — an audience nobody can be resolved into cannot accidentally be admitted.

The predicate

Three rules, and they are what the SQL says line for line:

  1. A reader matching the entry’s deny list is refused, whatever else is true.
  2. An empty allow list means unrestricted.
  3. Otherwise the reader must match the allow list.
COALESCE((
  SELECT NOT (ea.deny && $reader)
     AND (cardinality(ea.allow) = 0 OR ea.allow && $reader)
  FROM entry_access ea WHERE ea.entry_id = article.id
), true)

COALESCE(…, true) is the load-bearing half: no row means unrestricted. An entry with no access row is what every entry starts as, and reading absence as a closed door would black out an installation on the day the plugin is installed.

Its mirror image: no reader context means anonymous. A request the middleware did not cover is treated as carrying no tags, because treating an unknown caller as unconstrained would turn every gap in coverage into an open door. Two absences, two opposite readings, both deliberate.

Relations are covered, on every protocol

A reader who may see an article is not thereby allowed to see everything it links to. The scope is asked about the target type on every hop — REST expansion, the /relations/:field route, GraphQL’s nested resolvers and the MCP relations tool all reach the same check.

It sits inside the window and the count, so a restricted target is missing from the items and absent from the total. Counting it would leak the cardinality of what is hidden: “5 links, 2 visible” tells the reader that three restricted records exist here.

Admin reads pass no visibility and are untouched — an editor must see the records their entry links to in order to manage them.

Access travels with the save, and with the version

The admin does not call the access endpoint. The plugin binds content’s entry write extension under the key access, so an entry’s audiences arrive in the save body and are applied on the save’s own transaction. Three things follow:

  • The access row and the entry row commit together. A save cannot land with its restriction missing.
  • The revision that save appends captures the access it applied. A separate later request could only ever be captured by the next version, so every version would record the access the entry used to have.
  • Restoring a version puts its audiences back with its words.

A PUT /api/segments/entries/:entryId route still exists for an API client that is not saving an entry. It simply gets neither the atomicity nor the version.

Access is not a translated field

“Who may read this” is a fact about the record, not about the German wording of it, so it is written to the whole locale group — set on one locale, set on all of them, exactly as a non-localized field behaves.

Left per row it was a hole you could not see from any screen: an editor restricted the English article and published the German one to everyone.

Creating a translation inherits the group’s audiences rather than being born public beside a restricted sibling, and that inheritance needs no permission — nothing is being decided, and the alternative to inheriting is publishing it to everyone.

Routes

PathScopeNeeds
/api/segmentsInstallationsegments:read / segments:manage
/api/segments/lookupInstallationsegments:read
/api/segments/:idInstallationsegments:read
/api/segments/entries/:entryIdWorkspacesegments:read / segments:manage
/api/v1/content/:type/:id/accessToken bucketsegments:read / segments:manage

Plus the access key of every entry save, which carries no permission of its own at the route: the save already required content:create or content:update on the record, and the extension checks segments:manage itself — otherwise a contributor could restrict or un-restrict any entry they can edit just by naming the key in the save body.

A request asking for exactly what is already stored changes nothing and needs no authority, which is what keeps restore working for anyone who may restore. A restore that genuinely would change the audiences is refused, and that is right rather than a special case.

Permissions

segments:read is held by contributor and viewer: an editor who cannot see that an entry is restricted will publish one believing it is public. segments:manage is admin-only — renaming a segment’s tags changes who every entry naming it is visible to, which is a configuration decision rather than an editorial one.

A segment names where it is offered

workspace_ids on a segment is a plain array, and empty means every workspace — the same reading as an entry’s empty allow list.

It narrows where an audience can be chosen, never who it lets in. The predicate does not consult it and must not: a stored decision means what its editor meant, and re-deciding it from a screen about where an audience is offered would change who can read published content with nothing on either screen to say so.

What the predicate does not reach

Entitlements are enforced on content reads. Media bytes are not covered: PublicMediaController is guarded by the API token guards alone and asks nothing about audiences, so an asset attached to a restricted entry is served by GET /api/v1/media/assets/:id/raw to any caller holding a valid token and the asset id.

That matters most in the case this plugin is bought for. A gated report whose PDF is a media asset is gated in the API and open at the file. If the bytes themselves are the secret, put them behind your own CDN or proxy rules — the CMS does not do it for you.

The asset id is a uuid and is not guessable, so this is not a directory anyone can walk. It is still a URL that outlives the restriction, and one that travels: a reader who could see the entry yesterday keeps a working link to the file today.

Agent surfaces

Three tools reach this plugin: segments_list, content_access_get and content_access_set. The write tool is offered over MCP only — an external agent holding a token scoped to segments:manage — while the copilot reaches access changes through a proposal a person accepts, carried out on the entry’s own save like any other write.