Control content permissions based on the page the content is published on


Shortcut: If you "just" want to know how to make different distinct sites (read: communities or organizations) appear as just one site, continue below the reasoning.

Problem space

Permissions are a sensitive area in any application. Quite often requirement documents can give the impression that it's more important what an application must not allow than the actual value proposition. Try to balance "increased communication" with "very strictly controlled access" and you see what I mean.


The content on the marked pages should be editable only by dedicated (different) users
This is not to say that those requirements are bogus - after all there are valid reasons to limit write access to various areas of the portal. However, such requirements should not lead to overly complex solutions. I'd like to go into more detail for one specific sample solution request(*): "Provide write access only for WebContent on a given set of pages of a site".

In Liferay we have sites (communities or organizations), pages and content - the content happens to be scoped to a site, as are all the permissions: Within a site users typically have the same permissions to all content.

Content can be shown on any number of pages on a site (or none at all). And the navigation shows the pages for only one site. For this reason, the solution given is not trivial (and not natural) to implement. But you'll see that the underlying hidden Feature Request can be easy to implement.

Solutions

1. Organizational: Assume the best, blame the rest

My first, simplest and favourite answer to this kind of request is: "Try to go with organizational (nontechnical) permissions - assume your authors are responsible people and know what they should and shouldn't change". This is underwritten by the possibility to audit: You can see who edited a given article - and if a change is not appropriate, you can hold them responsible for their changes. If this is not enough, you might want to consider workflow. You might be using workflow already, so you already have someone double checking each article. My assumption is that this level of control is adequate for a lot of applications out there.

But...

But then, there still are some conditions in which this does absolutely not apply. I'm seeing two different solutions proposed for these remaining applications: One requires lots of work, is almost impossible to get completely correct and will make upgrades a pain. The other is simple and imposes a bit of thinking or structuring, but has no notable effect on upgrading. Guess which one I prefer.

2. Custom Permission checking (You don't want this)

Naturally, many think about customizing Liferay's permission checking code to take the new criteria into account.

For many reasons this is hard:

  • The permission checker itself is a very abstract component when you want to extend it with code knowing about WebContent and the currently displayed page.
  • You need to take into account that this might break access to content through the API
  • How do you handle editing of articles that are not (yet) positioned on any page?
  • As you'll be working somewhere in the guts of Liferay, it's easy to break something completely unrelated.
  • Think about upgrading to the next service pack or major version - prepare to do everything again.
  • Oh - and think about the nightmare of a UI that you'd have to create to configure who has access to what content.

Conclusion: You most likely do not want to change the internal permission checking code.

3. Customize the presentation of the content (try this)

This is the easy route: For your application you know what kind of different content you have. Divide it up in communities or organizations (referred to as sites). This maps neatly to how Liferay allows you to provide permissions to users.

Done.

Well - almost.

You still want to display all the pages as if they were still in the same site. You can do that easily: Your theme is already tailored to your needs, you can use it to handle this aspect as well. Just add the following templates. Out of habit I'm using velocity here, the two different sites that are combined here are "public", "researchdevelopment" and "production".

(Edit: As Erik commented, I've made a debugging/copying mistake writing this post. I also fixed navigation.vm to show only the toplevel pages on the top level. The code is now corrected, version 2)

navigation.vm

#set ($layoutLS = $serviceLocator.findService("com.liferay.portal.service.LayoutLocalService"))
#set ($groupService = $serviceLocator.findService("com.liferay.portal.service.GroupService"))

<nav class="$nav_css_class" id="navigation">
    <h1>
        <span>#language("navigation")</span>
    </h1>

    <ul>
        #set ($current_group = $groupService.getGroup($company_id,"public"))
        #set ($current_nav_items = $layoutLS.getLayouts($current_group.getGroupId(), false, 0))
        #parse ("$full_templates_path/navigation_list.vm")

        #set ($current_group = $groupService.getGroup($company_id,"researchdevelopment"))
        #set ($current_nav_items = $layoutLS.getLayouts($current_group.getGroupId(), false, 0))
        #parse ("$full_templates_path/navigation_list.vm")

        #set ($current_group = $groupService.getGroup($company_id,"production"))
        #set ($current_nav_items = $layoutLS.getLayouts($current_group.getGroupId(), false, 0))
        #parse ("$full_templates_path/navigation_list.vm")
    </ul>
</nav>

 

navigation_list.vm

#foreach ($nav_item in $current_nav_items)
    #if($current_group.getGroupId() == $scopeGroupId && $nav_item.getLayoutId() == $layout.getLayoutId())
        <li class="selected">
    #else
        <li>
    #end
        <a href="/web$current_group.getFriendlyURL()$nav_item.getFriendlyURL()">

            <span>$nav_item.getName()</span>
        </a>

        #if ($nav_item.hasChildren())
            <ul class="child-menu">
            #foreach ($nav_child in $nav_item.getChildren())
                #if($current_group.getGroupId() == $scopeGroupId && $nav_child.getLayoutId() == $layout.getLayoutId())
                    <li class="selected">
                #else
                    <li>
                #end
                <a href="/web$current_group.getFriendlyURL()$nav_child.getFriendlyURL()">
                    $nav_child.getName()
                </a>
                </li>
            #end
        </ul>
        #end
    </li>
#end

 

What's the drawback?

Well - it's kind of hardcoded content. But on the other hand: You typically modify your theme - if you have one plugin deployed, it's most likely a theme. The theme contains everything that makes your site look like yours. I find that this is the perfect place to hardcode something that makes the site appear as yours.

Is there more?

Sure. Another solution to the same problem - without changing the theme - will be part of my symposium presentations at the upcoming US-Westcoast and European Symposiums. I hope you don't mind the teaser and shameless plug ;-)

 

(*) Some vocabulary definition might help, namely "Feature Request" vs. "Solution Request": A Feature Request typically describes a usecase that a system can cover once it's implemented. It typically states the problem space so that it's easy to check if the proposed business case is covered. In contrast to this, I refer to a Solution Request as what should be implemented (and how) without stating the original problem. The problem with this is that it's not easy to see if the proposed solution is an appropriate answer to the underlying problem. In fact, typically it's impossible.

Blogs
Wow!!
This is awesome!!
This trick solves a lot of problems for us: many thanks for sharing!! ;D

I appreciated it very much! ;)
Thanks again! emoticon
Hi Olaf,

Great walktrough of a common problem. We have used somewhat the same technique as you described. I haven't tried out your code yet, but I think that your check for whether a nav item is selected or not would fail. The $nav_items list that is available in the velocity context is comprised by NavItem (com.liferay.portal.theme.NavItem) objects. In your example you're pulling layouts from LayoutLocalService, not the same nav_item objects as are put in the velocity context. Thus, in navigation_list.vm, instead of:

#if ($nav_item.isSelected())
<li class="selected">
#else
...

You would need to check for two things, whether the friendly url of the current group and the current layout. The check would look something like this (haven't tried the code, just wrote it down out of my head):

#if($current_group.getGroupId() == $scopeGroup.getGroupId() && $nav_item.getLayoutId() == $layout.getLayoutId())
<li class="selected">
#else
...

Looking forward to meeting you again at the European Symposium.

Cheers,
Erik
oops - I might have messed up my two solutions. Will correct once I have a stable internet connection. Thanks for pointing out, good to know that somebody actually reads this. emoticon

(Edit: The code is corrected - I also fixed a second bug that was in there. I seem to have not copied from the latest version while preparing this post. Oh joy of branching and branch-switching)

Good to hear that you'll come to the symposium - did you consider the Call For Paper or the Lightning Talks? (can't resist these days)
Hi Olaf

To make it less hard-coded, you could define a property in portal-ext.properties that contains a comma-separated list of group names that should be merged. This way, your code becomes more flexible and less dirty ;)

Still a nice solution, though.
Hi Peter,
Thanks for the feedback - Yes, configurable options could be one way to go. However I don't feel that hardcoding is a bad thing here in most of the cases that I see: A theme already hardcodes how a portal should look like for a given installation. And many plugins (hooks etc.) do as well.
If Liferay would offer this solution OOTB, it would need to be generic. However, if somebody who installs & configures Liferay for their own use uses some hardcoding in their plugins, it's typically fine to include a bit of business knowledge in the theme or even in the portlets that are created in addition to the stock portlets.
Why would you provide many configurable options when you only need your own solution?
Hi Olaf,
doesn't this design will break navigation and site map. I have used another approach in almost same type of requirement. Since for me it was only web content which need to be protected. What I have done created web content in a community , made one common user which is admin of both community say community A and B. Using this common user now I can place the common content in web content display using advance search in web content display. Correct me if I am wrong???
Hi Olaf,
I really like your approach to use different communities for different purposes and combine them into one site -- very pragmatic solution. I presented a more complex solution last year at the european symposium under the label 'Junction Points'.

I missed two features with your approach: how to deal with hidden pages and how to integrate permission checking. Both issues are adressed in the method 'getViewableLayouts' of the class 'ServicePreAction'.

Therefore I adapted your idea and implemented a groups-merging-hook, that installs a new class instead of the ServicePreAction. The hook has to use the portal class loader, because it extends a core class. Thus a context.xml is needed containing the following declaration:

<Loader loaderClass="com.liferay.support.tomcat.loader.PortalClassLoader" />


The liferay-hook.xml file just defines a portal.properties file:

<hook>
<portal-properties>portal.properties</portal-properties>
</hook>


And the portal.properties file replaces the servlet.service.events.pre.action:

servlet.service.events.pre=de.hansemerkur.liferay.portal.events.GroupsMergingPreAction

The new action executes all the code of the original action and the checks, if one of the combined communities is use. If this is the case, a list of all top level layouts is created and that list is filtered by the 'getViewableLayouts' method of the super class. The result value value is stored in the themeDisplay and the request to be rendered later. The code of the new action is as follows:

public class GroupsMergingPreAction extends ServicePreAction {
private static List<String> groupNames = Arrays.asList("public", "researchdevelopment", "production");

@Override
protected void servicePre(HttpServletRequest request, HttpServletResponse response) throws Exception {
// execute liferay's normal code
super.servicePre(request, response);
// get the themeDisplay from the request...
ThemeDisplay themeDisplay = (ThemeDisplay) request.getAttribute(WebKeys.THEME_DISPLAY);
// ... and check, if the layout belongs to the listed merging groups
Layout layout = themeDisplay.getLayout();
if (layout.isPrivateLayout() || !groupNames.contains(layout.getGroup().getName())) {
return;
}

// put all layouts of the merging groups into a list ...
long companyId = PortalUtil.getCompanyId(request);
List<Layout> layouts = new ArrayList<Layout>();
for (String groupName: groupNames) {
Group group = GroupLocalServiceUtil.getGroup(companyId, groupName);
List<Layout> groupLayouts = LayoutLocalServiceUtil.getLayouts(group.getGroupId(), false, LayoutConstants.DEFAULT_PARENT_LAYOUT_ID);
layouts.addAll(groupLayouts);
}
// ... and filter out all hidden or privileged layouts
Object[] viewableLayouts = super.getViewableLayouts(request, themeDisplay.getUser(), themeDisplay.getPermissionChecker(), layout, layouts);
layouts = (List<Layout>) viewableLayouts[1];
request.setAttribute(WebKeys.LAYOUTS, layouts);
themeDisplay.setLayouts(layouts);
}
}


Best regards,
Olaf Fricke
@KK rajput: Thanks, yes there are a few limitations, e.g. in navigation and sitemap. Read on below, because that comment matches as well

@Olaf Fricke: The teasered alternative solution also is based on a hook. It's a lot easier to work in java than in velocity.

I'll make sure to mention the limitations a bit more clearly in the teasered presentations
We had a slightly different Feature Request: "Show all sites the user has access to in one menubar" -- and based on the above code, we came up with an extended version: http://www.liferay.com/de/community/forums/-/message_boards/message/10913877

BTW: The above check for "selected" does not distinguish between public and private layouts -- so sometimes it shows two menu items as selected. Fixed version:
#if($current_group.getGroupId() == $scopeGroupId && $nav_item.getLayoutId() == $layout.getLayoutId() && $nav_item.isPrivateLayout() == $layout.isPrivateLayout())
We had similar requirement but changing in theme was not what we wanted to implement because the same theme is being used on dozens of intranet sites and also did not want to use any conditional statements only for that particular website. So, I ended up creating all top level pages on 2nd site with page type "URL" and redirecting them to pages of 1st site. And assigning necessary permissions to two different group of users.
@Navin Singh - good solution as well. Another option is to create small variations of a theme - like this: https://www.liferay.com/en/web/olaf.kock/blog/-/blogs/theme-development%3A-choosing-your-parent . With this technique you'll create one basic theme for the look and feel and then a variation, based on this, with some site-specific code. That being said, the code documented here probably needs an update..
Though this is an old blog article, somebody might still find it. In this case please note that I've published a simpler (and configurable) solution on the marketplace. This works with any theme out-of-the-box: https://www.liferay.com/marketplace/-/mp/application/27362781