Gated content with public metadata teaser. Is anyone working on this?

I'm exploring a pattern for Plone 6 / Volto sites where content is publicly discoverable (by Google and by logged-out visitors) but only the metadata surfaces. The full content is gated behind authentication.

This need is common in the nonprofit and education space: you want Google to index your resources so people can find them, but the actual content is for members or registered users only. Right now the only real options are to keep content private, which means Google sees nothing useful, or to publish it fully, which means anyone can read it. Plone doesn't currently offer anything between these two extremes.

What I have in mind

The core idea is a custom workflow state — something like members_only — that grants anonymous users View permission (so Plone returns a 200 OK, not a redirect to the login page), paired with a lightweight backend endpoint that returns only a safe subset of fields for unauthenticated requests — title, description, preview image, and optionally effective date and author. The full content payload never leaves the server for anonymous users.

On the Volto side, SSR checks for an auth token and fetches either the teaser endpoint or the full content endpoint accordingly. The teaser view renders the public metadata plus a clear login prompt. The <head> is fully populated with Open Graph and schema.org markup (isAccessibleForFree: false) so Google understands the content is gated and indexes the teaser honestly.

A useful side effect is that site search also becomes more meaningful for logged-out users — Plone's catalog already stores title, description, and preview image as metadata columns, so search results show useful listings for gated content. The click-through is where users are invited to log in.

We're also thinking about a control panel where site admins can configure which fields surface in the teaser — some sites may only want the title, others may want the full set. Possibly configurable per content type.

What I'm asking

Before building this from scratch as an addon, we wanted to check: has anyone already built something similar, or is there existing work in the Plone ecosystem we should be building on? Any thoughts on the approach (particularly around the workflow state and the backend endpoint) would also be welcome.

1 Like

In the past, Simples Consultoria had a client with a similar demand.

For this particular client we had a solution with a new workflow (Similar to Simple Publication Workflow but with a new state Members Only) and playing with permissions.

In the Members Only state, the "Access contents information" permission (ability to query the catalog and return the metadata for contents) was given to the Anonymous while "View" would be granted to Reader (and users/groups would be granted this role).

I am pretty sure it would be possible -- probably overriding the content get endpoint -- to implement what you are suggesting, and return only the data the user can see. (i.e. Anonymous would get the serialization of the catalog brain while a user with View permission would get the complete result).

In a project we wrote a custom serializer to achieve that.

Depending on the user permissions we serialize some fields or others.

Then it is frontend's work to decide how to show the content.

1 Like

Thanks @ericof and @erral

Is any of this perhaps available somewhere to look at and gain some insight from?

Our serializer looks like this:

@implementer(ISerializeToJson)
@adapter(ILocation, Interface)
class LocationFullSerializer(SerializeFolderToJson):
    def __call__(self, *args, **kwargs):
        self.request.form["fullobjects"] = True
        data = super().__call__(*args, **kwargs)
        fields_to_remove = []

        if api.user.is_anonymous():
            fields_to_remove.extend(FIELDS_FOR_AUTHORIZED_MEMBERS)
            fields_to_remove.extend(FIELDS_FOR_MEMBERS_REQUESTED)
            fields_to_remove.extend(FIELDS_FOR_MEMBERS)

        else:
            current_user_id = api.user.get_current().getId()
            user_roles = api.user.get_roles(username=current_user_id)
            if "Manager" in user_roles:
                fields_to_remove = []
            elif "Member" in user_roles:
                utility = get_location_user_utility()
                status = utility.get_user_status_in_location(
                    self.context, current_user_id
                )
                if status == ALLOWED:
                    fields_to_remove = []
                elif status == UNKNOWN:
                    # Not added to my locations
                    fields_to_remove.extend(FIELDS_FOR_AUTHORIZED_MEMBERS)
                    fields_to_remove.extend(FIELDS_FOR_MEMBERS_REQUESTED)
                else:
                    # Added to my locations but not allowed to see
                    fields_to_remove.extend(FIELDS_FOR_AUTHORIZED_MEMBERS)

            else:
                fields_to_remove.extend(FIELDS_FOR_AUTHORIZED_MEMBERS)
                fields_to_remove.extend(FIELDS_FOR_MEMBERS_REQUESTED)
                fields_to_remove.extend(FIELDS_FOR_MEMBERS)

        for fieldname in fields_to_remove:
            del data[fieldname]

        return data

2 Likes

Thanks for sharing that snippet Mikel. Is also useful in a more general RestAPI context. In my case retrieving product data, where some fields (groups) may be for internal vs. external use.

If I remember correctly, you can set schema permissions on individual fields using permissions.zcml

1 Like

Apparently no permissions.zcml in the way I described. Kimi 2.6 tells me I am full of it…

No — in pure Zope 3 / ZTK you cannot declare permissions (or roles) on individual schema fields in ZCML. You are probably thinking of views, where each browser:page does get its own permission="..." attribute. Schema security works very differently.

[edit: snip/add context]

What you might be remembering

If you work in Plone / Dexterity, you can annotate fields directly in Python:

from plone.autoform import directives
from zope.schema import TextLine

class IMyType(model.Schema):
    directives.read_permission(secret='myapp.ViewSecret')
    directives.write_permission(secret='myapp.EditSecret')
    secret = TextLine(title=u"Secret")

That is a Plone-specific extension, not standard Zope ZCML. There is no equivalent native ZCML directive like <field> or <schema_field>.

Summary

Level Granularity Where declared
View (browser:page) Individual view ZCML on the view
Schema (whole) All fields together <class> with interface / set_schema
Schema (granular) Per-field Split into multiple interfaces, or use attributes / set_attributes on <class>
Field inline Per-field in Python Plone-only (directives.read_permission)

There is no mechanism to declare roles or permissions on specific fields inside permissions.zcml.

The takeaway is apparently:

for simple “different fields per role,” stick with form.read_permission — it keeps security declarations co-located with the schema and automatically respected by forms, validation, and REST.

Summary

Goal Mechanism in Plone 6
Hide/show fields per role in REST JSON form.read_permission(field='Permission Title') in model.Schema
Declare a custom permission to use above permissions.zcml + rolemap.xml
Complex conditional logic Custom ISerializeToJson adapter
Cache-safe external tiering Vary on Authorization or separate service endpoint

At work we use SSR on Plone 6 and basically we use exactly what you described: custom workflows to make content visible to anyone, but parts of it gated depending on the user permissions.

We used to show an internal paywall (I work for a newspaper), but for almost a year we are integrating an external paywall solution that provides more flexibility/analytics and what not :person_shrugging:

I’ve put together an add-on to handle this use case:

It introduces a members_only workflow state that sits between private and published.

In this state:

  • Anonymous users get a 200 OK, but only see safe metadata via a dedicated teaser endpoint
  • Authenticated users get full access to the content
  • Volto renders a teaser view with a clear login prompt
  • Content still appears in search results and listings with meaningful titles and descriptions

Why this matters:

  • You can share member-only content on social media and still get proper link previews
  • Visitors can discover what’s available, even if they’re not logged in
  • Search engines can index the content and treat it appropriately (e.g. as gated/paid content)

We’re still in the process of testing this in real-world scenarios, so things may evolve. Any feedback or suggestions would be genuinely helpful.

It’s been a solid fit for our needs so far, and I’m hoping it’s useful for others as well.

3 Likes